From b69d1a4408a258bcef590e52f913ec14bdc008d6 Mon Sep 17 00:00:00 2001 From: "derek.niles" Date: Sun, 29 Mar 2026 15:04:15 -0400 Subject: [PATCH] Initial commit --- .gitignore | 55 ++ CLAUDE.md | 68 ++ MovieTagger.spec | 40 + assets/app.ico | Bin 0 -> 77572 bytes main.py | 4 + pyproject.toml | 25 + src/movietagger/__init__.py | 1 + src/movietagger/__main__.py | 4 + src/movietagger/app.py | 26 + src/movietagger/core/__init__.py | 6 + src/movietagger/core/config.py | 100 +++ src/movietagger/core/datatypes.py | 64 ++ src/movietagger/core/naming.py | 95 ++ src/movietagger/core/ratelimiter.py | 21 + src/movietagger/qt/__init__.py | 1 + src/movietagger/qt/config_dialog.py | 116 +++ src/movietagger/qt/delegates.py | 72 ++ src/movietagger/qt/dialogs.py | 178 ++++ src/movietagger/qt/main_window.py | 841 ++++++++++++++++++ src/movietagger/qt/models.py | 199 +++++ src/movietagger/qt/proxy.py | 96 ++ src/movietagger/qt/workers.py | 0 src/movietagger/qt/workers/__init__.py | 7 + .../qt/workers/batch_scan_worker.py | 119 +++ .../qt/workers/imdb_search_worker.py | 25 + src/movietagger/qt/workers/load_worker.py | 153 ++++ src/movietagger/qt/workers/save_worker.py | 66 ++ .../qt/workers/tmdb_search_worker.py | 28 + src/movietagger/services/__init__.py | 1 + src/movietagger/services/imdb_service.py | 48 + src/movietagger/services/tmdb_service.py | 61 ++ 31 files changed, 2520 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 MovieTagger.spec create mode 100644 assets/app.ico create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 src/movietagger/__init__.py create mode 100644 src/movietagger/__main__.py create mode 100644 src/movietagger/app.py create mode 100644 src/movietagger/core/__init__.py create mode 100644 src/movietagger/core/config.py create mode 100644 src/movietagger/core/datatypes.py create mode 100644 src/movietagger/core/naming.py create mode 100644 src/movietagger/core/ratelimiter.py create mode 100644 src/movietagger/qt/__init__.py create mode 100644 src/movietagger/qt/config_dialog.py create mode 100644 src/movietagger/qt/delegates.py create mode 100644 src/movietagger/qt/dialogs.py create mode 100644 src/movietagger/qt/main_window.py create mode 100644 src/movietagger/qt/models.py create mode 100644 src/movietagger/qt/proxy.py create mode 100644 src/movietagger/qt/workers.py create mode 100644 src/movietagger/qt/workers/__init__.py create mode 100644 src/movietagger/qt/workers/batch_scan_worker.py create mode 100644 src/movietagger/qt/workers/imdb_search_worker.py create mode 100644 src/movietagger/qt/workers/load_worker.py create mode 100644 src/movietagger/qt/workers/save_worker.py create mode 100644 src/movietagger/qt/workers/tmdb_search_worker.py create mode 100644 src/movietagger/services/__init__.py create mode 100644 src/movietagger/services/imdb_service.py create mode 100644 src/movietagger/services/tmdb_service.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e50f501 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg +*.egg-info/ +dist/ +build/ +eggs/ +parts/ +var/ +sdist/ +wheels/ +*.egg-link +.installed.cfg +lib/ +lib64/ +pip-wheel-metadata/ +share/python-wheels/ +.Python +env/ +venv/ +.venv/ +ENV/ +.env + +# PyInstaller +*.spec.bak +dist/ +build/ + +# PyCharm +.idea/ +*.iml +*.iws +*.ipr +out/ + +# Vim +*.swp +*.swo +*~ +.netrwhist +tags + +# OS +.DS_Store +Thumbs.db + +# Local runner (contains credentials) +run.bat + +# Claude session files +claude_resume diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..04c7c94 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +MovieTagger is a PySide6/Qt desktop application for managing and tagging movie video files. It parses filenames, queries the TMDB API for metadata, and renames files to a standardized format. + +## Commands + +```bash +# Install dependencies (including dev tools) +pip install -e ".[dev]" + +# Run in development +python -m movietagger +# or +python main.py + +# Build standalone executable (PyInstaller) +pyinstaller MovieTagger.spec +``` + +**Required environment variables** (set in `~/.config/MovieTagger/config.ini` or shell): +- `TMDB_KEY` — TMDB API key +- `TMDB_LANGUAGE` — e.g. `en-US` +- `TMDB_REGION` — e.g. `US` + +There are no tests or linting configurations. + +## Architecture + +### Layer Structure + +``` +core/ — Pure business logic (no Qt imports) +services/ — External API clients +qt/ — All UI and threading (Qt-dependent) +qt/workers/ — Background QThread workers +``` + +### Data Flow + +1. **Load**: `LoadWorker` scans directories, calls `naming.parse_video_stem()` to extract `MovieRow` objects (title, year, tags, existing TMDB/IMDB IDs). +2. **Display**: `MovieTableModel` + `MovieProxyModel` hold and filter the `MovieRow` list in the main table view. +3. **Search**: `TmdbSearchWorker` (or `BatchScanWorker` for bulk) calls `TmdbService`, returns `MoviePick` objects. The user selects a pick via `MoviePickDialog`, which updates the row's IDs. +4. **Save**: `SaveWorker` calls `naming.build_video_stem()` on each row to construct the new filename, then renames files on disk. + +### Key Design Patterns + +- **Threading**: Workers are `QObject` subclasses moved to `QThread`. Communication is strictly via Qt signals/slots. Cancellation uses `threading.Event`. +- **Proxy model**: `MovieProxyModel` wraps `MovieTableModel` — handles both filtering (hide already-matched rows) and token-based title search without modifying the source model. +- **Config**: `core/config.py` reads/writes `~/.config/MovieTagger/config.ini` via `platformdirs`. Settings schema is defined as a `SETTINGS` list of `SettingKind`-typed dicts in `config.py`. +- **Rate limiting**: `RateLimiter` in `core/ratelimiter.py` throttles TMDB API calls across concurrent workers. + +### Filename Format + +Parsed and generated by `core/naming.py`: +``` +Title (YEAR) [tag1] [tag2] {edition-X} {tmdb-12345}.mkv +``` +- Tags are freeform bracketed tokens (e.g. `[Directors Cut]`, `[2160p]`) +- Edition is `{edition-...}` +- Provider IDs are `{tmdb-...}` or `{imdb-...}` + +### IMDB Service + +`services/imdb_service.py` and `qt/workers/imdb_search_worker.py` are stubs — they exist in the UI but return no results. diff --git a/MovieTagger.spec b/MovieTagger.spec new file mode 100644 index 0000000..580662a --- /dev/null +++ b/MovieTagger.spec @@ -0,0 +1,40 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis( + ['main.py'], + pathex=["."], + binaries=[], + datas=[('assets', 'assets')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + name='MovieTagger', + icon=['assets\\app.ico'], + console=True, +) diff --git a/assets/app.ico b/assets/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..48ac1b278f1c3cf88bf4ae9ee57273bf708a16ed GIT binary patch literal 77572 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

