feat: session resume, hide exported files, profile-based file hiding
- Resume last session: reload previous playlist files on startup - Hide exported checkbox: filter out files with existing clips - Profile-based hiding: right-click → "Hide in profile" persists via DB - Playlist scrollbar fix: disable updates during batch add - Drop event and profile switch use unified _apply_playlist_filters() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -400,6 +400,13 @@ class ProcessedDB:
|
|||||||
self._con.execute(
|
self._con.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
|
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
|
||||||
)
|
)
|
||||||
|
self._con.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS hidden_files ("
|
||||||
|
" filename TEXT NOT NULL,"
|
||||||
|
" profile TEXT NOT NULL DEFAULT 'default',"
|
||||||
|
" PRIMARY KEY (filename, profile)"
|
||||||
|
")"
|
||||||
|
)
|
||||||
self._con.commit()
|
self._con.commit()
|
||||||
|
|
||||||
def add(self, filename: str, start_time: float, output_path: str,
|
def add(self, filename: str, start_time: float, output_path: str,
|
||||||
@@ -532,6 +539,32 @@ class ProcessedDB:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [r[0] for r in rows]
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
def hide_file(self, filename: str, profile: str = "default") -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
self._con.execute(
|
||||||
|
"INSERT OR IGNORE INTO hidden_files (filename, profile) VALUES (?, ?)",
|
||||||
|
(filename, profile),
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
|
||||||
|
def unhide_file(self, filename: str, profile: str = "default") -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
self._con.execute(
|
||||||
|
"DELETE FROM hidden_files WHERE filename = ? AND profile = ?",
|
||||||
|
(filename, profile),
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
|
||||||
|
def get_hidden_files(self, profile: str = "default") -> set[str]:
|
||||||
|
if not self._enabled:
|
||||||
|
return set()
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT filename FROM hidden_files WHERE profile = ?", (profile,)
|
||||||
|
).fetchall()
|
||||||
|
return {r[0] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
class _DBWorker(QThread):
|
class _DBWorker(QThread):
|
||||||
"""Runs ProcessedDB fuzzy-match lookup off the main thread."""
|
"""Runs ProcessedDB fuzzy-match lookup off the main thread."""
|
||||||
@@ -1362,23 +1395,29 @@ class PlaylistWidget(QListWidget):
|
|||||||
self.setTextElideMode(Qt.TextElideMode.ElideMiddle)
|
self.setTextElideMode(Qt.TextElideMode.ElideMiddle)
|
||||||
self._paths: list[str] = []
|
self._paths: list[str] = []
|
||||||
self._path_set: set[str] = set() # O(1) duplicate check
|
self._path_set: set[str] = set() # O(1) duplicate check
|
||||||
|
self._done_set: set[str] = set() # paths with exported clips
|
||||||
|
self._hide_exported = False
|
||||||
self.itemClicked.connect(self._on_item_clicked)
|
self.itemClicked.connect(self._on_item_clicked)
|
||||||
|
|
||||||
def add_files(self, paths: list[str]) -> None:
|
def add_files(self, paths: list[str]) -> None:
|
||||||
"""Append paths not already in queue; auto-select first if queue was empty."""
|
"""Append paths not already in queue; auto-select first if queue was empty."""
|
||||||
was_empty = len(self._paths) == 0
|
was_empty = len(self._paths) == 0
|
||||||
|
self.setUpdatesEnabled(False)
|
||||||
for path in paths:
|
for path in paths:
|
||||||
if path not in self._path_set and os.path.isfile(path):
|
if path not in self._path_set and os.path.isfile(path):
|
||||||
self._paths.append(path)
|
self._paths.append(path)
|
||||||
self._path_set.add(path)
|
self._path_set.add(path)
|
||||||
self.addItem(os.path.basename(path))
|
self.addItem(os.path.basename(path))
|
||||||
|
self.setUpdatesEnabled(True)
|
||||||
if was_empty and self._paths:
|
if was_empty and self._paths:
|
||||||
self._select(0)
|
self._select(0)
|
||||||
|
self.scrollToItem(self.item(0))
|
||||||
|
|
||||||
def mark_done(self, path: str, n_clips: int = 0) -> None:
|
def mark_done(self, path: str, n_clips: int = 0) -> None:
|
||||||
"""Gray out and show clip count on the queue item for path."""
|
"""Gray out and show clip count on the queue item for path."""
|
||||||
if path not in self._path_set:
|
if path not in self._path_set:
|
||||||
return
|
return
|
||||||
|
self._done_set.add(path)
|
||||||
row = self._paths.index(path)
|
row = self._paths.index(path)
|
||||||
item = self.item(row)
|
item = self.item(row)
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -1387,23 +1426,46 @@ class PlaylistWidget(QListWidget):
|
|||||||
tag = f"[{n_clips}]" if n_clips else "✓"
|
tag = f"[{n_clips}]" if n_clips else "✓"
|
||||||
item.setText(f"{tag} {name}")
|
item.setText(f"{tag} {name}")
|
||||||
item.setForeground(QColor(100, 180, 100))
|
item.setForeground(QColor(100, 180, 100))
|
||||||
|
if self._hide_exported:
|
||||||
|
item.setHidden(True)
|
||||||
|
|
||||||
def unmark_done(self, path: str) -> None:
|
def unmark_done(self, path: str) -> None:
|
||||||
"""Remove the ✓ prefix and restore default color."""
|
"""Remove the done mark and restore default color."""
|
||||||
if path not in self._path_set:
|
if path not in self._path_set:
|
||||||
return
|
return
|
||||||
|
self._done_set.discard(path)
|
||||||
row = self._paths.index(path)
|
row = self._paths.index(path)
|
||||||
item = self.item(row)
|
item = self.item(row)
|
||||||
if item is None:
|
if item is None:
|
||||||
return
|
return
|
||||||
item.setText(os.path.basename(path))
|
item.setText(os.path.basename(path))
|
||||||
item.setForeground(QColor(200, 200, 200))
|
item.setForeground(QColor(200, 200, 200))
|
||||||
|
if self._hide_exported:
|
||||||
|
item.setHidden(False)
|
||||||
|
|
||||||
|
def hide_paths(self, basenames: set[str]) -> None:
|
||||||
|
"""Hide items whose basename is in the set (profile-based hiding)."""
|
||||||
|
for i, path in enumerate(self._paths):
|
||||||
|
if os.path.basename(path) in basenames:
|
||||||
|
item = self.item(i)
|
||||||
|
if item:
|
||||||
|
item.setHidden(True)
|
||||||
|
|
||||||
|
def set_hide_exported(self, hide: bool) -> None:
|
||||||
|
self._hide_exported = hide
|
||||||
|
for i, path in enumerate(self._paths):
|
||||||
|
item = self.item(i)
|
||||||
|
if item:
|
||||||
|
item.setHidden(hide and path in self._done_set)
|
||||||
|
|
||||||
def advance(self) -> None:
|
def advance(self) -> None:
|
||||||
"""Move to next item in queue. Does nothing if at end or nothing selected."""
|
"""Move to next visible item in queue."""
|
||||||
row = self.currentRow()
|
row = self.currentRow()
|
||||||
if row >= 0 and row < self.count() - 1:
|
for r in range(row + 1, self.count()):
|
||||||
self._select(row + 1)
|
item = self.item(r)
|
||||||
|
if item and not item.isHidden():
|
||||||
|
self._select(r)
|
||||||
|
return
|
||||||
|
|
||||||
def current_path(self) -> str | None:
|
def current_path(self) -> str | None:
|
||||||
row = self.currentRow()
|
row = self.currentRow()
|
||||||
@@ -1416,8 +1478,15 @@ class PlaylistWidget(QListWidget):
|
|||||||
self._refresh_item_text(prev)
|
self._refresh_item_text(prev)
|
||||||
if self.item(row):
|
if self.item(row):
|
||||||
item = self.item(row)
|
item = self.item(row)
|
||||||
prefix = "✓ " if item.foreground().color() == QColor(100, 180, 100) else ""
|
cur = item.text()
|
||||||
item.setText(f"▶ {prefix}{os.path.basename(self._paths[row])}")
|
# Preserve [N] tag from mark_done.
|
||||||
|
if cur.startswith("[") and "] " in cur:
|
||||||
|
tag = cur[:cur.index("] ") + 2]
|
||||||
|
elif item.foreground().color() == QColor(100, 180, 100):
|
||||||
|
tag = "✓ "
|
||||||
|
else:
|
||||||
|
tag = ""
|
||||||
|
item.setText(f"▶ {tag}{os.path.basename(self._paths[row])}")
|
||||||
self.file_selected.emit(self._paths[row])
|
self.file_selected.emit(self._paths[row])
|
||||||
|
|
||||||
def _refresh_item_text(self, row: int) -> None:
|
def _refresh_item_text(self, row: int) -> None:
|
||||||
@@ -1425,7 +1494,12 @@ class PlaylistWidget(QListWidget):
|
|||||||
if item is None:
|
if item is None:
|
||||||
return
|
return
|
||||||
name = os.path.basename(self._paths[row])
|
name = os.path.basename(self._paths[row])
|
||||||
if item.foreground().color() == QColor(100, 180, 100):
|
# Preserve the [N] prefix from mark_done if present.
|
||||||
|
cur = item.text()
|
||||||
|
if cur.startswith("[") and "] " in cur:
|
||||||
|
prefix = cur[:cur.index("] ") + 2]
|
||||||
|
item.setText(f"{prefix}{name}")
|
||||||
|
elif item.foreground().color() == QColor(100, 180, 100):
|
||||||
item.setText(f"✓ {name}")
|
item.setText(f"✓ {name}")
|
||||||
else:
|
else:
|
||||||
item.setText(name)
|
item.setText(name)
|
||||||
@@ -1433,6 +1507,8 @@ class PlaylistWidget(QListWidget):
|
|||||||
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
||||||
self._select(self.row(item))
|
self._select(self.row(item))
|
||||||
|
|
||||||
|
hide_requested = pyqtSignal(str) # emits full path to hide in current profile
|
||||||
|
|
||||||
def contextMenuEvent(self, event) -> None:
|
def contextMenuEvent(self, event) -> None:
|
||||||
item = self.itemAt(event.pos())
|
item = self.itemAt(event.pos())
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -1440,11 +1516,20 @@ class PlaylistWidget(QListWidget):
|
|||||||
row = self.row(item)
|
row = self.row(item)
|
||||||
from PyQt6.QtWidgets import QMenu
|
from PyQt6.QtWidgets import QMenu
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
action = menu.addAction(f"Remove: {os.path.basename(self._paths[row])}")
|
name = os.path.basename(self._paths[row])
|
||||||
if menu.exec(event.globalPos()) == action:
|
act_remove = menu.addAction(f"Remove: {name}")
|
||||||
|
act_hide = menu.addAction(f"Hide in profile: {name}")
|
||||||
|
chosen = menu.exec(event.globalPos())
|
||||||
|
if chosen == act_remove:
|
||||||
path = self._paths.pop(row)
|
path = self._paths.pop(row)
|
||||||
self._path_set.discard(path)
|
self._path_set.discard(path)
|
||||||
|
self._done_set.discard(path)
|
||||||
self.takeItem(row)
|
self.takeItem(row)
|
||||||
|
elif chosen == act_hide:
|
||||||
|
path = self._paths[row]
|
||||||
|
self.hide_requested.emit(path)
|
||||||
|
# Visually hide the item immediately.
|
||||||
|
item.setHidden(True)
|
||||||
|
|
||||||
|
|
||||||
class _KeyFilter(QObject):
|
class _KeyFilter(QObject):
|
||||||
@@ -1527,6 +1612,7 @@ class MainWindow(QMainWindow):
|
|||||||
# Widgets
|
# Widgets
|
||||||
self._playlist = PlaylistWidget()
|
self._playlist = PlaylistWidget()
|
||||||
self._playlist.file_selected.connect(self._load_file)
|
self._playlist.file_selected.connect(self._load_file)
|
||||||
|
self._playlist.hide_requested.connect(self._on_hide_file)
|
||||||
|
|
||||||
self._mpv = MpvWidget()
|
self._mpv = MpvWidget()
|
||||||
self._mpv.file_loaded.connect(self._after_load)
|
self._mpv.file_loaded.connect(self._after_load)
|
||||||
@@ -1847,10 +1933,21 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_open = QPushButton("+ Open Files")
|
self._btn_open = QPushButton("+ Open Files")
|
||||||
self._btn_open.setToolTip("Add video files to the queue")
|
self._btn_open.setToolTip("Add video files to the queue")
|
||||||
self._btn_open.clicked.connect(self._on_open_files)
|
self._btn_open.clicked.connect(self._on_open_files)
|
||||||
|
|
||||||
|
self._chk_hide_exported = QCheckBox("Hide exported")
|
||||||
|
self._chk_hide_exported.setToolTip("Hide files that already have exported clips")
|
||||||
|
self._chk_hide_exported.setChecked(
|
||||||
|
self._settings.value("hide_exported", "false") == "true"
|
||||||
|
)
|
||||||
|
self._chk_hide_exported.toggled.connect(self._on_hide_exported_toggled)
|
||||||
|
|
||||||
left = QWidget()
|
left = QWidget()
|
||||||
left_layout = QVBoxLayout(left)
|
left_layout = QVBoxLayout(left)
|
||||||
left_layout.setContentsMargins(4, 4, 4, 4)
|
left_layout.setContentsMargins(4, 4, 4, 4)
|
||||||
left_layout.addWidget(self._btn_open)
|
left_top = QHBoxLayout()
|
||||||
|
left_top.addWidget(self._btn_open)
|
||||||
|
left_top.addWidget(self._chk_hide_exported)
|
||||||
|
left_layout.addLayout(left_top)
|
||||||
left_layout.addWidget(self._playlist)
|
left_layout.addWidget(self._playlist)
|
||||||
|
|
||||||
# Root: horizontal splitter
|
# Root: horizontal splitter
|
||||||
@@ -1899,6 +1996,15 @@ class MainWindow(QMainWindow):
|
|||||||
for key in ("?", "F1"):
|
for key in ("?", "F1"):
|
||||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
||||||
|
|
||||||
|
# Resume last session: reload previous playlist files.
|
||||||
|
session_files = self._settings.value("session_files", [])
|
||||||
|
if session_files:
|
||||||
|
valid = [p for p in session_files if os.path.isfile(p)]
|
||||||
|
if valid:
|
||||||
|
self._playlist.add_files(valid)
|
||||||
|
self._apply_playlist_filters()
|
||||||
|
_log(f"Resumed session: {len(valid)} file(s)")
|
||||||
|
|
||||||
def _show_shortcuts(self) -> None:
|
def _show_shortcuts(self) -> None:
|
||||||
text = (
|
text = (
|
||||||
"<table cellpadding='4' style='font-size:13px'>"
|
"<table cellpadding='4' style='font-size:13px'>"
|
||||||
@@ -1975,12 +2081,30 @@ class MainWindow(QMainWindow):
|
|||||||
if not self._last_export_path:
|
if not self._last_export_path:
|
||||||
self._btn_delete.setEnabled(False)
|
self._btn_delete.setEnabled(False)
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
self._refresh_playlist_checks()
|
self._apply_playlist_filters()
|
||||||
if self._file_path:
|
if self._file_path:
|
||||||
self._refresh_markers()
|
self._refresh_markers()
|
||||||
_log(f"Profile switched: {text}")
|
_log(f"Profile switched: {text}")
|
||||||
self.statusBar().showMessage(f"Profile: {text}", 3000)
|
self.statusBar().showMessage(f"Profile: {text}", 3000)
|
||||||
|
|
||||||
|
def _on_hide_exported_toggled(self, hide: bool) -> None:
|
||||||
|
self._settings.setValue("hide_exported", "true" if hide else "false")
|
||||||
|
self._playlist.set_hide_exported(hide)
|
||||||
|
|
||||||
|
def _on_hide_file(self, path: str) -> None:
|
||||||
|
"""Persistently hide a file in the current profile."""
|
||||||
|
self._db.hide_file(os.path.basename(path), self._profile)
|
||||||
|
_log(f"Hidden file: {os.path.basename(path)} in profile {self._profile}")
|
||||||
|
|
||||||
|
def _apply_playlist_filters(self) -> None:
|
||||||
|
"""Apply profile-hidden files, export marks, and hide-exported filter."""
|
||||||
|
self._refresh_playlist_checks()
|
||||||
|
hidden = self._db.get_hidden_files(self._profile)
|
||||||
|
if hidden:
|
||||||
|
self._playlist.hide_paths(hidden)
|
||||||
|
if self._chk_hide_exported.isChecked():
|
||||||
|
self._playlist.set_hide_exported(True)
|
||||||
|
|
||||||
def _on_open_files(self) -> None:
|
def _on_open_files(self) -> None:
|
||||||
paths, _ = QFileDialog.getOpenFileNames(
|
paths, _ = QFileDialog.getOpenFileNames(
|
||||||
self, "Open video files", "",
|
self, "Open video files", "",
|
||||||
@@ -1988,10 +2112,7 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
if paths:
|
if paths:
|
||||||
self._playlist.add_files(paths)
|
self._playlist.add_files(paths)
|
||||||
for p in paths:
|
self._apply_playlist_filters()
|
||||||
markers = self._db.get_markers(os.path.basename(p), self._profile)
|
|
||||||
if markers:
|
|
||||||
self._playlist.mark_done(p, len(markers))
|
|
||||||
|
|
||||||
def _load_file(self, path: str):
|
def _load_file(self, path: str):
|
||||||
self._file_path = path
|
self._file_path = path
|
||||||
@@ -2593,6 +2714,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
_log("Shutting down…")
|
_log("Shutting down…")
|
||||||
|
# Save session playlist for resume.
|
||||||
|
self._settings.setValue("session_files", self._playlist._paths)
|
||||||
# Stop timers first to prevent callbacks into dead objects.
|
# Stop timers first to prevent callbacks into dead objects.
|
||||||
self._preview_timer.stop()
|
self._preview_timer.stop()
|
||||||
self._mpv._render_timer.stop()
|
self._mpv._render_timer.stop()
|
||||||
@@ -2636,10 +2759,7 @@ class MainWindow(QMainWindow):
|
|||||||
]
|
]
|
||||||
if paths:
|
if paths:
|
||||||
self._playlist.add_files(paths)
|
self._playlist.add_files(paths)
|
||||||
for p in paths:
|
self._apply_playlist_filters()
|
||||||
markers = self._db.get_markers(os.path.basename(p), self._profile)
|
|
||||||
if markers:
|
|
||||||
self._playlist.mark_done(p, len(markers))
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user