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:
2026-07-01 16:43:39 -04:00
parent 1274e12aeb
commit 2917a42b75
18 changed files with 2856 additions and 30 deletions
+2
View File
@@ -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()
+15 -7
View File
@@ -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
View File
@@ -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")
+423
View File
@@ -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)
+19 -4
View File
@@ -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()