-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 mQ6KeU>JtEc)JN2N>Hh($|JdFeHO#620000=s literal 0 HcmV?d00001 diff --git a/main.py b/main.py new file mode 100644 index 0000000..59f9903 --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +from movietagger.app import main + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2363794 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "movietagger" +version = "0.2.0" +requires-python = ">=3.11" +dependencies = [ + 'PySide6', + 'QtAwesome', + 'platformdirs', + 'themoviedb', +] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[project.optional-dependencies] +dev = [ + 'pyinstaller' +] \ No newline at end of file diff --git a/src/movietagger/__init__.py b/src/movietagger/__init__.py new file mode 100644 index 0000000..eb061bd --- /dev/null +++ b/src/movietagger/__init__.py @@ -0,0 +1 @@ +__all__ = ['app'] diff --git a/src/movietagger/__main__.py b/src/movietagger/__main__.py new file mode 100644 index 0000000..50ba344 --- /dev/null +++ b/src/movietagger/__main__.py @@ -0,0 +1,4 @@ +from movietagger.app import main + +if __name__ == '__main__': + main() diff --git a/src/movietagger/app.py b/src/movietagger/app.py new file mode 100644 index 0000000..7dbb823 --- /dev/null +++ b/src/movietagger/app.py @@ -0,0 +1,26 @@ +import sys +from pathlib import Path + +from PySide6 import QtWidgets, QtGui + +from movietagger.core.config import load_config +from movietagger.qt.main_window import MovieTaggerWindow + + +def resource_path(rel: str) -> str: + base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[2])) + return str(base / rel) + + +def main() -> int: + load_config() + + app = QtWidgets.QApplication([]) + app.setWindowIcon(QtGui.QIcon(resource_path("assets/app.ico"))) + app.setStyle("Fusion") + w = MovieTaggerWindow() + w.resize(1500, 700) + w.show() + app.exec() + + return 0 diff --git a/src/movietagger/core/__init__.py b/src/movietagger/core/__init__.py new file mode 100644 index 0000000..4e325d9 --- /dev/null +++ b/src/movietagger/core/__init__.py @@ -0,0 +1,6 @@ +from . import config +from . import datatypes +from . import naming +from . import ratelimiter + +__all__ = ['config', 'datatypes', 'naming', 'ratelimiter'] diff --git a/src/movietagger/core/config.py b/src/movietagger/core/config.py new file mode 100644 index 0000000..be85db9 --- /dev/null +++ b/src/movietagger/core/config.py @@ -0,0 +1,100 @@ +import configparser +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Sequence, Callable + +import platformdirs + +from movietagger.core.datatypes import SettingKind + +APP_NAME = "MovieTagger" +APP_AUTHOR = "Blue Parity" + +TMDB_REQUIRED_VARS = ("TMDB_KEY", "TMDB_LANGUAGE", "TMDB_REGION") + + +@dataclass(frozen=True) +class SettingSpec: + section: str + key: str + label: str + kind: SettingKind + default: str = "" + required: bool = False + choices: Optional[Sequence[str]] = None + validator: Optional[Callable[[str], Optional[str]]] = None # returns error msg or None + + +SETTINGS = [ + SettingSpec("tmdb", "TMDB_KEY", "TMDB API Key", kind=SettingKind.SECRET, default="", required=True), + SettingSpec("tmdb", "TMDB_LANGUAGE", "TMDB Language (ISO 639-1)", kind=SettingKind.STRING, default="en-US", required=True), + SettingSpec("tmdb", "TMDB_REGION", "TMDB Region (ISO 3166-1)", kind=SettingKind.STRING, default="US", required=True), +] + + +def __get_config_file() -> Path: + return platformdirs.user_config_path(appauthor=APP_AUTHOR, appname=APP_NAME) / "config.ini" + + +def __create_parser() -> configparser.ConfigParser: + config = configparser.ConfigParser() + config.optionxform = str # preserve key casing + return config + + +# def _allowed_map() -> dict[str, set[str]]: +# allowed: dict[str, set[str]] = {} +# for s in SETTINGS: +# allowed.setdefault(s.section, set()).add(s.key) +# return allowed +# +# +# def __reconcile_config() -> bool: +# return True + + +def __create_blank_config_file(file: Path) -> None: + config = configparser.ConfigParser() + config.optionxform = str # preserve key casing + config['env'] = { + "TMDB_KEY": "", + "TMDB_LANGUAGE": "en-US", + "TMDB_REGION": "US" + } + with open(file, 'w') as configfile: + # noinspection PyTypeChecker + config.write(configfile) + + +def load_config(override_env=False) -> None: + config_file = __get_config_file() + + if not config_file.parent.exists(): + config_file.parent.mkdir(parents=True, exist_ok=True) + if not config_file.exists(): + __create_blank_config_file(config_file) + + config = configparser.ConfigParser() + config.optionxform = str + config.read(config_file, encoding='utf-8') + + section = "env" + if section in config: + for key, value in config[section].items(): + if override_env or not os.getenv(key): + os.environ[key] = value + + +def save_config(vars=list[tuple[str, str, str]]): + pass + + +def require_tmdb_env() -> None: + missing = [n for n in TMDB_REQUIRED_VARS if not os.getenv(n)] + if missing: + raise RuntimeError(f"Missing required environment variables: {", ".join(missing)}") + + +def require_imdb_env() -> None: + pass diff --git a/src/movietagger/core/datatypes.py b/src/movietagger/core/datatypes.py new file mode 100644 index 0000000..9f2cfab --- /dev/null +++ b/src/movietagger/core/datatypes.py @@ -0,0 +1,64 @@ +import enum +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +class Provider(enum.Enum): + InvalidProvider = -1 + TMDB = 0x0 + IMDB = 0x1 + +class SettingKind(enum.Enum): + STRING = 0x0 + CHOICE = 0x1 + SECRET = 0x2 + +@dataclass(frozen=True) +class RenameOp: + src_row: int + old_path: Path + new_path: Path + + +@dataclass +class MoviePick: + provider: Provider + id: str + title: str + year: Optional[int] = None + overview: str = "" + poster_url: str = "" + + +@dataclass +class MovieRow: + path: Path + title: str + year: int + tags: list[str] + edition: Optional[str] = None + tmdb_id: Optional[str] = None + imdb_id: Optional[str] = None + valid: bool = True + status: str = "" + original_title: str = "" + canonical_title: str = "" # raw TMDB/IMDB title + original_year: int = 0 + + def title_changed(self) -> bool: + return self.title != self.original_title + + def year_changed(self) -> bool: + return self.year != self.original_year + + def is_matched(self) -> bool: + return bool((self.tmdb_id and self.tmdb_id.strip()) or (self.imdb_id and self.imdb_id.strip())) + + def preferred_id(self) -> tuple[Optional[str], Optional[str]]: + # TMDB overrules IMDB + if self.tmdb_id and self.tmdb_id.strip(): + return "tmdb", self.tmdb_id.strip() + if self.imdb_id and self.imdb_id.strip(): + return "imdb", self.imdb_id.strip() + return None, None diff --git a/src/movietagger/core/naming.py b/src/movietagger/core/naming.py new file mode 100644 index 0000000..690e8d2 --- /dev/null +++ b/src/movietagger/core/naming.py @@ -0,0 +1,95 @@ +import re +from pathlib import Path +from typing import Optional, Sequence + +from movietagger.core.datatypes import MovieRow + +VIDEO_EXTS = {".mkv", ".mp4"} + +FILENAME_TAGS_RE = re.compile( + r"""^ + (?P.+?)\s* + \(\s*(?P\d{4})\s*\) + (?P(?:\s*\[[^]]+])*) # 0+ [tag] + (?:\s*\{edition-(?P[^}]+)})? # optional edition + (?:\s*\{(?Ptmdb|imdb)-(?P[^}]+)})? # optional id + $""", + re.VERBOSE | re.IGNORECASE, +) + +BRACKET_RE = re.compile(r"\[([^]]+)]") +IMDB_ID_RE = re.compile(r"^tt\d{7,10}$", re.IGNORECASE) +TMDB_ID_RE = re.compile(r"^\d+$") + +WINDOWS_ILLEGAL = { + ": ": " - ", + "<": "", + ">": "", + '"': "", + "/": "", + "\\": "", + "|": "", + "?": "", + "*": "", +} + +def clean_tag_list(tags: list[str]) -> list[str]: + # Keep order, trim whitespace, drop empties + out: list[str] = [] + for t in tags: + t = t.strip() + if t: + out.append(t) + return out + + +def _norm_title(s: str) -> str: + # Lowercase, strip punctuation/spaces — used only for title comparison + s = (s or "").casefold() + return re.sub(r"[^a-z0-9]+", "", s) + + +def sanitize_title_for_windows(title: str) -> str: + out = title + for bad, repl in WINDOWS_ILLEGAL.items(): + out = out.replace(bad, repl) + return out.rstrip(" .") + +def is_confident_single_pick(row_title: str, row_year: int | None, pick_title: str, pick_year: int | None) -> bool: + # If we have a year, require exact year match + if row_year and pick_year and pick_year != row_year: + return False + + # Title check: exact normalized match or one contains the other (handles minor punctuation differences) + a = _norm_title(row_title) + b = _norm_title(pick_title) + if not a or not b: + return False + + if a == b: + return True + if a in b or b in a: + return True + + return False + + +def build_video_stem(row: MovieRow) -> str: + """ + Convert row data to filename stem (no extension): + Title (YEAR) [tag]... {edition-...}? {tmdb-...|imdb-...}? + TMDB wins if both are present. + """ + parts = [f"{row.title} ({row.year})"] + + for t in clean_tag_list(row.tags): + parts.append(f"[{t}]") + + if row.edition and row.edition.strip(): + parts.append(f"{{edition-{row.edition.strip()}}}") + + kind, val = row.preferred_id() + if kind and val: + parts.append(f"{{{kind}-{val}}}") + + return " ".join(parts) diff --git a/src/movietagger/core/ratelimiter.py b/src/movietagger/core/ratelimiter.py new file mode 100644 index 0000000..aee76a1 --- /dev/null +++ b/src/movietagger/core/ratelimiter.py @@ -0,0 +1,21 @@ +import time +import threading + +class RateLimiter: + def __init__(self, max_per_sec: float): + self._min_interval = 1.0 / float(max_per_sec) + self._lock = threading.Lock() + self._next_time = 0.0 + + def acquire(self, cancel_event: threading.Event | None = None) -> None: + while True: + if cancel_event and cancel_event.is_set(): + return + with self._lock: + now = time.monotonic() + wait_s = self._next_time - now + if wait_s <= 0: + self._next_time = now + self._min_interval + return + # Sleep outside lock + time.sleep(min(wait_s, 0.25)) diff --git a/src/movietagger/qt/__init__.py b/src/movietagger/qt/__init__.py new file mode 100644 index 0000000..88e503e --- /dev/null +++ b/src/movietagger/qt/__init__.py @@ -0,0 +1 @@ +__all__ = ['main_window'] diff --git a/src/movietagger/qt/config_dialog.py b/src/movietagger/qt/config_dialog.py new file mode 100644 index 0000000..963ca92 --- /dev/null +++ b/src/movietagger/qt/config_dialog.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import configparser +from typing import Any + +from PySide6 import QtWidgets, QtCore + +from movietagger.core.config import SETTINGS, normalize_value, save_config, export_to_env + + +class ConfigDialog(QtWidgets.QDialog): + """ + Schema-driven config editor. Builds a form from movietagger.core.config.SETTINGS. + + On save: + - updates the provided ConfigParser + - writes config.ini + - exports values to os.environ (override existing env) + """ + + configSaved = QtCore.Signal() + + def __init__(self, cfg: configparser.ConfigParser, parent: QtWidgets.QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Configuration") + self.setModal(True) + + self._cfg = cfg + self._widgets: dict[tuple[str, str], Any] = {} + + self._build_ui() + + def _build_ui(self) -> None: + root = QtWidgets.QVBoxLayout(self) + form = QtWidgets.QFormLayout() + form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + root.addLayout(form) + + # Build fields from SETTINGS + for spec in SETTINGS: + section, key = spec.section, spec.key + cur = self._cfg.get(section, key, fallback=spec.default) + + if spec.kind == "choice": + w = QtWidgets.QComboBox() + for c in (spec.choices or []): + w.addItem(str(c), str(c)) + idx = w.findData(cur) + if idx >= 0: + w.setCurrentIndex(idx) + else: + w = QtWidgets.QLineEdit() + w.setText(cur) + if spec.kind == "secret": + w.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password) + w.setPlaceholderText("Paste key here") + else: + w.setPlaceholderText(spec.default) + + self._widgets[(section, key)] = w + form.addRow(spec.label + ":", w) + + # Helper text + hint = QtWidgets.QLabel("TMDB Language examples: en-US, fr-FR, ja-JP") + hint.setWordWrap(True) + hint.setStyleSheet("color: #777;") + root.addWidget(hint) + + # Buttons + btn_row = QtWidgets.QHBoxLayout() + btn_row.addStretch(1) + + self._btn_cancel = QtWidgets.QPushButton("Cancel") + self._btn_save = QtWidgets.QPushButton("Save") + + self._btn_cancel.clicked.connect(self.reject) + self._btn_save.clicked.connect(self._on_save_clicked) + + btn_row.addWidget(self._btn_cancel) + btn_row.addWidget(self._btn_save) + + root.addLayout(btn_row) + + def _on_save_clicked(self) -> None: + # Validate + write back to cfg + for spec in SETTINGS: + section, key = spec.section, spec.key + w = self._widgets[(section, key)] + + if isinstance(w, QtWidgets.QComboBox): + val = str(w.currentData() or "").strip() + else: + val = str(w.text()).strip() + + val = normalize_value(section, key, val) + + if spec.required and not val: + QtWidgets.QMessageBox.warning(self, "Missing value", f"{spec.label} is required.") + return + + if spec.validator: + err = spec.validator(val) + if err: + QtWidgets.QMessageBox.warning(self, "Invalid value", err) + return + + if section not in self._cfg: + self._cfg[section] = {} + self._cfg[section][key] = val + + # Persist + export to env (override) + save_config(self._cfg) + export_to_env(self._cfg, override_env=True) + + self.configSaved.emit() + self.accept() diff --git a/src/movietagger/qt/delegates.py b/src/movietagger/qt/delegates.py new file mode 100644 index 0000000..b841de1 --- /dev/null +++ b/src/movietagger/qt/delegates.py @@ -0,0 +1,72 @@ +import qtawesome as qta +from PySide6 import QtCore, QtWidgets, QtGui + + +class SearchIconDelegate(QtWidgets.QStyledItemDelegate): + """ + Paints a magnifier icon at the far-right of the cell (4px padding). + Clicking the icon emits searchClicked(proxy_row). + + - Keeps default edit/selection behavior unless the icon was clicked. + - Does not create widgets; scales well for large tables. + """ + + searchClicked = QtCore.Signal(int) # proxy row + + def __init__( + self, + parent: QtCore.QObject | None = None, + *, + right_padding: int = 4, + icon_size: QtCore.QSize = QtCore.QSize(16, 16), + ): + super().__init__(parent) + self._right_padding = int(right_padding) + self._icon_size = QtCore.QSize(icon_size) + self._icon = qta.icon("mdi.movie-search") + + def _icon_rect(self, cell_rect: QtCore.QRect) -> QtCore.QRect: + # Always pinned to the far-right with 2px padding (or configured padding). + w = self._icon_size.width() + h = self._icon_size.height() + + x = cell_rect.right() - self._right_padding - w + 1 # +1 because right() is inclusive + y = cell_rect.top() + (cell_rect.height() - h) // 2 + return QtCore.QRect(x, y, w, h) + + def initStyleOption(self, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex) -> None: + super().initStyleOption(option, index) + # Reserve space so text doesn't go under the icon + option.rect = option.rect.adjusted(0, 0, -(self._icon_size.width() + self._right_padding + 4), 0) + + def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex): + # Let the base delegate paint the cell text/selection/etc. + super().paint(painter, option, index) + + # Draw icon on top, at pinned position. + cell_rect = option.widget.visualRect(index) if option.widget else option.rect + icon_rect = self._icon_rect(cell_rect) + + # If cell is too narrow, icon rect might overlap; still draw (you can guard if desired). + mode = QtGui.QIcon.Mode.Selected if ( + option.state & QtWidgets.QStyle.State_Selected) else QtGui.QIcon.Mode.Normal + state = QtGui.QIcon.State.On + self._icon.paint(painter, icon_rect, QtCore.Qt.AlignmentFlag.AlignCenter, mode, state) + + def editorEvent( + self, + event: QtCore.QEvent, + model: QtCore.QAbstractItemModel, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> bool: + # Only intercept mouse release; everything else (double click, keypress) behaves normally. + if event.type() == QtCore.QEvent.Type.MouseButtonRelease: + mouse = event # type: ignore[assignment] + if isinstance(mouse, QtGui.QMouseEvent): + if mouse.button() == QtCore.Qt.MouseButton.LeftButton: + if self._icon_rect(option.rect).contains(mouse.pos()): + # index.row() is a proxy row if the view model is a proxy (typical). + self.searchClicked.emit(index.row()) + return True # event handled; don't start editing + return super().editorEvent(event, model, option, index) diff --git a/src/movietagger/qt/dialogs.py b/src/movietagger/qt/dialogs.py new file mode 100644 index 0000000..360cd1c --- /dev/null +++ b/src/movietagger/qt/dialogs.py @@ -0,0 +1,178 @@ +from typing import Optional + +from PySide6 import QtCore, QtGui, QtWidgets, QtNetwork + +from movietagger.core.datatypes import MoviePick + + +class PosterCache(QtCore.QObject): + pixmap_ready = QtCore.Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._nam = QtNetwork.QNetworkAccessManager(self) + self._cache: dict[str, QtGui.QPixmap] = {} + self._pending: set[str] = set() + + def get(self, url: str) -> Optional[QtGui.QPixmap]: + return self._cache.get(url) + + def request(self, url: str) -> None: + if not url or url in self._cache or url in self._pending: + return + self._pending.add(url) + reply = self._nam.get(QtNetwork.QNetworkRequest(QtCore.QUrl(url))) + reply.finished.connect(lambda r=reply, u=url: self._finish(r, u)) + + def _finish(self, reply: QtNetwork.QNetworkReply, url: str) -> None: + self._pending.discard(url) + if reply.error() == QtNetwork.QNetworkReply.NetworkError.NoError: + data = bytes(reply.readAll().data()) + pm = QtGui.QPixmap() + if pm.loadFromData(data): + self._cache[url] = pm + reply.deleteLater() + self.pixmap_ready.emit(url) + + +class PicksModel(QtCore.QAbstractListModel): + PickRole = QtCore.Qt.ItemDataRole.UserRole + 1 + + def __init__(self, picks: list[MoviePick], parent=None): + super().__init__(parent) + self._picks = picks + + def rowCount(self, parent=QtCore.QModelIndex()) -> int: + return len(self._picks) + + def data(self, index: QtCore.QModelIndex, role: int = int(QtCore.Qt.ItemDataRole.DisplayRole)): + if not index.isValid(): + return None + p = self._picks[index.row()] + if role == QtCore.Qt.ItemDataRole.DisplayRole: + y = f" ({p.year})" if p.year else "" + return f"{p.title}{y} — {p.provider.name}: {p.id}" + if role == self.PickRole: + return p + return None + + +class PickDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, cache: PosterCache, parent=None): + super().__init__(parent) + self._cache = cache + self._placeholder = QtGui.QPixmap(64, 96) + self._placeholder.fill(QtGui.QColor(230, 230, 230)) + + def sizeHint(self, option, index): + return QtCore.QSize(option.rect.width(), 112) + + def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex): + painter.save() + + pick: MoviePick = index.data(PicksModel.PickRole) + rect = option.rect + + if option.state & QtWidgets.QStyle.State_Selected: + painter.fillRect(rect, option.palette.highlight()) + text_color = option.palette.highlightedText().color() + else: + painter.fillRect(rect, option.palette.base()) + text_color = option.palette.text().color() + + inner = rect.adjusted(8, 6, -8, -6) + + poster_rect = QtCore.QRect(inner.left(), inner.top(), 64, 96) + pm = self._cache.get(pick.poster_url) if pick.poster_url else None + if pm is None: + painter.drawPixmap(poster_rect, self._placeholder) + if pick.poster_url: + self._cache.request(pick.poster_url) + else: + painter.drawPixmap( + poster_rect, + pm.scaled(poster_rect.size(), + QtCore.Qt.AspectRatioMode.KeepAspectRatioByExpanding, + QtCore.Qt.TransformationMode.SmoothTransformation), + ) + + x = poster_rect.right() + 10 + text_rect = QtCore.QRect(x, inner.top(), inner.right() - x, inner.height()) + + title_line = f"{pick.title}" + (f" ({pick.year})" if pick.year else "") + f" — {pick.provider.name}: {pick.id}" + overview = (pick.overview or "").replace("\n", " ").strip() + if len(overview) > 260: + overview = overview[:260].rstrip() + "…" + + painter.setPen(text_color) + + title_font = option.font + title_font.setBold(True) + title_font.setPointSize(title_font.pointSize() + 1) + painter.setFont(title_font) + painter.drawText(text_rect.adjusted(0, 0, 0, -52), QtCore.Qt.TextFlag.TextSingleLine, title_line) + + painter.setFont(option.font) + painter.drawText(text_rect.adjusted(0, 30, 0, 0), QtCore.Qt.TextFlag.TextWordWrap, overview) + + painter.restore() + + +class MoviePickDialog(QtWidgets.QDialog): + def __init__(self, parent, query_title: str, query_year: Optional[int], picks: list[MoviePick]): + super().__init__(parent) + self.setWindowTitle("Select Match") + self.resize(900, 600) + self._selected: Optional[MoviePick] = None + + header = QtWidgets.QLabel(f"Search: {query_title}" + (f" ({query_year})" if query_year else "")) + header.setWordWrap(True) + + self.cache = PosterCache(self) + self.model = PicksModel(picks, self) + + self.view = QtWidgets.QListView() + self.view.setModel(self.model) + self.view.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection) + + self.delegate = PickDelegate(self.cache, self.view) + self.view.setItemDelegate(self.delegate) + self.cache.pixmap_ready.connect(lambda _url: self.view.viewport().update()) + + self.btn_select = QtWidgets.QPushButton("Select") + self.btn_select.setEnabled(False) + self.btn_cancel = QtWidgets.QPushButton("Cancel") + + btns = QtWidgets.QHBoxLayout() + btns.addStretch(1) + btns.addWidget(self.btn_cancel) + btns.addWidget(self.btn_select) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(header) + layout.addWidget(self.view, 1) + layout.addLayout(btns) + + self.view.selectionModel().selectionChanged.connect(self._sel_changed) + self.view.doubleClicked.connect(lambda _idx: self._accept()) + self.btn_select.clicked.connect(self._accept) + self.btn_cancel.clicked.connect(self.reject) + + def _sel_changed(self, *_): + self.btn_select.setEnabled(bool(self.view.selectionModel().selectedIndexes())) + + def _accept(self): + idxs = self.view.selectionModel().selectedIndexes() + if not idxs: + return + self._selected = idxs[0].data(PicksModel.PickRole) + self.accept() + + @property + def selected(self) -> Optional[MoviePick]: + return self._selected + + +class OptionsDialog(QtWidgets.QDialog): + def __init__(self, parent): + super().__init__(parent) diff --git a/src/movietagger/qt/main_window.py b/src/movietagger/qt/main_window.py new file mode 100644 index 0000000..73d003a --- /dev/null +++ b/src/movietagger/qt/main_window.py @@ -0,0 +1,841 @@ +import pathlib +import webbrowser +from functools import partial +from typing import Sequence, Optional + +import qtawesome as qta +from PySide6 import QtWidgets, QtGui, QtCore + +from movietagger.core.datatypes import MovieRow, MoviePick, Provider, RenameOp +from movietagger.core.naming import build_video_stem, is_confident_single_pick, sanitize_title_for_windows +from movietagger.qt.delegates import SearchIconDelegate +from movietagger.qt.dialogs import MoviePickDialog +from movietagger.qt.models import MovieTableModel +from movietagger.qt.proxy import MovieProxyModel +from movietagger.qt.workers import * + + +class MovieTaggerWindow(QtWidgets.QMainWindow): + # ------------------------- + # Main UI + # ------------------------- + + def __init__(self, parent=None): + super().__init__(parent) + self.disabled_when_loading = [] + + self.setWindowTitle("Movie Tagger") + + self._current_progress_msg = "" + + self._imdb_thread = None + self._imdb_worker = None + self._scan_thread = None + self._scan_worker = None + self._save_thread = None + self._save_worker = None + + self._build_menubar() + self._build_statusbar() + self._build_content() + + def _build_menubar(self): + menubar = self.menuBar() + + ### --- File menu --- + file_menu = menubar.addMenu("&File") + + file_open_files = QtGui.QAction("Open &Files...", self) + file_open_files.setIcon(qta.icon("fa6s.file-video")) + file_open_files.setShortcut(QtGui.QKeySequence.StandardKey.Open) + file_open_files.triggered.connect(self._open_files) # or expose a public method + + file_open_dir = QtGui.QAction("Open &Directory...", self) + file_open_dir.setIcon(qta.icon("fa6s.folder-open")) + file_open_dir.triggered.connect(partial(self._open_directory, recurse=False)) + + file_open_dir_r = QtGui.QAction("Open Directory (&Recursive)...", self) + file_open_dir_r.setIcon(qta.icon("fa6s.folder-tree")) + file_open_dir_r.triggered.connect(partial(self._open_directory, recurse=True)) + + file_exit = QtGui.QAction("E&xit", self) + file_exit.setIcon(qta.icon("fa6s.power-off")) + file_exit.setShortcut(QtGui.QKeySequence.StandardKey.Quit) + file_exit.triggered.connect(self.close) + + file_menu.addAction(file_open_files) + file_menu.addAction(file_open_dir) + file_menu.addAction(file_open_dir_r) + file_menu.addSeparator() + file_menu.addAction(file_exit) + + self.disabled_when_loading.append(file_open_files) + self.disabled_when_loading.append(file_open_dir) + self.disabled_when_loading.append(file_open_dir_r) + + ### --- View menu --- + view_menu = menubar.addMenu("&View") + + self.view_hide_matched = QtGui.QAction("Hide &Matched", self) + self.view_hide_matched.setCheckable(True) + self.view_hide_matched.setChecked(False) + self.view_hide_matched.triggered.connect(self._on_hide_matched_toggled) + + view_focus_filter = QtGui.QAction("Focus &Filter", self) + view_focus_filter.setIcon(qta.icon("ri.focus-3-line")) + view_focus_filter.setShortcut("Ctrl+F") + view_focus_filter.triggered.connect( + lambda: self.filter_edit.setFocus(QtCore.Qt.FocusReason.ShortcutFocusReason)) + + view_options = QtGui.QAction("&Configuration", self) + view_options.setIcon(qta.icon("msc.settings")) + # view_options.triggered.connect() TODO + + view_menu.addAction(self.view_hide_matched) + view_menu.addSeparator() + view_menu.addAction(view_focus_filter) + view_menu.addSeparator() + view_menu.addAction(view_options) + + ### --- Exec menu --- + run_menu = menubar.addMenu("&Run") + + self.run_auto_scan = QtGui.QAction("&Scan && Auto Match", self) + self.run_auto_scan.setIcon(qta.icon("msc.run-all")) + self.run_auto_scan.setShortcut("Ctrl+S") + self.run_auto_scan.setEnabled(False) + self.run_auto_scan.triggered.connect(self._start_async_scan) + run_menu.addAction(self.run_auto_scan) + + # Wrap the menubar to add left padding + wrapper = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(wrapper) + layout.setContentsMargins(2, 8, 0, 0) # <-- left padding here + layout.setSpacing(0) + layout.addWidget(menubar) + + # Replace the menu-bar area with your wrapper + self.setMenuWidget(wrapper) + + def _build_statusbar(self): + self.status_lbl = QtWidgets.QLabel("Loaded: 0 | Unmatched: 0 | Visible: 0") + + self.cancel_btn = QtWidgets.QPushButton() + self.cancel_btn.setIcon(QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.SP_TabCloseButton)) + self.cancel_btn.setToolTip("Cancel") + self.cancel_btn.setFixedSize(24, 24) + self.cancel_btn.hide() + + self.busy = QtWidgets.QProgressBar() + self.busy.setFixedWidth(200) + self.busy.setTextVisible(False) + self.busy.setRange(0, 0) + self.busy.hide() + + status_bar = self.statusBar() + wrapper = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(wrapper) + layout.setContentsMargins(7, 0, 0, 5) + layout.addWidget(self.status_lbl, 1) + layout.addWidget(self.cancel_btn, 0, QtCore.Qt.AlignmentFlag.AlignRight) + layout.addWidget(self.busy, 0, QtCore.Qt.AlignmentFlag.AlignRight) + status_bar.addWidget(wrapper, 1) + + def _build_content(self): + self.movie_tool_widget = QtWidgets.QWidget() + self._autosave = False + self._autoselect = True + + self.setWindowTitle("Movie Tagger") + + self._load_thread: Optional[QtCore.QThread] = None + self._load_worker: Optional[LoadWorker] = None + + self._tmdb_thread: Optional[QtCore.QThread] = None + self._tmdb_worker: Optional[TmdbSearchWorker] = None + + self._imdb_thread: Optional[QtCore.QThread] = None + self._imdb_worker: Optional[ImdbSearchWorker] = None + + self.filter_edit = QtWidgets.QLineEdit() + self.filter_edit.setPlaceholderText("Filter tokens (AND): dune 2021 hdr ...") + + self.hide_matched_chk = QtWidgets.QCheckBox("Hide matched") + + self.auto_select_chk = QtWidgets.QCheckBox("Auto-select if 1 result") + self.auto_select_chk.setChecked(self._autoselect) + + self.autosave_chk = QtWidgets.QCheckBox("Auto-rename after ID selection") + self.autosave_chk.setChecked(self._autosave) + + self.rename_selected_btn = QtWidgets.QPushButton("Rename") + self.rename_selected_btn.setIcon(qta.icon("mdi6.rename")) + self.rename_selected_btn.setToolTip("Rename Selected Files") + self.rename_selected_btn.setFixedSize(80, 30) + self.rename_selected_btn.setEnabled(False) + self.rename_selected_btn.setFlat(True) + + # Table + self.table = QtWidgets.QTableView() + self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + self.table.setSortingEnabled(True) + self.table.verticalHeader().setVisible(False) + self.table.setAlternatingRowColors(True) + self.table.setWordWrap(False) + self.table.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.table.customContextMenuRequested.connect(self._on_table_context_menu) + + # Layout + top = QtWidgets.QHBoxLayout() + top.addWidget(self.filter_edit, 1) + top.addWidget(self.hide_matched_chk) + top.addWidget(self.auto_select_chk) + top.addWidget(self.autosave_chk) + top.addWidget(self.rename_selected_btn) + + central = QtWidgets.QWidget() + self.setCentralWidget(central) + + layout = QtWidgets.QVBoxLayout(central) + layout.addLayout(top) + layout.addWidget(self.table, 1) + + # Model + proxy + self.model = MovieTableModel([]) + self.proxy = MovieProxyModel(self) + self.proxy.setSourceModel(self.model) + self.table.setModel(self.proxy) + + # Action delegate + self.tmdb_search_delegate = SearchIconDelegate(self.table, right_padding=4, icon_size=QtCore.QSize(16, 16)) + self.imdb_search_delegate = SearchIconDelegate(self.table, right_padding=4, icon_size=QtCore.QSize(16, 16)) + + self.table.setItemDelegateForColumn(MovieTableModel.COL_TMDB, self.tmdb_search_delegate) + self.table.setItemDelegateForColumn(MovieTableModel.COL_IMDB, self.imdb_search_delegate) + + self.tmdb_search_delegate.searchClicked.connect(self._on_find_tmdb_clicked) + self.imdb_search_delegate.searchClicked.connect(self._on_find_imdb_clicked) + + # Column sizing + self.table.setColumnWidth(MovieTableModel.COL_TITLE, 400) + self.table.setColumnWidth(MovieTableModel.COL_YEAR, 70) + self.table.setColumnWidth(MovieTableModel.COL_TAGS, 120) + self.table.setColumnWidth(MovieTableModel.COL_TMDB, 110) + self.table.setColumnWidth(MovieTableModel.COL_IMDB, 140) + self.table.horizontalHeader().setStretchLastSection(True) + + self.movie_tool_widget.setLayout(layout) + self.setCentralWidget(self.movie_tool_widget) + + # Signals + self.autosave_chk.toggled.connect(self._set_autosave) + self.auto_select_chk.toggled.connect(self._set_autoselect) + self.cancel_btn.clicked.connect(self._cancel_worker) + self.rename_selected_btn.clicked.connect(self._bulk_save) + + self.filter_edit.textChanged.connect(self._on_filter_text_changed) + self.hide_matched_chk.clicked.connect(self._on_hide_matched_toggled) + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) + + self._update_status() + + def _on_table_context_menu(self, pos: QtCore.QPoint) -> None: + idx = self.table.indexAt(pos) + enable_single = len(self.table.selectionModel().selectedRows()) == 1 + if not idx.isValid(): + return + + proxy_row = idx.row() + src_row = self._source_row_from_proxy_row(proxy_row) + row = self.model.get_row(src_row) + + menu = QtWidgets.QMenu(self) + + act_rename = menu.addAction("Rename") + act_rename.setIcon(qta.icon("mdi6.rename")) + + menu.addSeparator() + + act_tmdb = menu.addAction("Search TMDB") + act_tmdb.setIcon(qta.icon("mdi.movie-search")) + act_tmdb.setEnabled(enable_single) + + act_tmdb_url = menu.addAction("TMDB Listing") + act_tmdb_url.setIcon(qta.icon("mdi.web")) + act_tmdb_url.setEnabled(enable_single and row.tmdb_id is not None) + + menu.addSeparator() + + act_imdb = menu.addAction("Search IMDB") + act_imdb.setIcon(qta.icon("mdi.movie-search")) + act_imdb.setEnabled(enable_single) + + act_imdb_url = menu.addAction("IMDB Listing") + act_imdb_url.setIcon(qta.icon("mdi.web")) + act_imdb_url.setEnabled(enable_single and row.imdb_id is not None) + + menu.addSeparator() + + act_restore_title = menu.addAction("Restore original title") + act_restore_title.setIcon(qta.icon("mdi.restore")) + act_restore_title.setEnabled(enable_single and row.title_changed()) + + act_restore_year = menu.addAction("Restore original year") + act_restore_year.setIcon(qta.icon("mdi.restore")) + act_restore_year.setEnabled(enable_single and row.year_changed()) + + chosen = menu.exec(self.table.viewport().mapToGlobal(pos)) + if chosen is None: + return + + if chosen == act_rename: + self._bulk_save() + + if chosen == act_tmdb: + self._on_find_tmdb_clicked(proxy_row) + pass + + if chosen == act_imdb: + # self._on_find_imdb_clicked(proxy_row) + pass + + if chosen == act_tmdb_url: + self._on_open_tmdb_url(src_row) + + if chosen == act_imdb_url: + self._on_open_imdb_url(src_row) + + if chosen == act_restore_title: + self.model.restore_original_title(src_row) + self._update_status() + if self._autosave: + self._save_rows([src_row]) + return + + if chosen == act_restore_year: + self.model.restore_original_year(src_row) + self._update_status() + if self._autosave: + self._save_rows([src_row]) + return + + def _source_row_from_proxy_row(self, proxy_row: int) -> int: + proxy_index = self.proxy.index(proxy_row, 0) + src_index = self.proxy.mapToSource(proxy_index) + return src_index.row() + + # ------------------------- + # URLs + # ------------------------- + + def _on_open_tmdb_url(self, src_row: int) -> None: + row = self.model.get_row(src_row) + webbrowser.open(f"https://www.themoviedb.org/movie/{row.tmdb_id}") + + def _on_open_imdb_url(self, src_row: int) -> None: + row = self.model.get_row(src_row) + webbrowser.open(f"https://www.imdb.com/title/{row.imdb_id}") + + # ------------------------- + # Filter / toggles + # ------------------------- + + def _set_autosave(self, checked: bool) -> None: + if self._autosave == checked: + return + self._autosave = checked + self.autosave_chk.setChecked(checked) + + def _set_autoselect(self, checked: bool) -> None: + self._autoselect = checked + + def _on_filter_text_changed(self, text: str) -> None: + self.proxy.set_filter_text(text) + self._update_status() + + def _on_hide_matched_toggled(self, checked: bool) -> None: + self.proxy.set_hide_matched(checked) + self.hide_matched_chk.setChecked(checked) + self.view_hide_matched.setChecked(checked) + self._update_status() + + def _on_selection_changed(self, *_args) -> None: + self.rename_selected_btn.setEnabled(bool(self.table.selectionModel().selectedRows())) + self._update_status() + + # ------------------------- + # Loading + # ------------------------- + + def _open_files(self) -> None: + files, _ = QtWidgets.QFileDialog.getOpenFileNames( + self, + "Select video files", + "", + "Video Files (*.mkv *.mp4)", + ) + if not files: + return + self._start_async_file_load(files=files, directory=None, recurse=False) + + def _open_directory(self, recurse: bool) -> None: + directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select directory") + if not directory: + return + self._start_async_file_load(files=[], directory=directory, recurse=recurse) + + # ------------------------- + # Workers + # ------------------------- + + def _enter_async_state(self, minimum: int, maximum: int) -> None: + self.cancel_btn.show() + self.busy.show() + self.busy.setRange(minimum, maximum) + for widget in self.disabled_when_loading: + widget.setEnabled(False) + + def _exit_async_state(self) -> None: + self.cancel_btn.hide() + self.busy.hide() + self.busy.setRange(0, 0) + for widget in self.disabled_when_loading: + widget.setEnabled(True) + + def _on_worker_progress(self, done: int, total: int) -> None: + if total > 0: + self.busy.setValue(done) + self.status_lbl.setText(f"{self._current_progress_msg} {done}/{total}") + else: + self.status_lbl.setText(f"{self._current_progress_msg} {done}") + + def _cancel_worker(self) -> None: + if self._load_worker is not None: + self._load_worker.cancel() + self.status_lbl.setText("Cancelling file load...") + if self._scan_worker is not None: + self._scan_worker.cancel() + self.status_lbl.setText("Cancelling batch scan...") + if self._save_worker is not None: + self._save_worker.cancel() + self.status_lbl.setText("Cancelling file save...") + + # ------------------------- + # Load Files + # ------------------------- + + def _start_async_file_load(self, files: Sequence[str], directory: Optional[str], recurse: Optional[bool]) -> None: + if self._load_thread is not None: + QtWidgets.QMessageBox.information(self, "Loading", "File load is already running.") + return + + # UI into loading state + self._enter_async_state(0, 0) + self._current_progress_msg = "Loading videos..." + + self._load_thread = QtCore.QThread(self) + self._load_worker = LoadWorker( + files=files, + directory=directory, + recursive=recurse, + ) + self._load_worker.moveToThread(self._load_thread) + + self._load_thread.started.connect(self._load_worker.run) + self._load_worker.progress.connect(self._on_worker_progress) + self._load_worker.finished.connect(self._on_load_finished) + self._load_worker.cancelled.connect(self._on_load_cancelled) + self._load_worker.error.connect(self._on_load_error) + + # Cleanup + self._load_worker.finished.connect(self._load_thread.quit) + self._load_worker.cancelled.connect(self._load_thread.quit) + self._load_worker.error.connect(self._load_thread.quit) + self._load_thread.finished.connect(self._on_load_cleanup) + + self._load_thread.start() + + def _on_load_finished(self, rows: list[MovieRow]) -> None: + self._apply_loaded_rows(rows) + self._exit_async_state() + self._update_status() + self.run_auto_scan.setEnabled(True) + + def _on_load_cancelled(self, rows: list[MovieRow]) -> None: + self._apply_loaded_rows(rows) + self._exit_async_state() + + # Keep a little hint that it was partial + if rows: + self.status_lbl.setText("Load cancelled (partial list)") + + def _on_load_error(self, msg: str) -> None: + self._exit_async_state() + QtWidgets.QMessageBox.critical(self, "Load error", msg) + self._update_status() + + def _on_load_cleanup(self) -> None: + if self._load_worker is not None: + self._load_worker.deleteLater() + self._load_worker = None + + if self._load_thread is not None: + self._load_thread.deleteLater() + self._load_thread = None + + def _apply_loaded_rows(self, rows: list[MovieRow]) -> None: + self.model = MovieTableModel(rows) + self.proxy.setSourceModel(self.model) + self.table.setModel(self.proxy) + self.table.sortByColumn(MovieTableModel.COL_TITLE, QtCore.Qt.SortOrder.AscendingOrder) + + self.rename_selected_btn.setEnabled(bool(rows)) + + # ------------------------- + # Scan & Match + # ------------------------- + + def _start_async_scan(self) -> None: + # Disable auto-saving + self._set_autosave(False) + + # Avoid double-start + if self._scan_thread is not None: + QtWidgets.QMessageBox.information(self, "Scanning", "Auto-Scan is already running.") + return + + rows = [] + for proxy_row in range(self.proxy.rowCount()): + src_row = self._source_row_from_proxy_row(proxy_row) + r = self.model.get_row(src_row) + + if r.tmdb_id: # already matched + continue + + rows.append((src_row, r.title, r.year)) + + if not rows: + self.status_lbl.setText("No unmatched files to scan.") + return + + self._enter_async_state(0, len(rows)) + self._current_progress_msg = "Scanning videos..." + + self._scan_thread = QtCore.QThread(self) + + self._scan_worker = None + + try: + self._scan_worker = BatchScanWorker(rows=rows) + except RuntimeError as e: + self.status_lbl.setText(f"Error in TDMB module: {e}") + self._on_scan_cleanup() + return + + self._scan_worker.moveToThread(self._scan_thread) + + self._scan_thread.started.connect(self._scan_worker.run) + + self._scan_worker.progress.connect(self._on_worker_progress) + self._scan_worker.resultReady.connect(self._on_auto_scan_result) + self._scan_worker.errorReady.connect(self._on_auto_scan_error) + + # Cleanup + self._scan_worker.finished.connect(self._scan_thread.quit) + self._scan_thread.finished.connect(self._on_scan_cleanup) + + self._scan_thread.start() + + def _on_scan_cleanup(self) -> None: + self._exit_async_state() + + if self._scan_worker is not None: + self._scan_worker.deleteLater() + self._scan_worker = None + + if self._scan_thread is not None: + self._scan_thread.deleteLater() + self._scan_thread = None + + self._update_status() + + def _on_auto_scan_result(self, src_row: int, pick: MoviePick) -> None: + if not pick: + return + r = self.model.get_row(src_row) + warn = "" + if sanitize_title_for_windows(pick.title) != r.title: + warn = "(WARN: title changed)" + if pick.provider == Provider.TMDB: + self.model.set_tmdb_selection(src_row, pick, status=f"{pick.provider.name}: auto-selected {warn}") + elif pick.provider == Provider.IMDB: + # TODO + pass + + def _on_auto_scan_error(self, src_row: int, msg: str) -> None: + r = self.model.get_row(src_row) + self.model.set_ids_for_row(src_row, r.tmdb_id, r.imdb_id, status=f"TMDB error: {msg}") + + # ------------------------- + # Find ID action + # ------------------------- + + def _on_find_imdb_clicked(self, proxy_row: int) -> None: + src_row = self._source_row_from_proxy_row(proxy_row) + row = self.model.get_row(src_row) + + if not row.valid: + QtWidgets.QMessageBox.warning(self, "Invalid filename", + "Fix the filename or edit fields in-table first.") + return + + # Prevent overlapping IMDB searches + if self._imdb_thread is not None: + QtWidgets.QMessageBox.information(self, "IMDB Search", "An IMDB search is already running.") + return + + # UI feedback (use your status bar) + self.status_lbl.setText(f"Searching IMDB... {row.title} ({row.year})") + + # Start worker thread for TMDB search + self._imdb_thread = QtCore.QThread(self) + self._imdb_worker = ImdbSearchWorker( + src_row_idx=src_row, + title=row.title, + year=row.year if row.year else None, + ) + + self._imdb_worker.moveToThread(self._imdb_thread) + + self._imdb_thread.started.connect(self._imdb_worker.run) + self._imdb_worker.finished.connect(self._on_search_finished) + self._imdb_worker.error.connect(self._on_search_error) + + # Cleanup + self._imdb_worker.finished.connect(self._imdb_thread.quit) + self._imdb_worker.error.connect(self._imdb_thread.quit) + self._imdb_thread.finished.connect(self._on_imdb_search_cleanup) + + self._imdb_thread.start() + + def _on_find_tmdb_clicked(self, proxy_row: int) -> None: + src_row = self._source_row_from_proxy_row(proxy_row) + row = self.model.get_row(src_row) + + if not row.valid: + QtWidgets.QMessageBox.warning(self, "Invalid filename", + "Fix the filename or edit fields in-table first.") + return + + # Prevent overlapping TMDB searches + if self._tmdb_thread is not None: + QtWidgets.QMessageBox.information(self, "TMDB Search", "A TMDB search is already running.") + return + + # UI feedback (use your status bar) + self.status_lbl.setText(f"Searching TMDB... {row.title} ({row.year})") + + # Start worker thread for TMDB search + self._tmdb_thread = QtCore.QThread(self) + self._tmdb_worker = None + try: + self._tmdb_worker = TmdbSearchWorker( + src_row_idx=src_row, + title=row.title, + year=row.year if row.year else None, + ) + except RuntimeError as e: + self.status_lbl.setText(f"Error in TDMB module: {e}") + self._on_tmdb_search_cleanup() + return + + self._tmdb_worker.moveToThread(self._tmdb_thread) + + self._tmdb_thread.started.connect(self._tmdb_worker.run) + self._tmdb_worker.finished.connect(self._on_search_finished) + self._tmdb_worker.error.connect(self._on_search_error) + + # Cleanup + self._tmdb_worker.finished.connect(self._tmdb_thread.quit) + self._tmdb_worker.error.connect(self._tmdb_thread.quit) + self._tmdb_thread.finished.connect(self._on_tmdb_search_cleanup) + + self._tmdb_thread.start() + + def _on_search_finished(self, src_row: int, picks: list[MoviePick], provider: Provider) -> None: + if not picks: + self.model.set_ids_for_row(src_row, self.model.get_row(src_row).tmdb_id, + self.model.get_row(src_row).imdb_id, + status=f"{provider.name}: no results") + self._update_status() + return + + dirty = False + warn = "" + + # Auto-select if only one result (guarded) + if self._autoselect and len(picks) == 1: + pick = picks[0] + r = self.model.get_row(src_row) + if is_confident_single_pick(r.title, r.year, pick.title, pick.year): + if sanitize_title_for_windows(pick.title) != r.title: + warn = "(WARN: title changed)" + if provider == Provider.TMDB: + self.model.set_tmdb_selection(src_row, pick, status=f"{provider.name}: auto-selected {warn}") + elif provider == Provider.IMDB: + # TODO + pass + + self._update_status() + if self._autosave: + self._save_rows([src_row]) + return + + r = self.model.get_row(src_row) + dlg = MoviePickDialog(self, query_title=r.title, query_year=r.year if r.year else None, picks=picks) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted or dlg.selected is None: + self.model.set_ids_for_row(src_row, r.tmdb_id, r.imdb_id, status=f"{provider.name}: cancelled") + self._update_status() + return + + chosen = dlg.selected + if sanitize_title_for_windows(chosen.title) != r.title: + dirty = True + warn = "(WARN: title changed)" + self.model.set_tmdb_selection(src_row, chosen, status=f"{provider.name}: selected {warn}") + self._update_status() + + if not dirty and self._autosave: + self._save_rows([src_row]) + + def _on_search_error(self, src_row: int, msg: str, provider: Provider) -> None: + r = self.model.get_row(src_row) + self.model.set_ids_for_row(src_row, r.tmdb_id, r.imdb_id, status=f"{provider.name} error: {msg}") + self._update_status() + + def _on_tmdb_search_cleanup(self) -> None: + if self._tmdb_worker is not None: + self._tmdb_worker.deleteLater() + self._tmdb_worker = None + + if self._tmdb_thread is not None: + self._tmdb_thread.deleteLater() + self._tmdb_thread = None + + def _on_imdb_search_cleanup(self) -> None: + if self._imdb_worker is not None: + self._imdb_worker.deleteLater() + self._imdb_worker = None + + if self._imdb_thread is not None: + self._imdb_thread.deleteLater() + self._imdb_thread = None + + # ------------------------- + # Save = rename files on disk + # ------------------------- + + def _save_rows(self, src_rows: list[int]) -> None: + """ + Rename files on disk to match the row data. + - Preserves extension + - Writes tags as [tag] blocks, edition as {edition-...}, id as {tmdb-...} or {imdb-...} + - TMDB wins if both set + - If target exists, skip and set status + """ + if not src_rows: + return + + # Confirm if bulk (optional) + if len(src_rows) > 1: + resp = QtWidgets.QMessageBox.question( + self, + "Rename files", + f"Rename {len(src_rows)} file(s) on disk?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + ) + if resp != QtWidgets.QMessageBox.StandardButton.Yes: + return + + ops: list[RenameOp] = [] + for row_idx in src_rows: + row = self.model.get_row(row_idx) + + if not row.valid: + self.model.update_path_and_status(row_idx, row.path, "Rename skipped: invalid format") + continue + + new_stem = build_video_stem(row) + new_path = row.path.with_name(new_stem + row.path.suffix) + + if new_path == row.path: + self.model.update_path_and_status(row_idx, row.path, "No change") + continue + + if new_path.exists(): + self.model.update_path_and_status(row_idx, row.path, "Rename skipped: target exists") + continue + + ops.append(RenameOp(row_idx, row.path, new_path)) + + if not ops: + return + + self._enter_async_state(0, len(ops)) + self._current_progress_msg = "Saving files..." + + self._save_thread = QtCore.QThread(self) + self._save_worker = SaveWorker(ops) + self._save_worker.moveToThread(self._save_thread) + # + self._save_thread.started.connect(self._save_worker.run) + + self._save_worker.resultReady.connect(self._on_save_result) + self._save_worker.progress.connect(self._on_worker_progress) + + self._save_worker.finished.connect(self._save_thread.quit) + self._save_thread.finished.connect(self._on_save_cleanup) + + self._save_thread.start() + + self._update_status() + + def _bulk_save(self) -> None: + proxy_rows = [idx.row() for idx in self.table.selectionModel().selectedRows()] + src_rows = [self._source_row_from_proxy_row(r) for r in proxy_rows] + self._save_rows(src_rows) + + def _on_save_result(self, src_row: int, ok: bool, msg: str, file_path: pathlib.Path): + if ok: + self.model.update_path_and_status(src_row, file_path, "Renamed") + else: + self.model.update_path_and_status(src_row, file_path, f"Rename failed: {msg}") + pass + + def _on_save_cleanup(self): + self._exit_async_state() + + if self._save_worker is not None: + self._save_worker.deleteLater() + self._save_worker = None + + if self._save_thread is not None: + self._save_thread.deleteLater() + self._save_thread = None + + # ------------------------- + # Status + # ------------------------- + + def _update_status(self) -> None: + rows = self.model.rows() + total = len(rows) + unmatched = sum(1 for r in rows if r.valid and not r.is_matched()) + invalid = sum(1 for r in rows if not r.valid) + visible = self.proxy.rowCount() + + parts = [f"Loaded: {total}", f"Unmatched: {unmatched}", f"Visible: {visible}"] + if invalid: + parts.append(f"Invalid: {invalid}") + self.status_lbl.setText(" | ".join(parts)) diff --git a/src/movietagger/qt/models.py b/src/movietagger/qt/models.py new file mode 100644 index 0000000..24c56ba --- /dev/null +++ b/src/movietagger/qt/models.py @@ -0,0 +1,199 @@ +from datetime import date +from pathlib import Path +from typing import Optional + +from PySide6 import QtCore, QtGui + +from movietagger.core.datatypes import MovieRow, MoviePick +from movietagger.core.naming import IMDB_ID_RE, TMDB_ID_RE, clean_tag_list, sanitize_title_for_windows + + +class MovieTableModel(QtCore.QAbstractTableModel): + COL_TITLE = 0 + COL_YEAR = 1 + COL_TAGS = 2 + COL_EDITION = 3 + COL_TMDB = 4 + COL_IMDB = 5 + COL_PATH = 6 + COL_STATUS = 7 + + HEADERS = ["Title", "Year", "Tags (CSV)", "Edition", "TMDB", "IMDB", "Path", "Status"] + + def __init__(self, rows: list[MovieRow]): + super().__init__() + self._rows = rows + + def rowCount(self, parent=QtCore.QModelIndex()) -> int: + return len(self._rows) + + def columnCount(self, parent=QtCore.QModelIndex()) -> int: + return len(self.HEADERS) + + def headerData(self, section: int, orientation: QtCore.Qt.Orientation, + role: int = QtCore.Qt.ItemDataRole.DisplayRole): + if orientation == QtCore.Qt.Orientation.Horizontal and role == QtCore.Qt.ItemDataRole.DisplayRole: + return self.HEADERS[section] + return None + + def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: + if not index.isValid(): + return QtCore.Qt.ItemFlag.NoItemFlags + + base = QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled + + if index.column() in ( + self.COL_TITLE, self.COL_YEAR, self.COL_TAGS, self.COL_EDITION, self.COL_TMDB, self.COL_IMDB): + base |= QtCore.Qt.ItemFlag.ItemIsEditable + + return base + + def data(self, index: QtCore.QModelIndex, role: int = QtCore.Qt.ItemDataRole.DisplayRole): + if not index.isValid(): + return None + + row = self._rows[index.row()] + col = index.column() + + if role == QtCore.Qt.ItemDataRole.DisplayRole or role == QtCore.Qt.ItemDataRole.EditRole: + if col == self.COL_TITLE: + return row.title + if col == self.COL_YEAR: + return "" if not row.year else str(row.year) + if col == self.COL_TAGS: + return ", ".join(row.tags) + if col == self.COL_EDITION: + return row.edition or "" + if col == self.COL_TMDB: + return row.tmdb_id or "" + if col == self.COL_IMDB: + return row.imdb_id or "" + if col == self.COL_PATH: + return str(row.path) + if col == self.COL_STATUS: + return row.status + return "" + + if role == QtCore.Qt.ItemDataRole.ToolTipRole: + if not row.valid: + return ( + "Invalid format. Expected:\n" + "Title (YEAR) [tag]* {edition-XYZ}? {tmdb-123|imdb-tt1234567}? .mkv|.mp4\n" + "Strict order after YEAR: [tags] then {edition} then {id}." + ) + elif col == self.COL_STATUS: + return row.status or None + elif col == self.COL_PATH: + return str(row.path) or None + + if role == QtCore.Qt.ItemDataRole.ForegroundRole and not row.valid: + return QtGui.QBrush(QtGui.QColor("red")) + + return None + + def setData(self, index: QtCore.QModelIndex, value, role: int = QtCore.Qt.ItemDataRole.DisplayRole) -> bool: + if role != QtCore.Qt.ItemDataRole.EditRole or not index.isValid(): + return False + + row = self._rows[index.row()] + col = index.column() + text = str(value).strip() + + row.status = "" + + if col == self.COL_TITLE: + if text is not None: + row.title = text + row.canonical_title = text + elif col == self.COL_YEAR: + try: + year = int(text) + if 1887 < year <= date.today().year: + row.year = year + else: + row.status = "Invalid Year" + except ValueError: + row.status = "Invalid Year" + elif col == self.COL_TAGS: + parts = [p.strip() for p in text.split(",")] + row.tags = clean_tag_list(parts) + elif col == self.COL_EDITION: + row.edition = text or None + elif col == self.COL_TMDB: + if text and not TMDB_ID_RE.match(text): + row.status = "Bad TMDB id" + else: + row.tmdb_id = text or None + elif col == self.COL_IMDB: + if text and not IMDB_ID_RE.match(text): + row.status = "Bad IMDB id" + else: + row.imdb_id = text or None + elif col == self.COL_STATUS: + row.status = text + else: + return False + + self.dataChanged.emit(index, index, [QtCore.Qt.ItemDataRole.DisplayRole, QtCore.Qt.ItemDataRole.EditRole]) + return True + + def get_row(self, row_idx: int) -> MovieRow: + return self._rows[row_idx] + + def rows(self) -> list[MovieRow]: + return self._rows + + def set_ids_for_row(self, row_idx: int, tmdb_id: Optional[str], imdb_id: Optional[str], status: str = "") -> None: + row = self._rows[row_idx] + row.tmdb_id = (tmdb_id or "").strip() or None + row.imdb_id = (imdb_id or "").strip() or None + if status: + row.status = status + + left = self.index(row_idx, self.COL_TMDB) + right = self.index(row_idx, self.COL_STATUS) + self.dataChanged.emit(left, right, [QtCore.Qt.ItemDataRole.DisplayRole]) + + def update_path_and_status(self, row_idx: int, new_path: Path, status: str) -> None: + row = self._rows[row_idx] + row.path = new_path + row.status = status + left = self.index(row_idx, self.COL_PATH) + right = self.index(row_idx, self.COL_STATUS) + self.dataChanged.emit(left, right, [QtCore.Qt.ItemDataRole.DisplayRole]) + + def set_tmdb_selection(self, row_idx: int, pick: MoviePick, status: str = "TMDB: selected") -> None: + row = self._rows[row_idx] + row.tmdb_id = str(pick.id) + + # Update canonical metadata from TMDB + if pick.title and pick.title.strip(): + row.title = sanitize_title_for_windows(pick.title.strip()) + row.canonical_title = pick.title.strip() + if pick.year: + row.year = int(pick.year) + + row.status = status + + left = self.index(row_idx, self.COL_TITLE) + right = self.index(row_idx, self.COL_STATUS) + self.dataChanged.emit(left, right, [QtCore.Qt.ItemDataRole.DisplayRole, QtCore.Qt.ItemDataRole.EditRole]) + + def restore_original_title(self, row_idx: int) -> None: + r = self._rows[row_idx] + if r.original_title: + r.title = r.original_title + r.canonical_title = "" + r.status = "Restored original title" + left = self.index(row_idx, self.COL_TMDB) + right = self.index(row_idx, self.COL_STATUS) + self.dataChanged.emit(left, right, [QtCore.Qt.ItemDataRole.DisplayRole]) + + def restore_original_year(self, row_idx: int) -> None: + r = self._rows[row_idx] + if r.original_year: + r.year = r.original_year + r.status = "Restored original year" + left = self.index(row_idx, self.COL_TMDB) + right = self.index(row_idx, self.COL_STATUS) + self.dataChanged.emit(left, right, [QtCore.Qt.ItemDataRole.DisplayRole]) diff --git a/src/movietagger/qt/proxy.py b/src/movietagger/qt/proxy.py new file mode 100644 index 0000000..3c32d5e --- /dev/null +++ b/src/movietagger/qt/proxy.py @@ -0,0 +1,96 @@ +from typing import cast + +from PySide6 import QtCore + +from movietagger.core.datatypes import Provider +from movietagger.core.naming import MovieRow +from movietagger.qt.models import MovieTableModel + + +class MovieProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self, parent=None): + super().__init__(parent) + self._hide_matched = False + self._tokens: list[str] = [] + self.setSortCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + + def _compare_ids(self, left, right, *, provider: Provider) -> bool: + l = self.sourceModel().data(left, QtCore.Qt.ItemDataRole.DisplayRole) + r = self.sourceModel().data(right, QtCore.Qt.ItemDataRole.DisplayRole) + + # Normalize empty values → always sort last + if not l and not r: + return False + if not l: + return False + if not r: + return True + + try: + if provider == Provider.TMDB: + return int(l) < int(r) + + if provider == Provider.IMDB: + # IMDB IDs like "tt0123456" + return int(l.lstrip("tt")) < int(r.lstrip("tt")) + + except ValueError: + # Fallback to string compare if malformed + return str(l) < str(r) + + return False + + def lessThan(self, left, right) -> bool: + col = left.column() + + # TMDB column + if col == MovieTableModel.COL_TMDB: + return self._compare_ids(left, right, provider=Provider.TMDB) + + # IMDB column + if col == MovieTableModel.COL_IMDB: + return self._compare_ids(left, right, provider=Provider.IMDB) + + return super().lessThan(left, right) + + def set_hide_matched(self, hide: bool) -> None: + self._hide_matched = hide + self.invalidateFilter() + + def set_filter_text(self, text: str) -> None: + self._tokens = [t.casefold() for t in text.split() if t.strip()] + self.invalidateFilter() + + def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex) -> bool: + model = cast(MovieTableModel, self.sourceModel()) + if model is None: + return True + + row: MovieRow = model.get_row(source_row) + + if self._hide_matched and row.is_matched(): + return False + + if not self._tokens: + return True + + flags = [] + if not row.valid: + flags.append("invalid") + + haystack = " ".join( + [ + row.title or "", + row.original_title or "", + str(row.year) if row.year else "", + ", ".join(row.tags) if row.tags else "", + row.edition or "", + row.tmdb_id or "", + row.imdb_id or "", + str(row.path) if row.path else "", + row.status or "", + *flags, + ] + ).casefold() + + return all(tok in haystack for tok in self._tokens) diff --git a/src/movietagger/qt/workers.py b/src/movietagger/qt/workers.py new file mode 100644 index 0000000..e69de29 diff --git a/src/movietagger/qt/workers/__init__.py b/src/movietagger/qt/workers/__init__.py new file mode 100644 index 0000000..2eb9489 --- /dev/null +++ b/src/movietagger/qt/workers/__init__.py @@ -0,0 +1,7 @@ +from .load_worker import LoadWorker +from .tmdb_search_worker import TmdbSearchWorker +from .imdb_search_worker import ImdbSearchWorker +from .batch_scan_worker import BatchScanWorker +from .save_worker import SaveWorker + +__all__ = ['LoadWorker', 'TmdbSearchWorker', 'ImdbSearchWorker', 'BatchScanWorker', 'SaveWorker'] \ No newline at end of file diff --git a/src/movietagger/qt/workers/batch_scan_worker.py b/src/movietagger/qt/workers/batch_scan_worker.py new file mode 100644 index 0000000..befe18d --- /dev/null +++ b/src/movietagger/qt/workers/batch_scan_worker.py @@ -0,0 +1,119 @@ +import threading +import time +from concurrent.futures import Future, ThreadPoolExecutor, wait, FIRST_COMPLETED + +from PySide6 import QtCore + +from movietagger.core.config import require_tmdb_env +from movietagger.core.datatypes import MoviePick +from movietagger.core.naming import is_confident_single_pick +from movietagger.core.ratelimiter import RateLimiter +from movietagger.services.tmdb_service import tmdb_search_picks_sync + + +class BatchScanWorker(QtCore.QObject): + finished = QtCore.Signal() + progress = QtCore.Signal(int, int) # done, total + resultReady = QtCore.Signal(int, object) + errorReady = QtCore.Signal(int, str) # src_row_idx, message + + def __init__(self, rows: list[tuple[int, str, int]], *, max_workers: int = 10): + """ + rows: list of (src_row, title, year) + We pass a snapshot so the worker doesn't touch the model from another thread. + """ + super().__init__() + self._rows = rows + self._cancel_event = threading.Event() + self._max_workers = max(1, int(max_workers)) + + self._limiter = RateLimiter(max_per_sec=20) + + self._emit_interval_s = 0.10 + + require_tmdb_env() + + @QtCore.Slot() + def cancel(self) -> None: + self._cancel_event.set() + + @QtCore.Slot() + def run(self) -> None: + total = len(self._rows) + done = 0 + + if total == 0: + self.finished.emit() + return + + self.progress.emit(done, total) + row_iter = iter(self._rows) + pending: dict[Future, int] = {} # future -> src_row + + last_emit = 0.0 + + def maybe_emit_progress(force: bool = False) -> None: + nonlocal last_emit + now = time.monotonic() + if force or (now - last_emit) >= self._emit_interval_s: + last_emit = now + self.progress.emit(done, total) + + def submit_next(ex: ThreadPoolExecutor) -> None: + if self._cancel_event.is_set(): + return + row = next(row_iter, None) + if row is None: + return + src_row, title, year = row + pending[ex.submit(self._scan_one_row, src_row, title, year)] = src_row + + try: + with ThreadPoolExecutor(max_workers=self._max_workers) as ex: + # Prime the pool + for _ in range(self._max_workers): + submit_next(ex) + + while pending: + if self._cancel_event.is_set(): + for f in list(pending): + f.cancel() + break + + done_set, _ = wait(pending, return_when=FIRST_COMPLETED) + + for f in done_set: + src_row = pending.pop(f) + done += 1 + + if not f.cancelled(): + try: + res = f.result() + if res is not None: + self.resultReady.emit(*res) + except Exception as e: + self.errorReady.emit(src_row, str(e)) + + maybe_emit_progress() + submit_next(ex) + finally: + self.finished.emit() + + def _scan_one_row(self, src_row: int, title: str, year: int) -> tuple[int, MoviePick] | None: + """ + Runs in a thread-pool thread. No Qt objects, no model access. + Returns a MoviePick (or None) to send back to UI thread. + """ + self._limiter.acquire(self._cancel_event) + if self._cancel_event.is_set(): + return None + try: + picks = tmdb_search_picks_sync(title, year) + except Exception: + # IMDB fallback will go here when implemented + raise + + if len(picks) == 1: + pick = picks[0] + if is_confident_single_pick(title, year, pick.title, pick.year): + return src_row, pick diff --git a/src/movietagger/qt/workers/imdb_search_worker.py b/src/movietagger/qt/workers/imdb_search_worker.py new file mode 100644 index 0000000..3503227 --- /dev/null +++ b/src/movietagger/qt/workers/imdb_search_worker.py @@ -0,0 +1,25 @@ +from typing import Optional + +from PySide6 import QtCore + +from movietagger.core.datatypes import Provider +from movietagger.services.imdb_service import imdb_search_picks_sync + + +class ImdbSearchWorker(QtCore.QObject): + finished = QtCore.Signal(int, list, Provider) # src_row_idx, picks + error = QtCore.Signal(int, str, Provider) # src_row_idx, message + + def __init__(self, src_row_idx: int, title: str, year: Optional[int]): + super().__init__() + self.src_row_idx = src_row_idx + self.title = title + self.year = year + + @QtCore.Slot() + def run(self) -> None: + try: + picks = imdb_search_picks_sync(self.title, self.year) + self.finished.emit(self.src_row_idx, picks, Provider.IMDB) + except Exception as e: + self.error.emit(self.src_row_idx, str(e), Provider.IMDB) \ No newline at end of file diff --git a/src/movietagger/qt/workers/load_worker.py b/src/movietagger/qt/workers/load_worker.py new file mode 100644 index 0000000..63c8108 --- /dev/null +++ b/src/movietagger/qt/workers/load_worker.py @@ -0,0 +1,153 @@ +import os +import time +from pathlib import Path +from typing import Optional, Sequence + +from PySide6 import QtCore + +from movietagger.core.naming import MovieRow, VIDEO_EXTS, FILENAME_TAGS_RE, clean_tag_list, \ + BRACKET_RE, TMDB_ID_RE, IMDB_ID_RE + + +def _parse_video_filename(path: Path) -> MovieRow: + stem = path.stem + m = FILENAME_TAGS_RE.match(stem) + if not m: + return MovieRow(path=path, title=stem, year=0, tags=[], valid=False, status="Invalid format") + + title = (m.group("name") or "").strip() + year = int(m.group("year")) + brackets_raw = m.group("brackets") or "" + tags = clean_tag_list([t for t in BRACKET_RE.findall(brackets_raw)]) + + edition = (m.group("edition") or "").strip() or None + id_kind = (m.group("id_kind") or "").lower() or None + id_value = (m.group("id_value") or "").strip() or None + + tmdb_id: Optional[str] = None + imdb_id: Optional[str] = None + + if id_kind and id_value: + if id_kind == "tmdb": + if not TMDB_ID_RE.match(id_value): + return MovieRow(path=path, title=title, year=year, tags=tags, edition=edition, + valid=False, status="Bad TMDB id") + tmdb_id = id_value + elif id_kind == "imdb": + if not IMDB_ID_RE.match(id_value): + return MovieRow(path=path, title=title, year=year, tags=tags, edition=edition, + valid=False, status="Bad IMDB id") + imdb_id = id_value + + row = MovieRow( + path=path, + title=title, + year=year, + tags=tags, + edition=edition, + tmdb_id=tmdb_id, + imdb_id=imdb_id, + valid=True, + status="" + ) + row.original_title = row.title + row.original_year = row.year + return row + + +class LoadWorker(QtCore.QObject): + started = QtCore.Signal() + progress = QtCore.Signal(int, int) # done, total + finished = QtCore.Signal(list) # list[MovieRow] + cancelled = QtCore.Signal(list) # partial list[MovieRow] + error = QtCore.Signal(str) + + def __init__(self, files: Sequence[str], directory: Optional[str], recursive: bool): + super().__init__() + self._files = list(files) + self._directory = directory + self._recursive = recursive + self._cancel = False + + def cancel(self) -> None: + self._cancel = True + + def _collect_video_files(self, files: Sequence[str], directory: Optional[str] = None, recursive: bool = False) -> \ + list[Path]: + paths: list[Path] = [] + last_emit = 0.0 + emit_interval = 0.1 # seconds (100ms) + for f in files: + if self._cancel: + break + p = Path(f) + if p.is_file() and p.suffix.lower() in VIDEO_EXTS: + paths.append(p) + self.progress.emit(0, len(paths)) + + exts_tuple = tuple(VIDEO_EXTS) + if directory: + if recursive: + for root, _, files in os.walk(directory): + if self._cancel: + break + for name in files: + if self._cancel: + break + # cheap string check first + if name.lower().endswith(exts_tuple): + paths.append(Path(os.path.join(root, name))) + now = time.monotonic() + if now - last_emit >= emit_interval: + last_emit = now + self.progress.emit(0, len(paths)) + else: + with os.scandir(directory) as it: + for entry in it: + if self._cancel: + break + if entry.is_file(): + if entry.name.lower().endswith(exts_tuple): + paths.append(Path(entry.path)) + now = time.monotonic() + if now - last_emit >= emit_interval: + last_emit = now + self.progress.emit(0, len(paths)) + + # de-dupe stable + seen: set[Path] = set() + out: list[Path] = [] + for p in paths: + if p not in seen: + seen.add(p) + out.append(p) + + return out + + @QtCore.Slot() + def run(self) -> None: + last_emit = 0.0 + emit_interval = 0.1 # seconds (100ms) + self.started.emit() + try: + paths = self._collect_video_files(files=self._files, directory=self._directory, recursive=self._recursive) + total = len(paths) + rows: list[MovieRow] = [] + + self.progress.emit(0, total) + + for i, p in enumerate(paths, start=1): + if self._cancel: + self.cancelled.emit(rows) + return + + rows.append(_parse_video_filename(p)) + + now = time.monotonic() + if now - last_emit >= emit_interval or i == total: + last_emit = now + self.progress.emit(i, total) + + self.finished.emit(rows) + except Exception as e: + self.error.emit(str(e)) diff --git a/src/movietagger/qt/workers/save_worker.py b/src/movietagger/qt/workers/save_worker.py new file mode 100644 index 0000000..529b401 --- /dev/null +++ b/src/movietagger/qt/workers/save_worker.py @@ -0,0 +1,66 @@ +import os +import threading +import time + +from PySide6 import QtCore + +from movietagger.core.datatypes import RenameOp + + +class SaveWorker(QtCore.QObject): + progress = QtCore.Signal(int, int) # done, total + resultReady = QtCore.Signal(int, bool, str, str) # (src_row, ok, message, new_path_str) + finished = QtCore.Signal() + + def __init__(self, ops: list[RenameOp], *, emit_interval_s: float = 0.10, parent=None): + super().__init__(parent) + self._ops = ops + self._emit_interval_s = float(emit_interval_s) + self._cancel = threading.Event() + + @QtCore.Slot() + def cancel(self) -> None: + self._cancel.set() + + @QtCore.Slot() + def run(self) -> None: + total = len(self._ops) + done = 0 + last_emit = 0.0 + + self.progress.emit(0, total) + + for op in self._ops: + if self._cancel.is_set(): + break + + ok = True + msg = "Renamed" + + try: + # Ensure destination directory exists (usually same dir, but safe) + op.new_path.parent.mkdir(parents=True, exist_ok=True) + + # On Windows, Path.rename can fail across volumes; os.replace is a bit more robust. + os.replace(str(op.old_path), str(op.new_path)) + except FileExistsError: + ok = False + msg = "Target already exists" + except PermissionError as e: + ok = False + msg = f"Permission error: {e}" + except OSError as e: + ok = False + msg = f"OS error: {e}" + + done += 1 + self.resultReady.emit(op.src_row, ok, msg, str(op.new_path) if ok else str(op.old_path)) + + now = time.monotonic() + if (now - last_emit) >= self._emit_interval_s: + last_emit = now + self.progress.emit(done, total) + + # final progress tick + self.progress.emit(done, total) + self.finished.emit() diff --git a/src/movietagger/qt/workers/tmdb_search_worker.py b/src/movietagger/qt/workers/tmdb_search_worker.py new file mode 100644 index 0000000..570d557 --- /dev/null +++ b/src/movietagger/qt/workers/tmdb_search_worker.py @@ -0,0 +1,28 @@ +from typing import Optional + +from PySide6 import QtCore + +from movietagger.core.config import require_tmdb_env +from movietagger.core.datatypes import Provider +from movietagger.services.tmdb_service import tmdb_search_picks_sync + + +class TmdbSearchWorker(QtCore.QObject): + finished = QtCore.Signal(int, list, Provider) # src_row_idx, picks + error = QtCore.Signal(int, str, Provider) # src_row_idx, message + + def __init__(self, src_row_idx: int, title: str, year: Optional[int]): + super().__init__() + self.src_row_idx = src_row_idx + self.title = title + self.year = year + + require_tmdb_env() + + @QtCore.Slot() + def run(self) -> None: + try: + picks = tmdb_search_picks_sync(self.title, self.year) + self.finished.emit(self.src_row_idx, picks, Provider.TMDB) + except Exception as e: + self.error.emit(self.src_row_idx, str(e), Provider.TMDB) diff --git a/src/movietagger/services/__init__.py b/src/movietagger/services/__init__.py new file mode 100644 index 0000000..c5babd6 --- /dev/null +++ b/src/movietagger/services/__init__.py @@ -0,0 +1 @@ +__all__ = ['tmdb_service', 'imdb_service'] diff --git a/src/movietagger/services/imdb_service.py b/src/movietagger/services/imdb_service.py new file mode 100644 index 0000000..fc0897a --- /dev/null +++ b/src/movietagger/services/imdb_service.py @@ -0,0 +1,48 @@ +import asyncio +from typing import Any, Optional + +from movietagger.core.datatypes import MoviePick, Provider + + +def _safe_int(x: Any) -> Optional[int]: + try: + return int(x) + except ValueError: + return None + + +TMDB_POSTER_BASE = "https://image.tmdb.org/t/p/w92" + + +def _to_pick(obj: Any) -> MoviePick: + # Be defensive: the library objects are attribute-based + mid = getattr(obj, "id") + title = getattr(obj, "title", None) or getattr(obj, "name", "") or "" + year = _safe_int(getattr(obj, "year", None)) + overview = getattr(obj, "overview", "") or "" + + poster_url = "" + + poster_path = getattr(obj, "poster_path", None) + if poster_path: + poster_url = f"{TMDB_POSTER_BASE}{poster_path}" + elif hasattr(obj, "poster_url"): + try: + val = obj.poster_url + if callable(val): + val = val() + if isinstance(val, str) and val: + poster_url = val + except TypeError: + pass + # The README indicates poster_url exists on details; search results may or may not. + # poster_url = getattr(obj, "poster_url", "") or "" + return MoviePick(provider=Provider.IMDB, id=mid, title=title, year=year, overview=overview, poster_url=poster_url) + + +async def imdb_search_picks_async(title: str, year: Optional[int]) -> list[MoviePick]: + return [] + + +def imdb_search_picks_sync(title: str, year: Optional[int]) -> list[MoviePick]: + return asyncio.run(imdb_search_picks_async(title, year)) diff --git a/src/movietagger/services/tmdb_service.py b/src/movietagger/services/tmdb_service.py new file mode 100644 index 0000000..a2f161f --- /dev/null +++ b/src/movietagger/services/tmdb_service.py @@ -0,0 +1,61 @@ +import asyncio +import pprint +from typing import Any, Optional + +from themoviedb import aioTMDb + +from movietagger.core.datatypes import MoviePick, Provider + +TMDB_POSTER_BASE = "https://image.tmdb.org/t/p/w92" + + +def _safe_int(x: Any) -> Optional[int]: + try: + return int(x) + except ValueError: + return None + + +def _to_pick(obj: Any) -> MoviePick: + # Be defensive: the library objects are attribute-based + movie_id = getattr(obj, "id") + title = getattr(obj, "title", None) or getattr(obj, "name", "") or "" + year = _safe_int(getattr(obj, "year", None)) + overview = getattr(obj, "overview", "") or "" + + poster_url = "" + + poster_path = getattr(obj, "poster_path", None) + if poster_path: + poster_url = f"{TMDB_POSTER_BASE}{poster_path}" + elif hasattr(obj, "poster_url"): + try: + val = obj.poster_url + if callable(val): + val = val() + if isinstance(val, str) and val: + poster_url = val + except TypeError: + pass + # The README indicates poster_url exists on details; search results may or may not. + # poster_url = getattr(obj, "poster_url", "") or "" + return MoviePick(provider=Provider.TMDB, id=movie_id, title=title, year=year, overview=overview, poster_url=poster_url) + + +async def tmdb_search_picks_async(title: str, year: Optional[int]) -> list[MoviePick]: + tmdb = aioTMDb() + results = await tmdb.search().movies(title) + + picks = [_to_pick(m) for m in results if m.year is not None] + + # If year was provided: filter client-side --- also grab surrounding years, to be safe + if year: + exactish = [p for p in picks if (year - 2 <= p.year <= year + 2)] + if exactish: + picks = exactish + + return picks + + +def tmdb_search_picks_sync(title: str, year: Optional[int]) -> list[MoviePick]: + return asyncio.run(tmdb_search_picks_async(title, year))