diff --git a/CHANGELOG.md b/CHANGELOG.md index c660b90..6fa7f38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,21 +19,31 @@ Each version entry uses these sections (include only those that apply): - Always update this file before bumping the version in `pyproject.toml` - Document changes as they are made, not all at once at release time -## Unreleased +## 1.14.0 — 2026-10-17 + +### Changed +- **The codebase is mypy-clean** and plain `uv run mypy` works (`[tool.mypy]` + in `pyproject.toml` lists the targets). Qt enums are now fully qualified + (`QDialog.DialogCode.Accepted`, `QMessageBox.StandardButton.Yes`, + `Qt.GlobalColor.red`, …) instead of PySide6's short aliases, and the few + loosely typed spots (tag schema, transforms, `on_files_changed`, dialog cell + widgets) are annotated. No behaviour change. + +### Removed +- `src/core/media_utils.py` — Tagger-era leftovers (`load_icon` returned a + Tkinter image, `add_video_resolution_tag` logged via `print()`); nothing in + the app called either. + +### Dependencies +- Removed `pillow` — `media_utils` was its only user. + +## 1.13.0 — 2026-10-12 ### Changed - **The standalone build is now a single file.** `Curator.spec` produces one self-contained `dist/Curator` executable (onefile) instead of a `dist/Curator/` folder of libraries. It unpacks to a temp folder on every start, so launching takes a few seconds longer. - -### Dependencies -- Added `pyinstaller` to the dev group — `uv run pyinstaller Curator.spec` - now works out of the box (it was used for builds but never declared). - -## 1.13.0 — 2026-10-12 - -### Changed - **Custom attributes are gone from the GUI.** The attribute table in *Upravit film* and *Filmy → "Nastavit atribut…"* (menu and context menu) are removed. Values already stored keep working in miniDLNA filename templates @@ -473,6 +483,8 @@ Each version entry uses these sections (include only those that apply): Commands are now `uv sync` / `uv add` / `uv run` (`DESIGN_DOCUMENT_PYTHON.md` §11). Dependency lower bounds moved up to the versions uv resolved; Poetry's upper bounds (`<7.0.0`, …) are gone. +- Added `pyinstaller` to the dev group — `uv run pyinstaller Curator.spec` + now works out of the box (it was used for builds but never declared). - **ruff 0.15 → 0.17** (also mypy 2.4, pytest 9.1, PySide6 6.12, Pillow 12.3, python-dotenv 1.2.4). ruff 0.17 enables a much wider default rule set; the whole codebase was brought in line with it — see *Changed* below. diff --git a/README.md b/README.md index 6838187..e3b125c 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,63 @@ # Curator -Curator is a desktop **movie-library manager (Filmotéka)**. You import video -files into a managed **pool**, tag them, and Curator generates a browsable -**Filmotéka** — a directory tree of hardlinks into the pool — without ever -duplicating the actual video data on disk. +Curator is a PySide6 desktop app for managing a **Jellyfin-first movie and series +library**. You import video files into a managed **pool**; Curator identifies them +through **TMDb**, names and lays them out the way Jellyfin expects, and can +generate a genre-split **miniDLNA / Filmotéka** tree of hardlinks for dumb DLNA +players — without ever duplicating the video data on disk. -Curator is a fork of the former *Tagger* project; the tagging, filtering and -hardlink-tree machinery is inherited and extended into a full library workflow. +Metadata (posters, plots, `.nfo` files) is Jellyfin's job; Curator owns the files, +their names and the library structure. ## Concepts -- **Pool** — the managed repository of files and the **single source of truth**. - Curator owns it: it imports and removes files itself, so nothing moves behind - its back. The pool has two top-level folders: - - `Filmy` — movies, organized by tags into the generated tree. - - `Seriály` — series (and any other **copy-as-is** folder), mirrored verbatim. -- **Filmotéka (output)** — a generated tree of **hardlinks** into the pool. It is - fully disposable: delete it and nothing is lost, because it is regenerated from - the pool on demand. -- **Pool index** — all pool metadata lives in a single `.Curator.!index` JSON - file at the pool root (title, ČSFD link, tags, date per file). +- **Pool** — the managed folder and the **single source of truth**. Curator + imports, renames and removes files itself, so nothing moves behind its back. + Each top-level folder is a **library** with a kind: -## Workflow + | Kind | Holds | Layout | + |------|-------|--------| + | `movie` | movies | `Title (rok) [tmdbid].ext`, or a folder once a film has several versions | + | `series` | series | `Series (rok) [tmdbid]/Season NN/SxxEyy - Series.ext` | + | `mixed` | movies and series together | as above, side by side | + | `homevideo` | files without a TMDb id | mirrored as-is | -1. Configure the **pool** and the **Filmotéka output** folder. -2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The - file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and - recorded in the index. If a ČSFD link is given, Curator fetches the movie from - [ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země původu** tags - automatically (use "Načíst tagy z ČSFD" to (re)fetch later). -3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI. -4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree - (from the `Rok` / `Žánr` / `Hodnocení` categories); copy-as-is folders such as - `Seriály` are mirrored 1:1 as hardlinks. +- **Pool index** — Curator's own metadata (TMDb id, ČSFD link, versions, tags, + library settings) lives in one `.Curator.!index` JSON file at the pool root. +- **miniDLNA output** — a generated tree of **hardlinks** into the pool, built + from the genres/years/ratings in Jellyfin's NFO files. It is disposable: delete + it and regenerate it at any time. + +## Features + +- Import movies and episodes with a mandatory **TMDb id**; Czech titles come from + TMDb, with **ČSFD** as a fallback and as a bridge for finding the right TMDb id. +- **Multiple versions** of one film (2D/3D, 4K, director's cut) and **multi-part** + films and episodes, named so Jellyfin groups them. +- Subtitle sidecars follow their video through renames. +- Duplicate check, video integrity check (FFmpeg), library statistics. +- Jellyfin integration: trigger a library scan, read what Jellyfin has. +- miniDLNA tree scoped per library, with "Tipy dne" and "Nově přidané" folders. + +## Requirements + +- [uv](https://docs.astral.sh/uv/) — manages Python 3.14 and all dependencies +- `ffmpeg` / `ffprobe` on `PATH` — optional, for the integrity check + +## Configuration + +Secrets and settings are read from a `.env` file in the project root (never +committed): + +| Variable | Purpose | +|----------|---------| +| `TMDB_API_KEY` | TMDb API key — required for identifying movies and series | +| `TMDB_LANGUAGE` | TMDb language, default `cs-CZ` | +| `JELLYFIN_URL` | Jellyfin server URL — optional, for the Jellyfin menu | +| `JELLYFIN_API_KEY` | Jellyfin API key — optional | +| `ENV_DEBUG` | `true` enables debug mode | + +The pool and output folders are chosen in the app (*Pool* menu). ## Running @@ -41,20 +66,23 @@ uv sync uv run python Curator.py ``` -The GUI is built with **PySide6**. - ## Development ```bash uv run pytest # tests -uv run ruff check # lint / format +uv run ruff check # lint +uv run ruff format # format uv run mypy # type checking ``` +Detailed documentation — architecture and a per-module reference — is in +[`docs/`](docs/README.md). + ## Building a standalone executable ```bash -uv run pyinstaller Curator.spec +uv run python prebuild.py +uv run pyinstaller Curator.spec --noconfirm ``` -The resulting `dist/Curator` is the distribution artifact. +The result is a single self-contained `dist/Curator` executable. diff --git a/docs/README.md b/docs/README.md index b486e79..03cb322 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,7 +26,6 @@ Filmotéka tree. | [`src/core/jellyfin.py`](modules/core.jellyfin.md) | Jellyfin server API client — thin, hybrid integration. | | [`src/core/legacy_meta.py`](modules/core.legacy_meta.md) | Readers for Curator's *pre-index* metadata layouts. | | [`src/core/list_manager.py`](modules/core.list_manager.md) | Sorting of file lists by name or by date added. | -| [`src/core/media_utils.py`](modules/core.media_utils.md) | Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/p` tag. | | [`src/core/migrate.py`](modules/core.migrate.md) | One-off migration of an existing pool to the Jellyfin naming convention. | | [`src/core/movie.py`](modules/core.movie.md) | The movie object — metadata first, files second. | | [`src/core/naming.py`](modules/core.naming.md) | Pool file naming convention. | diff --git a/docs/modules/core.media_utils.md b/docs/modules/core.media_utils.md deleted file mode 100644 index b5ff493..0000000 --- a/docs/modules/core.media_utils.md +++ /dev/null @@ -1,27 +0,0 @@ -# `src/core/media_utils.py` - -Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/p` tag. - -Source: [`src/core/media_utils.py`](../../src/core/media_utils.py) - -## Functions - -### `load_icon` - -*function · line 14* - -```python -load_icon(path) -> ImageTk.PhotoImage -``` - -Load an image file and scale it to 16×16 px as a Qt-compatible icon. - -### `add_video_resolution_tag` - -*function · line 19* - -```python -add_video_resolution_tag(file_obj: File, tagmanager: TagManager) -``` - -Read the vertical resolution of a video with `ffprobe` and tag the file with `Rozlišení/p`. Needs FFmpeg on the PATH. diff --git a/pyproject.toml b/pyproject.toml index b7f9bb6..67a1bc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,10 @@ [project] name = "curator" -version = "1.13.0" +version = "1.14.0" requires-python = ">=3.14" dependencies = [ "beautifulsoup4>=4.15.0", "loguru>=0.7.3", - "pillow>=12.3.0", "pyside6>=6.12.0", "python-dotenv>=1.2.4", "requests>=2.34.2", @@ -22,3 +21,6 @@ dev = [ [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.mypy] +files = ["Curator.py", "prebuild.py", "src", "tests", "scripts"] diff --git a/src/_version.py b/src/_version.py index ff618f2..6327036 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "1.13.0" +__version__ = "1.14.0" diff --git a/src/core/config.py b/src/core/config.py index f5f6fc3..4d0472c 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -41,7 +41,7 @@ GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag" # folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root. # This lives in the code as the default; the effective schema is stored per-pool # in the index (``PoolIndex.settings["tag_schema"]``). -DEFAULT_TAG_SCHEMA = [ +DEFAULT_TAG_SCHEMA: list[dict] = [ { "category": "Žánr", "csfd_field": "genres", diff --git a/src/core/file_manager.py b/src/core/file_manager.py index 314a788..3b1d489 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -112,7 +112,9 @@ class FileManager: def __init__(self, tagmanager: TagManager): self.filelist: list[File] = [] self.tagmanager = tagmanager - self.on_files_changed = None # callback do GUI + self.on_files_changed: Callable[[list[File]], None] | None = ( + None # callback do GUI + ) self.global_config = load_global_config() self.index: PoolIndex | None = None # unified pool metadata + settings @@ -689,7 +691,7 @@ class FileManager: total_size = 0 with_nfo = 0 untagged = 0 - ratings: list[int] = [] + ratings: list[float] = [] categories: dict[str, Counter] = {} for f in files: diff --git a/src/core/media_utils.py b/src/core/media_utils.py deleted file mode 100644 index 5b7e6ac..0000000 --- a/src/core/media_utils.py +++ /dev/null @@ -1,57 +0,0 @@ -# Module header -import sys - -from .file import File -from .tag_manager import TagManager - -if __name__ == "__main__": - sys.exit("This module is not intended to be executed as the main program.") - -# Imports -import subprocess - -from PIL import Image, ImageTk - - -# Functions -def load_icon(path) -> ImageTk.PhotoImage: - img = Image.open(path) - img = img.resize((16, 16), Image.Resampling.LANCZOS) - return ImageTk.PhotoImage(img) - - -def add_video_resolution_tag(file_obj: File, tagmanager: TagManager): - """ - Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p. - Vyžaduje ffprobe (FFmpeg). - """ - path = str(file_obj.file_path) - try: - # ffprobe vrátí width a height ve formátu JSON - result = subprocess.run( - [ - "ffprobe", - "-v", - "error", - "-select_streams", - "v:0", - "-show_entries", - "stream=width,height", - "-of", - "csv=p=0:s=x", - path, - ], - capture_output=True, - text=True, - check=True, - ) - res = result.stdout.strip() # např. "1920x1080" - if "x" not in res: - return - _width, height = map(int, res.split("x")) - tag_name = f"Rozlišení/{height}p" - tag_obj = tagmanager.add_tag("Rozlišení", f"{height}p") - file_obj.add_tag(tag_obj) - print(f"Přiřazen tag {tag_name} k {file_obj.filename}") - except (OSError, subprocess.SubprocessError, ValueError) as e: - print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}") diff --git a/src/core/transforms.py b/src/core/transforms.py index 0f6c6b5..5f4939c 100644 --- a/src/core/transforms.py +++ b/src/core/transforms.py @@ -6,6 +6,7 @@ value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it """ import re +from collections.abc import Callable def rating_band(rating: int) -> str: @@ -18,14 +19,14 @@ def rating_band(rating: int) -> str: return f"{low}–{high} %" -def _decade_band(value) -> str: +def _decade_band(value: str) -> str: """``decade_band`` transform: pull the leading integer and bucket it.""" match = re.search(r"\d+", str(value)) return rating_band(int(match.group())) if match else str(value) # Named value transforms usable from the tag schema (config-driven, extensible). -TAG_TRANSFORMS = { +TAG_TRANSFORMS: dict[str | None, Callable[[str], str]] = { None: str, "identity": str, "decade_band": _decade_band, diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index 9da374a..a42e821 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -13,7 +13,7 @@ import os import subprocess import sys from pathlib import Path -from typing import ClassVar +from typing import ClassVar, cast from PySide6.QtCore import QRegularExpression, Qt, QTimer from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator @@ -149,16 +149,16 @@ class ImportMoviesDialog(QDialog): "", ] ) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeToContents) - header.setSectionResizeMode(1, QHeaderView.Stretch) - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.ResizeToContents) - header.setSectionResizeMode(4, QHeaderView.Stretch) - header.setSectionResizeMode(5, QHeaderView.ResizeToContents) - header.setSectionResizeMode(6, QHeaderView.ResizeToContents) - header.setSectionResizeMode(7, QHeaderView.ResizeToContents) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents) layout.addWidget(self.table) add_row = QHBoxLayout() @@ -201,7 +201,9 @@ class ImportMoviesDialog(QDialog): # sources dropped by "Vynechat řádky bez TMDb ID" on accept self._skipped_no_tmdb: list[Path] = [] - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -213,7 +215,7 @@ class ImportMoviesDialog(QDialog): row = self.table.rowCount() self.table.insertRow(row) name_item = QTableWidgetItem(source.name) - name_item.setFlags(Qt.ItemIsEnabled) + name_item.setFlags(Qt.ItemFlag.ItemIsEnabled) self.table.setItem(row, 0, name_item) title_edit = QLineEdit(source.stem) @@ -386,8 +388,7 @@ class ImportMoviesDialog(QDialog): "QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "" ) title_edit.setToolTip( - "V poolu už existuje film „" + stem + "“ — při importu se tě zeptám, " - "co s ním." + f"V poolu už existuje film „{stem}“ — při importu se tě zeptám, co s ním." if clashes else "" ) @@ -496,7 +497,7 @@ class ImportMoviesDialog(QDialog): f"„{target_name}“ (popisek „{label or DEFAULT_VERSION_LABEL}“), " "ale samostatný film?", ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return del self._version_links[source] self._refresh_version_button(source) @@ -546,7 +547,7 @@ class ImportMoviesDialog(QDialog): initial=f"3D {fmt}" if fmt else "", intro=f"„{source.name}“ → verze filmu „{choice}“.", ) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return self._version_links[source] = (target_source, dialog.label()) @@ -586,7 +587,7 @@ class ImportMoviesDialog(QDialog): return found = 0 - QApplication.setOverrideCursor(Qt.WaitCursor) + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: for _src, title_edit, year_edit, tmdb_edit, _csfd in self._rows: try: @@ -635,7 +636,7 @@ class ImportMoviesDialog(QDialog): session = csfd.requests.Session() found = 0 errors: list[str] = [] - QApplication.setOverrideCursor(Qt.WaitCursor) + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: for source, title_edit, year_edit, tmdb_edit, csfd_edit in self._rows: try: @@ -856,11 +857,11 @@ class ImportEpisodesDialog(QDialog): self.table = QTableWidget(0, 3) self.table.setHorizontalHeaderLabels(["Soubor", "SxxEyy", ""]) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.Stretch) - header.setSectionResizeMode(1, QHeaderView.ResizeToContents) - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) layout.addWidget(self.table) add_row = QHBoxLayout() @@ -880,7 +881,9 @@ class ImportEpisodesDialog(QDialog): self.move_check = QCheckBox("Přesunout soubory do poolu (jinak zkopírovat)") layout.addWidget(self.move_check) - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -892,7 +895,7 @@ class ImportEpisodesDialog(QDialog): row = self.table.rowCount() self.table.insertRow(row) name_item = QTableWidgetItem(source.name) - name_item.setFlags(Qt.ItemIsEnabled) + name_item.setFlags(Qt.ItemFlag.ItemIsEnabled) self.table.setItem(row, 0, name_item) code = series.parse_episode(source.name) part = series.parse_episode_part(source.name) if code else None @@ -1019,7 +1022,7 @@ class ImportEpisodesDialog(QDialog): f"{len(missing)} epizod nemá platné SxxEyy a při importu se " f"přeskočí:\n{listing}{more}\n\nPokračovat?", ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return super().accept() @@ -1074,7 +1077,7 @@ class SeriesOverviewDialog(QDialog): layout = QVBoxLayout(self) self.tree = QTreeWidget() self.tree.setHeaderLabels(["Seriál / sezóna / epizoda", "Stav"]) - self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self.tree.itemDoubleClicked.connect(lambda _i, _c: self._edit_selected()) layout.addWidget(self.tree) @@ -1110,23 +1113,23 @@ class SeriesOverviewDialog(QDialog): if ser.tmdb_id: label += f" [tmdb-{ser.tmdb_id}]" top = QTreeWidgetItem([label, f"{len(ser.episodes)} epizod"]) - top.setData(0, Qt.UserRole, idx) + top.setData(0, Qt.ItemDataRole.UserRole, idx) self.tree.addTopLevelItem(top) for season, eps in ser.seasons().items(): sname = ( series.season_folder_name(season) if season >= 0 else "? bez sezóny" ) snode = QTreeWidgetItem([sname, f"{len(eps)} dílů"]) - snode.setData(0, Qt.UserRole + 1, (season, eps)) + snode.setData(0, Qt.ItemDataRole.UserRole + 1, (season, eps)) top.addChild(snode) for ep in eps: ok = series.is_canonical_episode(ep.path, ser.title) enode = QTreeWidgetItem( [f"{ep.code} {ep.path.name}", "OK" if ok else "přejmenovat"] ) - enode.setData(0, Qt.UserRole + 1, ep) + enode.setData(0, Qt.ItemDataRole.UserRole + 1, ep) if not ok: - enode.setForeground(1, Qt.red) + enode.setForeground(1, Qt.GlobalColor.red) snode.addChild(enode) top.setExpanded(True) @@ -1134,7 +1137,7 @@ class SeriesOverviewDialog(QDialog): item = self.tree.currentItem() while item is not None and item.parent() is not None: item = item.parent() - return item.data(0, Qt.UserRole) if item is not None else None + return item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None def _selected_node(self): """``("series"|"season"|"episode", Series, extra)`` for the current @@ -1144,12 +1147,12 @@ class SeriesOverviewDialog(QDialog): if item is None: return None if item.parent() is None: - return "series", self._series[item.data(0, Qt.UserRole)], None + return "series", self._series[item.data(0, Qt.ItemDataRole.UserRole)], None if item.parent().parent() is None: - ser = self._series[item.parent().data(0, Qt.UserRole)] - return "season", ser, item.data(0, Qt.UserRole + 1) - ser = self._series[item.parent().parent().data(0, Qt.UserRole)] - return "episode", ser, item.data(0, Qt.UserRole + 1) + ser = self._series[item.parent().data(0, Qt.ItemDataRole.UserRole)] + return "season", ser, item.data(0, Qt.ItemDataRole.UserRole + 1) + ser = self._series[item.parent().parent().data(0, Qt.ItemDataRole.UserRole)] + return "episode", ser, item.data(0, Qt.ItemDataRole.UserRole + 1) def _rename_selected(self) -> None: idx = self._selected_series_index() @@ -1193,7 +1196,10 @@ class SeriesOverviewDialog(QDialog): f"Smazat epizodu {ep.code} „{ep.path.name}“ ze seriálu „{ser.title}“?" ) action = lambda: self.filehandler.remove_episodes([ep]) - if QMessageBox.question(self, "Odebrat z poolu", question) != QMessageBox.Yes: + if ( + QMessageBox.question(self, "Odebrat z poolu", question) + != QMessageBox.StandardButton.Yes + ): return try: action() @@ -1209,7 +1215,7 @@ class SeriesOverviewDialog(QDialog): return ser = self._series[idx] dialog = SeriesEditDialog(self, ser, self.filehandler) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return # 1) apply per-episode season/episode overrides, 2) normalize episode files, # 3) rename/move the series folder + retitle episodes to the new name. @@ -1269,7 +1275,9 @@ class VersionLabelDialog(QDialog): form.addRow("Popisek:", self.label_edit) layout.addLayout(form) - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -1311,7 +1319,7 @@ class DuplicatesDialog(QDialog): self.tree = QTreeWidget() self.tree.setHeaderLabels(["Film / soubor", "Knihovna", "Verzí", "Velikost"]) - self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self.tree.currentItemChanged.connect(lambda *_: self._sync_buttons()) layout.addWidget(self.tree) @@ -1345,7 +1353,7 @@ class DuplicatesDialog(QDialog): font = top.font(0) font.setBold(True) top.setFont(0, font) - top.setData(0, Qt.UserRole, (gi, None)) + top.setData(0, Qt.ItemDataRole.UserRole, (gi, None)) for mi, movie in enumerate(group["movies"]): versions = self.filehandler.list_movie_versions(movie) grouped = self.filehandler.is_movie_group_member(movie) @@ -1366,7 +1374,7 @@ class DuplicatesDialog(QDialog): ] ) child.setToolTip(0, "\n".join(str(v.file_path) for v in versions)) - child.setData(0, Qt.UserRole, (gi, mi)) + child.setData(0, Qt.ItemDataRole.UserRole, (gi, mi)) top.addChild(child) self.tree.addTopLevelItem(top) top.setExpanded(True) @@ -1386,7 +1394,7 @@ class DuplicatesDialog(QDialog): item = self.tree.currentItem() if item is None: return None - gi, mi = item.data(0, Qt.UserRole) + gi, mi = item.data(0, Qt.ItemDataRole.UserRole) if mi is None: return None group = self.groups[gi] @@ -1420,7 +1428,7 @@ class DuplicatesDialog(QDialog): initial=f"3D {fmt}" if fmt else "", intro=f"„{other.filename}“ → verze filmu „{keep.title or keep.filename}“.", ) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return try: self.filehandler.merge_as_version(keep, other, dialog.label()) @@ -1443,7 +1451,7 @@ class DuplicatesDialog(QDialog): f"Smazat „{movie.file_path.name}“{extra} z poolu včetně souboru, " "titulků a metadat?", ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return try: self.filehandler.remove_movie(movie) @@ -1523,8 +1531,10 @@ class MovieEditDialog(QDialog): ) self.subs_table = QTableWidget(0, 2) self.subs_table.setHorizontalHeaderLabels(["Soubor", "Jazyk / příznak"]) - self.subs_table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.subs_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.subs_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.subs_table.horizontalHeader().setSectionResizeMode( + 0, QHeaderView.ResizeMode.Stretch + ) self.subs_table.setMaximumHeight(110) self._subtitles: list = [] layout.addWidget(self.subs_table) @@ -1570,10 +1580,14 @@ class MovieEditDialog(QDialog): self.versions_table = QTableWidget(0, 3) self.versions_table.setHorizontalHeaderLabels(["Soubor", "Typ", "Popisek"]) - self.versions_table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.versions_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.versions_table.setEditTriggers( + QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.versions_table.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows + ) self.versions_table.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.Stretch + 0, QHeaderView.ResizeMode.Stretch ) self.versions_table.setMaximumHeight(130) self._versions: list = [] @@ -1608,7 +1622,9 @@ class MovieEditDialog(QDialog): layout.addWidget(media_box) self._reload_versions() - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -1648,10 +1664,10 @@ class MovieEditDialog(QDialog): self, "Titulky", "Jsou to vynucené (forced) titulky?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - == QMessageBox.Yes + == QMessageBox.StandardButton.Yes ) try: self.filehandler.attach_subtitle( @@ -1674,7 +1690,7 @@ class MovieEditDialog(QDialog): answer = QMessageBox.question( self, "Odebrat titulky", f"Smazat soubor „{sub.path.name}“?" ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return try: self.filehandler.remove_subtitle(sub.path) @@ -1815,7 +1831,7 @@ class MovieEditDialog(QDialog): f"Soubory se přejmenují na díly v tomto pořadí (pak jde " f"pořadí změnit šipkami):\n{order}", ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: self._reload_versions() return self.filehandler.set_movie_group_mode(self.file_obj, "parts") @@ -1827,7 +1843,10 @@ class MovieEditDialog(QDialog): intro=f"Popisek verze pro „{f.file_path.name}“ " f"(první soubor bude výchozí verze).", ) - if dialog.exec() != QDialog.Accepted or not dialog.label(): + if ( + dialog.exec() != QDialog.DialogCode.Accepted + or not dialog.label() + ): self._reload_versions() return labels.append(dialog.label()) @@ -1846,7 +1865,7 @@ class MovieEditDialog(QDialog): added = self.filehandler.add_movie_part(self.file_obj, Path(path)) else: dialog = VersionLabelDialog(self) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return added = self.filehandler.add_movie_version( self.file_obj, Path(path), dialog.label() @@ -1879,7 +1898,7 @@ class MovieEditDialog(QDialog): dialog = VersionLabelDialog( self, initial=label_item.text() if label_item else "" ) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return try: self.filehandler.rename_movie_version(version, dialog.label()) @@ -1904,7 +1923,7 @@ class MovieEditDialog(QDialog): answer = QMessageBox.question( self, "Odebrat médium", f"Smazat soubor „{version.file_path.name}“?" ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return # The dialog may be open on the very file being removed (it opens on # the movie's primary version) — carry on with a sibling instead. All @@ -2033,13 +2052,13 @@ class SeriesEditDialog(QDialog): layout.addWidget(QLabel("Epizody — uprav sezónu/díl (uloží se jako SxxEyy):")) table = QTableWidget(0, 3) table.setHorizontalHeaderLabels(["Soubor", "Sezóna", "Díl"]) - table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) - table.setEditTriggers(QAbstractItemView.NoEditTriggers) + table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) for ep in ser.episodes: row = table.rowCount() table.insertRow(row) name_item = QTableWidgetItem(ep.path.name) - name_item.setFlags(Qt.ItemIsEnabled) + name_item.setFlags(Qt.ItemFlag.ItemIsEnabled) table.setItem(row, 0, name_item) season_spin = QSpinBox() season_spin.setRange(0, 99) @@ -2052,7 +2071,9 @@ class SeriesEditDialog(QDialog): self._spins.append((ep, season_spin, ep_spin)) layout.addWidget(table) - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -2155,9 +2176,11 @@ class LibraryOrganizerDialog(QDialog): self.table = QTableWidget(0, 2) self.table.setHorizontalHeaderLabels(["Název složky", "Typ"]) - self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode( - 1, QHeaderView.ResizeToContents + 0, QHeaderView.ResizeMode.Stretch + ) + self.table.horizontalHeader().setSectionResizeMode( + 1, QHeaderView.ResizeMode.ResizeToContents ) layout.addWidget(self.table) for lib in libraries: @@ -2182,7 +2205,9 @@ class LibraryOrganizerDialog(QDialog): note.setStyleSheet("color: #888;") layout.addWidget(note) - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -2210,7 +2235,7 @@ class LibraryOrganizerDialog(QDialog): name = name_item.text().strip() if name_item else "" if not name: continue - combo = self.table.cellWidget(row, 1) + combo = cast(QComboBox, self.table.cellWidget(row, 1)) result.append({"name": name, "kind": combo.currentData()}) return result @@ -2247,24 +2272,28 @@ class MinidlnaLibrariesDialog(QDialog): chosen = set(selected) if selected is not None else None # None = all for lib in libraries: item = QListWidgetItem(f"{lib['name']} ({lib['kind']})") - item.setData(Qt.UserRole, lib["name"]) + item.setData(Qt.ItemDataRole.UserRole, lib["name"]) item.setCheckState( - Qt.Checked if chosen is None or lib["name"] in chosen else Qt.Unchecked + Qt.CheckState.Checked + if chosen is None or lib["name"] in chosen + else Qt.CheckState.Unchecked ) self.list.addItem(item) layout.addWidget(self.list) quick = QHBoxLayout() all_btn = QPushButton("Vše") - all_btn.clicked.connect(lambda: self._set_all(Qt.Checked)) + all_btn.clicked.connect(lambda: self._set_all(Qt.CheckState.Checked)) none_btn = QPushButton("Nic") - none_btn.clicked.connect(lambda: self._set_all(Qt.Unchecked)) + none_btn.clicked.connect(lambda: self._set_all(Qt.CheckState.Unchecked)) quick.addWidget(all_btn) quick.addWidget(none_btn) quick.addStretch(1) layout.addLayout(quick) - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -2275,9 +2304,9 @@ class MinidlnaLibrariesDialog(QDialog): def selected_libraries(self) -> list[str]: return [ - self.list.item(i).data(Qt.UserRole) + self.list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list.count()) - if self.list.item(i).checkState() == Qt.Checked + if self.list.item(i).checkState() == Qt.CheckState.Checked ] @@ -2316,22 +2345,22 @@ class MigrateDialog(QDialog): table = QTableWidget(len(proposals), 3) table.setHorizontalHeaderLabels(["Soubor", "Stav", "Kandidát z TMDb"]) - table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) - table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) - table.setEditTriggers(QAbstractItemView.NoEditTriggers) + table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) for row, p in enumerate(proposals): name_item = QTableWidgetItem(p.current_name) - name_item.setFlags(Qt.ItemIsEnabled) + name_item.setFlags(Qt.ItemFlag.ItemIsEnabled) if getattr(p, "note", ""): name_item.setToolTip(p.note) - name_item.setForeground(Qt.darkBlue) + name_item.setForeground(Qt.GlobalColor.darkBlue) table.setItem(row, 0, name_item) status_item = QTableWidgetItem(self._STATUS.get(p.status, p.status)) - status_item.setFlags(Qt.ItemIsEnabled) + status_item.setFlags(Qt.ItemFlag.ItemIsEnabled) if p.status == "auto": - status_item.setForeground(Qt.darkGreen) + status_item.setForeground(Qt.GlobalColor.darkGreen) elif p.status in ("no_match", "error"): - status_item.setForeground(Qt.red) + status_item.setForeground(Qt.GlobalColor.red) table.setItem(row, 1, status_item) if p.candidates: @@ -2355,7 +2384,7 @@ class MigrateDialog(QDialog): self._rows.append((p, combo)) else: cell = QTableWidgetItem(p.error or "nic nenalezeno") - cell.setFlags(Qt.ItemIsEnabled) + cell.setFlags(Qt.ItemFlag.ItemIsEnabled) table.setItem(row, 2, cell) self._rows.append((p, None)) layout.addWidget(table) @@ -2441,7 +2470,9 @@ class AssignTagsDialog(QDialog): add_btn.clicked.connect(self._add_new_tag) layout.addWidget(add_btn) - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -2454,7 +2485,7 @@ class AssignTagsDialog(QDialog): """Return (creating if needed) the header item for a category.""" if category not in self._cat_items: cat_item = QTreeWidgetItem([category]) - cat_item.setFlags(Qt.ItemIsEnabled) + cat_item.setFlags(Qt.ItemFlag.ItemIsEnabled) self.tree.addTopLevelItem(cat_item) cat_item.setExpanded(True) self._cat_items[category] = cat_item @@ -2466,17 +2497,21 @@ class AssignTagsDialog(QDialog): for fp, item in self._items: if fp == full_path: # already listed → optionally (re)check it if force_checked: - item.setCheckState(0, Qt.Checked) + item.setCheckState(0, Qt.CheckState.Checked) return item have = sum(1 for s in self._file_tag_sets if full_path in s) if force_checked or have == self._file_count and self._file_count: - state = Qt.Checked + state = Qt.CheckState.Checked elif have == 0: - state = Qt.Unchecked + state = Qt.CheckState.Unchecked else: - state = Qt.PartiallyChecked + state = Qt.CheckState.PartiallyChecked item = QTreeWidgetItem([name]) - item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsAutoTristate) + item.setFlags( + Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsAutoTristate + ) item.setCheckState(0, state) self._category_item(category).addChild(item) self._items.append((full_path, item)) @@ -2501,7 +2536,11 @@ class AssignTagsDialog(QDialog): self.tree.scrollToItem(item) def accept(self) -> None: - mapping = {Qt.Checked: 1, Qt.Unchecked: 0, Qt.PartiallyChecked: 2} + mapping = { + Qt.CheckState.Checked: 1, + Qt.CheckState.Unchecked: 0, + Qt.CheckState.PartiallyChecked: 2, + } self.result_map = {fp: mapping[item.checkState(0)] for fp, item in self._items} super().accept() @@ -2556,12 +2595,12 @@ class TagSchemaDialog(QDialog): ] ) hdr = self.table.horizontalHeader() - hdr.setSectionResizeMode(0, QHeaderView.Stretch) - hdr.setSectionResizeMode(1, QHeaderView.ResizeToContents) - hdr.setSectionResizeMode(2, QHeaderView.ResizeToContents) - hdr.setSectionResizeMode(3, QHeaderView.ResizeToContents) - hdr.setSectionResizeMode(4, QHeaderView.Stretch) - hdr.setSectionResizeMode(5, QHeaderView.Stretch) + hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) + hdr.setSectionResizeMode(5, QHeaderView.ResizeMode.Stretch) layout.addWidget(self.table) btn_row = QHBoxLayout() @@ -2574,7 +2613,9 @@ class TagSchemaDialog(QDialog): btn_row.addStretch(1) layout.addLayout(btn_row) - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self._save) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -2632,17 +2673,21 @@ class TagSchemaDialog(QDialog): def _save(self) -> None: schema: list[dict] = [] for row in range(self.table.rowCount()): - category = self.table.cellWidget(row, 0).text().strip() + category = cast(QLineEdit, self.table.cellWidget(row, 0)).text().strip() if not category: continue - make_folders = self.table.cellWidget(row, 3).isChecked() - folder = self.table.cellWidget(row, 4).text().strip() - template = self.table.cellWidget(row, 5).text().strip() + make_folders = cast(QCheckBox, self.table.cellWidget(row, 3)).isChecked() + folder = cast(QLineEdit, self.table.cellWidget(row, 4)).text().strip() + template = cast(QLineEdit, self.table.cellWidget(row, 5)).text().strip() schema.append( { "category": category, - "csfd_field": self.table.cellWidget(row, 1).currentData(), - "transform": self.table.cellWidget(row, 2).currentData(), + "csfd_field": cast( + QComboBox, self.table.cellWidget(row, 1) + ).currentData(), + "transform": cast( + QComboBox, self.table.cellWidget(row, 2) + ).currentData(), "filmoteka_root": folder if make_folders else None, "filename_template": template or None, } @@ -2683,7 +2728,7 @@ class StatsDialog(QDialog): cat_item.addChild(QTreeWidgetItem([name, str(count)])) layout.addWidget(tree) - buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) buttons.rejected.connect(self.reject) buttons.accepted.connect(self.accept) layout.addWidget(buttons) @@ -2812,7 +2857,7 @@ class QtApp(QMainWindow): return action def _build_central(self) -> None: - splitter = QSplitter(Qt.Horizontal) + splitter = QSplitter(Qt.Orientation.Horizontal) # Sidebar — tag filter sidebar = QWidget() @@ -2849,18 +2894,20 @@ class QtApp(QMainWindow): self.table.setHorizontalHeaderLabels( ["Název", "Typ", "Knihovna", "Štítky", "Velikost"] ) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.table.setContextMenuPolicy(Qt.CustomContextMenu) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self._show_table_menu) self.table.doubleClicked.connect(lambda _: self.edit_item()) self.table.itemSelectionChanged.connect(self._update_selection_status) header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.Stretch) # Název - header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Typ - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Knihovna - header.setSectionResizeMode(3, QHeaderView.Stretch) # Štítky + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) # Název + header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Typ + header.setSectionResizeMode( + 2, QHeaderView.ResizeMode.ResizeToContents + ) # Knihovna + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) # Štítky main_layout.addWidget(self.table) self._refresh_library_filter() splitter.addWidget(main) @@ -2873,7 +2920,9 @@ class QtApp(QMainWindow): form = QFormLayout() self.d_filename = QLabel("—") self.d_filename.setWordWrap(True) - self.d_filename.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.d_filename.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) self.d_title = QLabel("—") self.d_title.setWordWrap(True) self.d_library = QLabel("—") @@ -2970,30 +3019,35 @@ class QtApp(QMainWindow): expanded = {} for i in range(self.tag_tree.topLevelItemCount()): cat = self.tag_tree.topLevelItem(i) - expanded[cat.text(0)] = cat.isExpanded() + if cat is not None: + expanded[cat.text(0)] = cat.isExpanded() self.tag_tree.blockSignals(True) self.tag_tree.clear() for category in self.tagmanager.get_categories(): cat_item = QTreeWidgetItem([category]) - cat_item.setFlags(Qt.ItemIsEnabled) + cat_item.setFlags(Qt.ItemFlag.ItemIsEnabled) self.tag_tree.addTopLevelItem(cat_item) cat_item.setExpanded(expanded.get(category, True)) for tag in self.tagmanager.get_tags_in_category(category): count = counts.get(tag.full_path, 0) item = QTreeWidgetItem([f"{tag.name} ({count})"]) - item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + item.setFlags( + Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled + ) checked = tag.full_path in self._active_filter - item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) - item.setData(0, Qt.UserRole, tag.full_path) + item.setCheckState( + 0, Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked + ) + item.setData(0, Qt.ItemDataRole.UserRole, tag.full_path) cat_item.addChild(item) self.tag_tree.blockSignals(False) def _on_tag_filter_changed(self, item, _col) -> None: - full_path = item.data(0, Qt.UserRole) + full_path = item.data(0, Qt.ItemDataRole.UserRole) if full_path is None: return # category header row, not a tag - if item.checkState(0) == Qt.Checked: + if item.checkState(0) == Qt.CheckState.Checked: self._active_filter.add(full_path) else: self._active_filter.discard(full_path) @@ -3271,7 +3325,7 @@ class QtApp(QMainWindow): dialog = ImportMoviesDialog( self, sources, existing_names, self.filehandler.libraries_accepting_movies ) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return entries = dialog.entries() version_links = dialog.version_links() @@ -3306,9 +3360,10 @@ class QtApp(QMainWindow): on_conflict = "suffix" version_labels: dict[Path, str] = {} if colliding: - on_conflict = self._resolve_import_conflicts(colliding) - if on_conflict is None: + choice = self._resolve_import_conflicts(colliding) + if choice is None: return # user cancelled the whole import + on_conflict = choice if on_conflict == "version": # Each already-pooled film's new file gets its own popisek. for s, t, y, tid, _c, fmt in entries: @@ -3319,7 +3374,7 @@ class QtApp(QMainWindow): initial=f"3D {fmt}" if fmt else "", intro=f"„{s.name}“ → nová verze filmu „{t}“, který už v poolu je.", ) - if label_dialog.exec() != QDialog.Accepted: + if label_dialog.exec() != QDialog.DialogCode.Accepted: return # cancelled — nothing imported yet version_labels[s] = label_dialog.label() @@ -3396,17 +3451,19 @@ class QtApp(QMainWindow): twice is a duplicate, and a duplicate is what *versions* are for. """ box = QMessageBox(self) - box.setIcon(QMessageBox.Question) + box.setIcon(QMessageBox.Icon.Question) box.setWindowTitle("Film už v poolu je") listing = "\n".join(f" • {n}" for n in names[:10]) more = f"\n … a další ({len(names) - 10})" if len(names) > 10 else "" box.setText(f"Tyhle filmy už v poolu jsou:\n{listing}{more}\n\nCo s nimi?") - version_btn = box.addButton("Přidat jako verzi", QMessageBox.AcceptRole) - replace_btn = box.addButton( - "Nahradit celý film novým", QMessageBox.DestructiveRole + version_btn = box.addButton( + "Přidat jako verzi", QMessageBox.ButtonRole.AcceptRole ) - skip_btn = box.addButton("Přeskočit", QMessageBox.ActionRole) - box.addButton("Zrušit import", QMessageBox.RejectRole) + replace_btn = box.addButton( + "Nahradit celý film novým", QMessageBox.ButtonRole.DestructiveRole + ) + skip_btn = box.addButton("Přeskočit", QMessageBox.ButtonRole.ActionRole) + box.addButton("Zrušit import", QMessageBox.ButtonRole.RejectRole) box.setDefaultButton(version_btn) box.exec() clicked = box.clickedButton() @@ -3440,7 +3497,7 @@ class QtApp(QMainWindow): return schema_categories = [e["category"] for e in self.filehandler.tag_schema] dialog = AssignTagsDialog(self, self.tagmanager, files, schema_categories) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return for full_path, state in dialog.result_map.items(): category, name = full_path.split("/", 1) @@ -3498,7 +3555,7 @@ class QtApp(QMainWindow): QMessageBox.information(self, "Upravit film", "Vyber právě jeden film.") return dialog = MovieEditDialog(self, files[0], self.filehandler) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: self.refresh_table() # versions may have been added/removed meanwhile return f = dialog.file_obj # its own file may have been removed — a sibling then @@ -3523,7 +3580,7 @@ class QtApp(QMainWindow): def edit_series(self, ser) -> None: """Open the series editor (params + per-episode SxxEyy tree) for ``ser``.""" dialog = SeriesEditDialog(self, ser, self.filehandler) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return for episode, season, number in dialog.episode_numbers(): episode.season, episode.episode = season, number @@ -3556,15 +3613,15 @@ class QtApp(QMainWindow): "Přejmenovat dle metadat (rok)", f"Přejmenovat {scope} ({len(files)}) na „Název (rok) [tmdbid].ext“ " "podle metadat?\n\nFilmy bez známého roku se přeskočí.", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, len(files), self) progress.setWindowTitle("Přejmenovat dle metadat (rok)") - progress.setWindowModality(Qt.WindowModal) + progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setMinimumDuration(0) def on_progress(done: int, total: int, f: File) -> None: @@ -3603,7 +3660,9 @@ class QtApp(QMainWindow): detail.append(f"■ Kolize názvu ({len(conflicts)}):") detail += [f" • {old} — {msg}" for old, msg in conflicts] box = QMessageBox(self) - box.setIcon(QMessageBox.Warning if conflicts else QMessageBox.Information) + box.setIcon( + QMessageBox.Icon.Warning if conflicts else QMessageBox.Icon.Information + ) box.setWindowTitle("Přejmenování dle metadat") box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") box.setDetailedText("\n".join(detail)) @@ -3620,7 +3679,7 @@ class QtApp(QMainWindow): QMessageBox.warning(self, "Pool", "Nejprve nastavte pool.") return dialog = ImportEpisodesDialog(self, [], self.filehandler.series_libraries) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return result = self.filehandler.import_episodes( dialog.sources(), @@ -3667,15 +3726,15 @@ class QtApp(QMainWindow): f"Přejmenovat epizody u {count} seriálů na „SxxEyy - Název " "seriálu.ext“ a uklidit prázdné Season složky?\n\nEpizody bez " "rozpoznaného SxxEyy se ponechají a vypíšou.", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, count, self) progress.setWindowTitle("Napravit názvy souborů seriálů") - progress.setWindowModality(Qt.WindowModal) + progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setMinimumDuration(0) def on_progress(done: int, total: int, name: str) -> None: @@ -3716,7 +3775,9 @@ class QtApp(QMainWindow): detail.append(f"■ Kolize ({len(conflicts)}):") detail += [f" • [{s}] {n} — {msg}" for s, n, msg in conflicts] box = QMessageBox(self) - box.setIcon(QMessageBox.Warning if conflicts else QMessageBox.Information) + box.setIcon( + QMessageBox.Icon.Warning if conflicts else QMessageBox.Icon.Information + ) box.setWindowTitle("Napravit názvy souborů seriálů") box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") box.setDetailedText("\n".join(detail)) @@ -3735,7 +3796,7 @@ class QtApp(QMainWindow): "Výchozí verze filmů s víc verzemi se přejmenuje z „Název - Výchozí.ext“ " "na holé „Název.ext“ (titulky a metadata se přenesou). Pokračovat?", ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return renamed = self.filehandler.normalize_default_version_names() self.refresh_table() @@ -3771,7 +3832,7 @@ class QtApp(QMainWindow): progress = QProgressDialog("Hledám na TMDb…", "Zrušit", 0, len(files), self) progress.setWindowTitle("Migrace — párování s TMDb") - progress.setWindowModality(Qt.WindowModal) + progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setMinimumDuration(0) def on_progress(done: int, total: int, f: File) -> None: @@ -3803,7 +3864,7 @@ class QtApp(QMainWindow): "Načítám žánry z TMDb…", "Zrušit", 0, len(files), self ) progress.setWindowTitle("Načíst žánry z TMDb") - progress.setWindowModality(Qt.WindowModal) + progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setMinimumDuration(0) def on_progress(done: int, total: int, f: File) -> None: @@ -3844,7 +3905,7 @@ class QtApp(QMainWindow): f"Smazat {len(files)} film(ů) z poolu včetně souborů, titulků a " f"metadat?{note}", ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return for f in files: try: @@ -3866,7 +3927,7 @@ class QtApp(QMainWindow): f"Smazat {len(targets)} seriál(ů) z poolu včetně všech sezón a " f"epizod?\n{listing}{more}", ) - if answer != QMessageBox.Yes: + if answer != QMessageBox.StandardButton.Yes: return for ser in targets: try: @@ -3884,7 +3945,7 @@ class QtApp(QMainWindow): dialog = MinidlnaLibrariesDialog( self, self.filehandler.libraries, self.filehandler.minidlna_libraries ) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return chosen = dialog.selected_libraries() all_names = {lib["name"] for lib in self.filehandler.libraries} @@ -3987,7 +4048,7 @@ class QtApp(QMainWindow): ) scope_note = ( - f"\n(omezeno na {len(self.filehandler.minidlna_libraries)} knihoven — " + f"\n(omezeno na {len(self.filehandler.minidlna_libraries or [])} knihoven — " "miniDLNA → „Knihovny pro generování…“)" if scoped else "" @@ -4016,7 +4077,7 @@ class QtApp(QMainWindow): QMessageBox.information(self, "Knihovny", "Nejprve nastavte pool.") return dialog = LibraryOrganizerDialog(self, self.filehandler.libraries) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return self.filehandler.set_libraries(dialog.libraries()) pool = self.filehandler.pool_dir @@ -4043,10 +4104,10 @@ class QtApp(QMainWindow): "Úklid složek", f"V poolu jsou prázdné složky, které nejsou knihovnou " f"({len(empty)}):\n{listing}{more}\n\nSmazat je?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if ans == QMessageBox.Yes: + if ans == QMessageBox.StandardButton.Yes: removed = sum( 1 for n in empty if self.filehandler.remove_empty_pool_folder(n) ) @@ -4111,7 +4172,7 @@ class QtApp(QMainWindow): """Run a Jellyfin call under a wait cursor; return (result, error_str).""" from src.core import jellyfin - QApplication.setOverrideCursor(Qt.WaitCursor) + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: return fn(), None except jellyfin.JellyfinError as exc: @@ -4137,9 +4198,12 @@ class QtApp(QMainWindow): if not self._jellyfin_ready(): return - version, err = self._jellyfin_call( - lambda: jellyfin.scan_all() or jellyfin.server_version() - ) + + def scan_then_version() -> str: + jellyfin.scan_all() + return jellyfin.server_version() + + version, err = self._jellyfin_call(scan_then_version) if err: self._update_jellyfin_status(None) QMessageBox.warning(self, "Jellyfin", err) @@ -4188,7 +4252,7 @@ class QtApp(QMainWindow): detail += [f" • {n}" for n in jf_only] box = QMessageBox(self) - box.setIcon(QMessageBox.Warning) + box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle("Ověření proti Jellyfin — nesrovnalosti") box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") box.setDetailedText("\n".join(detail)) @@ -4196,7 +4260,7 @@ class QtApp(QMainWindow): def edit_tag_schema(self) -> None: dialog = TagSchemaDialog(self, self.filehandler.tag_schema) - if dialog.exec() != QDialog.Accepted or dialog.result_schema is None: + if dialog.exec() != QDialog.DialogCode.Accepted or dialog.result_schema is None: return self.filehandler.set_tag_schema(dialog.result_schema) self.refresh_table() # categories/sidebar may have changed @@ -4250,7 +4314,7 @@ class QtApp(QMainWindow): detail += [f" • {k}" for k in untracked] box = QMessageBox(self) - box.setIcon(QMessageBox.Warning) + box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle("Kontrola konzistence — nalezeny nesrovnalosti") box.setText( f"Metadata bez souboru: {len(missing)}\n" @@ -4275,7 +4339,7 @@ class QtApp(QMainWindow): # Depth: deep = full decode (thorough, slow), quick = ffprobe (fast). box = QMessageBox(self) - box.setIcon(QMessageBox.Question) + box.setIcon(QMessageBox.Icon.Question) box.setWindowTitle("Kontrola integrity videa") box.setText( f"Zkontrolovat {scope} ({len(files)}).\n\n" @@ -4283,9 +4347,9 @@ class QtApp(QMainWindow): "uvnitř souboru, ale čte celý soubor (pomalé).\n" "Rychlá: ffprobe — odhalí nečitelné/uříznuté soubory (rychlé)." ) - deep_btn = box.addButton("Důkladná", QMessageBox.AcceptRole) - quick_btn = box.addButton("Rychlá", QMessageBox.AcceptRole) - box.addButton("Zrušit", QMessageBox.RejectRole) + deep_btn = box.addButton("Důkladná", QMessageBox.ButtonRole.AcceptRole) + quick_btn = box.addButton("Rychlá", QMessageBox.ButtonRole.AcceptRole) + box.addButton("Zrušit", QMessageBox.ButtonRole.RejectRole) box.exec() clicked = box.clickedButton() if clicked not in (deep_btn, quick_btn): @@ -4296,7 +4360,7 @@ class QtApp(QMainWindow): "Kontroluji integritu videa…", "Zrušit", 0, len(files), self ) progress.setWindowTitle("Kontrola integrity videa") - progress.setWindowModality(Qt.WindowModal) + progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setMinimumDuration(0) def on_progress(done: int, total: int, f: File) -> None: @@ -4339,7 +4403,7 @@ class QtApp(QMainWindow): detail += [f" • {r.path.name} — {r.error}" for r in unchecked] msg = QMessageBox(self) - msg.setIcon(QMessageBox.Warning) + msg.setIcon(QMessageBox.Icon.Warning) msg.setWindowTitle("Kontrola integrity — nalezeny problémy") msg.setText( f"Zkontrolováno: {checked}{suffix}\n" diff --git a/tests/test_media_utils.py b/tests/test_media_utils.py deleted file mode 100644 index 61c8335..0000000 --- a/tests/test_media_utils.py +++ /dev/null @@ -1,76 +0,0 @@ -import tempfile -import tkinter as tk -from pathlib import Path - -import pytest -from PIL import Image, ImageTk - -from src.core.media_utils import load_icon - - -@pytest.fixture(scope="module") -def tk_root(): - """Fixture pro inicializaci Tkinteru (nutné pro ImageTk).""" - root = tk.Tk() - yield root - root.destroy() - - -def test_load_icon_returns_photoimage(tk_root): - """Test že load_icon vrací PhotoImage""" - # vytvoříme dočasný obrázek - with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: - tmp_path = Path(tmp.name) - try: - # vytvoříme 100x100 červený obrázek - img = Image.new("RGB", (100, 100), color="red") - img.save(tmp_path) - - icon = load_icon(tmp_path) - - # musí být PhotoImage - assert isinstance(icon, ImageTk.PhotoImage) - - # ověříme velikost 16x16 - assert icon.width() == 16 - assert icon.height() == 16 - finally: - tmp_path.unlink(missing_ok=True) - - -def test_load_icon_resizes_image(tk_root): - """Test že load_icon správně změní velikost obrázku""" - with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: - tmp_path = Path(tmp.name) - try: - # vytvoříme velký obrázek 500x500 - img = Image.new("RGB", (500, 500), color="blue") - img.save(tmp_path) - - icon = load_icon(tmp_path) - - # i velký obrázek by měl být zmenšen na 16x16 - assert icon.width() == 16 - assert icon.height() == 16 - finally: - tmp_path.unlink(missing_ok=True) - - -def test_load_icon_different_formats(tk_root): - """Test načítání různých formátů obrázků""" - formats = [".png", ".jpg", ".bmp"] - - for fmt in formats: - with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp: - tmp_path = Path(tmp.name) - try: - img = Image.new("RGB", (32, 32), color="green") - img.save(tmp_path) - - icon = load_icon(tmp_path) - - assert isinstance(icon, ImageTk.PhotoImage) - assert icon.width() == 16 - assert icon.height() == 16 - finally: - tmp_path.unlink(missing_ok=True) diff --git a/tests/test_movie.py b/tests/test_movie.py index 638361d..4ed0e2c 100644 --- a/tests/test_movie.py +++ b/tests/test_movie.py @@ -1,6 +1,7 @@ """Tests for the movie object and the pool layout it derives (src.core.movie).""" from pathlib import Path +from typing import Any import pytest @@ -79,8 +80,8 @@ class TestIdentityKey: assert identity_key(None, "Nějaký film", None) == "name-nejaky-film" -def _movie(**kwargs) -> Movie: - base = { +def _movie(**kwargs: Any) -> Movie: + base: dict[str, Any] = { "key": "tmdb-19995", "title": "Avatar", "year": "2009", diff --git a/uv.lock b/uv.lock index 978c4cb..6e244ce 100644 --- a/uv.lock +++ b/uv.lock @@ -206,12 +206,11 @@ wheels = [ [[package]] name = "curator" -version = "1.13.0" +version = "1.14.0" source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, { name = "loguru" }, - { name = "pillow" }, { name = "pyside6" }, { name = "python-dotenv" }, { name = "requests" }, @@ -230,7 +229,6 @@ dev = [ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.15.0" }, { name = "loguru", specifier = ">=0.7.3" }, - { name = "pillow", specifier = ">=12.3.0" }, { name = "pyside6", specifier = ">=6.12.0" }, { name = "python-dotenv", specifier = ">=1.2.4" }, { name = "requests", specifier = ">=2.34.2" }, @@ -436,56 +434,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] -[[package]] -name = "pillow" -version = "12.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, - { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, - { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, - { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, - { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, - { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, - { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, - { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, - { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, - { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, - { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, - { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, - { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, - { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, - { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, - { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, - { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, - { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, - { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, - { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, - { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, -] - [[package]] name = "pluggy" version = "1.6.0"