Files
8-cut/docs/plans/2026-04-06-playlist-db-implementation.md
T
Ethanfel 6573fa6e05 feat: mpv Wayland embedding, timeline redesign, UX polish
mpv embedding:
- Replace wid/QOpenGLWidget with QOffscreenSurface + QOpenGLFramebufferObject
  + QPainter readback — works on Wayland/KDE without sub-surface compositing
- Force desktop OpenGL 3.3 core profile before QApplication (fixes black output on GLES)
- Timer-based render polling (16 ms) replaces signal-flood from mpv C thread;
  fixes playback animation and scrubbing preview
- Fix AB-loop: set ab-loop-a/b to "no" on stop (0 means infinite in mpv)

Timeline:
- Full redesign: time ruler with adaptive major/minor ticks, playhead triangle
  handle, selection region with edge lines, numbered marker badges
- Height 160 px; layout collapsed from 4 rows to 2 below timeline
- Markers appear immediately on export (optimistic update before ffmpeg finishes)
- Right-click marker → context menu to delete from DB

Hotkeys:
- Replace keyPressEvent with QShortcut(ApplicationShortcut) so keys work
  regardless of focused widget; MpvWidget gets NoFocus policy

Export:
- WebP: switch lossless→lossy quality 85, compression_level 1 (~10x faster)
- Add -threads 0 for full CPU utilisation during decode/filter
- Remember last export folder across sessions via QSettings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:22:58 +02:00

495 lines
15 KiB
Markdown

