Add smart playlists with full iTunes 12 import (Phase 1)
Auto-populating, criteria-driven playlists that import faithfully from iTunes. - lintunes/smart.py: recursive SmartCriteria/SmartGroup/SmartRule/SmartLimit model (JSON round-tripping), a shared FIELD_REGISTRY used by both the evaluator and the editor, and evaluate() (match all/any, nested groups, string/int/duration/rating/date/bool ops, "in the last N", limit by items/time/size). Null play/skip dates are treated as the distant past, matching iTunes. - iTunes import via a vendored MIT-licensed binary parser (lintunes/itunes_smart/, from cvzi/itunes_smartplaylist). Nested groups parse and evaluate; blobs we can't represent (MediaKind/iCloud/etc.) flag unsupported and keep the imported snapshot. "loved" is dropped per user pref. - library_manager: create/set/recompute smart playlists (undoable), field-scoped coalesced live recompute hooked into the edit/play/skip/add funnels, a no-op equality guard to avoid Syncthing churn, and manual-edit guards. main.py recomputes on load; conflict_resolver keeps newest criteria for smart lists. - GUI: ❧ glyph painted in the sidebar branch column, read-only track table for smart playlists, New/Edit Smart Playlist menus, and SmartPlaylistEditorDialog (per-field rule rows, match all/any, limits, live updating). Tests: tests/test_round14.py (real captured blobs in tests/smart_blobs.json).
This commit is contained in:
@@ -146,6 +146,8 @@ class MainWindow(QMainWindow):
|
||||
file_menu = bar.addMenu("&File")
|
||||
self._add_action(file_menu, "New Playlist", "Ctrl+N",
|
||||
lambda: self._sidebar.tree.create_playlist_interactive())
|
||||
self._add_action(file_menu, "New Smart Playlist", "Ctrl+Alt+N",
|
||||
lambda: self._sidebar.tree.create_smart_playlist_interactive())
|
||||
self._add_action(file_menu, "New Playlist Folder", "Ctrl+Shift+N",
|
||||
lambda: self._manager.create_folder("untitled folder"))
|
||||
file_menu.addSeparator()
|
||||
|
||||
@@ -62,6 +62,8 @@ class PlaylistView(QWidget):
|
||||
def show_playlist(self, playlist):
|
||||
self._playlist = playlist
|
||||
self.table.set_source_playlist(playlist.persistent_id)
|
||||
# Smart playlists are auto-populated: no manual add/remove/reorder.
|
||||
self.table.set_content_editable(not playlist.is_smart)
|
||||
self.table.apply_settings(playlist.settings)
|
||||
self._reload_tracks()
|
||||
|
||||
@@ -73,7 +75,8 @@ class PlaylistView(QWidget):
|
||||
if tid in self._manager.library.tracks]
|
||||
self.table.set_tracks(tracks)
|
||||
# Count/time now live in the bottom status bar, not next to the name.
|
||||
self._name_label.setText(self._playlist.name)
|
||||
prefix = "❧ " if self._playlist.is_smart else ""
|
||||
self._name_label.setText(prefix + self._playlist.name)
|
||||
|
||||
def _on_content_changed(self, pid):
|
||||
if self._playlist is not None and pid == self._playlist.persistent_id:
|
||||
@@ -114,35 +117,40 @@ class PlaylistView(QWidget):
|
||||
|
||||
# ---- content edits ----
|
||||
|
||||
def _editable(self) -> bool:
|
||||
"""Manual content edits are blocked for smart playlists (membership is
|
||||
derived from criteria)."""
|
||||
return self._playlist is not None and not self._playlist.is_smart
|
||||
|
||||
def _on_reorder(self, rows, dest):
|
||||
if self._playlist is not None:
|
||||
if self._editable():
|
||||
self._manager.move_tracks_in_playlist(
|
||||
self._playlist.persistent_id, rows, dest)
|
||||
|
||||
def _on_tracks_dropped(self, track_ids, drop_row):
|
||||
if self._playlist is not None:
|
||||
if self._editable():
|
||||
add_tracks_with_dup_check(
|
||||
self._manager, self, self._playlist.persistent_id,
|
||||
track_ids, drop_row)
|
||||
|
||||
def _on_files_dropped(self, paths, drop_row):
|
||||
if self._playlist is not None:
|
||||
if self._editable():
|
||||
self.files_dropped.emit(paths, self._playlist.persistent_id)
|
||||
|
||||
def _on_remove(self, rows):
|
||||
if self._playlist is not None:
|
||||
if self._editable():
|
||||
self._manager.remove_tracks_from_playlist(
|
||||
self._playlist.persistent_id, rows)
|
||||
|
||||
def _on_cut(self, rows):
|
||||
# Cut removes immediately (so the track visibly leaves); the ids are
|
||||
# already on the clipboard for a later paste to re-insert.
|
||||
if self._playlist is not None:
|
||||
if self._editable():
|
||||
self._manager.remove_tracks_from_playlist(
|
||||
self._playlist.persistent_id, rows)
|
||||
|
||||
def _on_paste(self):
|
||||
if self._playlist is None:
|
||||
if not self._editable():
|
||||
return
|
||||
payload = parse_tracks_mime(QApplication.clipboard().mimeData())
|
||||
if not payload:
|
||||
|
||||
+64
-7
@@ -14,7 +14,11 @@ from lintunes.gui import drag_ghost
|
||||
|
||||
|
||||
PID_ROLE = Qt.ItemDataRole.UserRole
|
||||
KIND_ROLE = Qt.ItemDataRole.UserRole + 1 # "folder" | "playlist"
|
||||
KIND_ROLE = Qt.ItemDataRole.UserRole + 1 # "folder" | "playlist" | "smart"
|
||||
|
||||
# Rotated Floral Heart Bullet, painted in the branch area (where the folder
|
||||
# triangle sits) for smart playlists, so the name stays aligned with the others.
|
||||
SMART_GLYPH = "❧"
|
||||
|
||||
|
||||
class SidebarPanel(QWidget):
|
||||
@@ -186,10 +190,12 @@ class PlaylistTree(QTreeWidget):
|
||||
def add_level(parent_item, parent_pid):
|
||||
for playlist in sorted(children.get(parent_pid, []),
|
||||
key=lambda p: p.name.lower()):
|
||||
is_folder = playlist.playlist_type == PlaylistType.FOLDER
|
||||
is_smart = playlist.is_smart
|
||||
item = QTreeWidgetItem([playlist.name])
|
||||
item.setData(0, PID_ROLE, playlist.persistent_id)
|
||||
is_folder = playlist.playlist_type == PlaylistType.FOLDER
|
||||
item.setData(0, KIND_ROLE, "folder" if is_folder else "playlist")
|
||||
kind = "folder" if is_folder else "smart" if is_smart else "playlist"
|
||||
item.setData(0, KIND_ROLE, kind)
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
|
||||
if parent_item is None:
|
||||
self.addTopLevelItem(item)
|
||||
@@ -227,6 +233,24 @@ class PlaylistTree(QTreeWidget):
|
||||
stack.extend(item.child(i) for i in range(item.childCount()))
|
||||
return None
|
||||
|
||||
def drawBranches(self, painter, rect, index):
|
||||
"""Paint the ❧ for smart playlists in the branch column (where a folder's
|
||||
disclosure triangle sits), so the playlist name stays left-aligned with
|
||||
the others instead of being pushed right by a text prefix."""
|
||||
super().drawBranches(painter, rect, index)
|
||||
item = self.itemFromIndex(index)
|
||||
if item is None or item.data(0, KIND_ROLE) != "smart":
|
||||
return
|
||||
selected = self.selectionModel().isSelected(index)
|
||||
role = (self.palette().highlightedText() if selected
|
||||
else self.palette().text())
|
||||
painter.save()
|
||||
painter.setPen(role.color())
|
||||
painter.drawText(rect.adjusted(0, 0, -2, 0),
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||
SMART_GLYPH)
|
||||
painter.restore()
|
||||
|
||||
def current_playlist_id(self) -> str:
|
||||
item = self.currentItem()
|
||||
if item is None:
|
||||
@@ -252,12 +276,38 @@ class PlaylistTree(QTreeWidget):
|
||||
self.setCurrentItem(item) # selects + navigates to the new playlist
|
||||
self.editItem(item, 0) # live inline rename; editor selects all
|
||||
|
||||
def create_smart_playlist_interactive(self, parent_pid: str = ""):
|
||||
"""Open the criteria editor; on accept, create the smart playlist and
|
||||
drop into inline rename (mirrors create_playlist_interactive)."""
|
||||
from lintunes.gui.smart_playlist_dialog import SmartPlaylistEditorDialog
|
||||
from lintunes.smart import SmartCriteria
|
||||
dialog = SmartPlaylistEditorDialog(SmartCriteria(), self,
|
||||
title="New Smart Playlist")
|
||||
if not dialog.exec():
|
||||
return
|
||||
playlist = self._manager.create_smart_playlist(
|
||||
"untitled smart playlist", dialog.criteria(), parent_pid)
|
||||
item = self._find_item(playlist.persistent_id)
|
||||
if item is not None:
|
||||
self.setCurrentItem(item)
|
||||
self.editItem(item, 0)
|
||||
|
||||
def edit_smart_playlist(self, pid: str):
|
||||
playlist = self._manager.library.playlists.get(pid)
|
||||
if not playlist or not playlist.is_smart:
|
||||
return
|
||||
from lintunes.gui.smart_playlist_dialog import SmartPlaylistEditorDialog
|
||||
dialog = SmartPlaylistEditorDialog(playlist.smart_criteria, self,
|
||||
title="Edit Smart Playlist")
|
||||
if dialog.exec():
|
||||
self._manager.set_smart_criteria(pid, dialog.criteria())
|
||||
|
||||
# ---- selection / rename ----
|
||||
|
||||
def _on_selection_changed(self, current, _previous):
|
||||
if current is None:
|
||||
return
|
||||
if current.data(0, KIND_ROLE) == "playlist":
|
||||
if current.data(0, KIND_ROLE) in ("playlist", "smart"):
|
||||
self.playlist_selected.emit(current.data(0, PID_ROLE))
|
||||
|
||||
def _on_item_renamed(self, item, _column):
|
||||
@@ -275,17 +325,20 @@ class PlaylistTree(QTreeWidget):
|
||||
parent_pid = ""
|
||||
if kind == "folder":
|
||||
parent_pid = pid
|
||||
elif kind == "playlist":
|
||||
elif kind in ("playlist", "smart"):
|
||||
playlist = self._manager.library.playlists.get(pid)
|
||||
if playlist:
|
||||
parent_pid = playlist.parent_persistent_id
|
||||
|
||||
menu = QMenu(self)
|
||||
new_playlist = menu.addAction("New Playlist")
|
||||
new_smart = menu.addAction("New Smart Playlist")
|
||||
new_folder = menu.addAction("New Playlist Folder")
|
||||
rename = delete = None
|
||||
if kind in ("playlist", "folder"):
|
||||
rename = delete = edit_smart = None
|
||||
if kind in ("playlist", "folder", "smart"):
|
||||
menu.addSeparator()
|
||||
if kind == "smart":
|
||||
edit_smart = menu.addAction("Edit Smart Playlist…")
|
||||
rename = menu.addAction("Rename")
|
||||
delete = menu.addAction("Delete")
|
||||
|
||||
@@ -294,6 +347,10 @@ class PlaylistTree(QTreeWidget):
|
||||
return
|
||||
if chosen is new_playlist:
|
||||
self.create_playlist_interactive(parent_pid)
|
||||
elif chosen is new_smart:
|
||||
self.create_smart_playlist_interactive(parent_pid)
|
||||
elif edit_smart is not None and chosen is edit_smart:
|
||||
self.edit_smart_playlist(pid)
|
||||
elif chosen is new_folder:
|
||||
name, ok = QInputDialog.getText(self, "New Playlist Folder", "Name:",
|
||||
text="untitled folder")
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Editor dialog for smart-playlist criteria (the LinTunes equivalent of
|
||||
iTunes' "Smart Playlist" sheet).
|
||||
|
||||
Phase 1 edits a flat rule list (one top-level *Match all/any* + rules + an
|
||||
optional limit). Imported playlists that use nested rule groups are shown
|
||||
read-only with an explanatory banner; nested-group editing is Phase 2.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QLineEdit, QSpinBox,
|
||||
QDateEdit, QCheckBox, QToolButton, QDialogButtonBox,
|
||||
QScrollArea, QWidget, QFrame,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QDate
|
||||
|
||||
from lintunes.smart import (
|
||||
FIELD_REGISTRY, FieldType, SmartCriteria, SmartGroup, SmartRule, SmartLimit,
|
||||
)
|
||||
|
||||
|
||||
OP_LABELS = {
|
||||
"is": "is", "is_not": "is not", "contains": "contains",
|
||||
"not_contains": "does not contain", "starts_with": "starts with",
|
||||
"ends_with": "ends with", "greater": "is greater than",
|
||||
"less": "is less than", "in_range": "is in the range",
|
||||
"after": "is after", "before": "is before",
|
||||
"not_in_range": "is not in the range", "in_last": "is in the last",
|
||||
"not_in_last": "is not in the last",
|
||||
}
|
||||
|
||||
_LIMIT_BY = [("items", "items"), ("minutes", "minutes"), ("hours", "hours"),
|
||||
("mb", "MB"), ("gb", "GB")]
|
||||
_SELECTIONS = [
|
||||
("random", "random"), ("name", "name"), ("album", "album"),
|
||||
("artist", "artist"), ("genre", "genre"),
|
||||
("highest_rating", "highest rating"), ("lowest_rating", "lowest rating"),
|
||||
("most_recently_played", "most recently played"),
|
||||
("least_recently_played", "least recently played"),
|
||||
("most_often_played", "most often played"),
|
||||
("least_often_played", "least often played"),
|
||||
("most_recently_added", "most recently added"),
|
||||
("least_recently_added", "least recently added"),
|
||||
]
|
||||
_DATE_UNITS = [("days", "days"), ("weeks", "weeks"), ("months", "months")]
|
||||
|
||||
_INT_MAX = 2_000_000_000
|
||||
|
||||
|
||||
def _iso_to_qdate(value) -> QDate:
|
||||
try:
|
||||
d = QDate.fromString(str(value)[:10], "yyyy-MM-dd")
|
||||
if d.isValid():
|
||||
return d
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return QDate.currentDate()
|
||||
|
||||
|
||||
def _qdate_to_iso(qdate: QDate) -> str:
|
||||
return qdate.toString("yyyy-MM-dd") + "T00:00:00"
|
||||
|
||||
|
||||
class RuleRow(QWidget):
|
||||
"""One condition: field combo -> operator combo -> value widget(s)."""
|
||||
|
||||
def __init__(self, dialog: "SmartPlaylistEditorDialog", rule: SmartRule):
|
||||
super().__init__(dialog)
|
||||
self._dialog = dialog
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.field_combo = QComboBox()
|
||||
for key, meta in FIELD_REGISTRY.items():
|
||||
self.field_combo.addItem(meta.label, key)
|
||||
layout.addWidget(self.field_combo)
|
||||
|
||||
self.op_combo = QComboBox()
|
||||
layout.addWidget(self.op_combo)
|
||||
|
||||
self.value_host = QWidget()
|
||||
self.value_layout = QHBoxLayout(self.value_host)
|
||||
self.value_layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self.value_host, stretch=1)
|
||||
|
||||
self.minus = QToolButton(); self.minus.setText("–")
|
||||
self.plus = QToolButton(); self.plus.setText("+")
|
||||
layout.addWidget(self.minus)
|
||||
layout.addWidget(self.plus)
|
||||
self.minus.clicked.connect(lambda: dialog.remove_row(self))
|
||||
self.plus.clicked.connect(lambda: dialog.add_row_after(self))
|
||||
|
||||
self.field_combo.currentIndexChanged.connect(self._on_field_changed)
|
||||
self.op_combo.currentIndexChanged.connect(self._rebuild_value)
|
||||
|
||||
self._load(rule)
|
||||
|
||||
# ---- population ----
|
||||
|
||||
def _load(self, rule: SmartRule):
|
||||
idx = self.field_combo.findData(rule.field)
|
||||
self.field_combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self._populate_ops()
|
||||
opidx = self.op_combo.findData(rule.operator)
|
||||
self.op_combo.setCurrentIndex(opidx if opidx >= 0 else 0)
|
||||
self._rebuild_value()
|
||||
self._set_values(rule)
|
||||
|
||||
def _current_field(self) -> str:
|
||||
return self.field_combo.currentData()
|
||||
|
||||
def _current_meta(self):
|
||||
return FIELD_REGISTRY[self._current_field()]
|
||||
|
||||
def _populate_ops(self):
|
||||
self.op_combo.blockSignals(True)
|
||||
self.op_combo.clear()
|
||||
for op in self._current_meta().operators:
|
||||
self.op_combo.addItem(OP_LABELS.get(op, op), op)
|
||||
self.op_combo.blockSignals(False)
|
||||
|
||||
def _on_field_changed(self):
|
||||
self._populate_ops()
|
||||
self._rebuild_value()
|
||||
|
||||
# ---- value widget(s) ----
|
||||
|
||||
def _clear_value(self):
|
||||
while self.value_layout.count():
|
||||
item = self.value_layout.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.deleteLater()
|
||||
|
||||
def _new_spin(self, maximum=_INT_MAX, minimum=0) -> QSpinBox:
|
||||
spin = QSpinBox()
|
||||
spin.setRange(minimum, maximum)
|
||||
return spin
|
||||
|
||||
def _rebuild_value(self):
|
||||
self._clear_value()
|
||||
meta = self._current_meta()
|
||||
op = self.op_combo.currentData()
|
||||
self._kind = None
|
||||
|
||||
if meta.type is FieldType.STRING:
|
||||
self._kind = "string"
|
||||
self.w_line = QLineEdit()
|
||||
self.value_layout.addWidget(self.w_line)
|
||||
|
||||
elif meta.type is FieldType.BOOL:
|
||||
self._kind = "bool"
|
||||
self.w_bool = QComboBox()
|
||||
self.w_bool.addItem("true", True)
|
||||
self.w_bool.addItem("false", False)
|
||||
self.value_layout.addWidget(self.w_bool)
|
||||
|
||||
elif meta.type is FieldType.RATING:
|
||||
self._kind = "rating"
|
||||
self.w_spin = self._new_spin(maximum=5)
|
||||
self.w_spin.setSuffix(" stars")
|
||||
self.value_layout.addWidget(self.w_spin)
|
||||
if op == "in_range":
|
||||
self.value_layout.addWidget(QLabel("to"))
|
||||
self.w_spin2 = self._new_spin(maximum=5)
|
||||
self.w_spin2.setSuffix(" stars")
|
||||
self.value_layout.addWidget(self.w_spin2)
|
||||
self._kind = "rating_range"
|
||||
|
||||
elif meta.type in (FieldType.INT, FieldType.DURATION):
|
||||
unit = " sec" if meta.type is FieldType.DURATION else ""
|
||||
self._kind = "int" if meta.type is FieldType.INT else "duration"
|
||||
self.w_spin = self._new_spin()
|
||||
if unit:
|
||||
self.w_spin.setSuffix(unit)
|
||||
self.value_layout.addWidget(self.w_spin)
|
||||
if op == "in_range":
|
||||
self.value_layout.addWidget(QLabel("to"))
|
||||
self.w_spin2 = self._new_spin()
|
||||
if unit:
|
||||
self.w_spin2.setSuffix(unit)
|
||||
self.value_layout.addWidget(self.w_spin2)
|
||||
self._kind += "_range"
|
||||
|
||||
elif meta.type is FieldType.DATE:
|
||||
if op in ("in_last", "not_in_last"):
|
||||
self._kind = "date_last"
|
||||
self.w_count = self._new_spin(maximum=100000, minimum=1)
|
||||
self.w_unit = QComboBox()
|
||||
for key, label in _DATE_UNITS:
|
||||
self.w_unit.addItem(label, key)
|
||||
self.value_layout.addWidget(self.w_count)
|
||||
self.value_layout.addWidget(self.w_unit)
|
||||
elif op in ("in_range", "not_in_range"):
|
||||
self._kind = "date_range"
|
||||
self.w_date = self._new_date()
|
||||
self.w_date2 = self._new_date()
|
||||
self.value_layout.addWidget(self.w_date)
|
||||
self.value_layout.addWidget(QLabel("to"))
|
||||
self.value_layout.addWidget(self.w_date2)
|
||||
else:
|
||||
self._kind = "date"
|
||||
self.w_date = self._new_date()
|
||||
self.value_layout.addWidget(self.w_date)
|
||||
|
||||
def _new_date(self) -> QDateEdit:
|
||||
d = QDateEdit()
|
||||
d.setCalendarPopup(True)
|
||||
d.setDisplayFormat("yyyy-MM-dd")
|
||||
d.setDate(QDate.currentDate())
|
||||
return d
|
||||
|
||||
def _set_values(self, rule: SmartRule):
|
||||
k = self._kind
|
||||
if k == "string":
|
||||
self.w_line.setText("" if rule.value is None else str(rule.value))
|
||||
elif k == "bool":
|
||||
self.w_bool.setCurrentIndex(0 if bool(rule.value) else 1)
|
||||
elif k in ("rating", "rating_range"):
|
||||
self.w_spin.setValue(int((rule.value or 0)) // 20)
|
||||
if k == "rating_range":
|
||||
self.w_spin2.setValue(int((rule.value2 or 0)) // 20)
|
||||
elif k in ("int", "duration", "int_range", "duration_range"):
|
||||
div = 1000 if k.startswith("duration") else 1
|
||||
self.w_spin.setValue(int((rule.value or 0)) // div)
|
||||
if k.endswith("_range"):
|
||||
self.w_spin2.setValue(int((rule.value2 or 0)) // div)
|
||||
elif k == "date":
|
||||
self.w_date.setDate(_iso_to_qdate(rule.value))
|
||||
elif k == "date_range":
|
||||
self.w_date.setDate(_iso_to_qdate(rule.value))
|
||||
self.w_date2.setDate(_iso_to_qdate(rule.value2))
|
||||
elif k == "date_last":
|
||||
self.w_count.setValue(int(rule.value or 1))
|
||||
uidx = self.w_unit.findData(rule.unit or "days")
|
||||
self.w_unit.setCurrentIndex(uidx if uidx >= 0 else 0)
|
||||
|
||||
# ---- read back ----
|
||||
|
||||
def rule(self) -> SmartRule:
|
||||
field = self._current_field()
|
||||
op = self.op_combo.currentData()
|
||||
k = self._kind
|
||||
if k == "string":
|
||||
return SmartRule(field, op, self.w_line.text())
|
||||
if k == "bool":
|
||||
return SmartRule(field, "is", self.w_bool.currentData())
|
||||
if k == "rating":
|
||||
return SmartRule(field, op, self.w_spin.value() * 20)
|
||||
if k == "rating_range":
|
||||
return SmartRule(field, op, self.w_spin.value() * 20,
|
||||
self.w_spin2.value() * 20)
|
||||
if k in ("int", "duration"):
|
||||
mul = 1000 if k == "duration" else 1
|
||||
return SmartRule(field, op, self.w_spin.value() * mul)
|
||||
if k in ("int_range", "duration_range"):
|
||||
mul = 1000 if k.startswith("duration") else 1
|
||||
return SmartRule(field, op, self.w_spin.value() * mul,
|
||||
self.w_spin2.value() * mul)
|
||||
if k == "date":
|
||||
return SmartRule(field, op, _qdate_to_iso(self.w_date.date()))
|
||||
if k == "date_range":
|
||||
return SmartRule(field, op, _qdate_to_iso(self.w_date.date()),
|
||||
_qdate_to_iso(self.w_date2.date()))
|
||||
if k == "date_last":
|
||||
return SmartRule(field, op, self.w_count.value(),
|
||||
unit=self.w_unit.currentData())
|
||||
return SmartRule(field, op, "")
|
||||
|
||||
|
||||
class SmartPlaylistEditorDialog(QDialog):
|
||||
def __init__(self, criteria: SmartCriteria | None, parent=None,
|
||||
title="Smart Playlist"):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(title)
|
||||
self.setMinimumWidth(560)
|
||||
self._original = criteria or SmartCriteria()
|
||||
self._read_only = self._original.has_nested or self._original.unsupported
|
||||
self._rows: list[RuleRow] = []
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
|
||||
if self._read_only:
|
||||
banner = QLabel(
|
||||
"This smart playlist was imported from iTunes and uses "
|
||||
"conditions LinTunes can't edit yet"
|
||||
+ (" (nested rule groups)." if self._original.has_nested
|
||||
else ".")
|
||||
+ " It still updates and plays; saving here is disabled.")
|
||||
banner.setWordWrap(True)
|
||||
banner.setStyleSheet("color: palette(highlight); padding: 4px;")
|
||||
outer.addWidget(banner)
|
||||
|
||||
# Match all/any header.
|
||||
match_row = QHBoxLayout()
|
||||
match_row.addWidget(QLabel("Match"))
|
||||
self.match_combo = QComboBox()
|
||||
self.match_combo.addItem("all", "all")
|
||||
self.match_combo.addItem("any", "any")
|
||||
self.match_combo.setCurrentIndex(
|
||||
1 if self._original.root.conjunction == "any" else 0)
|
||||
match_row.addWidget(self.match_combo)
|
||||
match_row.addWidget(QLabel("of the following rules:"))
|
||||
match_row.addStretch(1)
|
||||
outer.addLayout(match_row)
|
||||
|
||||
# Scrollable rules area.
|
||||
self._rules_container = QWidget()
|
||||
self._rules_layout = QVBoxLayout(self._rules_container)
|
||||
self._rules_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._rules_layout.addStretch(1)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setWidget(self._rules_container)
|
||||
scroll.setMinimumHeight(140)
|
||||
outer.addWidget(scroll, stretch=1)
|
||||
|
||||
# Limit + options.
|
||||
outer.addWidget(self._build_limit_row())
|
||||
self.live_check = QCheckBox("Live updating")
|
||||
self.live_check.setChecked(self._original.live_update)
|
||||
outer.addWidget(self.live_check)
|
||||
self.checked_check = QCheckBox("Match only checked items")
|
||||
self.checked_check.setChecked(self._original.match_only_checked)
|
||||
self.checked_check.setEnabled(False)
|
||||
self.checked_check.setToolTip(
|
||||
"LinTunes has no per-track checkbox, so this iTunes option is "
|
||||
"ignored.")
|
||||
outer.addWidget(self.checked_check)
|
||||
|
||||
# Buttons.
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
self._ok_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
|
||||
outer.addWidget(buttons)
|
||||
|
||||
# Populate rule rows (flat criteria only).
|
||||
if not self._read_only:
|
||||
rules = [c for c in self._original.root.children
|
||||
if isinstance(c, SmartRule)]
|
||||
if not rules:
|
||||
rules = [SmartRule()]
|
||||
for rule in rules:
|
||||
self._append_row(rule)
|
||||
else:
|
||||
self._set_read_only()
|
||||
|
||||
def _build_limit_row(self) -> QWidget:
|
||||
box = QFrame()
|
||||
row = QHBoxLayout(box)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
limit = self._original.limit
|
||||
self.limit_check = QCheckBox("Limit to")
|
||||
self.limit_check.setChecked(limit.enabled)
|
||||
row.addWidget(self.limit_check)
|
||||
self.limit_count = QSpinBox()
|
||||
self.limit_count.setRange(1, _INT_MAX)
|
||||
self.limit_count.setValue(max(1, limit.count))
|
||||
row.addWidget(self.limit_count)
|
||||
self.limit_by = QComboBox()
|
||||
for key, label in _LIMIT_BY:
|
||||
self.limit_by.addItem(label, key)
|
||||
self.limit_by.setCurrentIndex(
|
||||
max(0, self.limit_by.findData(limit.by)))
|
||||
row.addWidget(self.limit_by)
|
||||
row.addWidget(QLabel("selected by"))
|
||||
self.limit_selection = QComboBox()
|
||||
for key, label in _SELECTIONS:
|
||||
self.limit_selection.addItem(label, key)
|
||||
self.limit_selection.setCurrentIndex(
|
||||
max(0, self.limit_selection.findData(limit.selection)))
|
||||
row.addWidget(self.limit_selection)
|
||||
row.addStretch(1)
|
||||
return box
|
||||
|
||||
def _set_read_only(self):
|
||||
for w in (self.match_combo, self.limit_check, self.limit_count,
|
||||
self.limit_by, self.limit_selection, self.live_check):
|
||||
w.setEnabled(False)
|
||||
self._ok_button.setEnabled(False)
|
||||
|
||||
# ---- row management ----
|
||||
|
||||
def _append_row(self, rule: SmartRule):
|
||||
row = RuleRow(self, rule)
|
||||
# Insert before the trailing stretch.
|
||||
self._rules_layout.insertWidget(self._rules_layout.count() - 1, row)
|
||||
self._rows.append(row)
|
||||
|
||||
def add_row_after(self, after: RuleRow):
|
||||
row = RuleRow(self, SmartRule())
|
||||
idx = self._rules_layout.indexOf(after)
|
||||
self._rules_layout.insertWidget(idx + 1, row)
|
||||
pos = self._rows.index(after) + 1
|
||||
self._rows.insert(pos, row)
|
||||
|
||||
def remove_row(self, row: RuleRow):
|
||||
if row not in self._rows:
|
||||
return
|
||||
self._rows.remove(row)
|
||||
self._rules_layout.removeWidget(row)
|
||||
row.deleteLater()
|
||||
|
||||
# ---- result ----
|
||||
|
||||
def criteria(self) -> SmartCriteria:
|
||||
if self._read_only:
|
||||
return self._original
|
||||
root = SmartGroup(
|
||||
conjunction=self.match_combo.currentData(),
|
||||
children=[row.rule() for row in self._rows])
|
||||
limit = SmartLimit(
|
||||
enabled=self.limit_check.isChecked(),
|
||||
count=self.limit_count.value(),
|
||||
by=self.limit_by.currentData(),
|
||||
selection=self.limit_selection.currentData())
|
||||
return SmartCriteria(
|
||||
root=root, limit=limit,
|
||||
live_update=self.live_check.isChecked(),
|
||||
match_only_checked=self._original.match_only_checked,
|
||||
unsupported=False, has_nested=False)
|
||||
@@ -342,6 +342,8 @@ class TrackTableView(QTableView):
|
||||
super().__init__(parent)
|
||||
self._playlist_mode = playlist_mode
|
||||
self._source_playlist_id = ""
|
||||
# False for smart playlists: blocks manual add/remove/reorder/cut/paste.
|
||||
self._content_editable = True
|
||||
|
||||
self.model_ = TrackTableModel(self, with_index_column=playlist_mode)
|
||||
self.proxy = QSortFilterProxyModel(self)
|
||||
@@ -411,6 +413,12 @@ class TrackTableView(QTableView):
|
||||
def set_source_playlist(self, pid: str):
|
||||
self._source_playlist_id = pid
|
||||
|
||||
def set_content_editable(self, editable: bool):
|
||||
"""Smart playlists pass False: dropping, reordering, cut/paste and
|
||||
remove are all disabled (membership is derived from criteria)."""
|
||||
self._content_editable = editable
|
||||
self.setAcceptDrops(editable)
|
||||
|
||||
def context_id(self) -> str:
|
||||
"""Identifies what this table is currently showing, so the now-playing
|
||||
icon can be scoped to the context playback started from."""
|
||||
@@ -515,7 +523,7 @@ class TrackTableView(QTableView):
|
||||
rows = self.selected_source_rows()
|
||||
QApplication.clipboard().setMimeData(
|
||||
make_tracks_mime(ids, self._source_playlist_id, rows))
|
||||
if self._playlist_mode and self._source_playlist_id:
|
||||
if self._playlist_mode and self._source_playlist_id and self._content_editable:
|
||||
self.cut_requested.emit(rows)
|
||||
|
||||
def paste_anchor_row(self):
|
||||
@@ -571,7 +579,7 @@ class TrackTableView(QTableView):
|
||||
copy_action = menu.addAction("Copy\tCtrl+C")
|
||||
cut_action = menu.addAction("Cut\tCtrl+X")
|
||||
paste_action = None
|
||||
if (self._playlist_mode
|
||||
if (self._playlist_mode and self._content_editable
|
||||
and parse_tracks_mime(QApplication.clipboard().mimeData())):
|
||||
paste_action = menu.addAction("Paste\tCtrl+V")
|
||||
|
||||
@@ -596,7 +604,7 @@ class TrackTableView(QTableView):
|
||||
show_in_menu.addAction(name).setData(pid)
|
||||
|
||||
remove_action = None
|
||||
if self._playlist_mode:
|
||||
if self._playlist_mode and self._content_editable:
|
||||
menu.addSeparator()
|
||||
remove_action = menu.addAction("Remove from Playlist\tDel")
|
||||
chosen = menu.exec(self.viewport().mapToGlobal(pos))
|
||||
@@ -630,7 +638,8 @@ class TrackTableView(QTableView):
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if (event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace)
|
||||
and self._playlist_mode and self.selected_source_rows()):
|
||||
and self._playlist_mode and self._content_editable
|
||||
and self.selected_source_rows()):
|
||||
self.remove_requested.emit(self.selected_source_rows())
|
||||
return
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
@@ -704,6 +713,10 @@ class TrackTableView(QTableView):
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
if not self._content_editable: # smart playlist: no drops
|
||||
event.ignore()
|
||||
self._set_drop_indicator(None)
|
||||
return
|
||||
drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint()))
|
||||
mime = event.mimeData()
|
||||
accepted = False
|
||||
@@ -735,6 +748,8 @@ class TrackTableView(QTableView):
|
||||
super().dragLeaveEvent(event)
|
||||
|
||||
def dropEvent(self, event):
|
||||
if not self._content_editable: # smart playlist: no drops
|
||||
return
|
||||
self._set_drop_indicator(None)
|
||||
self._stop_autoscroll()
|
||||
mime = event.mimeData()
|
||||
|
||||
Reference in New Issue
Block a user