# Playlist & Processed-Files Database Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a file queue (playlist) that auto-advances after each export, and a SQLite database that warns when a newly loaded file is fuzzy-similar to one already processed.
**Architecture:** `ProcessedDB` wraps `sqlite3` with fuzzy matching via `difflib.SequenceMatcher` on normalized filenames (resolution/quality tags stripped). `PlaylistWidget` is a `QListWidget` subclass that owns the queue and emits `file_selected` on advance or click. `MainWindow` is wired to use both.
**Tech Stack:** Python built-ins only — `sqlite3`, `difflib`, `re`, `datetime`. No new dependencies.
---
### Task 1: `_normalize_filename` and `ProcessedDB` (TDD)
**Files:**
- Modify: `main.py` — add `_normalize_filename`, `ProcessedDB`
- Modify: `tests/test_utils.py` — add DB and normalization tests
**Step 1: Add imports at top of main.py**
Add to the existing imports:
```python
import re
import sqlite3
from datetime import datetime
from difflib import SequenceMatcher
```
**Step 2: Write failing tests**
Add to `tests/test_utils.py`:
```python
import tempfile, os
from main import _normalize_filename, ProcessedDB
# --- _normalize_filename ---
def test_normalize_strips_extension():
assert _normalize_filename("clip.mp4") == "clip"
def test_normalize_strips_resolution():
assert _normalize_filename("clip_2160p.mp4") == "clip"
def test_normalize_strips_1080p():
assert _normalize_filename("clip_1080p.mkv") == "clip"
def test_normalize_strips_multiple_tags():
assert _normalize_filename("show_1080p_HDR.mkv") == "show"
def test_normalize_lowercases():
assert _normalize_filename("MyVideo_4K.mp4") == "myvideo"
def test_normalize_collapses_separators():
assert _normalize_filename("my__video--2160p.mp4") == "my_video"
# --- ProcessedDB ---
def test_db_add_and_find_exact():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4")
assert db.find_similar("video.mp4") == "video.mp4"
finally:
os.unlink(path)
def test_db_find_similar_resolution_variant():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("episode_s01e01_2160p.mkv")
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
finally:
os.unlink(path)
def test_db_find_similar_no_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("alpha.mp4")
assert db.find_similar("completely_different_zzzz.mp4") is None
finally:
os.unlink(path)
def test_db_disabled_survives_bad_path():
db = ProcessedDB("/no/such/directory/8cut.db")
db.add("x.mp4") # must not raise
assert db.find_similar("x.mp4") is None # gracefully returns None
```
**Step 3: Run tests to verify they fail**
```bash
pytest tests/test_utils.py -k "normalize or db" -v
```
Expected: ImportError — functions not defined yet.
**Step 4: Add `_normalize_filename` to main.py**
Add after the existing imports, before `build_export_path`:
```python
def _normalize_filename(filename: str) -> str:
"""Strip extension and common resolution/quality tags for fuzzy comparison."""
name = os.path.splitext(filename)[0].lower()
name = re.sub(
r'\b(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?'
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc'
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)\b',
'', name, flags=re.IGNORECASE,
)
name = re.sub(r'[\s_\-\.]+', '_', name).strip('_')
return name
```
**Step 5: Add `ProcessedDB` to main.py**
Add after `_normalize_filename`:
```python
class ProcessedDB:
def __init__(self, db_path: str | None = None):
if db_path is None:
db_path = str(Path.home() / ".8cut.db")
try:
self._con = sqlite3.connect(db_path)
self._con.execute(
"CREATE TABLE IF NOT EXISTS processed "
"(filename TEXT NOT NULL, processed_at TEXT NOT NULL)"
)
self._con.commit()
self._enabled = True
except Exception as e:
print(f"8-cut: DB unavailable: {e}", file=sys.stderr)
self._con = None
self._enabled = False
def add(self, filename: str) -> None:
if not self._enabled:
return
self._con.execute(
"INSERT INTO processed (filename, processed_at) VALUES (?, ?)",
(filename, datetime.utcnow().isoformat()),
)
self._con.commit()
def find_similar(self, filename: str) -> str | None:
if not self._enabled:
return None
rows = self._con.execute(
"SELECT DISTINCT filename FROM processed"
).fetchall()
norm_new = _normalize_filename(filename)
best_ratio, best_match = 0.0, None
for (stored,) in rows:
ratio = SequenceMatcher(
None, norm_new, _normalize_filename(stored)
).ratio()
if ratio >= 0.75 and ratio > best_ratio:
best_ratio, best_match = ratio, stored
return best_match
```
**Step 6: Run tests to verify they pass**
```bash
pytest tests/test_utils.py -k "normalize or db" -v
```
Expected: all 10 new tests PASS (plus existing 8 = 18 total).
**Step 7: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: ProcessedDB and _normalize_filename with tests"
```
---
### Task 2: `PlaylistWidget`
**Files:**
- Modify: `main.py` — add `PlaylistWidget` class
**Step 1: Add PlaylistWidget before MainWindow**
Add after `MpvWidget`, before `main()`:
```python
class PlaylistWidget(QListWidget):
file_selected = pyqtSignal(str) # emits full path of selected file
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
self.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly)
self.setFixedWidth(200)
self.setWordWrap(True)
self._paths: list[str] = []
self.itemClicked.connect(self._on_item_clicked)
def add_files(self, paths: list[str]) -> None:
"""Append paths not already in queue; auto-select first if queue was empty."""
was_empty = len(self._paths) == 0
for path in paths:
if path not in self._paths and os.path.isfile(path):
self._paths.append(path)
self.addItem(os.path.basename(path))
if was_empty and self._paths:
self._select(0)
def advance(self) -> None:
"""Move to next item in queue. Does nothing if at end."""
row = self.currentRow()
if row < self.count() - 1:
self._select(row + 1)
def current_path(self) -> str | None:
row = self.currentRow()
return self._paths[row] if 0 <= row < len(self._paths) else None
def _select(self, row: int) -> None:
self.setCurrentRow(row)
self._refresh_labels()
self.file_selected.emit(self._paths[row])
def _refresh_labels(self) -> None:
current = self.currentRow()
for i in range(self.count()):
name = os.path.basename(self._paths[i])
self.item(i).setText(f"{name}" if i == current else name)
def _on_item_clicked(self, item: QListWidgetItem) -> None:
self._select(self.row(item))
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent) -> None:
paths = [
u.toLocalFile() for u in event.mimeData().urls()
if os.path.isfile(u.toLocalFile())
]
if paths:
self.add_files(paths)
```
**Step 2: Add missing imports**
`QListWidget`, `QListWidgetItem`, and `QAbstractItemView` need to be in the QtWidgets import line. Check which are missing and add them. The updated import line should be:
```python
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter,
)
```
(`QSplitter` is added now for use in Task 3.)
**Step 3: Verify headless import**
```bash
python -c "from main import PlaylistWidget"
```
Expected: no output.
**Step 4: Run all tests**
```bash
pytest tests/ -v
```
Expected: all 18 tests pass.
**Step 5: Commit**
```bash
git add main.py
git commit -m "feat: PlaylistWidget with drop support and auto-advance"
```
---
### Task 3: Wire MainWindow
**Files:**
- Modify: `main.py` — update `MainWindow.__init__`, `_load_file`, `_after_load`, `_on_export_done`, layout
**Step 1: Update MainWindow.__init__**
Replace the `MainWindow.__init__` method entirely with the version below. Key changes:
- Add `self._db = ProcessedDB()` and `self._playlist = PlaylistWidget()`
- Connect `_playlist.file_selected` to `_load_file`
- Change the root layout to a horizontal split: playlist on left, existing content on right
- Remove `self.setAcceptDrops(True)` from MainWindow (playlist handles drops now)
```python
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.resize(1100, 680)
# Services
self._db = ProcessedDB()
# State
self._file_path: str = ""
self._cursor: float = 0.0
self._export_counter: int = 1
self._export_worker: ExportWorker | None = None
# Widgets
self._playlist = PlaylistWidget()
self._playlist.file_selected.connect(self._load_file)
self._mpv = MpvWidget()
self._mpv.file_loaded.connect(self._after_load)
self._timeline = TimelineWidget()
self._timeline.cursor_changed.connect(self._on_cursor_changed)
self._lbl_file = QLabel("Drop files onto the queue →")
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
self._btn_play = QPushButton("▶ Play 8s")
self._btn_play.setEnabled(False)
self._btn_play.clicked.connect(self._on_play)
self._btn_pause = QPushButton("⏸ Pause")
self._btn_pause.setEnabled(False)
self._btn_pause.clicked.connect(self._on_pause)
self._lbl_cursor = QLabel("cursor: --")
self._lbl_duration = QLabel("dur: --")
self._txt_name = QLineEdit("clip")
self._txt_name.setPlaceholderText("base name")
self._txt_name.setMaximumWidth(150)
self._txt_name.textChanged.connect(self._reset_counter)
self._txt_folder = QLineEdit(str(Path.home()))
self._txt_folder.textChanged.connect(self._reset_counter)
self._btn_folder = QPushButton("Browse")
self._btn_folder.clicked.connect(self._pick_folder)
self._lbl_next = QLabel()
self._update_next_label()
self._btn_export = QPushButton("Export")
self._btn_export.setEnabled(False)
self._btn_export.clicked.connect(self._on_export)
# Right-side layout (video + controls)
top_bar = QHBoxLayout()
top_bar.addWidget(self._lbl_file, stretch=1)
controls = QHBoxLayout()
controls.addWidget(self._btn_play)
controls.addWidget(self._btn_pause)
controls.addStretch()
controls.addWidget(self._lbl_cursor)
controls.addWidget(self._lbl_duration)
export_row = QHBoxLayout()
export_row.addWidget(QLabel("Name:"))
export_row.addWidget(self._txt_name)
export_row.addWidget(QLabel("Folder:"))
export_row.addWidget(self._txt_folder, stretch=1)
export_row.addWidget(self._btn_folder)
export_row.addWidget(self._lbl_next)
export_row.addWidget(self._btn_export)
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.addLayout(top_bar)
right_layout.addWidget(self._mpv, stretch=1)
right_layout.addWidget(self._timeline)
right_layout.addLayout(controls)
right_layout.addLayout(export_row)
# Left: playlist label + list
queue_label = QLabel("Queue")
queue_label.setStyleSheet("color: #aaa; padding: 4px;")
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(4, 4, 4, 4)
left_layout.addWidget(queue_label)
left_layout.addWidget(self._playlist)
# Root: horizontal split
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setSizes([200, 900])
splitter.setCollapsible(0, False)
splitter.setCollapsible(1, False)
self.setCentralWidget(splitter)
self.setStatusBar(QStatusBar())
```
**Step 2: Update `_load_file` — remove isfile guard (PlaylistWidget already filters)**
```python
def _load_file(self, path: str):
self._file_path = path
self._lbl_file.setText(os.path.basename(path))
self._mpv.load(path)
# _after_load triggered by MpvWidget.file_loaded signal
```
**Step 3: Update `_after_load` — add DB similarity check**
```python
def _after_load(self):
dur = self._mpv.get_duration()
self._timeline.set_duration(dur)
self._cursor = 0.0
self._lbl_duration.setText(f"dur: {format_time(dur)}")
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True)
match = self._db.find_similar(os.path.basename(self._file_path))
if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
```
**Step 4: Update `_on_export_done` — record to DB and advance queue**
```python
def _on_export_done(self, path: str):
self._db.add(os.path.basename(self._file_path))
self._export_counter += 1
self._update_next_label()
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
self._playlist.advance()
```
**Step 5: Remove `dragEnterEvent` and `dropEvent` from MainWindow**
These methods are no longer needed on `MainWindow` — drops go directly to `PlaylistWidget`. Delete both methods.
**Step 6: Verify headless import**
```bash
python -c "from main import MainWindow"
```
Expected: no output.
**Step 7: Run all tests**
```bash
pytest tests/ -v
```
Expected: all 18 tests pass.
**Step 8: Manual smoke test**
```bash
python main.py
```
- Drop one or more video files onto the queue panel → they appear in the list
- First file loads automatically into the player
- Scrub, play, pause — all work as before
- Export → file saved, counter increments, next file in queue loads automatically
- Drop the same file again → `⚠ Similar to already processed:` appears in status bar
**Step 9: Commit**
```bash
git add main.py
git commit -m "feat: wire playlist and DB into MainWindow"
```