diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fdacba --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.pyc +*.pyo + +# Virtual environment +.venv/ + +# Tooling caches +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ + +# Environment and secrets +.env + +# Logs (loguru file sink) +logs/ + +# PyInstaller build artifacts (dist/ is committed — see DESIGN_DOCUMENT §13) +build/ + +# Curator runtime metadata (user/machine-specific, regenerated) +.Curator.!gtag +*.!gtag +*.!ftag +*.!tag +*.!index + +# IDE +.vscode/ +.idea/ + +AGENTS.md +CLAUDE.md +DESIGN_DOCUMENT.md +.claude/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3479bca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +Document all notable changes to the project, grouped by version and release date. + +## Format + +Each version entry uses these sections (include only those that apply): + +- **Added** — new features +- **Changed** — changes to existing functionality +- **Fixed** — bug fixes +- **Removed** — removed features +- **Dependencies** — added, updated, or removed dependencies + +## Rules + +- Follow semantic versioning: `MAJOR.MINOR.PATCH` +- Newest version goes at the top +- 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 + +### Added +- Fork of the former **Tagger** project as **Curator**, a movie-library manager. +- **Pool** concept (single source of truth) with `Filmy` / `Seriály` folders and + a configurable **Filmotéka** output folder, stored in the global config. +- "Import movie" flow: pick a video, enter Title + ČSFD link, the file is copied + into `pool/Filmy` as `Title.ext` (non-destructive) and indexed. +- `File` now stores `title` and `csfd_link`. +- New **PySide6** GUI reframed around the Filmotéka workflow (pool setup, import, + tag filter sidebar, movie table, one-click Filmotéka generation), replacing the + tkinter GUI as the entry point. +- Seriály **copy-as-is mirror**: `pool/Seriály` is cloned 1:1 into the Filmotéka + output as hardlinks (`HardlinkManager.mirror_as_is`), generated alongside the + movie tree. +- **Unified pool metadata index** (`pool_index.py`): the whole pool's metadata + lives in a single `/.Curator.!index` JSON keyed by pool-relative path. + `File` reads/writes the index when one is injected and otherwise keeps using + per-file `.!tag` sidecars; `FileManager` uses the index for the pool. +- **Configurable copy-as-is folders**: `copyasis_folders` in the global config + (editable from the GUI) lists pool subfolders mirrored 1:1 during generation; + `Seriály` is the default. Generalizes the previously hardcoded Seriály mirror. +- Project `README.md` (overview, concepts, workflow, run/build instructions). +- **ČSFD scraping** (`csfd.py`, ported from the Tagger devel branch): fetches + movie data from a ČSFD link (JSON-LD + HTML parsing). `File.apply_csfd_tags` + assigns Žánr / Rok / Země tags and caches the fetched data in the metadata. + The GUI auto-fetches on import when a link is given and offers "Načíst tagy + z ČSFD" for selected movies. +- App startup injects `truststore` so HTTPS uses the OS certificate store — + ČSFD fetching works behind corporate SSL inspection (where certifi's bundle + lacks the proxy root CA). + +### Fixed +- `media_utils.add_video_resolution_tag` referenced `subprocess` without importing it. +- ČSFD parsing updated for the current site HTML: year is read from JSON-LD + `dateCreated`, and the origin line (now bullet-separated, no commas) is + tokenized so country / year / duration are extracted correctly. + +### Changed +- All references to "Tagger" renamed to "Curator" (code, spec, config filenames + `.Curator.!gtag` / `.Curator.!ftag`, tests). +- `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility). + +### Dependencies +- Added `pyside6` (GUI), `requests` + `beautifulsoup4` (ČSFD scraping), + `truststore` (OS cert store for HTTPS). Declared `python-dotenv`, `pillow`, + `loguru` (already imported by the inherited code). diff --git a/Curator.py b/Curator.py new file mode 100644 index 0000000..c09c57b --- /dev/null +++ b/Curator.py @@ -0,0 +1,34 @@ +# Imports +from src.ui.qt_app import run +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager + + +def _use_system_certificates() -> None: + """Make HTTPS (ČSFD fetching) use the OS certificate store. + + Required behind corporate SSL inspection, where the proxy's root CA is in the + Windows trust store but not in certifi's bundle. Best-effort; ignored if the + optional `truststore` package is unavailable. + """ + try: + import truststore + truststore.inject_into_ssl() + except Exception: + pass + + +class State: + def __init__(self) -> None: + self.tagmanager = TagManager() + self.filehandler = FileManager(self.tagmanager) + + +def main() -> None: + _use_system_certificates() + state = State() + run(state.filehandler, state.tagmanager) + + +if __name__ == "__main__": + main() diff --git a/Curator.spec b/Curator.spec new file mode 100644 index 0000000..9a162de --- /dev/null +++ b/Curator.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['Curator.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='Curator', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + onefile=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='Curator', +) diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..e276266 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,117 @@ +# PROJECT.md + +This file is project-specific. Only include information directly related to the concrete project — goals, current state, architecture decisions, known issues, and tasks. + +## Origin + +Curator is a fork of the former **Tagger** project. The tagging, filtering and +hardlink-tree parts are inherited and keep working as before. On top of that, +Curator becomes a full **movie library manager (Filmotéka)**. + +## Core idea + +Curator manages a personal movie library based on two folders: + +- **Pool** — the managed repository of video files. This is the **single source + of truth**. Curator manages the pool itself (insert/remove file), so files are + never moved by hand. The pool has exactly two top-level folders: **Filmy** + (movies — tag-based tree) and **Seriály** (series — a "copy-as-is" folder + mirrored 1:1 into the output; see Design decisions). Every file lives here + exactly once. +- **Filmotéka (output)** — a generated, browsable directory tree made only of + **hardlinks** into the pool (the same mechanism as today's hardlink manager). + It is fully disposable: deleting the Filmotéka folder loses nothing, because + it can always be regenerated from the pool. + +### Workflow + +1. The user configures two folders: the **pool** and the **Filmotéka output**. +2. The user picks a video file via "Open file". +3. Curator opens a dialog to fill in basic info — at minimum the **title/name** + and a **ČSFD link**. +4. Curator **renames** the file and **moves** it into the managed pool, and + writes a **metadata file** describing it. +5. From the pool, Curator **generates the Filmotéka** — a complex tree of + hardlinks built from each file's tags/metadata (like the current hardlink + manager, but driven by the pool). +6. Deleting the Filmotéka has no effect on the pool; the tree is regenerated on + demand. + +## Current state + +- Inherited from Tagger: `Tag`, `TagManager`, `File` (sidecar metadata), + `FileManager` (folder scan, filtering, ignore patterns), 3-level config, + `HardlinkManager` (create/sync/cleanup), pytest suite. +- Rename Tagger → Curator done across code, spec, config filenames + (`.Curator.!gtag` / `.Curator.!ftag`) and tests. +- **PySide6 GUI** (`src/ui/qt_app.py`) reframed around the Filmotéka workflow is + the entry point; the old tkinter `src/ui/gui.py` is retained for reference. +- **Pool + Filmotéka wired up:** global config holds `pool_dir` / `filmoteka_dir`; + `FileManager` creates `Filmy`/`Seriály`, imports movies (copy → `Title.ext`), + loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`. +- `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index** + (`/.Curator.!index`, see `pool_index.py`); `File` writes there when an + index is injected, and still falls back to per-file `.!tag` sidecars for + arbitrary (non-pool) folders. + +### GUI decision + +The GUI was **reframed around the Filmotéka** (not kept as a generic tagger) and +**rewritten in PySide6**: Pool/Filmotéka setup, Import movie, tag-filter sidebar, +movie table, and one-click Filmotéka generation. + +## Design decisions + +- **Metadata storage:** one **unified metadata file** for the whole pool (a + central index), not per-file sidecars. Justified because Curator owns the pool + and files are never moved manually, so it is not exposed to path drift. +- **Import dialog:** collects only **Title** + **ČSFD link**. The file is renamed + to `Title.ext`. When a ČSFD link is given, Curator fetches the movie and assigns + Žánr / Rok / Země tags automatically; further tags can be added via the UI. +- **Genres:** a movie can have **multiple genres**, so it appears under each of + its genre branches in the Filmotéka (multiple hardlinks). +- **Pool layout:** two top-level folders — **Filmy** and **Seriály**. Movies are + the first target; the Seriály branch follows the "copy-as-is" rule below. +- **Copy-as-is folders (Seriály):** a subfolder inside the pool can be marked as + **copy / as-is**. For such a folder Curator does **not** build a tag-based tree; + instead it **mirrors the exact directory hierarchy** from the pool into the + Filmotéka output, with the files materialized as **hardlinks** into the pool. + So `pool/Seriály/...` is cloned 1:1 into `output/Seriály/...` (same structure, + hardlinked files). This is how Seriály work. +- **File naming:** imported movies are renamed to **`Title.ext`** (no year in the + filename; year lives in metadata/tags). +- **Import is non-destructive:** the original file is **copied** into the pool, + the source is left in place. +- **Filmotéka tree:** **one level per category** — `output/Category/Tag/film` + (hardlink), same shape as the current hardlink manager. For now the tree is + built from these categories: **Rok**, **Žánr**, **Hodnocení**. + +## Tasks + +# (no open tasks — see Done) + +## Done + +- Pool-root and Filmotéka-output folder settings in the global config +- Filmy / Seriály top-level folder handling in the pool +- "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext +- Remove-from-pool (delete file + its metadata) +- Generate the Filmotéka hardlink tree from the pool (Rok / Žánr / Hodnocení) +- Filmotéka fully regenerable from the pool alone (delete output = no loss) +- GUI reframed around the Filmotéka and rewritten in PySide6 +- Seriály "copy-as-is" mirror: pool/Seriály cloned 1:1 into the output as + hardlinks (`HardlinkManager.mirror_as_is`), wired into Filmotéka generation +- Fixed `media_utils` missing `subprocess` import +- Unified pool metadata index (`pool_index.py`): one `.Curator.!index` per pool; + `File` reads/writes it when injected, `FileManager` uses it for the pool +- Configurable copy-as-is folders (`copyasis_folders` in global config, editable + from the GUI); each is mirrored 1:1 during Filmotéka generation (Seriály default) +- README.md written (overview, concepts, workflow, run/build instructions) +- ČSFD scraping (`csfd.py`, ported from Tagger devel): `File.apply_csfd_tags` + fetches a movie and assigns Žánr / Rok / Země tags (cached in metadata); wired + into the GUI (auto-fetch on import with a ČSFD link, plus "Načíst tagy z ČSFD"). + Parsing updated for current ČSFD HTML and verified live against Matrix + (film/9499); HTTPS uses the OS cert store via `truststore` (corporate SSL) +- Fixed template cruft: `src/constants.py` made consistent (Curator values, + `get_version`/`get_debug_mode` API) and `test_constants.py` aligned; removed + the imported `tagger/` devel dump diff --git a/README.md b/README.md index e69de29..80d663a 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,60 @@ +# 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 fork of the former *Tagger* project; the tagging, filtering and +hardlink-tree machinery is inherited and extended into a full library workflow. + +## 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). + +## Workflow + +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ě** 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. + +## Running + +```bash +poetry install +poetry run python Curator.py +``` + +The GUI is built with **PySide6**. + +## Development + +```bash +poetry run pytest # tests +poetry run ruff check # lint / format +poetry run mypy # type checking +``` + +## Building a standalone executable + +```bash +poetry run pyinstaller Curator.spec +``` + +The resulting `dist/Curator` is the distribution artifact. diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..bcf5db9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,861 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "ast-serialize" +version = "0.5.0" +description = "Python bindings for mypy AST serialization" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb"}, + {file = "ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101"}, + {file = "ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d"}, + {file = "ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a"}, + {file = "ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590"}, + {file = "ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642"}, + {file = "ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6"}, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.7.0" +groups = ["main"] +files = [ + {file = "beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9"}, + {file = "beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7"}, +] + +[package.dependencies] +soupsieve = ">=1.6.1" +typing-extensions = ">=4.0.0" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "certifi" +version = "2026.5.20" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, + {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "idna" +version = "3.18" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, +] + +[package.extras] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "librt" +version = "0.11.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"}, + {file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"}, + {file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"}, + {file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"}, + {file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"}, + {file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"}, + {file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"}, + {file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"}, + {file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"}, + {file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"}, + {file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"}, + {file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"}, + {file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"}, + {file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"}, + {file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"}, + {file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"}, + {file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"}, + {file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"}, + {file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"}, + {file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"}, + {file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"}, + {file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"}, + {file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"}, + {file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"}, + {file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"}, + {file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"}, + {file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"}, + {file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"}, + {file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"}, + {file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"}, + {file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"}, + {file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"}, + {file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"}, + {file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"}, + {file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"}, + {file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"}, + {file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"}, + {file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"}, + {file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"}, + {file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"}, +] + +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +groups = ["main"] +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] + +[[package]] +name = "mypy" +version = "2.1.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc"}, + {file = "mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849"}, + {file = "mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd"}, + {file = "mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166"}, + {file = "mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8"}, + {file = "mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8"}, + {file = "mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e"}, + {file = "mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41"}, + {file = "mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca"}, + {file = "mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538"}, + {file = "mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398"}, + {file = "mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563"}, + {file = "mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389"}, + {file = "mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666"}, + {file = "mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af"}, + {file = "mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6"}, + {file = "mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211"}, + {file = "mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b"}, + {file = "mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22"}, + {file = "mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b"}, + {file = "mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8"}, + {file = "mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5"}, + {file = "mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e"}, + {file = "mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e"}, + {file = "mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285"}, + {file = "mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5"}, + {file = "mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65"}, + {file = "mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d"}, + {file = "mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2"}, + {file = "mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f"}, + {file = "mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4"}, + {file = "mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef"}, + {file = "mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135"}, + {file = "mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21"}, + {file = "mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57"}, + {file = "mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e"}, + {file = "mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780"}, + {file = "mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd"}, + {file = "mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08"}, + {file = "mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081"}, + {file = "mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7"}, + {file = "mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6"}, + {file = "mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289"}, + {file = "mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633"}, +] + +[package.dependencies] +ast-serialize = ">=0.3.0,<1.0.0" +librt = {version = ">=0.11.0", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=1.0.0" +typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""} + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "26.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + +[[package]] +name = "pillow" +version = "12.2.0" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, + {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, + {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, + {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, + {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, + {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, + {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, + {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, + {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, + {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, + {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, + {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, + {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, + {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, + {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, + {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, + {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, + {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, + {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, + {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, + {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, + {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, + {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyside6" +version = "6.11.1" +description = "Python bindings for the Qt cross-platform application and UI framework" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6"}, + {file = "pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2"}, + {file = "pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84"}, + {file = "pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6"}, + {file = "pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805"}, +] + +[package.dependencies] +PySide6_Addons = "6.11.1" +PySide6_Essentials = "6.11.1" +shiboken6 = "6.11.1" + +[[package]] +name = "pyside6-addons" +version = "6.11.1" +description = "Python bindings for the Qt cross-platform application and UI framework (Addons)" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9"}, + {file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f"}, + {file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993"}, + {file = "pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923"}, + {file = "pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37"}, +] + +[package.dependencies] +PySide6_Essentials = "6.11.1" +shiboken6 = "6.11.1" + +[[package]] +name = "pyside6-essentials" +version = "6.11.1" +description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957"}, + {file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60"}, + {file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d"}, + {file = "pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40"}, + {file = "pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f"}, +] + +[package.dependencies] +shiboken6 = "6.11.1" + +[[package]] +name = "pytest" +version = "9.0.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "ruff" +version = "0.15.17" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f"}, + {file = "ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7"}, + {file = "ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719"}, + {file = "ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4"}, + {file = "ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7"}, + {file = "ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f"}, + {file = "ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f"}, + {file = "ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8"}, + {file = "ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392"}, + {file = "ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084"}, + {file = "ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456"}, + {file = "ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219"}, +] + +[[package]] +name = "shiboken6" +version = "6.11.1" +description = "Python/C++ bindings helper module" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02"}, + {file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe"}, + {file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f"}, + {file = "shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7"}, + {file = "shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d"}, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65"}, + {file = "soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e"}, +] + +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.14,<3.15" +content-hash = "5906affa631fe91b3b93e881e52420a2e591110023722a7a2d5264c96706dcf2" diff --git a/prebuild.py b/prebuild.py new file mode 100644 index 0000000..bc5707e --- /dev/null +++ b/prebuild.py @@ -0,0 +1,66 @@ +import os +import sys +from pathlib import Path +from dotenv import load_dotenv +from src.constants import VERSION + +load_dotenv() + +print("=" * 50) +print("PREBUILD CONFIGURATION") +print("=" * 50) + +# Check if running in virtual environment +project_root = Path(__file__).parent +expected_venv_path = project_root / ".venv" +current_executable = Path(sys.executable) + +print(f"\nPython executable: {sys.executable}") + +is_correct_venv = False +try: + current_executable.relative_to(expected_venv_path) + is_correct_venv = True +except ValueError: + is_correct_venv = False + +if is_correct_venv: + print("✓ Correct environment selected for building") +else: + print("✗ Wrong environment selected") + print(f" Expected: {expected_venv_path}") + print(f" Current: {current_executable.parent.parent}") + +print(f"✓ Version: {VERSION}") + +env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true" +console_mode = env_debug +default_spec = Path(__file__).parent.name + ".spec" +spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec) + +print(f"\n{'-' * 50}") +print("BUILD SETTINGS") +print(f"{'-' * 50}") +print(f"ENV_DEBUG: {env_debug}") +print(f"Console mode: {console_mode}") +print(f"Spec file: {spec_filename}") + +spec_path = Path(__file__).parent / spec_filename +if spec_path.exists(): + with open(spec_path, "r", encoding="utf-8") as f: + spec_content = f.read() + + if f"console={not console_mode}" in spec_content: + new_spec_content = spec_content.replace( + f"console={not console_mode}", + f"console={console_mode}" + ) + with open(spec_path, "w", encoding="utf-8") as f: + f.write(new_spec_content) + print(f"✓ Updated {spec_filename}: console={console_mode}") + else: + print(f"✓ {spec_filename} already configured: console={console_mode}") +else: + print(f"✗ {spec_filename} not found!") + +print(f"{'-' * 50}\n") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ca16aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "curator" +version = "0.1.0" +description = "" +authors = [ + {name = "jan.doubravsky@gmail.com"} +] +readme = "README.md" +requires-python = ">=3.14,<3.15" +dependencies = [ + "pyside6 (>=6.11.1,<7.0.0)", + "requests (>=2.34.2,<3.0.0)", + "beautifulsoup4 (>=4.15.0,<5.0.0)", + "python-dotenv (>=1.2.2,<2.0.0)", + "pillow (>=12.2.0,<13.0.0)", + "loguru (>=0.7.3,<0.8.0)", + "truststore (>=0.10.4,<0.11.0)" +] + +[tool.poetry] +package-mode = false + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[dependency-groups] +dev = [ + "ruff (>=0.15.17,<0.16.0)", + "mypy (>=2.1.0,<3.0.0)", + "pytest (>=9.0.3,<10.0.0)" +] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/_version.py b/src/_version.py new file mode 100644 index 0000000..941897d --- /dev/null +++ b/src/_version.py @@ -0,0 +1,2 @@ +"""Auto-generated — do not edit manually.""" +__version__ = "0.1.0" diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..49c324b --- /dev/null +++ b/src/constants.py @@ -0,0 +1,66 @@ +""" +Application-level constants for Curator (build/runtime metadata). + +This module derives the app version and debug flag. The version is loaded from +`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for +frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`). + +Note: per-feature constants (window size, tag colors, …) live in +`src/core/constants.py`; this module is only the version/debug surface used by +the build tooling and frozen builds. +""" + +import os +import tomllib +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +_PYPROJECT_PATH = Path(__file__).parent.parent / "pyproject.toml" +_VERSION_FILE = Path(__file__).parent / "_version.py" + +APP_NAME: str = "Curator" + + +def get_version() -> str: + """Return the project version (pyproject → _version.py → unknown).""" + # 1. pyproject.toml (preferred) + try: + with open(_PYPROJECT_PATH, "rb") as f: + version = tomllib.load(f)["project"]["version"] + # Cache a fallback for frozen/PyInstaller builds (best-effort). + try: + _VERSION_FILE.write_text( + f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n', + encoding="utf-8", + ) + except OSError: + pass + return version + except (FileNotFoundError, KeyError): + pass + + # 2. generated _version.py + try: + from src._version import __version__ # type: ignore[import] + return __version__ + except ImportError: + pass + + # 3. last resort + return "0.0.0-unknown" + + +def get_debug_mode() -> bool: + """Return True when ENV_DEBUG is set to a truthy value in the environment.""" + return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes") + + +APP_VERSION: str = get_version() +ENV_DEBUG: bool = get_debug_mode() +APP_TITLE: str = f"{APP_NAME} v{APP_VERSION}" + ("-DEV" if ENV_DEBUG else "") + +# Backwards-compatible alias used by prebuild.py +VERSION: str = APP_VERSION diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..540a939 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,115 @@ +""" +Configuration management for Curator + +Three levels of configuration: +1. Global config (.Curator.!gtag next to Curator.py) - app-wide settings +2. Folder config (.Curator.!ftag in project root) - folder-specific settings +3. File tags (.{filename}.!tag) - per-file metadata (handled in file.py) +""" +import json +from pathlib import Path + +# Global config file (next to the main script) +GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag" + +# Folder config filename +FOLDER_CONFIG_NAME = ".Curator.!ftag" + + +# ============================================================================= +# GLOBAL CONFIG - Application settings +# ============================================================================= + +DEFAULT_GLOBAL_CONFIG = { + "window_geometry": "1200x800", + "window_maximized": False, + "last_folder": None, + "sidebar_width": 250, + "recent_folders": [], + "pool_dir": None, # managed pool root (single source of truth) + "filmoteka_dir": None, # generated Filmotéka output (hardlink tree) + "copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is) +} + + +def load_global_config() -> dict: + """Load global application config""" + if GLOBAL_CONFIG_FILE.exists(): + try: + with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f: + config = json.load(f) + # Merge with defaults for any missing keys + for key, value in DEFAULT_GLOBAL_CONFIG.items(): + if key not in config: + config[key] = value + return config + except Exception: + return DEFAULT_GLOBAL_CONFIG.copy() + return DEFAULT_GLOBAL_CONFIG.copy() + + +def save_global_config(cfg: dict): + """Save global application config""" + with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + +# ============================================================================= +# FOLDER CONFIG - Per-folder settings +# ============================================================================= + +DEFAULT_FOLDER_CONFIG = { + "ignore_patterns": [], + "custom_tags": {}, # Additional tags specific to this folder + "recursive": True, # Whether to scan subfolders + "hardlink_output_dir": None, # Output directory for hardlink structure + "hardlink_categories": None, # Categories to include in hardlink (None = all) +} + + +def get_folder_config_path(folder: Path) -> Path: + """Get path to folder config file""" + return folder / FOLDER_CONFIG_NAME + + +def load_folder_config(folder: Path) -> dict: + """Load folder-specific config""" + config_path = get_folder_config_path(folder) + if config_path.exists(): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + # Merge with defaults for any missing keys + for key, value in DEFAULT_FOLDER_CONFIG.items(): + if key not in config: + config[key] = value + return config + except Exception: + return DEFAULT_FOLDER_CONFIG.copy() + return DEFAULT_FOLDER_CONFIG.copy() + + +def save_folder_config(folder: Path, cfg: dict): + """Save folder-specific config""" + config_path = get_folder_config_path(folder) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + +def folder_has_config(folder: Path) -> bool: + """Check if folder has a tagger config""" + return get_folder_config_path(folder).exists() + + +# ============================================================================= +# BACKWARDS COMPATIBILITY +# ============================================================================= + +def load_config(): + """Legacy function - returns global config""" + return load_global_config() + + +def save_config(cfg: dict): + """Legacy function - saves global config""" + save_global_config(cfg) diff --git a/src/core/constants.py b/src/core/constants.py new file mode 100644 index 0000000..4256897 --- /dev/null +++ b/src/core/constants.py @@ -0,0 +1,4 @@ +# src/core/constants.py +VERSION = "v1.0.3" +APP_NAME = "Curator" +APP_VIEWPORT = "1000x700" \ No newline at end of file diff --git a/src/core/csfd.py b/src/core/csfd.py new file mode 100644 index 0000000..5c57f13 --- /dev/null +++ b/src/core/csfd.py @@ -0,0 +1,429 @@ +""" +CSFD.cz scraper module for fetching movie information. + +This module provides functionality to fetch movie data from CSFD.cz (Czech-Slovak Film Database). +""" + +from __future__ import annotations + +import re +import json +from dataclasses import dataclass, field +from typing import Optional, TYPE_CHECKING +from urllib.parse import urljoin + +try: + import requests + from bs4 import BeautifulSoup + HAS_DEPENDENCIES = True +except ImportError: + HAS_DEPENDENCIES = False + requests = None # type: ignore + BeautifulSoup = None # type: ignore + +if TYPE_CHECKING: + from bs4 import BeautifulSoup + + +CSFD_BASE_URL = "https://www.csfd.cz" +CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/" + +# User agent to avoid being blocked +HEADERS = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept-Language": "cs,en;q=0.9", +} + + +@dataclass +class CSFDMovie: + """Represents movie data from CSFD.cz""" + title: str + url: str + year: Optional[int] = None + genres: list[str] = field(default_factory=list) + directors: list[str] = field(default_factory=list) + actors: list[str] = field(default_factory=list) + rating: Optional[int] = None # Percentage 0-100 + rating_count: Optional[int] = None + duration: Optional[int] = None # Minutes + country: Optional[str] = None + poster_url: Optional[str] = None + plot: Optional[str] = None + csfd_id: Optional[int] = None + + def to_dict(self) -> dict: + """Serialize to a plain dict for storage in .!tag cache.""" + return { + "title": self.title, + "url": self.url, + "year": self.year, + "genres": self.genres, + "directors": self.directors, + "actors": self.actors, + "rating": self.rating, + "rating_count": self.rating_count, + "duration": self.duration, + "country": self.country, + "poster_url": self.poster_url, + "plot": self.plot, + "csfd_id": self.csfd_id, + } + + @classmethod + def from_dict(cls, data: dict) -> "CSFDMovie": + """Deserialize from a plain dict (e.g. loaded from .!tag cache).""" + return cls( + title=data.get("title", ""), + url=data.get("url", ""), + year=data.get("year"), + genres=data.get("genres", []), + directors=data.get("directors", []), + actors=data.get("actors", []), + rating=data.get("rating"), + rating_count=data.get("rating_count"), + duration=data.get("duration"), + country=data.get("country"), + poster_url=data.get("poster_url"), + plot=data.get("plot"), + csfd_id=data.get("csfd_id"), + ) + + def __str__(self) -> str: + parts = [self.title] + if self.year: + parts[0] += f" ({self.year})" + if self.rating is not None: + parts.append(f"Hodnocení: {self.rating}%") + if self.genres: + parts.append(f"Žánr: {', '.join(self.genres)}") + if self.directors: + parts.append(f"Režie: {', '.join(self.directors)}") + return " | ".join(parts) + + +def _check_dependencies(): + """Check if required dependencies are installed.""" + if not HAS_DEPENDENCIES: + raise ImportError( + "CSFD module requires 'requests' and 'beautifulsoup4' packages. " + "Install them with: pip install requests beautifulsoup4" + ) + + +def _extract_csfd_id(url: str) -> Optional[int]: + """Extract CSFD movie ID from URL.""" + match = re.search(r"/film/(\d+)", url) + return int(match.group(1)) if match else None + + +def _parse_duration(duration_str: str) -> Optional[int]: + """Parse ISO 8601 duration (PT97M) to minutes.""" + match = re.search(r"PT(\d+)M", duration_str) + return int(match.group(1)) if match else None + + +def fetch_movie(url: str) -> CSFDMovie: + """ + Fetch movie information from CSFD.cz URL. + + Args: + url: Full CSFD.cz movie URL (e.g., https://www.csfd.cz/film/9423-pane-vy-jste-vdova/) + + Returns: + CSFDMovie object with extracted data + + Raises: + ImportError: If required dependencies are not installed + requests.RequestException: If network request fails + ValueError: If URL is invalid or page cannot be parsed + """ + _check_dependencies() + + response = requests.get(url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + + # Try to extract JSON-LD structured data first (most reliable) + movie_data = _extract_json_ld(soup) + + # Extract additional data from HTML + movie_data["url"] = url + movie_data["csfd_id"] = _extract_csfd_id(url) + + # Get rating from HTML if not in JSON-LD + if movie_data.get("rating") is None: + movie_data["rating"] = _extract_rating(soup) + + # Get poster URL + if movie_data.get("poster_url") is None: + movie_data["poster_url"] = _extract_poster(soup) + + # Get plot summary + if movie_data.get("plot") is None: + movie_data["plot"] = _extract_plot(soup) + + # Get country and year from origin info + origin_info = _extract_origin_info(soup) + if origin_info: + if movie_data.get("country") is None: + movie_data["country"] = origin_info.get("country") + if movie_data.get("year") is None: + movie_data["year"] = origin_info.get("year") + if movie_data.get("duration") is None: + movie_data["duration"] = origin_info.get("duration") + + # Get genres from HTML if not in JSON-LD + if not movie_data.get("genres"): + movie_data["genres"] = _extract_genres(soup) + + return CSFDMovie(**movie_data) + + +def _extract_json_ld(soup: BeautifulSoup) -> dict: + """Extract movie data from JSON-LD structured data.""" + data = { + "title": "", + "year": None, + "genres": [], + "directors": [], + "actors": [], + "rating": None, + "rating_count": None, + "duration": None, + "country": None, + "poster_url": None, + "plot": None, + } + + # Find JSON-LD script + script_tags = soup.find_all("script", type="application/ld+json") + for script in script_tags: + try: + json_data = json.loads(script.string) + + # Handle both single object and array + if isinstance(json_data, list): + for item in json_data: + if item.get("@type") == "Movie": + json_data = item + break + else: + continue + + if json_data.get("@type") != "Movie": + continue + + # Title + data["title"] = json_data.get("name", "") + + # Genres + genre = json_data.get("genre", []) + if isinstance(genre, str): + data["genres"] = [genre] + else: + data["genres"] = list(genre) + + # Directors + directors = json_data.get("director", []) + if isinstance(directors, dict): + directors = [directors] + data["directors"] = [d.get("name", "") for d in directors if d.get("name")] + + # Actors + actors = json_data.get("actor", []) + if isinstance(actors, dict): + actors = [actors] + data["actors"] = [a.get("name", "") for a in actors if a.get("name")] + + # Rating + agg_rating = json_data.get("aggregateRating", {}) + if agg_rating: + rating_value = agg_rating.get("ratingValue") + if rating_value is not None: + data["rating"] = round(float(rating_value)) + data["rating_count"] = agg_rating.get("ratingCount") + + # Duration + duration_str = json_data.get("duration", "") + if duration_str: + data["duration"] = _parse_duration(duration_str) + + # Year (CSFD exposes it via dateCreated / datePublished) + for date_key in ("dateCreated", "datePublished"): + date_val = json_data.get(date_key) + if date_val: + year_match = re.search(r"(19\d{2}|20\d{2})", str(date_val)) + if year_match: + data["year"] = int(year_match.group(1)) + break + + # Poster + image = json_data.get("image") + if image: + if isinstance(image, str): + data["poster_url"] = image + elif isinstance(image, dict): + data["poster_url"] = image.get("url") + + # Description + data["plot"] = json_data.get("description") + + break # Found movie data + + except (json.JSONDecodeError, KeyError, TypeError): + continue + + return data + + +def _extract_rating(soup: BeautifulSoup) -> Optional[int]: + """Extract rating percentage from HTML.""" + # Look for rating box + rating_elem = soup.select_one(".film-rating-average") + if rating_elem: + text = rating_elem.get_text(strip=True) + match = re.search(r"(\d+)%", text) + if match: + return int(match.group(1)) + return None + + +def _extract_poster(soup: BeautifulSoup) -> Optional[str]: + """Extract poster image URL from HTML.""" + # Look for poster image + poster = soup.select_one(".film-poster img") + if poster: + src = poster.get("src") or poster.get("data-src") + if src: + if src.startswith("//"): + return "https:" + src + return src + return None + + +def _extract_plot(soup: BeautifulSoup) -> Optional[str]: + """Extract plot summary from HTML.""" + # Look for plot/description section + plot_elem = soup.select_one(".plot-full p") + if plot_elem: + return plot_elem.get_text(strip=True) + + # Alternative: shorter plot + plot_elem = soup.select_one(".plot-preview p") + if plot_elem: + return plot_elem.get_text(strip=True) + + return None + + +def _extract_genres(soup: BeautifulSoup) -> list[str]: + """Extract genres from HTML.""" + genres = [] + genre_links = soup.select(".genres a") + for link in genre_links: + genre = link.get_text(strip=True) + if genre: + genres.append(genre) + return genres + + +def _extract_origin_info(soup: BeautifulSoup) -> dict: + """Extract country, year, duration from the origin info line. + + CSFD separates the values with inline bullet ```` elements (no commas), + so ``get_text(strip=True)`` would glue them together (e.g. "USA1999136 min"). + We tokenize on those inline boundaries (and on commas, for the older format) + before extracting each field. + """ + info: dict = {} + + origin_elem = soup.select_one(".origin") + if not origin_elem: + return info + + # Split on inline element boundaries, then also on commas (older format). + raw = origin_elem.get_text(separator="|", strip=True) + tokens = [t.strip() for part in raw.split("|") for t in part.split(",")] + tokens = [t for t in tokens if t] + + for token in tokens: + if "year" not in info and re.fullmatch(r"(19\d{2}|20\d{2})", token): + info["year"] = int(token) + continue + if "duration" not in info: + duration_match = re.search(r"(\d+)\s*min", token) + if duration_match: + info["duration"] = int(duration_match.group(1)) + continue + # Country: first alphabetic token that doesn't start with a digit. + if "country" not in info and not token[0].isdigit() and re.search(r"[^\W\d_]", token): + info["country"] = token + + return info + + +def search_movies(query: str, limit: int = 10) -> list[CSFDMovie]: + """ + Search for movies on CSFD.cz. + + Args: + query: Search query string + limit: Maximum number of results to return + + Returns: + List of CSFDMovie objects with basic info (title, url, year) + """ + _check_dependencies() + + search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}" + response = requests.get(search_url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + results = [] + + # Find movie results + movie_items = soup.select(".film-title-name, .search-result-item a[href*='/film/']") + + for item in movie_items[:limit]: + href = item.get("href", "") + if "/film/" not in href: + continue + + title = item.get_text(strip=True) + url = urljoin(CSFD_BASE_URL, href) + + # Try to get year from sibling/parent + year = None + parent = item.find_parent(class_="article-content") + if parent: + year_elem = parent.select_one(".info") + if year_elem: + year_match = re.search(r"\((\d{4})\)", year_elem.get_text()) + if year_match: + year = int(year_match.group(1)) + + results.append(CSFDMovie( + title=title, + url=url, + year=year, + csfd_id=_extract_csfd_id(url) + )) + + return results + + +def fetch_movie_by_id(csfd_id: int) -> CSFDMovie: + """ + Fetch movie by CSFD ID. + + Args: + csfd_id: CSFD movie ID number + + Returns: + CSFDMovie object with full data + """ + url = f"{CSFD_BASE_URL}/film/{csfd_id}/" + return fetch_movie(url) diff --git a/src/core/file.py b/src/core/file.py new file mode 100644 index 0000000..52babc5 --- /dev/null +++ b/src/core/file.py @@ -0,0 +1,216 @@ +from pathlib import Path +import json +from .tag import Tag + +# Bump this when the csfd_cache schema changes to force re-fetch on next open. +CSFD_CACHE_VERSION = 1 + + +class File: + def __init__(self, file_path: Path, tagmanager=None, index=None) -> None: + self.file_path = file_path + self.filename = file_path.name + self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag" + # Optional unified pool index; when set, metadata lives there instead of + # in the sidecar file (see PoolIndex). + self.index = index + self.new = True + self.ignored = False + self.tags: list[Tag] = [] + self.tagmanager = tagmanager + # new: optional date string "YYYY-MM-DD" (assigned manually) + self.date: str | None = None + # movie-library fields + self.title: str | None = None + self.csfd_link: str | None = None + # Cached CSFD data — avoids re-fetching on every open + self.csfd_cache: dict | None = None + self.get_metadata() + + def get_metadata(self) -> None: + if self.index is not None: + record = self.index.get(self.file_path) + if record is None: + self._init_new_metadata() + self.save_metadata() + else: + self._apply_record(record) + return + + if not self.metadata_filename.exists(): + self._init_new_metadata() + self.save_metadata() + else: + self.load_metadata() + + def _init_new_metadata(self) -> None: + self.new = True + self.ignored = False + self.tags = [] + self.date = None + self.title = None + self.csfd_link = None + self.csfd_cache = None + if self.tagmanager: + tag = self.tagmanager.add_tag("Stav", "Nové") + self.tags.append(tag) + + def _build_record(self) -> dict: + data = { + "new": self.new, + "ignored": self.ignored, + # ukládáme full_path tagů + "tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags], + # date může být None + "date": self.date, + "title": self.title, + "csfd_link": self.csfd_link, + } + if self.csfd_cache is not None: + data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache} + return data + + def _apply_record(self, data: dict) -> None: + self.new = data.get("new", True) + self.ignored = data.get("ignored", False) + self.tags = [] + self.date = data.get("date", None) + self.title = data.get("title", None) + self.csfd_link = data.get("csfd_link", None) + raw_cache = data.get("csfd_cache") + if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION: + self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"} + else: + self.csfd_cache = None + + if not self.tagmanager: + return + + for tag_str in data.get("tags", []): + if "/" in tag_str: + category, name = tag_str.split("/", 1) + tag = self.tagmanager.add_tag(category, name) + self.tags.append(tag) + + def save_metadata(self): + data = self._build_record() + if self.index is not None: + self.index.set(self.file_path, data) + return + with open(self.metadata_filename, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + def load_metadata(self) -> None: + with open(self.metadata_filename, "r", encoding="utf-8") as f: + data = json.load(f) + self._apply_record(data) + + def delete_metadata(self) -> None: + """Remove this file's metadata (from the index, or its sidecar file).""" + if self.index is not None: + self.index.delete(self.file_path) + elif self.metadata_filename.exists(): + self.metadata_filename.unlink() + + def set_date(self, date_str: str | None): + """Nastaví datum (např. '2025-09-25') nebo None pro smazání.""" + if date_str is None or date_str == "": + self.date = None + else: + # neprvádíme složitou validaci zde; očekáváme 'YYYY-MM-DD' + self.date = date_str + self.save_metadata() + + def set_csfd_link(self, url: str | None) -> None: + """Nastaví CSFD odkaz nebo None. Invaliduje cache při změně odkazu.""" + new_url = url if url else None + if new_url != self.csfd_link: + self.csfd_cache = None # odkaz se změnil — stará cache je neplatná + self.csfd_link = new_url + self.save_metadata() + + def get_cached_movie(self): + """Vrátí CSFDMovie z cache, nebo None. Nevyžaduje síť.""" + if self.csfd_cache is None: + return None + try: + from .csfd import CSFDMovie + return CSFDMovie.from_dict(self.csfd_cache) + except Exception: + return None + + def apply_csfd_tags( + self, add_genres: bool = True, add_year: bool = True, add_country: bool = True + ) -> dict: + """Načte informace z CSFD a přiřadí tagy (Žánr, Rok, Země); cachuje data. + + Returns: + dict s klíči 'success', 'movie'/'error', 'tags_added' + """ + if not self.csfd_link: + return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []} + + try: + from .csfd import fetch_movie + movie = fetch_movie(self.csfd_link) + self.csfd_cache = movie.to_dict() + except ImportError as e: + return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []} + except Exception as e: + return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []} + + tags_added: list[str] = [] + + def _add(category: str, name: str) -> None: + tag_obj = self.tagmanager.add_tag(category, name) if self.tagmanager else Tag(category, name) + if tag_obj not in self.tags: + self.tags.append(tag_obj) + tags_added.append(f"{category}/{name}") + + if add_genres and movie.genres: + for genre in movie.genres: + _add("Žánr", genre) + if add_year and movie.year: + _add("Rok", str(movie.year)) + if add_country and movie.country: + _add("Země", movie.country) + + # Use the CSFD title if we don't have one yet + if movie.title and not self.title: + self.title = movie.title + + self.save_metadata() + return {"success": True, "movie": movie, "tags_added": tags_added} + + def add_tag(self, tag): + # tag může být Tag nebo string + from .tag import Tag as TagClass + if isinstance(tag, str): + if "/" in tag and self.tagmanager: + category, name = tag.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + else: + tag_obj = TagClass("default", tag) + elif isinstance(tag, TagClass): + tag_obj = tag + else: + return + if tag_obj not in self.tags: + self.tags.append(tag_obj) + self.save_metadata() + + def remove_tag(self, tag): + # tag může být Tag nebo string (full_path) + if isinstance(tag, str): + if "/" in tag: + category, name = tag.split("/", 1) + tag_obj = Tag(category, name) + else: + tag_obj = Tag("default", tag) + elif isinstance(tag, Tag): + tag_obj = tag + else: + return + if tag_obj in self.tags: + self.tags.remove(tag_obj) + self.save_metadata() \ No newline at end of file diff --git a/src/core/file_manager.py b/src/core/file_manager.py new file mode 100644 index 0000000..151c7c7 --- /dev/null +++ b/src/core/file_manager.py @@ -0,0 +1,272 @@ +from pathlib import Path +import shutil +from .file import File +from .tag_manager import TagManager +from .pool_index import PoolIndex +from .utils import list_files +from typing import Iterable +import fnmatch +from src.core.config import ( + load_global_config, save_global_config, + load_folder_config, save_folder_config +) + +# Top-level folders inside the managed pool +POOL_MOVIES = "Filmy" +POOL_SERIES = "Seriály" + +# Curator metadata files that must never be treated as content +METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index") + + +class FileManager: + def __init__(self, tagmanager: TagManager): + self.filelist: list[File] = [] + self.folders: list[Path] = [] + self.tagmanager = tagmanager + self.on_files_changed = None # callback do GUI + self.global_config = load_global_config() + self.folder_configs: dict[Path, dict] = {} # folder -> config + self.current_folder: Path | None = None + self.index: PoolIndex | None = None # unified pool metadata index + + # ------------------------------------------------------------------ + # Pool (single source of truth) and Filmotéka (generated output) + # ------------------------------------------------------------------ + + @property + def pool_dir(self) -> Path | None: + value = self.global_config.get("pool_dir") + return Path(value) if value else None + + @property + def movies_dir(self) -> Path | None: + pool = self.pool_dir + return pool / POOL_MOVIES if pool else None + + @property + def series_dir(self) -> Path | None: + pool = self.pool_dir + return pool / POOL_SERIES if pool else None + + @property + def copyasis_folders(self) -> list[str]: + """Names of pool subfolders mirrored 1:1 (copy-as-is) into the output.""" + return self.global_config.get("copyasis_folders", [POOL_SERIES]) + + def set_copyasis_folders(self, names: list[str]) -> None: + """Set the copy-as-is subfolder list and persist it.""" + cleaned = [n.strip() for n in names if n.strip()] + self.global_config["copyasis_folders"] = cleaned + save_global_config(self.global_config) + + @property + def filmoteka_dir(self) -> Path | None: + value = self.global_config.get("filmoteka_dir") + return Path(value) if value else None + + def set_pool_dir(self, pool_dir: Path) -> None: + """Set the managed pool root and create its top-level folders.""" + pool_dir = Path(pool_dir) + (pool_dir / POOL_MOVIES).mkdir(parents=True, exist_ok=True) + (pool_dir / POOL_SERIES).mkdir(parents=True, exist_ok=True) + self.global_config["pool_dir"] = str(pool_dir) + save_global_config(self.global_config) + + def set_filmoteka_dir(self, filmoteka_dir: Path) -> None: + """Set the Filmotéka output folder (generated hardlink tree).""" + self.global_config["filmoteka_dir"] = str(Path(filmoteka_dir)) + save_global_config(self.global_config) + + def load_pool_movies(self) -> None: + """Reload the movie list from pool/Filmy using the unified pool index.""" + self.filelist = [] + movies = self.movies_dir + pool = self.pool_dir + if not (movies and movies.is_dir() and pool): + return + + self.index = PoolIndex(pool) + for each in list_files(movies): + if each.name.endswith(METADATA_SUFFIXES): + continue + file_obj = File(each, self.tagmanager, index=self.index) + self.filelist.append(file_obj) + + def import_movie(self, source: Path, title: str, csfd_link: str | None = None) -> File: + """Copy a video file into pool/Filmy as 'Title.ext', index its metadata. + + The original file is left in place (non-destructive copy). + """ + movies = self.movies_dir + pool = self.pool_dir + if movies is None or pool is None: + raise RuntimeError("Pool není nastaven.") + movies.mkdir(parents=True, exist_ok=True) + + if self.index is None: + self.index = PoolIndex(pool) + + source = Path(source) + safe_title = title.strip() or source.stem + target = movies / f"{safe_title}{source.suffix}" + + # Avoid clobbering an existing movie of the same name + counter = 1 + while target.exists(): + target = movies / f"{safe_title}_{counter}{source.suffix}" + counter += 1 + + shutil.copy2(source, target) + + file_obj = File(target, self.tagmanager, index=self.index) + file_obj.title = safe_title + file_obj.csfd_link = csfd_link or None + file_obj.save_metadata() + + self.filelist.append(file_obj) + if self.on_files_changed: + self.on_files_changed(self.filelist) + return file_obj + + def append(self, folder: Path) -> None: + """Add a folder to scan for files""" + self.folders.append(folder) + self.current_folder = folder + + # Update global config with last folder + self.global_config["last_folder"] = str(folder) + + # Update recent folders list + recent = self.global_config.get("recent_folders", []) + folder_str = str(folder) + if folder_str in recent: + recent.remove(folder_str) + recent.insert(0, folder_str) + self.global_config["recent_folders"] = recent[:10] # Keep max 10 + + save_global_config(self.global_config) + + # Load folder-specific config + folder_config = load_folder_config(folder) + self.folder_configs[folder] = folder_config + + # Get ignore patterns from folder config + ignore_patterns = folder_config.get("ignore_patterns", []) + + for each in list_files(folder): + # Skip all Curator metadata files (.!tag / .!ftag / .!gtag / .!index) + if each.name.endswith(METADATA_SUFFIXES): + continue + + full_path = each.as_posix() + + # Check against ignore patterns + if any( + fnmatch.fnmatch(each.name, pat) or fnmatch.fnmatch(full_path, pat) + for pat in ignore_patterns + ): + continue + + file_obj = File(each, self.tagmanager) + self.filelist.append(file_obj) + + def get_folder_config(self, folder: Path = None) -> dict: + """Get config for a folder (or current folder if not specified)""" + if folder is None: + folder = self.current_folder + if folder is None: + return {} + if folder not in self.folder_configs: + self.folder_configs[folder] = load_folder_config(folder) + return self.folder_configs[folder] + + def save_folder_config(self, folder: Path = None, config: dict = None): + """Save config for a folder""" + if folder is None: + folder = self.current_folder + if folder is None: + return + if config is None: + config = self.folder_configs.get(folder, {}) + self.folder_configs[folder] = config + save_folder_config(folder, config) + + def set_ignore_patterns(self, patterns: list[str], folder: Path = None): + """Set ignore patterns for a folder""" + config = self.get_folder_config(folder) + config["ignore_patterns"] = patterns + self.save_folder_config(folder, config) + + def get_ignore_patterns(self, folder: Path = None) -> list[str]: + """Get ignore patterns for a folder""" + config = self.get_folder_config(folder) + return config.get("ignore_patterns", []) + + def assign_tag_to_file_objects(self, files_objs: list[File], tag): + """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" + for f in files_objs: + if isinstance(tag, str): + if "/" in tag: + category, name = tag.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + else: + tag_obj = self.tagmanager.add_tag("default", tag) + else: + tag_obj = tag + if tag_obj not in f.tags: + f.tags.append(tag_obj) + f.save_metadata() + if self.on_files_changed: + self.on_files_changed(self.filelist) + + def remove_tag_from_file_objects(self, files_objs: list[File], tag): + """Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů.""" + for f in files_objs: + if isinstance(tag, str): + if "/" in tag: + category, name = tag.split("/", 1) + from .tag import Tag as TagClass + tag_obj = TagClass(category, name) + else: + from .tag import Tag as TagClass + tag_obj = TagClass("default", tag) + else: + tag_obj = tag + if tag_obj in f.tags: + f.tags.remove(tag_obj) + f.save_metadata() + if self.on_files_changed: + self.on_files_changed(self.filelist) + + def filter_files_by_tags(self, tags: Iterable): + """ + Vrátí jen soubory, které obsahují všechny zadané tagy. + 'tags' může být iterace Tag objektů nebo stringů 'category/name'. + """ + tags_list = list(tags) if tags is not None else [] + if not tags_list: + return self.filelist + + target_full_paths = set() + from .tag import Tag as TagClass + for t in tags_list: + if isinstance(t, TagClass): + target_full_paths.add(t.full_path) + elif isinstance(t, str): + target_full_paths.add(t) + else: + continue + + filtered = [] + for f in self.filelist: + file_tags = {t.full_path for t in f.tags} + if all(tag in file_tags for tag in target_full_paths): + filtered.append(f) + return filtered + + # Legacy property for backwards compatibility + @property + def config(self): + """Legacy: returns global config""" + return self.global_config diff --git a/src/core/hardlink_manager.py b/src/core/hardlink_manager.py new file mode 100644 index 0000000..cc75445 --- /dev/null +++ b/src/core/hardlink_manager.py @@ -0,0 +1,403 @@ +""" +Hardlink Manager for Curator + +Creates directory structure based on file tags and creates hardlinks +to organize files without duplicating them on disk. + +Example: + A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create: + + output/ + ├── žánr/ + │ ├── Komedie/ + │ │ └── film.mkv (hardlink) + │ └── Akční/ + │ └── film.mkv (hardlink) + └── rok/ + └── 1988/ + └── film.mkv (hardlink) +""" +import os +from pathlib import Path +from typing import List, Tuple, Optional +from .file import File + + +class HardlinkManager: + """Manager for creating hardlink-based directory structures from tagged files.""" + + def __init__(self, output_dir: Path): + """ + Initialize HardlinkManager. + + Args: + output_dir: Base directory where the tag-based structure will be created + """ + self.output_dir = Path(output_dir) + self.created_links: List[Path] = [] + self.errors: List[Tuple[Path, str]] = [] + + def create_structure_for_files( + self, + files: List[File], + categories: Optional[List[str]] = None, + dry_run: bool = False + ) -> Tuple[int, int]: + """ + Create hardlink structure for given files based on their tags. + + Args: + files: List of File objects to process + categories: Optional list of categories to include (None = all) + dry_run: If True, only simulate without creating actual links + + Returns: + Tuple of (successful_links, failed_links) + """ + self.created_links = [] + self.errors = [] + + success_count = 0 + fail_count = 0 + + for file_obj in files: + if not file_obj.tags: + continue + + for tag in file_obj.tags: + # Skip if category filter is set and this category is not included + if categories is not None and tag.category not in categories: + continue + + # Create target directory path: output/category/tag_name/ + target_dir = self.output_dir / tag.category / tag.name + target_file = target_dir / file_obj.filename + + try: + if not dry_run: + # Create directory structure + target_dir.mkdir(parents=True, exist_ok=True) + + # Skip if link already exists + if target_file.exists(): + # Check if it's already a hardlink to the same file + if self._is_same_file(file_obj.file_path, target_file): + continue + else: + # Different file exists, add suffix + target_file = self._get_unique_name(target_file) + + # Create hardlink + os.link(file_obj.file_path, target_file) + + self.created_links.append(target_file) + success_count += 1 + + except OSError as e: + self.errors.append((file_obj.file_path, str(e))) + fail_count += 1 + + return success_count, fail_count + + def mirror_as_is( + self, + source_dir: Path, + subfolder: str | None = None, + dry_run: bool = False + ) -> Tuple[int, int]: + """Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone. + + Recreates the exact directory hierarchy of ``source_dir`` under + ``output_dir/subfolder`` (or directly under ``output_dir`` when + ``subfolder`` is None) and hardlinks every file. Curator metadata files + (``.!tag`` / ``.!ftag`` / ``.!gtag`` / ``.!index``) are skipped. + + Used for Seriály: the pool structure is the source of truth and is cloned + verbatim instead of being rebuilt from tags. + + Returns: + Tuple of (successful_links, failed_links) + """ + source_dir = Path(source_dir) + if not source_dir.is_dir(): + return 0, 0 + + base = self.output_dir / subfolder if subfolder else self.output_dir + success_count = 0 + fail_count = 0 + + for src_file in source_dir.rglob("*"): + if not src_file.is_file(): + continue + if src_file.name.endswith((".!tag", ".!ftag", ".!gtag", ".!index")): + continue + + target_file = base / src_file.relative_to(source_dir) + try: + if not dry_run: + target_file.parent.mkdir(parents=True, exist_ok=True) + if target_file.exists(): + if self._is_same_file(src_file, target_file): + success_count += 1 + continue + target_file.unlink() + os.link(src_file, target_file) + self.created_links.append(target_file) + success_count += 1 + except OSError as e: + self.errors.append((src_file, str(e))) + fail_count += 1 + + return success_count, fail_count + + def _is_same_file(self, path1: Path, path2: Path) -> bool: + """Check if two paths point to the same file (same inode).""" + try: + return path1.stat().st_ino == path2.stat().st_ino + except OSError: + return False + + def _get_unique_name(self, path: Path) -> Path: + """Get a unique filename by adding a numeric suffix.""" + stem = path.stem + suffix = path.suffix + parent = path.parent + counter = 1 + + while True: + new_name = f"{stem}_{counter}{suffix}" + new_path = parent / new_name + if not new_path.exists(): + return new_path + counter += 1 + + def remove_created_links(self) -> int: + """ + Remove all hardlinks created by the last operation. + + Returns: + Number of links removed + """ + removed = 0 + for link_path in self.created_links: + try: + if link_path.exists() and link_path.is_file(): + link_path.unlink() + removed += 1 + + # Try to remove empty parent directories + self._remove_empty_parents(link_path.parent) + except OSError: + pass + + self.created_links = [] + return removed + + def _remove_empty_parents(self, path: Path) -> None: + """Remove empty parent directories up to output_dir.""" + try: + while path != self.output_dir and path.is_dir(): + if any(path.iterdir()): + break # Directory not empty + path.rmdir() + path = path.parent + except OSError: + pass + + def get_preview(self, files: List[File], categories: Optional[List[str]] = None) -> List[Tuple[Path, Path]]: + """ + Get a preview of what links would be created. + + Args: + files: List of File objects + categories: Optional list of categories to include + + Returns: + List of tuples (source_path, target_path) + """ + preview = [] + + for file_obj in files: + if not file_obj.tags: + continue + + for tag in file_obj.tags: + if categories is not None and tag.category not in categories: + continue + + target_dir = self.output_dir / tag.category / tag.name + target_file = target_dir / file_obj.filename + + preview.append((file_obj.file_path, target_file)) + + return preview + + def find_obsolete_links( + self, + files: List[File], + categories: Optional[List[str]] = None + ) -> List[Tuple[Path, Path]]: + """ + Find hardlinks in the output directory that no longer match file tags. + + Scans the output directory for hardlinks that point to source files, + but whose category/tag path no longer matches the file's current tags. + + Args: + files: List of File objects (source files) + categories: Optional list of categories to check (None = all) + + Returns: + List of tuples (link_path, source_path) for obsolete links + """ + obsolete = [] + + if not self.output_dir.exists(): + return obsolete + + # Build a map of source file inodes to File objects + inode_to_file: dict[int, File] = {} + for file_obj in files: + try: + inode = file_obj.file_path.stat().st_ino + inode_to_file[inode] = file_obj + except OSError: + continue + + # Build expected paths for each file based on current tags + expected_paths: dict[int, set[Path]] = {} + for file_obj in files: + try: + inode = file_obj.file_path.stat().st_ino + expected_paths[inode] = set() + + for tag in file_obj.tags: + if categories is not None and tag.category not in categories: + continue + target = self.output_dir / tag.category / tag.name / file_obj.filename + expected_paths[inode].add(target) + except OSError: + continue + + # Scan output directory for existing hardlinks + for category_dir in self.output_dir.iterdir(): + if not category_dir.is_dir(): + continue + + # Filter by categories if specified + if categories is not None and category_dir.name not in categories: + continue + + for tag_dir in category_dir.iterdir(): + if not tag_dir.is_dir(): + continue + + for link_file in tag_dir.iterdir(): + if not link_file.is_file(): + continue + + try: + link_inode = link_file.stat().st_ino + + # Check if this inode belongs to one of our source files + if link_inode in inode_to_file: + source_file = inode_to_file[link_inode] + + # Check if this link path is expected + if link_inode in expected_paths: + if link_file not in expected_paths[link_inode]: + # This link exists but tag was removed + obsolete.append((link_file, source_file.file_path)) + except OSError: + continue + + return obsolete + + def remove_obsolete_links( + self, + files: List[File], + categories: Optional[List[str]] = None, + dry_run: bool = False + ) -> Tuple[int, List[Path]]: + """ + Remove hardlinks that no longer match file tags. + + Args: + files: List of File objects + categories: Optional list of categories to check + dry_run: If True, only return what would be removed + + Returns: + Tuple of (removed_count, list_of_removed_paths) + """ + obsolete = self.find_obsolete_links(files, categories) + removed_paths = [] + + if dry_run: + return len(obsolete), [link for link, _ in obsolete] + + for link_path, _ in obsolete: + try: + link_path.unlink() + removed_paths.append(link_path) + + # Try to remove empty parent directories + self._remove_empty_parents(link_path.parent) + except OSError: + pass + + return len(removed_paths), removed_paths + + def sync_structure( + self, + files: List[File], + categories: Optional[List[str]] = None, + dry_run: bool = False + ) -> Tuple[int, int, int, int]: + """ + Synchronize hardlink structure with current file tags. + + This will: + 1. Remove hardlinks for removed tags + 2. Create new hardlinks for new tags + + Args: + files: List of File objects + categories: Optional list of categories to sync + dry_run: If True, only simulate + + Returns: + Tuple of (created, create_failed, removed, remove_failed) + """ + # First find how many obsolete links there are + obsolete_count = len(self.find_obsolete_links(files, categories)) + + # Remove obsolete links + removed, removed_paths = self.remove_obsolete_links(files, categories, dry_run) + remove_failed = obsolete_count - removed if not dry_run else 0 + + # Then create new links + created, create_failed = self.create_structure_for_files(files, categories, dry_run) + + return created, create_failed, removed, remove_failed + + +def create_hardlink_structure( + files: List[File], + output_dir: Path, + categories: Optional[List[str]] = None +) -> Tuple[int, int, List[Tuple[Path, str]]]: + """ + Convenience function to create hardlink structure. + + Args: + files: List of File objects to process + output_dir: Base directory for output + categories: Optional list of categories to include + + Returns: + Tuple of (successful_count, failed_count, errors_list) + """ + manager = HardlinkManager(output_dir) + success, fail = manager.create_structure_for_files(files, categories) + return success, fail, manager.errors diff --git a/src/core/list_manager.py b/src/core/list_manager.py new file mode 100644 index 0000000..341de49 --- /dev/null +++ b/src/core/list_manager.py @@ -0,0 +1,20 @@ +from typing import List +from .file import File + +class ListManager: + def __init__(self): + # 'name' nebo 'date' + self.sort_mode = "name" + + def set_sort(self, mode: str): + if mode in ("name", "date"): + self.sort_mode = mode + + def sort_files(self, files: List[File]) -> List[File]: + if self.sort_mode == "name": + return sorted(files, key=lambda f: f.filename.lower()) + else: + # sort by date (None last) — nejnovější nahoře? Zde dávám None jako "" + def date_key(f): + return (f.date is None, f.date or "") + return sorted(files, key=date_key) \ No newline at end of file diff --git a/src/core/media_utils.py b/src/core/media_utils.py new file mode 100644 index 0000000..0a933a2 --- /dev/null +++ b/src/core/media_utils.py @@ -0,0 +1,43 @@ +# 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 Exception as e: + print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}") \ No newline at end of file diff --git a/src/core/pool_index.py b/src/core/pool_index.py new file mode 100644 index 0000000..7c0ad2f --- /dev/null +++ b/src/core/pool_index.py @@ -0,0 +1,64 @@ +""" +Unified metadata index for the Curator pool. + +Instead of one sidecar file per movie, the whole pool keeps a single JSON index +at ``/.Curator.!index``. Curator owns the pool (it inserts/removes files +itself), so files never move behind its back and a central index is safe. + +Records are keyed by the file's path relative to the pool root (POSIX form), +e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines. +""" +import json +from pathlib import Path + +INDEX_FILENAME = ".Curator.!index" + + +class PoolIndex: + """Single-file JSON metadata store for all files in a pool.""" + + def __init__(self, pool_dir: Path) -> None: + self.pool_dir = Path(pool_dir) + self.index_path = self.pool_dir / INDEX_FILENAME + self.records: dict[str, dict] = {} + self.load() + + def _key(self, file_path: Path) -> str: + """Pool-relative POSIX key for a file path.""" + path = Path(file_path) + try: + return path.relative_to(self.pool_dir).as_posix() + except ValueError: + return path.as_posix() + + def load(self) -> None: + """Load the index from disk (missing/corrupt index = empty).""" + if not self.index_path.exists(): + self.records = {} + return + try: + with open(self.index_path, "r", encoding="utf-8") as f: + data = json.load(f) + self.records = data.get("movies", {}) + except (json.JSONDecodeError, OSError): + self.records = {} + + def save(self) -> None: + """Persist the index to disk.""" + self.pool_dir.mkdir(parents=True, exist_ok=True) + with open(self.index_path, "w", encoding="utf-8") as f: + json.dump({"movies": self.records}, f, indent=2, ensure_ascii=False) + + def get(self, file_path: Path) -> dict | None: + """Return the record for a file, or None if it is not indexed.""" + return self.records.get(self._key(file_path)) + + def set(self, file_path: Path, data: dict) -> None: + """Upsert a record and persist the index.""" + self.records[self._key(file_path)] = data + self.save() + + def delete(self, file_path: Path) -> None: + """Remove a record (if present) and persist the index.""" + if self.records.pop(self._key(file_path), None) is not None: + self.save() diff --git a/src/core/tag.py b/src/core/tag.py new file mode 100644 index 0000000..3858c22 --- /dev/null +++ b/src/core/tag.py @@ -0,0 +1,22 @@ +class Tag: + def __init__(self, category: str, name: str): + self.category = category + self.name = name + + @property + def full_path(self): + return f"{self.category}/{self.name}" + + def __str__(self): + return self.full_path + + def __repr__(self): + return f"Tag({self.full_path})" + + def __eq__(self, other): + if isinstance(other, Tag): + return self.category == other.category and self.name == other.name + return False + + def __hash__(self): + return hash((self.category, self.name)) \ No newline at end of file diff --git a/src/core/tag_manager.py b/src/core/tag_manager.py new file mode 100644 index 0000000..25a81ed --- /dev/null +++ b/src/core/tag_manager.py @@ -0,0 +1,67 @@ +from .tag import Tag + +# Default tags that are always available (order in list = display order) +DEFAULT_TAGS = { + "Hodnocení": ["⭐", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"], + "Barva": ["🔴 Červená", "🟠 Oranžová", "🟡 Žlutá", "🟢 Zelená", "🔵 Modrá", "🟣 Fialová"], +} + +# Tag sort order for default categories (preserves display order) +DEFAULT_TAG_ORDER = { + "Hodnocení": {name: i for i, name in enumerate(DEFAULT_TAGS["Hodnocení"])}, + "Barva": {name: i for i, name in enumerate(DEFAULT_TAGS["Barva"])}, +} + + +class TagManager: + def __init__(self): + self.tags_by_category = {} # {category: set(Tag)} + self._init_default_tags() + + def _init_default_tags(self): + """Initialize default tags (ratings and colors)""" + for category, tags in DEFAULT_TAGS.items(): + for tag_name in tags: + self.add_tag(category, tag_name) + + def add_category(self, category: str): + if category not in self.tags_by_category: + self.tags_by_category[category] = set() + + def remove_category(self, category: str): + if category in self.tags_by_category: + del self.tags_by_category[category] + + def add_tag(self, category: str, name: str) -> Tag: + self.add_category(category) + tag = Tag(category, name) + self.tags_by_category[category].add(tag) + return tag + + def remove_tag(self, category: str, name: str): + if category in self.tags_by_category: + tag = Tag(category, name) + self.tags_by_category[category].discard(tag) + if not self.tags_by_category[category]: + self.remove_category(category) + + def get_all_tags(self): + """Vrací list všech tagů full_path""" + return [tag.full_path for tags in self.tags_by_category.values() for tag in tags] + + def get_categories(self): + return list(self.tags_by_category.keys()) + + def get_tags_in_category(self, category: str) -> list[Tag]: + """Get tags in category, sorted by predefined order for default categories""" + tags = list(self.tags_by_category.get(category, [])) + + # Use predefined order for default categories + if category in DEFAULT_TAG_ORDER: + order = DEFAULT_TAG_ORDER[category] + tags.sort(key=lambda t: order.get(t.name, 999)) + else: + # Sort alphabetically for custom categories + tags.sort(key=lambda t: t.name) + + return tags \ No newline at end of file diff --git a/src/core/utils.py b/src/core/utils.py new file mode 100644 index 0000000..98edf75 --- /dev/null +++ b/src/core/utils.py @@ -0,0 +1,7 @@ +from pathlib import Path + +def list_files(folder_path: str | Path) -> list[Path]: + folder = Path(folder_path) + if not folder.is_dir(): + raise NotADirectoryError(f"{folder} není platná složka.") + return [file_path for file_path in folder.rglob("*") if file_path.is_file()] \ No newline at end of file diff --git a/src/resources/images/32/32_calendar.png b/src/resources/images/32/32_calendar.png new file mode 100644 index 0000000..71da133 Binary files /dev/null and b/src/resources/images/32/32_calendar.png differ diff --git a/src/resources/images/32/32_checked.png b/src/resources/images/32/32_checked.png new file mode 100644 index 0000000..0b91010 Binary files /dev/null and b/src/resources/images/32/32_checked.png differ diff --git a/src/resources/images/32/32_computer.png b/src/resources/images/32/32_computer.png new file mode 100644 index 0000000..58f77fe Binary files /dev/null and b/src/resources/images/32/32_computer.png differ diff --git a/src/resources/images/32/32_crossed.png b/src/resources/images/32/32_crossed.png new file mode 100644 index 0000000..e34c323 Binary files /dev/null and b/src/resources/images/32/32_crossed.png differ diff --git a/src/resources/images/32/32_tag.png b/src/resources/images/32/32_tag.png new file mode 100644 index 0000000..0d350f3 Binary files /dev/null and b/src/resources/images/32/32_tag.png differ diff --git a/src/resources/images/32/32_unchecked.png b/src/resources/images/32/32_unchecked.png new file mode 100644 index 0000000..c503b47 Binary files /dev/null and b/src/resources/images/32/32_unchecked.png differ diff --git a/src/resources/images/orig/orig_calendar.png b/src/resources/images/orig/orig_calendar.png new file mode 100644 index 0000000..eb3b953 Binary files /dev/null and b/src/resources/images/orig/orig_calendar.png differ diff --git a/src/resources/images/orig/orig_checked.png b/src/resources/images/orig/orig_checked.png new file mode 100644 index 0000000..54a504d Binary files /dev/null and b/src/resources/images/orig/orig_checked.png differ diff --git a/src/resources/images/orig/orig_computer.png b/src/resources/images/orig/orig_computer.png new file mode 100644 index 0000000..ac006a9 Binary files /dev/null and b/src/resources/images/orig/orig_computer.png differ diff --git a/src/resources/images/orig/orig_crossed.png b/src/resources/images/orig/orig_crossed.png new file mode 100644 index 0000000..8cbd67b Binary files /dev/null and b/src/resources/images/orig/orig_crossed.png differ diff --git a/src/resources/images/orig/orig_tag.png b/src/resources/images/orig/orig_tag.png new file mode 100644 index 0000000..f8de5fe Binary files /dev/null and b/src/resources/images/orig/orig_tag.png differ diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/gui.py b/src/ui/gui.py new file mode 100644 index 0000000..a6287ff --- /dev/null +++ b/src/ui/gui.py @@ -0,0 +1,1296 @@ +""" +Modern qBittorrent-style GUI for Curator +""" +import os +import sys +import subprocess +import tkinter as tk +from tkinter import ttk, simpledialog, messagebox, filedialog +from pathlib import Path +from typing import List + +from src.core.media_utils import load_icon +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager, DEFAULT_TAG_ORDER +from src.core.file import File +from src.core.tag import Tag +from src.core.list_manager import ListManager +from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT +from src.core.config import save_global_config +from src.core.hardlink_manager import HardlinkManager + + +# qBittorrent-inspired color scheme +COLORS = { + "bg": "#ffffff", + "sidebar_bg": "#f5f5f5", + "toolbar_bg": "#f0f0f0", + "selected": "#0078d7", + "selected_text": "#ffffff", + "border": "#d0d0d0", + "status_bg": "#f8f8f8", + "text": "#000000", +} + +# Tag category colors +TAG_COLORS = [ + "#e74c3c", # red + "#3498db", # blue + "#2ecc71", # green + "#f39c12", # orange + "#9b59b6", # purple + "#1abc9c", # teal + "#e91e63", # pink + "#00bcd4", # cyan +] + +# Fixed colors for default categories +DEFAULT_CATEGORY_COLORS = { + "Hodnocení": "#f1c40f", # gold/yellow for stars + "Barva": "#95a5a6", # gray for color category +} + + +# Categories where only one tag can be selected (exclusive/radio behavior) +EXCLUSIVE_CATEGORIES = {"Hodnocení"} + + +class MultiFileTagAssignDialog(tk.Toplevel): + """Dialog for bulk tag assignment to multiple files""" + def __init__(self, parent, all_tags: List[Tag], files: List[File], category_colors: dict = None): + super().__init__(parent) + self.title("Přiřadit tagy k vybraným souborům") + self.result = None + self.vars: dict[str, int] = {} + self.checkbuttons: dict[str, tk.Checkbutton] = {} + self.tags_by_full = {t.full_path: t for t in all_tags} + self.files = files + self.category_colors = category_colors or {} + self.category_checkbuttons: dict[str, list] = {} # category -> list of checkbuttons + + self.geometry("500x600") + self.minsize(400, 400) + self.configure(bg=COLORS["bg"]) + + tk.Label(self, text=f"Vybráno souborů: {len(files)}", + bg=COLORS["bg"], font=("Arial", 11, "bold")).pack(pady=10) + + # Scrollable frame + canvas = tk.Canvas(self, bg=COLORS["bg"]) + scrollbar = ttk.Scrollbar(self, orient="vertical", command=canvas.yview) + frame = tk.Frame(canvas, bg=COLORS["bg"]) + + frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True, padx=10) + scrollbar.pack(side="right", fill="y") + + # Enable mousewheel scrolling (only when dialog is active) + def on_mousewheel(event): + if canvas.winfo_exists(): + canvas.yview_scroll(int(-1*(event.delta/120)), "units") + + def on_scroll_up(event): + if canvas.winfo_exists(): + canvas.yview_scroll(-1, "units") + + def on_scroll_down(event): + if canvas.winfo_exists(): + canvas.yview_scroll(1, "units") + + canvas.bind("", on_mousewheel) + canvas.bind("", on_scroll_up) + canvas.bind("", on_scroll_down) + frame.bind("", on_mousewheel) + frame.bind("", on_scroll_up) + frame.bind("", on_scroll_down) + + file_tag_sets = [{t.full_path for t in f.tags} for f in files] + + # Group by category + tags_by_category = {} + for full_path, tag in self.tags_by_full.items(): + if tag.category not in tags_by_category: + tags_by_category[tag.category] = [] + tags_by_category[tag.category].append((full_path, tag)) + + # Sort tags within each category + for category in tags_by_category: + if category in DEFAULT_TAG_ORDER: + order = DEFAULT_TAG_ORDER[category] + tags_by_category[category].sort(key=lambda x: order.get(x[1].name, 999)) + else: + tags_by_category[category].sort(key=lambda x: x[1].name) + + for category in sorted(tags_by_category.keys()): + color = self.category_colors.get(category, "#333333") + is_exclusive = category in EXCLUSIVE_CATEGORIES + exclusive_note = " (pouze jedno)" if is_exclusive else "" + + cat_label = tk.Label(frame, text=f"▸ {category}{exclusive_note}", bg=COLORS["bg"], + fg=color, font=("Arial", 10, "bold")) + cat_label.pack(fill="x", anchor="w", pady=(12, 4)) + + self.category_checkbuttons[category] = [] + + for full_path, tag in tags_by_category[category]: + have_count = sum(1 for s in file_tag_sets if full_path in s) + if have_count == 0: + init = 0 + elif have_count == len(files): + init = 1 + else: + init = 2 # mixed + + cb = tk.Checkbutton(frame, text=f" {tag.name}", anchor="w", bg=COLORS["bg"], + font=("Arial", 10)) + cb.state_value = init + cb.full_path = full_path + cb.tag_color = color + cb.category = category + cb.pack(fill="x", anchor="w", padx=20) + cb.bind("", self._on_toggle) + + self._update_checkbox_look(cb) + self.checkbuttons[full_path] = cb + self.vars[full_path] = init + self.category_checkbuttons[category].append(cb) + + btn_frame = tk.Frame(self, bg=COLORS["bg"]) + btn_frame.pack(pady=15) + tk.Button(btn_frame, text="OK", command=self.on_ok, width=12, + font=("Arial", 10)).pack(side="left", padx=5) + tk.Button(btn_frame, text="Zrušit", command=self.destroy, width=12, + font=("Arial", 10)).pack(side="left", padx=5) + + self.transient(parent) + self.grab_set() + parent.wait_window(self) + + def _on_toggle(self, event): + cb: tk.Checkbutton = event.widget + category = cb.category + cur = cb.state_value + + # For exclusive categories, uncheck others first + if category in EXCLUSIVE_CATEGORIES: + if cur == 0 or cur == 2: # turning on + # Uncheck all others in this category + for other_cb in self.category_checkbuttons.get(category, []): + if other_cb != cb and other_cb.state_value != 0: + other_cb.state_value = 0 + self._update_checkbox_look(other_cb) + cb.state_value = 1 + else: # turning off + cb.state_value = 0 + else: + # Normal toggle behavior + if cur == 0: + cb.state_value = 1 + elif cur == 1: + cb.state_value = 0 + elif cur == 2: + cb.state_value = 1 + + self._update_checkbox_look(cb) + return "break" + + def _update_checkbox_look(self, cb: tk.Checkbutton): + v = cb.state_value + color = getattr(cb, 'tag_color', '#333333') + if v == 0: + cb.deselect() + cb.config(fg="#666666") + elif v == 1: + cb.select() + cb.config(fg=color) + elif v == 2: + cb.deselect() + cb.config(fg="#cc6600") # orange for mixed + + def on_ok(self): + self.result = {full: cb.state_value for full, cb in self.checkbuttons.items()} + self.destroy() + + +class App: + def __init__(self, filehandler: FileManager, tagmanager: TagManager): + self.filehandler = filehandler + self.tagmanager = tagmanager + self.list_manager = ListManager() + + # State + self.states = {} + self.file_items = {} # Treeview item_id -> File object mapping + self.selected_tree_item_for_context = None + self.hide_ignored_var = None + self.filter_text = "" + self.show_full_path = False + self.sort_mode = "name" + self.sort_order = "asc" + self.category_colors = {} # category -> color mapping + + self.filehandler.on_files_changed = self.update_files_from_manager + + def _on_close(self): + """Save window geometry and close""" + # Check if maximized + is_maximized = self.root.state() == 'zoomed' + self.filehandler.global_config["window_maximized"] = is_maximized + + # Save geometry only when not maximized + if not is_maximized: + self.filehandler.global_config["window_geometry"] = self.root.geometry() + + save_global_config(self.filehandler.global_config) + self.root.destroy() + + def main(self): + root = tk.Tk() + root.title(f"{APP_NAME} {VERSION}") + + # Load window geometry from global config + geometry = self.filehandler.global_config.get("window_geometry", APP_VIEWPORT) + root.geometry(geometry) + if self.filehandler.global_config.get("window_maximized", False): + root.state('zoomed') + + root.configure(bg=COLORS["bg"]) + self.root = root + + # Bind window close to save geometry + root.protocol("WM_DELETE_WINDOW", self._on_close) + + self.hide_ignored_var = tk.BooleanVar(value=False, master=root) + + # Load last folder + last = self.filehandler.global_config.get("last_folder") + if last: + try: + self.filehandler.append(Path(last)) + except Exception: + pass + + # Load icons + self._load_icons() + + # Build UI + self._create_menu() + self._create_toolbar() + self._create_main_layout() + self._create_status_bar() + self._create_context_menus() + self._bind_shortcuts() + + # Initial refresh + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + + root.mainloop() + + def _load_icons(self): + """Load application icons""" + try: + unchecked = load_icon("src/resources/images/32/32_unchecked.png") + checked = load_icon("src/resources/images/32/32_checked.png") + tag_icon = load_icon("src/resources/images/32/32_tag.png") + self.icons = {"unchecked": unchecked, "checked": checked, "tag": tag_icon} + self.root.unchecked_img = unchecked + self.root.checked_img = checked + self.root.tag_img = tag_icon + except Exception as e: + print(f"Warning: Could not load icons: {e}") + self.icons = {"unchecked": None, "checked": None, "tag": None} + + def _create_menu(self): + """Create menu bar""" + menu_bar = tk.Menu(self.root) + self.root.config(menu=menu_bar) + + # File menu + file_menu = tk.Menu(menu_bar, tearoff=0) + file_menu.add_command(label="Open Folder... (Ctrl+O)", command=self.open_folder_dialog) + file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns) + file_menu.add_separator() + file_menu.add_command(label="Exit (Ctrl+Q)", command=self.root.quit) + + # View menu + view_menu = tk.Menu(menu_bar, tearoff=0) + view_menu.add_checkbutton( + label="Skrýt ignorované", + variable=self.hide_ignored_var, + command=self.toggle_hide_ignored + ) + view_menu.add_command(label="Refresh (F5)", command=self.refresh_all) + + # Tools menu + tools_menu = tk.Menu(menu_bar, tearoff=0) + tools_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) + tools_menu.add_command(label="Detekovat rozlišení videí", command=self.detect_video_resolution) + tools_menu.add_command(label="Přiřadit tagy (Ctrl+T)", command=self.assign_tag_to_selected_bulk) + tools_menu.add_separator() + tools_menu.add_command(label="Nastavit hardlink složku...", command=self.configure_hardlink_folder) + tools_menu.add_command(label="Aktualizovat hardlink strukturu", command=self.update_hardlink_structure) + tools_menu.add_command(label="Vytvořit hardlink strukturu...", command=self.create_hardlink_structure) + + menu_bar.add_cascade(label="Soubor", menu=file_menu) + menu_bar.add_cascade(label="Pohled", menu=view_menu) + menu_bar.add_cascade(label="Nástroje", menu=tools_menu) + + def _create_toolbar(self): + """Create toolbar with buttons""" + toolbar = tk.Frame(self.root, bg=COLORS["toolbar_bg"], height=40, relief=tk.RAISED, bd=1) + toolbar.pack(side=tk.TOP, fill=tk.X) + + # Buttons + tk.Button(toolbar, text="📁 Otevřít složku", command=self.open_folder_dialog, + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=5, pady=5) + + tk.Button(toolbar, text="🔄 Obnovit", command=self.refresh_all, + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) + + tk.Button(toolbar, text="🏷️ Nový tag", command=lambda: self.tree_add_tag(background=True), + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + + tk.Button(toolbar, text="📅 Nastavit datum", command=self.set_date_for_selected, + relief=tk.FLAT, bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT, padx=2, pady=5) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) + + # Search box + search_frame = tk.Frame(toolbar, bg=COLORS["toolbar_bg"]) + search_frame.pack(side=tk.RIGHT, padx=10, pady=5) + + tk.Label(search_frame, text="🔍", bg=COLORS["toolbar_bg"]).pack(side=tk.LEFT) + self.search_var = tk.StringVar() + self.search_var.trace('w', lambda *args: self.on_filter_changed()) + search_entry = tk.Entry(search_frame, textvariable=self.search_var, width=25) + search_entry.pack(side=tk.LEFT, padx=5) + + def _create_main_layout(self): + """Create main split layout""" + # Main container + main_container = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, sashwidth=5, bg=COLORS["border"]) + main_container.pack(fill=tk.BOTH, expand=True) + + # Left sidebar (tags) + self._create_sidebar(main_container) + + # Right panel (files table) + self._create_file_panel(main_container) + + def _create_sidebar(self, parent): + """Create left sidebar with tag tree""" + sidebar_frame = tk.Frame(parent, bg=COLORS["sidebar_bg"], width=250) + + # Sidebar header + header = tk.Frame(sidebar_frame, bg=COLORS["sidebar_bg"]) + header.pack(fill=tk.X, padx=5, pady=5) + + tk.Label(header, text="📂 Štítky", font=("Arial", 10, "bold"), + bg=COLORS["sidebar_bg"]).pack(side=tk.LEFT) + + # Tag tree + tree_frame = tk.Frame(sidebar_frame) + tree_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + self.tag_tree = ttk.Treeview(tree_frame, selectmode="browse", show="tree") + self.tag_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + tree_scroll = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=self.tag_tree.yview) + tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) + self.tag_tree.config(yscrollcommand=tree_scroll.set) + + # Bind events + self.tag_tree.bind("", self.on_tree_left_click) + self.tag_tree.bind("", self.on_tree_right_click) + + parent.add(sidebar_frame) + + def _create_file_panel(self, parent): + """Create right panel with file table""" + file_frame = tk.Frame(parent, bg=COLORS["bg"]) + + # Control panel + control_frame = tk.Frame(file_frame, bg=COLORS["bg"]) + control_frame.pack(fill=tk.X, padx=5, pady=5) + + # View options + tk.Checkbutton(control_frame, text="Plná cesta", variable=tk.BooleanVar(), + command=self.toggle_show_path, bg=COLORS["bg"]).pack(side=tk.LEFT, padx=5) + + + # File table + table_frame = tk.Frame(file_frame) + table_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Define columns + columns = ("name", "date", "tags", "size") + self.file_table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended") + + # Column headers with sort commands + self.file_table.heading("name", text="📄 Název ▲", command=lambda: self.sort_by_column("name")) + self.file_table.heading("date", text="📅 Datum", command=lambda: self.sort_by_column("date")) + self.file_table.heading("tags", text="🏷️ Štítky") + self.file_table.heading("size", text="💾 Velikost", command=lambda: self.sort_by_column("size")) + + # Column widths + self.file_table.column("name", width=300) + self.file_table.column("date", width=100) + self.file_table.column("tags", width=200) + self.file_table.column("size", width=80) + + # Scrollbars + vsb = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.file_table.yview) + hsb = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.file_table.xview) + self.file_table.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + self.file_table.grid(row=0, column=0, sticky="nsew") + vsb.grid(row=0, column=1, sticky="ns") + hsb.grid(row=1, column=0, sticky="ew") + + table_frame.grid_rowconfigure(0, weight=1) + table_frame.grid_columnconfigure(0, weight=1) + + # Bind events + self.file_table.bind("", self.on_file_double_click) + self.file_table.bind("", self.on_file_right_click) + self.file_table.bind("<>", self.on_selection_changed) + + parent.add(file_frame) + + def _create_status_bar(self): + """Create status bar at bottom""" + status_frame = tk.Frame(self.root, bg=COLORS["status_bg"], relief=tk.SUNKEN, bd=1) + status_frame.pack(side=tk.BOTTOM, fill=tk.X) + + # Left side - status message + self.status_label = tk.Label(status_frame, text="Připraven", anchor=tk.W, + bg=COLORS["status_bg"], padx=10) + self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Right side - file count + self.file_count_label = tk.Label(status_frame, text="0 souborů", anchor=tk.E, + bg=COLORS["status_bg"], padx=10) + self.file_count_label.pack(side=tk.RIGHT) + + # Selected size + self.selected_size_label = tk.Label(status_frame, text="", anchor=tk.E, + bg=COLORS["status_bg"], padx=10) + self.selected_size_label.pack(side=tk.RIGHT) + + # Selected count + self.selected_count_label = tk.Label(status_frame, text="", anchor=tk.E, + bg=COLORS["status_bg"], padx=10) + self.selected_count_label.pack(side=tk.RIGHT) + + def _create_context_menus(self): + """Create context menus""" + # Tag context menu + self.tag_menu = tk.Menu(self.root, tearoff=0) + self.tag_menu.add_command(label="Nový štítek", command=self.tree_add_tag) + self.tag_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag) + + # File context menu + self.file_menu = tk.Menu(self.root, tearoff=0) + self.file_menu.add_command(label="Otevřít soubor", command=self.open_selected_files) + self.file_menu.add_command(label="Přiřadit štítky (Ctrl+T)", command=self.assign_tag_to_selected_bulk) + self.file_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected) + self.file_menu.add_separator() + self.file_menu.add_command(label="Smazat z indexu (Del)", command=self.remove_selected_files) + + def _bind_shortcuts(self): + """Bind keyboard shortcuts""" + self.root.bind("", lambda e: self.open_folder_dialog()) + self.root.bind("", lambda e: self.root.quit()) + self.root.bind("", lambda e: self.assign_tag_to_selected_bulk()) + self.root.bind("", lambda e: self.set_date_for_selected()) + self.root.bind("", lambda e: self.search_var.get()) # Focus search + self.root.bind("", lambda e: self.refresh_all()) + self.root.bind("", lambda e: self.remove_selected_files()) + + # ================================================== + # SIDEBAR / TAG TREE METHODS + # ================================================== + + def refresh_sidebar(self): + """Refresh tag tree in sidebar""" + # Clear tree + for item in self.tag_tree.get_children(): + self.tag_tree.delete(item) + + # Reset tag item mapping + self.tag_tree_items = {} # full_path -> tree item_id + + # Count files per tag (from all files) + tag_counts = {} + for f in self.filehandler.filelist: + for t in f.tags: + tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 + + # Add root + total_files = len(self.filehandler.filelist) + root_id = self.tag_tree.insert("", "end", text=f"📂 Všechny soubory ({total_files})", image=self.icons.get("tag")) + self.tag_tree.item(root_id, open=True) + self.root_tag_id = root_id + + # Assign colors to categories + categories = self.tagmanager.get_categories() + color_index = 0 + for category in categories: + if category not in self.category_colors: + # Use predefined color for default categories, otherwise cycle through TAG_COLORS + if category in DEFAULT_CATEGORY_COLORS: + self.category_colors[category] = DEFAULT_CATEGORY_COLORS[category] + else: + self.category_colors[category] = TAG_COLORS[color_index % len(TAG_COLORS)] + color_index += 1 + + # Add categories and tags + for category in categories: + color = self.category_colors.get(category, "#333333") + cat_id = self.tag_tree.insert(root_id, "end", text=f"📁 {category}", image=self.icons.get("tag"), + tags=(f"cat_{category}",)) + self.states[cat_id] = False + + for tag in self.tagmanager.get_tags_in_category(category): + count = tag_counts.get(tag.full_path, 0) + count_str = f" ({count})" if count > 0 else "" + tag_id = self.tag_tree.insert(cat_id, "end", text=f" {tag.name}{count_str}", + image=self.icons.get("unchecked"), + tags=(f"tag_{category}",)) + self.states[tag_id] = False + self.tag_tree_items[tag.full_path] = (tag_id, tag.name) + + # Apply color to category tags + self.tag_tree.tag_configure(f"cat_{category}", foreground=color) + self.tag_tree.tag_configure(f"tag_{category}", foreground=color) + + def update_tag_counts(self, filtered_files): + """Update tag counts in sidebar based on filtered files""" + if not hasattr(self, 'tag_tree_items'): + return + + # Count files per tag from filtered files + tag_counts = {} + for f in filtered_files: + for t in f.tags: + tag_counts[t.full_path] = tag_counts.get(t.full_path, 0) + 1 + + # Update each tag item text + for full_path, (item_id, tag_name) in self.tag_tree_items.items(): + count = tag_counts.get(full_path, 0) + count_str = f" ({count})" if count > 0 else "" + # Preserve the checkbox state + current_text = f" {tag_name}{count_str}" + self.tag_tree.item(item_id, text=current_text) + + # Update root count + total = len(filtered_files) + self.tag_tree.item(self.root_tag_id, text=f"📂 Všechny soubory ({total})") + + def on_tree_left_click(self, event): + """Handle left click on tag tree""" + region = self.tag_tree.identify("region", event.x, event.y) + if region not in ("tree", "icon"): + return + + item_id = self.tag_tree.identify_row(event.y) + if not item_id: + return + + parent_id = self.tag_tree.parent(item_id) + + # Toggle folder open/close + if parent_id == "" or parent_id == self.root_tag_id: + is_open = self.tag_tree.item(item_id, "open") + self.tag_tree.item(item_id, open=not is_open) + return + + # Toggle tag checkbox + self.states[item_id] = not self.states.get(item_id, False) + self.tag_tree.item(item_id, image=self.icons["checked"] if self.states[item_id] else self.icons["unchecked"]) + + # Update file list + self.update_files_from_manager(self.filehandler.filelist) + + def on_tree_right_click(self, event): + """Handle right click on tag tree""" + item_id = self.tag_tree.identify_row(event.y) + if item_id: + self.selected_tree_item_for_context = item_id + self.tag_tree.selection_set(item_id) + self.tag_menu.tk_popup(event.x_root, event.y_root) + + def tree_add_tag(self, background=False): + """Add new tag""" + name = simpledialog.askstring("Nový tag", "Název tagu:") + if not name: + return + + parent = self.selected_tree_item_for_context if not background else self.root_tag_id + new_id = self.tag_tree.insert(parent, "end", text=f" {name}", image=self.icons["unchecked"]) + self.states[new_id] = False + + if parent == self.root_tag_id: + self.tagmanager.add_category(name) + self.tag_tree.item(new_id, image=self.icons["tag"]) + else: + category = self.tag_tree.item(parent, "text").replace("📁 ", "") + self.tagmanager.add_tag(category, name) + + self.status_label.config(text=f"Vytvořen tag: {name}") + + def tree_delete_tag(self): + """Delete selected tag""" + item = self.selected_tree_item_for_context + if not item: + return + + name = self.tag_tree.item(item, "text").strip() + ans = messagebox.askyesno("Smazat tag", f"Opravdu chcete smazat '{name}'?") + if not ans: + return + + parent_id = self.tag_tree.parent(item) + self.tag_tree.delete(item) + self.states.pop(item, None) + + if parent_id == self.root_tag_id: + self.tagmanager.remove_category(name.replace("📁 ", "")) + else: + category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") + self.tagmanager.remove_tag(category, name) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Smazán tag: {name}") + + def get_checked_tags(self) -> List[Tag]: + """Get list of checked tags""" + tags = [] + for item_id, checked in self.states.items(): + if not checked: + continue + parent_id = self.tag_tree.parent(item_id) + if parent_id == "" or parent_id == self.root_tag_id: + continue + category = self.tag_tree.item(parent_id, "text").replace("📁 ", "") + # Get tag name from stored mapping (not from text which includes count) + tag_name = None + for full_path, (stored_id, stored_name) in self.tag_tree_items.items(): + if stored_id == item_id: + tag_name = stored_name + break + if tag_name: + tags.append(Tag(category, tag_name)) + return tags + + # ================================================== + # FILE TABLE METHODS + # ================================================== + + def update_files_from_manager(self, filelist=None): + """Update file table""" + if filelist is None: + filelist = self.filehandler.filelist + + # Filter by checked tags + checked_tags = self.get_checked_tags() + filtered_files = self.filehandler.filter_files_by_tags(checked_tags) + + # Filter by search text + search_text = self.search_var.get().lower() if hasattr(self, 'search_var') else "" + if search_text: + filtered_files = [ + f for f in filtered_files + if search_text in f.filename.lower() or + (self.show_full_path and search_text in str(f.file_path).lower()) + ] + + # Filter ignored + if self.hide_ignored_var and self.hide_ignored_var.get(): + filtered_files = [ + f for f in filtered_files + if "Stav/Ignorované" not in {t.full_path for t in f.tags} + ] + + # Sort + reverse = (self.sort_order == "desc") + if self.sort_mode == "name": + filtered_files.sort(key=lambda f: f.filename.lower(), reverse=reverse) + elif self.sort_mode == "date": + filtered_files.sort(key=lambda f: (f.date or ""), reverse=reverse) + elif self.sort_mode == "size": + filtered_files.sort(key=lambda f: f.file_path.stat().st_size if f.file_path.exists() else 0, reverse=reverse) + + # Clear table + for item in self.file_table.get_children(): + self.file_table.delete(item) + self.file_items.clear() + + # Populate table + for f in filtered_files: + name = str(f.file_path) if self.show_full_path else f.filename + date = f.date or "" + tags = ", ".join([t.name for t in f.tags[:3]]) # Show first 3 tags + if len(f.tags) > 3: + tags += f" +{len(f.tags) - 3}" + + try: + size = f.file_path.stat().st_size + size_str = self._format_size(size) + except: + size_str = "?" + + item_id = self.file_table.insert("", "end", values=(name, date, tags, size_str)) + self.file_items[item_id] = f + + # Update status + self.file_count_label.config(text=f"{len(filtered_files)} souborů") + self.status_label.config(text=f"Zobrazeno {len(filtered_files)} souborů") + + # Update tag counts in sidebar + self.update_tag_counts(filtered_files) + + def _format_size(self, size_bytes): + """Format file size""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + def get_selected_files(self) -> List[File]: + """Get selected files from table""" + selected_items = self.file_table.selection() + return [self.file_items[item] for item in selected_items if item in self.file_items] + + def on_selection_changed(self, event=None): + """Update status bar when selection changes""" + files = self.get_selected_files() + count = len(files) + + if count == 0: + self.selected_count_label.config(text="") + self.selected_size_label.config(text="") + else: + self.selected_count_label.config(text=f"{count} vybráno") + total_size = 0 + for f in files: + try: + total_size += f.file_path.stat().st_size + except: + pass + self.selected_size_label.config(text=f"[{self._format_size(total_size)}]") + + def on_file_double_click(self, event): + """Handle double click on file""" + files = self.get_selected_files() + for f in files: + self.open_file(f.file_path) + + def on_file_right_click(self, event): + """Handle right click on file""" + # Select item under cursor if not selected + item = self.file_table.identify_row(event.y) + if item and item not in self.file_table.selection(): + self.file_table.selection_set(item) + + # Update selected count + count = len(self.file_table.selection()) + self.selected_count_label.config(text=f"{count} vybráno" if count > 0 else "") + + self.file_menu.tk_popup(event.x_root, event.y_root) + + def open_file(self, path): + """Open file with default application""" + try: + if sys.platform.startswith("win"): + os.startfile(path) + elif sys.platform.startswith("darwin"): + subprocess.call(["open", path]) + else: + subprocess.call(["xdg-open", path]) + self.status_label.config(text=f"Otevírám: {path.name}") + except Exception as e: + messagebox.showerror("Chyba", f"Nelze otevřít {path}: {e}") + + # ================================================== + # ACTIONS + # ================================================== + + def open_folder_dialog(self): + """Open folder selection dialog""" + folder = filedialog.askdirectory(title="Vyber složku pro sledování") + if not folder: + return + + folder_path = Path(folder) + try: + self.filehandler.append(folder_path) + for f in self.filehandler.filelist: + if f.tags and f.tagmanager: + for t in f.tags: + f.tagmanager.add_tag(t.category, t.name) + + self.status_label.config(text=f"Přidána složka: {folder_path}") + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + except Exception as e: + messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}") + + def open_selected_files(self): + """Open selected files""" + files = self.get_selected_files() + for f in files: + self.open_file(f.file_path) + + def remove_selected_files(self): + """Remove selected files from index""" + files = self.get_selected_files() + if not files: + return + + ans = messagebox.askyesno("Smazat z indexu", f"Odstranit {len(files)} souborů z indexu?") + if ans: + for f in files: + if f in self.filehandler.filelist: + self.filehandler.filelist.remove(f) + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Odstraněno {len(files)} souborů z indexu") + + def assign_tag_to_selected_bulk(self): + """Assign tags to selected files (bulk mode)""" + files = self.get_selected_files() + if not files: + self.status_label.config(text="Nebyly vybrány žádné soubory") + return + + all_tags = [] + for category in self.tagmanager.get_categories(): + for tag in self.tagmanager.get_tags_in_category(category): + all_tags.append(tag) + + if not all_tags: + messagebox.showwarning("Chyba", "Žádné tagy nejsou definovány") + return + + dialog = MultiFileTagAssignDialog(self.root, all_tags, files, self.category_colors) + result = dialog.result + + if result is None: + self.status_label.config(text="Přiřazení zrušeno") + return + + for full_path, state in result.items(): + if state == 1: + if "/" in full_path: + category, name = full_path.split("/", 1) + tag_obj = self.tagmanager.add_tag(category, name) + self.filehandler.assign_tag_to_file_objects(files, tag_obj) + elif state == 0: + if "/" in full_path: + category, name = full_path.split("/", 1) + tag_obj = Tag(category, name) + self.filehandler.remove_tag_from_file_objects(files, tag_obj) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text="Hromadné přiřazení tagů dokončeno") + + def set_date_for_selected(self): + """Set date for selected files""" + files = self.get_selected_files() + if not files: + self.status_label.config(text="Nebyly vybrány žádné soubory") + return + + prompt = "Zadej datum ve formátu YYYY-MM-DD (nebo prázdné pro smazání):" + date_str = simpledialog.askstring("Nastavit datum", prompt, parent=self.root) + if date_str is None: + return + + for f in files: + f.set_date(date_str if date_str != "" else None) + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Nastaveno datum pro {len(files)} soubor(ů)") + + def detect_video_resolution(self): + """Detect video resolution using ffprobe""" + files = self.get_selected_files() + if not files: + self.status_label.config(text="Nebyly vybrány žádné soubory") + return + + count = 0 + for f in files: + try: + path = str(f.file_path) + result = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=height", "-of", "csv=p=0", path], + capture_output=True, + text=True, + check=True + ) + height_str = result.stdout.strip() + if not height_str.isdigit(): + continue + height = int(height_str) + tag_name = f"{height}p" + tag_obj = self.tagmanager.add_tag("Rozlišení", tag_name) + f.add_tag(tag_obj) + count += 1 + except Exception as e: + print(f"Chyba u {f.filename}: {e}") + + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text=f"Přiřazeno rozlišení tagů k {count} souborům") + + def set_ignore_patterns(self): + """Set ignore patterns for current folder""" + current = ", ".join(self.filehandler.get_ignore_patterns()) + s = simpledialog.askstring("Ignore patterns", + "Zadej patterny oddělené čárkou (např. *.png, *.tmp):", + initialvalue=current) + if s is None: + return + + patterns = [p.strip() for p in s.split(",") if p.strip()] + self.filehandler.set_ignore_patterns(patterns) + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text="Ignore patterns aktualizovány") + + def toggle_hide_ignored(self): + """Toggle hiding ignored files""" + self.update_files_from_manager(self.filehandler.filelist) + + def toggle_show_path(self): + """Toggle showing full path""" + self.show_full_path = not self.show_full_path + self.update_files_from_manager(self.filehandler.filelist) + + def sort_by_column(self, column: str): + """Sort by column header click""" + if self.sort_mode == column: + self.sort_order = "desc" if self.sort_order == "asc" else "asc" + else: + self.sort_mode = column + self.sort_order = "asc" + + self._update_sort_indicators() + self.update_files_from_manager(self.filehandler.filelist) + + def _update_sort_indicators(self): + """Update column header sort indicators""" + arrow = "▲" if self.sort_order == "asc" else "▼" + + headers = { + "name": "📄 Název", + "date": "📅 Datum", + "size": "💾 Velikost" + } + + for col, base_text in headers.items(): + if col == self.sort_mode: + self.file_table.heading(col, text=f"{base_text} {arrow}") + else: + self.file_table.heading(col, text=base_text) + + def on_filter_changed(self): + """Handle search/filter change""" + self.update_files_from_manager(self.filehandler.filelist) + + def refresh_all(self): + """Refresh everything""" + self.refresh_sidebar() + self.update_files_from_manager(self.filehandler.filelist) + self.status_label.config(text="Obnoveno") + + def configure_hardlink_folder(self): + """Configure hardlink output folder for current project""" + if not self.filehandler.current_folder: + messagebox.showwarning("Upozornění", "Nejprve otevřete složku") + return + + # Get current settings + folder_config = self.filehandler.get_folder_config() + current_dir = folder_config.get("hardlink_output_dir") + current_categories = folder_config.get("hardlink_categories") + + # Ask for output directory + initial_dir = current_dir if current_dir else str(self.filehandler.current_folder) + output_dir = filedialog.askdirectory( + title="Vyber cílovou složku pro hardlink strukturu", + initialdir=initial_dir, + mustexist=False + ) + if not output_dir: + return + + # Get available categories + categories = self.tagmanager.get_categories() + if not categories: + messagebox.showwarning("Upozornění", "Žádné kategorie tagů") + return + + # Show category selection dialog + selected_categories = self._show_category_selection_dialog( + categories, + preselected=current_categories + ) + if selected_categories is None: + return # Cancelled + + # Save to folder config + folder_config["hardlink_output_dir"] = output_dir + folder_config["hardlink_categories"] = selected_categories if selected_categories else None + self.filehandler.save_folder_config(config=folder_config) + + messagebox.showinfo("Hotovo", f"Hardlink složka nastavena:\n{output_dir}") + self.status_label.config(text=f"Hardlink složka nastavena: {output_dir}") + + def update_hardlink_structure(self): + """Quick update hardlink structure using saved settings""" + if not self.filehandler.current_folder: + messagebox.showwarning("Upozornění", "Nejprve otevřete složku") + return + + # Get saved settings + folder_config = self.filehandler.get_folder_config() + output_dir = folder_config.get("hardlink_output_dir") + saved_categories = folder_config.get("hardlink_categories") + + if not output_dir: + messagebox.showinfo("Info", "Hardlink složka není nastavena.\nPoužijte 'Nastavit hardlink složku...' pro konfiguraci.") + return + + output_path = Path(output_dir) + files = self.filehandler.filelist + + if not files: + messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") + return + + # Create manager and analyze + manager = HardlinkManager(output_path) + + # Find what needs to be created and removed + preview_create = manager.get_preview(files, saved_categories) + obsolete = manager.find_obsolete_links(files, saved_categories) + + # Filter out already existing links from preview + to_create = [] + for source, target in preview_create: + if not target.exists(): + to_create.append((source, target)) + elif not manager._is_same_file(source, target): + to_create.append((source, target)) + + if not to_create and not obsolete: + messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") + return + + # Build confirmation message + confirm_lines = [] + if to_create: + confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") + if obsolete: + confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") + confirm_lines.append(f"\nCílová složka: {output_path}") + confirm_lines.append("\nPokračovat?") + + if not messagebox.askyesno("Potvrdit aktualizaci", "\n".join(confirm_lines)): + return + + # Perform sync + self.status_label.config(text="Aktualizuji hardlink strukturu...") + self.root.update() + + created, create_fail, removed, remove_fail = manager.sync_structure(files, saved_categories) + + # Show result + result_lines = [] + if created > 0: + result_lines.append(f"Vytvořeno: {created} hardlinků") + if removed > 0: + result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") + + if create_fail > 0 or remove_fail > 0: + if create_fail > 0: + result_lines.append(f"Selhalo vytvoření: {create_fail}") + if remove_fail > 0: + result_lines.append(f"Selhalo odebrání: {remove_fail}") + messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) + else: + messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") + + self.status_label.config(text=f"Hardlink struktura aktualizována (vytvořeno: {created}, odebráno: {removed})") + + def create_hardlink_structure(self): + """Create hardlink directory structure based on file tags (manual selection)""" + files = self.filehandler.filelist + if not files: + messagebox.showwarning("Upozornění", "Žádné soubory k zpracování") + return + + # Ask for output directory + output_dir = filedialog.askdirectory( + title="Vyber cílovou složku pro hardlink strukturu", + mustexist=False + ) + if not output_dir: + return + + output_path = Path(output_dir) + + # Get available categories + categories = self.tagmanager.get_categories() + if not categories: + messagebox.showwarning("Upozornění", "Žádné kategorie tagů") + return + + # Show category selection dialog + selected_categories = self._show_category_selection_dialog(categories) + if selected_categories is None: + return # Cancelled + + cat_filter = selected_categories if selected_categories else None + + # Create manager and analyze + manager = HardlinkManager(output_path) + + # Find what needs to be created and removed + preview_create = manager.get_preview(files, cat_filter) + obsolete = manager.find_obsolete_links(files, cat_filter) + + # Filter out already existing links from preview + to_create = [] + for source, target in preview_create: + if not target.exists(): + to_create.append((source, target)) + elif not manager._is_same_file(source, target): + to_create.append((source, target)) + + if not to_create and not obsolete: + messagebox.showinfo("Info", "Struktura je již synchronizovaná, žádné změny nejsou potřeba") + return + + # Build confirmation message + confirm_lines = [] + if to_create: + confirm_lines.append(f"Vytvořit: {len(to_create)} hardlinků") + if obsolete: + confirm_lines.append(f"Odebrat: {len(obsolete)} zastaralých hardlinků") + confirm_lines.append(f"\nCílová složka: {output_path}") + confirm_lines.append("\nPokračovat?") + + if not messagebox.askyesno("Potvrdit synchronizaci", "\n".join(confirm_lines)): + return + + # Perform sync + self.status_label.config(text="Synchronizuji hardlink strukturu...") + self.root.update() + + created, create_fail, removed, remove_fail = manager.sync_structure(files, cat_filter) + + # Show result + result_lines = [] + if created > 0 or create_fail > 0: + result_lines.append(f"Vytvořeno: {created} hardlinků") + if create_fail > 0: + result_lines.append(f"Selhalo vytvoření: {create_fail}") + if removed > 0 or remove_fail > 0: + result_lines.append(f"Odebráno: {removed} zastaralých hardlinků") + if remove_fail > 0: + result_lines.append(f"Selhalo odebrání: {remove_fail}") + + if create_fail > 0 or remove_fail > 0: + if manager.errors: + result_lines.append("\nChyby:") + for path, err in manager.errors[:5]: + result_lines.append(f"- {path.name}: {err}") + if len(manager.errors) > 5: + result_lines.append(f"... a dalších {len(manager.errors) - 5} chyb") + messagebox.showwarning("Dokončeno s chybami", "\n".join(result_lines)) + else: + messagebox.showinfo("Hotovo", "\n".join(result_lines) if result_lines else "Žádné změny") + + self.status_label.config(text=f"Hardlink struktura synchronizována (vytvořeno: {created}, odebráno: {removed})") + + def _show_category_selection_dialog(self, categories: List[str], preselected: List[str] | None = None) -> List[str] | None: + """Show dialog to select which categories to include in hardlink structure + + Args: + categories: List of available category names + preselected: Optional list of categories to pre-check (None = all checked) + """ + dialog = tk.Toplevel(self.root) + dialog.title("Vybrat kategorie") + dialog.geometry("350x400") + dialog.transient(self.root) + dialog.grab_set() + + result = {"categories": None} + + tk.Label(dialog, text="Vyberte kategorie pro vytvoření struktury:", + font=("Arial", 10, "bold")).pack(pady=10) + + # Scrollable frame for checkboxes + frame = tk.Frame(dialog) + frame.pack(fill=tk.BOTH, expand=True, padx=10) + + canvas = tk.Canvas(frame) + scrollbar = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) + scrollable_frame = tk.Frame(canvas) + + scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # Category checkboxes + category_vars = {} + for category in sorted(categories): + # If preselected is None, check all; otherwise check only those in preselected + initial_value = preselected is None or category in preselected + var = tk.BooleanVar(value=initial_value) + category_vars[category] = var + color = self.category_colors.get(category, "#333333") + cb = tk.Checkbutton(scrollable_frame, text=category, variable=var, + fg=color, font=("Arial", 10), anchor="w") + cb.pack(fill="x", pady=2) + + # Buttons + btn_frame = tk.Frame(dialog) + btn_frame.pack(pady=10) + + def on_ok(): + result["categories"] = [cat for cat, var in category_vars.items() if var.get()] + dialog.destroy() + + def on_cancel(): + result["categories"] = None + dialog.destroy() + + def select_all(): + for var in category_vars.values(): + var.set(True) + + def select_none(): + for var in category_vars.values(): + var.set(False) + + tk.Button(btn_frame, text="Všechny", command=select_all, width=8).pack(side=tk.LEFT, padx=2) + tk.Button(btn_frame, text="Žádné", command=select_none, width=8).pack(side=tk.LEFT, padx=2) + tk.Button(btn_frame, text="OK", command=on_ok, width=10).pack(side=tk.LEFT, padx=10) + tk.Button(btn_frame, text="Zrušit", command=on_cancel, width=10).pack(side=tk.LEFT, padx=2) + + self.root.wait_window(dialog) + return result["categories"] diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py new file mode 100644 index 0000000..8266bf7 --- /dev/null +++ b/src/ui/qt_app.py @@ -0,0 +1,590 @@ +""" +PySide6 GUI for Curator — reframed around the Filmotéka (movie-library) workflow. + +Layout: +- Toolbar / menu: configure the Pool and the Filmotéka output, import a movie, + generate the Filmotéka hardlink tree. +- Left sidebar: tag tree used as an AND-filter over the movie list. +- Center: the movie table (the contents of pool/Filmy). +- Status bar: counts and the current pool/output paths. +""" +import os +import sys +import subprocess +from pathlib import Path +from typing import List + +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, + QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, + QFormLayout, QHeaderView, QMenu, QAbstractItemView, +) + +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager +from src.core.file import File +from src.core.tag import Tag +from src.core.constants import APP_NAME, VERSION +from src.core.hardlink_manager import HardlinkManager + +# Categories that drive the generated Filmotéka tree (see PROJECT.md) +FILMOTEKA_CATEGORIES = ["Rok", "Žánr", "Hodnocení"] + + +class ImportMovieDialog(QDialog): + """Collect the Title and ČSFD link for a movie being imported into the pool.""" + + def __init__(self, parent: QWidget, default_title: str) -> None: + super().__init__(parent) + self.setWindowTitle("Importovat film do poolu") + self.setMinimumWidth(420) + + layout = QVBoxLayout(self) + form = QFormLayout() + self.title_edit = QLineEdit(default_title) + self.csfd_edit = QLineEdit() + self.csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...") + form.addRow("Název:", self.title_edit) + form.addRow("ČSFD odkaz:", self.csfd_edit) + layout.addLayout(form) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @property + def title(self) -> str: + return self.title_edit.text().strip() + + @property + def csfd_link(self) -> str: + return self.csfd_edit.text().strip() + + +class AssignTagsDialog(QDialog): + """Tri-state bulk tag assignment over the selected movies. + + Result maps full_path -> 1 (assign), 0 (remove), 2 (leave mixed/unchanged). + """ + + def __init__(self, parent: QWidget, tagmanager: TagManager, files: List[File]) -> None: + super().__init__(parent) + self.setWindowTitle("Přiřadit štítky") + self.setMinimumSize(380, 480) + self.result_map: dict[str, int] = {} + + file_tag_sets = [{t.full_path for t in f.tags} for f in files] + + layout = QVBoxLayout(self) + layout.addWidget(QLabel(f"Vybráno filmů: {len(files)}")) + + self.tree = QTreeWidget() + self.tree.setHeaderHidden(True) + layout.addWidget(self.tree) + + self._items: list[tuple[str, QTreeWidgetItem]] = [] + for category in self.tagmanager_categories(tagmanager): + cat_item = QTreeWidgetItem([category]) + cat_item.setFlags(Qt.ItemIsEnabled) + self.tree.addTopLevelItem(cat_item) + cat_item.setExpanded(True) + for tag in tagmanager.get_tags_in_category(category): + have = sum(1 for s in file_tag_sets if tag.full_path in s) + if have == 0: + state = Qt.Unchecked + elif have == len(files): + state = Qt.Checked + else: + state = Qt.PartiallyChecked + item = QTreeWidgetItem([tag.name]) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsTristate) + item.setCheckState(0, state) + cat_item.addChild(item) + self._items.append((tag.full_path, item)) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @staticmethod + def tagmanager_categories(tagmanager: TagManager) -> List[str]: + return sorted(tagmanager.get_categories()) + + def accept(self) -> None: + mapping = {Qt.Checked: 1, Qt.Unchecked: 0, Qt.PartiallyChecked: 2} + self.result_map = {fp: mapping[item.checkState(0)] for fp, item in self._items} + super().accept() + + +class QtApp(QMainWindow): + def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None: + super().__init__() + self.filehandler = filehandler + self.tagmanager = tagmanager + self.file_rows: dict[int, File] = {} # table row -> File + self.filehandler.on_files_changed = lambda _=None: self.refresh_table() + + self.setWindowTitle(f"{APP_NAME} {VERSION} — Filmotéka") + geometry = self.filehandler.global_config.get("window_geometry", "1200x800") + try: + w, h = (int(x) for x in geometry.lower().split("x")) + self.resize(w, h) + except Exception: + self.resize(1200, 800) + + self._build_menu() + self._build_central() + self._build_statusbar() + + # Load the pool if one is configured + if self.filehandler.movies_dir: + self.filehandler.load_pool_movies() + self.refresh_sidebar() + self.refresh_table() + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _build_menu(self) -> None: + bar = self.menuBar() + + pool_menu = bar.addMenu("&Pool") + self._add_action(pool_menu, "Nastavit pool…", self.set_pool, "Ctrl+P") + self._add_action(pool_menu, "Nastavit Filmotéku (výstup)…", self.set_filmoteka) + pool_menu.addSeparator() + self._add_action(pool_menu, "Znovu načíst pool", self.reload_pool, "F5") + pool_menu.addSeparator() + self._add_action(pool_menu, "Konec", self.close, "Ctrl+Q") + + movie_menu = bar.addMenu("&Filmy") + self._add_action(movie_menu, "Importovat film…", self.import_movie, "Ctrl+I") + self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T") + self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D") + self._add_action(movie_menu, "Upravit ČSFD odkaz…", self.edit_csfd) + self._add_action(movie_menu, "Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) + movie_menu.addSeparator() + self._add_action(movie_menu, "Odebrat z poolu…", self.remove_movies, "Del") + + film_menu = bar.addMenu("Filmo&téka") + self._add_action(film_menu, "Vygenerovat Filmotéku", self.generate_filmoteka, "Ctrl+G") + self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders) + + def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction: + action = QAction(text, self) + if shortcut: + action.setShortcut(QKeySequence(shortcut)) + action.triggered.connect(slot) + menu.addAction(action) + return action + + def _build_central(self) -> None: + splitter = QSplitter(Qt.Horizontal) + + # Sidebar — tag filter + sidebar = QWidget() + side_layout = QVBoxLayout(sidebar) + side_layout.setContentsMargins(4, 4, 4, 4) + side_layout.addWidget(QLabel("📂 Štítky (filtr)")) + self.tag_tree = QTreeWidget() + self.tag_tree.setHeaderHidden(True) + self.tag_tree.itemChanged.connect(self._on_tag_filter_changed) + side_layout.addWidget(self.tag_tree) + splitter.addWidget(sidebar) + + # Main — search + movie table + main = QWidget() + main_layout = QVBoxLayout(main) + main_layout.setContentsMargins(4, 4, 4, 4) + + search_row = QHBoxLayout() + search_row.addWidget(QLabel("🔍")) + self.search_edit = QLineEdit() + self.search_edit.setPlaceholderText("Hledat film…") + self.search_edit.textChanged.connect(self.refresh_table) + search_row.addWidget(self.search_edit) + import_btn = QPushButton("➕ Importovat film") + import_btn.clicked.connect(self.import_movie) + search_row.addWidget(import_btn) + main_layout.addLayout(search_row) + + self.table = QTableWidget(0, 5) + self.table.setHorizontalHeaderLabels(["Název", "Datum", "Štítky", "Velikost", "ČSFD"]) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setContextMenuPolicy(Qt.CustomContextMenu) + self.table.customContextMenuRequested.connect(self._show_table_menu) + self.table.doubleClicked.connect(lambda _: self.open_movies()) + self.table.itemSelectionChanged.connect(self._update_selection_status) + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.Stretch) + main_layout.addWidget(self.table) + + splitter.addWidget(main) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + splitter.setSizes([260, 940]) + self.setCentralWidget(splitter) + + def _build_statusbar(self) -> None: + self.status = self.statusBar() + self._update_path_status() + + # ------------------------------------------------------------------ + # Sidebar (tag filter) + # ------------------------------------------------------------------ + + def refresh_sidebar(self) -> None: + self.tag_tree.blockSignals(True) + self.tag_tree.clear() + counts: dict[str, int] = {} + for f in self.filehandler.filelist: + for t in f.tags: + counts[t.full_path] = counts.get(t.full_path, 0) + 1 + + for category in self.tagmanager.get_categories(): + cat_item = QTreeWidgetItem([category]) + cat_item.setFlags(Qt.ItemIsEnabled) + self.tag_tree.addTopLevelItem(cat_item) + cat_item.setExpanded(True) + for tag in self.tagmanager.get_tags_in_category(category): + count = counts.get(tag.full_path, 0) + label = f"{tag.name} ({count})" if count else tag.name + item = QTreeWidgetItem([label]) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + item.setCheckState(0, Qt.Unchecked) + item.setData(0, Qt.UserRole, tag.full_path) + cat_item.addChild(item) + self.tag_tree.blockSignals(False) + + def _on_tag_filter_changed(self, _item, _col) -> None: + self.refresh_table() + + def _checked_filter_tags(self) -> List[Tag]: + tags: List[Tag] = [] + for i in range(self.tag_tree.topLevelItemCount()): + cat = self.tag_tree.topLevelItem(i) + for j in range(cat.childCount()): + child = cat.child(j) + if child.checkState(0) == Qt.Checked: + full_path = child.data(0, Qt.UserRole) + category, name = full_path.split("/", 1) + tags.append(Tag(category, name)) + return tags + + # ------------------------------------------------------------------ + # Movie table + # ------------------------------------------------------------------ + + def refresh_table(self, *_args) -> None: + filtered = self.filehandler.filter_files_by_tags(self._checked_filter_tags()) + search = self.search_edit.text().lower() if hasattr(self, "search_edit") else "" + if search: + filtered = [f for f in filtered if search in (f.title or f.filename).lower()] + filtered.sort(key=lambda f: (f.title or f.filename).lower()) + + self.table.setRowCount(len(filtered)) + self.file_rows.clear() + for row, f in enumerate(filtered): + self.file_rows[row] = f + name = f.title or f.filename + tags = ", ".join(t.name for t in f.tags) + try: + size = self._format_size(f.file_path.stat().st_size) + except OSError: + size = "?" + csfd = "🔗" if f.csfd_link else "" + for col, value in enumerate([name, f.date or "", tags, size, csfd]): + self.table.setItem(row, col, QTableWidgetItem(value)) + + self.refresh_sidebar() + self._update_selection_status() + self.status.showMessage(f"Zobrazeno {len(filtered)} filmů", 4000) + + @staticmethod + def _format_size(size_bytes: float) -> str: + for unit in ["B", "KB", "MB", "GB"]: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + def _selected_movies(self) -> List[File]: + rows = {idx.row() for idx in self.table.selectionModel().selectedRows()} + return [self.file_rows[r] for r in sorted(rows) if r in self.file_rows] + + def _show_table_menu(self, pos) -> None: + menu = QMenu(self) + menu.addAction("Otevřít", self.open_movies) + menu.addAction("Přiřadit štítky…", self.assign_tags) + menu.addAction("Nastavit datum…", self.set_date) + menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd) + menu.addAction("Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) + menu.addSeparator() + menu.addAction("Odebrat z poolu…", self.remove_movies) + menu.exec(self.table.viewport().mapToGlobal(pos)) + + def _update_selection_status(self) -> None: + files = self._selected_movies() + if not files: + self._update_path_status() + return + total = 0 + for f in files: + try: + total += f.file_path.stat().st_size + except OSError: + pass + self.status.showMessage(f"{len(files)} vybráno — {self._format_size(total)}") + + def _update_path_status(self) -> None: + pool = self.filehandler.pool_dir + out = self.filehandler.filmoteka_dir + self.status.showMessage( + f"Pool: {pool or '—'} | Filmotéka: {out or '—'} | " + f"{len(self.filehandler.filelist)} filmů" + ) + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def set_pool(self) -> None: + folder = QFileDialog.getExistingDirectory(self, "Vyber složku poolu") + if not folder: + return + self.filehandler.set_pool_dir(Path(folder)) + self.filehandler.load_pool_movies() + self.refresh_table() + self._update_path_status() + + def set_filmoteka(self) -> None: + folder = QFileDialog.getExistingDirectory(self, "Vyber výstupní složku Filmotéky") + if not folder: + return + self.filehandler.set_filmoteka_dir(Path(folder)) + self._update_path_status() + + def reload_pool(self) -> None: + if not self.filehandler.movies_dir: + QMessageBox.information(self, "Pool", "Nejprve nastavte pool.") + return + self.filehandler.load_pool_movies() + self.refresh_table() + + def import_movie(self) -> None: + if not self.filehandler.movies_dir: + QMessageBox.warning(self, "Pool", "Nejprve nastavte pool (menu Pool → Nastavit pool).") + return + path, _ = QFileDialog.getOpenFileName(self, "Vyber video soubor") + if not path: + return + source = Path(path) + dialog = ImportMovieDialog(self, default_title=source.stem) + if dialog.exec() != QDialog.Accepted: + return + try: + movie = self.filehandler.import_movie(source, dialog.title, dialog.csfd_link) + except Exception as exc: # noqa: BLE001 — surface any import failure to the user + QMessageBox.critical(self, "Chyba importu", str(exc)) + return + + # If a ČSFD link was given, enrich the movie with tags right away + if movie.csfd_link: + self.status.showMessage("Načítám z ČSFD…") + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + _, tags_total, errors = self._fetch_csfd_for([movie]) + finally: + QApplication.restoreOverrideCursor() + if errors: + QMessageBox.warning(self, "ČSFD", "Tagy se nepodařilo načíst:\n" + errors[0]) + else: + self.status.showMessage( + f"Importováno: {movie.title} (+{tags_total} tagů z ČSFD)", 5000 + ) + + self.refresh_table() + self.status.showMessage(f"Importováno: {dialog.title}", 5000) + + def open_movies(self) -> None: + for f in self._selected_movies(): + self._open_path(f.file_path) + + def _open_path(self, path: Path) -> None: + try: + if sys.platform.startswith("win"): + os.startfile(path) # type: ignore[attr-defined] + elif sys.platform.startswith("darwin"): + subprocess.call(["open", str(path)]) + else: + subprocess.call(["xdg-open", str(path)]) + except Exception as exc: # noqa: BLE001 + QMessageBox.critical(self, "Chyba", f"Nelze otevřít {path}: {exc}") + + def assign_tags(self) -> None: + files = self._selected_movies() + if not files: + QMessageBox.information(self, "Štítky", "Nejprve vyberte filmy.") + return + dialog = AssignTagsDialog(self, self.tagmanager, files) + if dialog.exec() != QDialog.Accepted: + return + for full_path, state in dialog.result_map.items(): + category, name = full_path.split("/", 1) + if state == 1: + self.filehandler.assign_tag_to_file_objects(files, self.tagmanager.add_tag(category, name)) + elif state == 0: + self.filehandler.remove_tag_from_file_objects(files, Tag(category, name)) + self.refresh_table() + + def set_date(self) -> None: + files = self._selected_movies() + if not files: + QMessageBox.information(self, "Datum", "Nejprve vyberte filmy.") + return + text, ok = QInputDialog.getText(self, "Nastavit datum", "Datum (YYYY-MM-DD, prázdné = smazat):") + if not ok: + return + for f in files: + f.set_date(text.strip() or None) + self.refresh_table() + + def edit_csfd(self) -> None: + files = self._selected_movies() + if len(files) != 1: + QMessageBox.information(self, "ČSFD", "Vyberte právě jeden film.") + return + f = files[0] + text, ok = QInputDialog.getText(self, "ČSFD odkaz", "URL:", text=f.csfd_link or "") + if not ok: + return + f.set_csfd_link(text.strip() or None) + self.refresh_table() + + def apply_csfd_tags_for_selected(self) -> None: + files = [f for f in self._selected_movies() if f.csfd_link] + if not files: + QMessageBox.information( + self, "ČSFD", "Vyberte filmy, které mají nastavený ČSFD odkaz." + ) + return + + self.status.showMessage(f"Načítám z ČSFD ({len(files)})…") + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + ok_count, tags_total, errors = self._fetch_csfd_for(files) + finally: + QApplication.restoreOverrideCursor() + + self.refresh_table() + msg = f"Načteno: {ok_count}/{len(files)} filmů, přidáno {tags_total} tagů." + if errors: + msg += "\n\nChyby:\n" + "\n".join(errors[:5]) + QMessageBox.warning(self, "ČSFD dokončeno s chybami", msg) + else: + QMessageBox.information(self, "ČSFD", msg) + + def _fetch_csfd_for(self, files) -> tuple[int, int, list[str]]: + """Apply CSFD tags to each file; return (ok_count, tags_added, errors).""" + ok_count = 0 + tags_total = 0 + errors: list[str] = [] + for f in files: + result = f.apply_csfd_tags() + if result["success"]: + ok_count += 1 + tags_total += len(result["tags_added"]) + else: + errors.append(f"{f.title or f.filename}: {result['error']}") + return ok_count, tags_total, errors + + def remove_movies(self) -> None: + files = self._selected_movies() + if not files: + return + answer = QMessageBox.question( + self, "Odebrat z poolu", + f"Smazat {len(files)} film(ů) z poolu včetně souboru a metadat?", + ) + if answer != QMessageBox.Yes: + return + for f in files: + try: + f.delete_metadata() + if f.file_path.exists(): + f.file_path.unlink() + except OSError as exc: + QMessageBox.warning(self, "Chyba", f"Nelze smazat {f.filename}: {exc}") + if f in self.filehandler.filelist: + self.filehandler.filelist.remove(f) + self.refresh_table() + + def generate_filmoteka(self) -> None: + out = self.filehandler.filmoteka_dir + if not out: + QMessageBox.warning(self, "Filmotéka", "Nejprve nastavte výstupní složku Filmotéky.") + return + files = self.filehandler.filelist + if not files: + QMessageBox.information(self, "Filmotéka", "Pool je prázdný.") + return + manager = HardlinkManager(out) + created, create_fail, removed, remove_fail = manager.sync_structure(files, FILMOTEKA_CATEGORIES) + + # Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked) + pool = self.filehandler.pool_dir + mirrored = 0 + mirror_fail = 0 + for name in self.filehandler.copyasis_folders: + src = pool / name if pool else None + if src and src.is_dir() and any(src.iterdir()): + m_created, m_failed = manager.mirror_as_is(src, name) + mirrored += m_created + mirror_fail += m_failed + + msg = ( + f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n" + f"Copy-as-is — zrcadleno: {mirrored}" + ) + if create_fail or remove_fail or mirror_fail: + msg += f"\nSelhalo: {create_fail + remove_fail + mirror_fail}" + QMessageBox.warning(self, "Filmotéka dokončena s chybami", msg) + else: + QMessageBox.information(self, "Filmotéka vygenerována", msg) + self.status.showMessage( + f"Filmotéka: filmy +{created}/-{removed}, copy-as-is +{mirrored}", 5000 + ) + + def edit_copyasis_folders(self) -> None: + current = ", ".join(self.filehandler.copyasis_folders) + text, ok = QInputDialog.getText( + self, "Copy-as-is složky", + "Názvy pool podsložek zrcadlených 1:1 (oddělené čárkou):", text=current + ) + if not ok: + return + self.filehandler.set_copyasis_folders(text.split(",")) + self.status.showMessage( + "Copy-as-is složky: " + ", ".join(self.filehandler.copyasis_folders), 5000 + ) + + def closeEvent(self, event) -> None: # noqa: N802 — Qt override + self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}" + from src.core.config import save_global_config + save_global_config(self.filehandler.global_config) + super().closeEvent(event) + + +def run(filehandler: FileManager, tagmanager: TagManager) -> None: + app = QApplication.instance() or QApplication(sys.argv) + window = QtApp(filehandler, tagmanager) + window.show() + app.exec() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9d10015 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +""" +Konfigurace pytest - sdílené fixtures a nastavení pro všechny testy +""" +import pytest +import tempfile +import shutil +from pathlib import Path + + +@pytest.fixture(scope="session") +def session_temp_dir(): + """Session-wide dočasný adresář""" + temp_dir = Path(tempfile.mkdtemp()) + yield temp_dir + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture(autouse=True) +def cleanup_config_files(): + """Automaticky vyčistí config.json soubory po každém testu""" + yield + # Cleanup po testu + config_file = Path("config.json") + if config_file.exists(): + try: + config_file.unlink() + except Exception: + pass diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..58d7999 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,413 @@ +import pytest +import json +from src.core.config import ( + load_global_config, save_global_config, DEFAULT_GLOBAL_CONFIG, + load_folder_config, save_folder_config, DEFAULT_FOLDER_CONFIG, + get_folder_config_path, folder_has_config, FOLDER_CONFIG_NAME, + load_config, save_config # Legacy functions +) + + +class TestGlobalConfig: + """Testy pro globální config""" + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + """Fixture pro dočasný globální config soubor""" + config_path = tmp_path / "config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + def test_default_global_config_structure(self): + """Test struktury defaultní globální konfigurace""" + assert "window_geometry" in DEFAULT_GLOBAL_CONFIG + assert "window_maximized" in DEFAULT_GLOBAL_CONFIG + assert "last_folder" in DEFAULT_GLOBAL_CONFIG + assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG + assert "recent_folders" in DEFAULT_GLOBAL_CONFIG + assert DEFAULT_GLOBAL_CONFIG["window_geometry"] == "1200x800" + assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False + assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None + + def test_load_global_config_nonexistent_file(self, temp_global_config): + """Test načtení globální konfigurace když soubor neexistuje""" + config = load_global_config() + assert config == DEFAULT_GLOBAL_CONFIG + + def test_save_global_config(self, temp_global_config): + """Test uložení globální konfigurace""" + test_config = { + "window_geometry": "800x600", + "window_maximized": True, + "last_folder": "/home/user/documents", + "sidebar_width": 300, + "recent_folders": ["/path1", "/path2"], + } + + save_global_config(test_config) + + assert temp_global_config.exists() + with open(temp_global_config, "r", encoding="utf-8") as f: + saved_data = json.load(f) + assert saved_data == test_config + + def test_load_global_config_existing_file(self, temp_global_config): + """Test načtení existující globální konfigurace""" + test_config = { + "window_geometry": "1920x1080", + "window_maximized": False, + "last_folder": "/test/path", + "sidebar_width": 250, + "recent_folders": [], + "pool_dir": None, + "filmoteka_dir": None, + "copyasis_folders": ["Seriály"], + } + + save_global_config(test_config) + loaded_config = load_global_config() + + assert loaded_config == test_config + + def test_load_global_config_merges_defaults(self, temp_global_config): + """Test že chybějící klíče jsou doplněny z defaultů""" + partial_config = {"window_geometry": "800x600"} + + with open(temp_global_config, "w", encoding="utf-8") as f: + json.dump(partial_config, f) + + loaded = load_global_config() + assert loaded["window_geometry"] == "800x600" + assert loaded["window_maximized"] == DEFAULT_GLOBAL_CONFIG["window_maximized"] + assert loaded["sidebar_width"] == DEFAULT_GLOBAL_CONFIG["sidebar_width"] + + def test_global_config_corrupted_file(self, temp_global_config): + """Test načtení poškozeného global config souboru""" + with open(temp_global_config, "w") as f: + f.write("{ invalid json }") + + config = load_global_config() + assert config == DEFAULT_GLOBAL_CONFIG + + def test_global_config_utf8_encoding(self, temp_global_config): + """Test UTF-8 encoding s českými znaky""" + test_config = { + **DEFAULT_GLOBAL_CONFIG, + "last_folder": "/cesta/s/českými/znaky", + "recent_folders": ["/složka/čeština"], + } + + save_global_config(test_config) + loaded_config = load_global_config() + + assert loaded_config["last_folder"] == "/cesta/s/českými/znaky" + assert loaded_config["recent_folders"] == ["/složka/čeština"] + + def test_global_config_returns_new_dict(self, temp_global_config): + """Test že load_global_config vrací nový dictionary""" + config1 = load_global_config() + config2 = load_global_config() + + assert config1 is not config2 + assert config1 == config2 + + def test_global_config_recent_folders(self, temp_global_config): + """Test ukládání recent_folders""" + folders = ["/path/one", "/path/two", "/path/three"] + test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders} + + save_global_config(test_config) + loaded = load_global_config() + + assert loaded["recent_folders"] == folders + assert len(loaded["recent_folders"]) == 3 + + +class TestFolderConfig: + """Testy pro složkový config""" + + def test_default_folder_config_structure(self): + """Test struktury defaultní složkové konfigurace""" + assert "ignore_patterns" in DEFAULT_FOLDER_CONFIG + assert "custom_tags" in DEFAULT_FOLDER_CONFIG + assert "recursive" in DEFAULT_FOLDER_CONFIG + assert isinstance(DEFAULT_FOLDER_CONFIG["ignore_patterns"], list) + assert isinstance(DEFAULT_FOLDER_CONFIG["custom_tags"], dict) + assert DEFAULT_FOLDER_CONFIG["recursive"] is True + + def test_get_folder_config_path(self, tmp_path): + """Test získání cesty ke složkovému configu""" + path = get_folder_config_path(tmp_path) + assert path == tmp_path / FOLDER_CONFIG_NAME + assert path.name == ".Curator.!ftag" + + def test_load_folder_config_nonexistent(self, tmp_path): + """Test načtení neexistujícího složkového configu""" + config = load_folder_config(tmp_path) + assert config == DEFAULT_FOLDER_CONFIG + + def test_save_folder_config(self, tmp_path): + """Test uložení složkového configu""" + test_config = { + "ignore_patterns": ["*.tmp", "*.log"], + "custom_tags": {"Projekt": ["Web", "API"]}, + "recursive": False, + } + + save_folder_config(tmp_path, test_config) + + config_path = get_folder_config_path(tmp_path) + assert config_path.exists() + + with open(config_path, "r", encoding="utf-8") as f: + saved_data = json.load(f) + assert saved_data == test_config + + def test_load_folder_config_existing(self, tmp_path): + """Test načtení existujícího složkového configu""" + test_config = { + "ignore_patterns": ["*.pyc"], + "custom_tags": {}, + "recursive": True, + "hardlink_output_dir": None, + "hardlink_categories": None, + } + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded == test_config + + def test_load_folder_config_merges_defaults(self, tmp_path): + """Test že chybějící klíče jsou doplněny z defaultů""" + partial_config = {"ignore_patterns": ["*.tmp"]} + + config_path = get_folder_config_path(tmp_path) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(partial_config, f) + + loaded = load_folder_config(tmp_path) + assert loaded["ignore_patterns"] == ["*.tmp"] + assert loaded["custom_tags"] == DEFAULT_FOLDER_CONFIG["custom_tags"] + assert loaded["recursive"] == DEFAULT_FOLDER_CONFIG["recursive"] + + def test_folder_has_config_true(self, tmp_path): + """Test folder_has_config když config existuje""" + save_folder_config(tmp_path, DEFAULT_FOLDER_CONFIG) + assert folder_has_config(tmp_path) is True + + def test_folder_has_config_false(self, tmp_path): + """Test folder_has_config když config neexistuje""" + assert folder_has_config(tmp_path) is False + + def test_folder_config_ignore_patterns(self, tmp_path): + """Test ukládání ignore patterns""" + patterns = ["*.tmp", "*.log", "*.cache", "*/node_modules/*", "*.pyc"] + test_config = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": patterns} + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["ignore_patterns"] == patterns + assert len(loaded["ignore_patterns"]) == 5 + + def test_folder_config_custom_tags(self, tmp_path): + """Test ukládání custom tagů""" + custom_tags = { + "Projekt": ["Frontend", "Backend", "API"], + "Stav": ["Hotovo", "Rozpracováno"], + } + test_config = {**DEFAULT_FOLDER_CONFIG, "custom_tags": custom_tags} + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["custom_tags"] == custom_tags + + def test_folder_config_corrupted_file(self, tmp_path): + """Test načtení poškozeného folder config souboru""" + config_path = get_folder_config_path(tmp_path) + with open(config_path, "w") as f: + f.write("{ invalid json }") + + config = load_folder_config(tmp_path) + assert config == DEFAULT_FOLDER_CONFIG + + def test_folder_config_utf8_encoding(self, tmp_path): + """Test UTF-8 v folder configu""" + test_config = { + "ignore_patterns": ["*.čeština"], + "custom_tags": {"Štítky": ["Červená", "Žlutá"]}, + "recursive": True, + } + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["ignore_patterns"] == ["*.čeština"] + assert loaded["custom_tags"]["Štítky"] == ["Červená", "Žlutá"] + + def test_multiple_folders_independent_configs(self, tmp_path): + """Test že různé složky mají nezávislé configy""" + folder1 = tmp_path / "folder1" + folder2 = tmp_path / "folder2" + folder1.mkdir() + folder2.mkdir() + + config1 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.txt"]} + config2 = {**DEFAULT_FOLDER_CONFIG, "ignore_patterns": ["*.jpg"]} + + save_folder_config(folder1, config1) + save_folder_config(folder2, config2) + + loaded1 = load_folder_config(folder1) + loaded2 = load_folder_config(folder2) + + assert loaded1["ignore_patterns"] == ["*.txt"] + assert loaded2["ignore_patterns"] == ["*.jpg"] + + +class TestLegacyFunctions: + """Testy pro zpětnou kompatibilitu""" + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + """Fixture pro dočasný globální config soubor""" + config_path = tmp_path / "config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + def test_load_config_legacy(self, temp_global_config): + """Test že load_config funguje jako alias pro load_global_config""" + test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/test"} + + save_global_config(test_config) + loaded = load_config() + + assert loaded["last_folder"] == "/test" + + def test_save_config_legacy(self, temp_global_config): + """Test že save_config funguje jako alias pro save_global_config""" + test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/legacy"} + + save_config(test_config) + loaded = load_global_config() + + assert loaded["last_folder"] == "/legacy" + + +class TestConfigEdgeCases: + """Testy pro edge cases""" + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + """Fixture pro dočasný globální config soubor""" + config_path = tmp_path / "config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + def test_config_path_with_spaces(self, temp_global_config): + """Test s cestou obsahující mezery""" + test_config = { + **DEFAULT_GLOBAL_CONFIG, + "last_folder": "/path/with spaces/in name" + } + + save_global_config(test_config) + loaded = load_global_config() + + assert loaded["last_folder"] == "/path/with spaces/in name" + + def test_config_long_path(self, temp_global_config): + """Test s dlouhou cestou""" + long_path = "/very/long/path/" + "subdir/" * 50 + "final" + test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": long_path} + + save_global_config(test_config) + loaded = load_global_config() + + assert loaded["last_folder"] == long_path + + def test_config_many_recent_folders(self, temp_global_config): + """Test s velkým počtem recent folders""" + folders = [f"/path/folder{i}" for i in range(100)] + test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders} + + save_global_config(test_config) + loaded = load_global_config() + + assert len(loaded["recent_folders"]) == 100 + + def test_folder_config_special_characters_in_patterns(self, tmp_path): + """Test se speciálními znaky v patterns""" + test_config = { + **DEFAULT_FOLDER_CONFIG, + "ignore_patterns": ["*.tmp", "file[0-9].txt", "test?.log"] + } + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["ignore_patterns"] == test_config["ignore_patterns"] + + def test_config_json_formatting(self, temp_global_config): + """Test že config je uložen ve správném JSON formátu s indentací""" + test_config = {**DEFAULT_GLOBAL_CONFIG} + + save_global_config(test_config) + + with open(temp_global_config, "r", encoding="utf-8") as f: + content = f.read() + + # Mělo by být naformátováno s indentací + assert " " in content + + def test_config_ensure_ascii_false(self, temp_global_config): + """Test že ensure_ascii=False funguje správně""" + test_config = { + **DEFAULT_GLOBAL_CONFIG, + "last_folder": "/cesta/čeština" + } + + save_global_config(test_config) + + with open(temp_global_config, "r", encoding="utf-8") as f: + content = f.read() + + assert "čeština" in content + assert "\\u" not in content # Nemělo by být escapováno + + def test_config_overwrite(self, temp_global_config): + """Test přepsání existující konfigurace""" + config1 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path1"} + config2 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path2"} + + save_global_config(config1) + save_global_config(config2) + + loaded = load_global_config() + assert loaded["last_folder"] == "/path2" + + def test_folder_config_recursive_false(self, tmp_path): + """Test nastavení recursive na False""" + test_config = {**DEFAULT_FOLDER_CONFIG, "recursive": False} + + save_folder_config(tmp_path, test_config) + loaded = load_folder_config(tmp_path) + + assert loaded["recursive"] is False + + def test_empty_folder_config(self, tmp_path): + """Test prázdného folder configu""" + config_path = get_folder_config_path(tmp_path) + with open(config_path, "w", encoding="utf-8") as f: + json.dump({}, f) + + loaded = load_folder_config(tmp_path) + # Mělo by doplnit všechny defaulty + assert loaded["ignore_patterns"] == [] + assert loaded["custom_tags"] == {} + assert loaded["recursive"] is True diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..718ed3d --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,140 @@ +"""Tests for constants module.""" + +import re +from pathlib import Path +from unittest.mock import patch + +import pytest + +from src.constants import ( + APP_NAME, + APP_TITLE, + APP_VERSION, + ENV_DEBUG, + get_debug_mode, + get_version, +) + + +# --------------------------------------------------------------------------- +# get_version() +# --------------------------------------------------------------------------- + + +def test_get_version_returns_string() -> None: + """get_version() should return a string.""" + assert isinstance(get_version(), str) + + +def test_get_version_semver_format() -> None: + """get_version() should return a semver-like string X.Y.Z.""" + version = get_version() + assert re.match(r"^\d+\.\d+\.\d+", version), f"Not semver: {version!r}" + + +def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None: + """get_version() returns '0.0.0-unknown' when pyproject.toml and _version.py are both missing.""" + missing = tmp_path / "nonexistent.toml" + with patch("src.constants._PYPROJECT_PATH", missing): + result = get_version() + # Either fallback _version.py exists (from previous run) or returns unknown + assert isinstance(result, str) + assert len(result) > 0 + + +def test_get_version_unknown_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """get_version() returns '0.0.0-unknown' when all sources are unavailable.""" + missing = tmp_path / "nonexistent.toml" + monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing) + + # Patch _version import to also fail + with patch("src.constants.Path.write_text", side_effect=OSError): + with patch.dict("sys.modules", {"src._version": None}): + result = get_version() + + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# get_debug_mode() +# --------------------------------------------------------------------------- + + +def test_get_debug_mode_returns_bool() -> None: + """get_debug_mode() should always return a bool.""" + assert isinstance(get_debug_mode(), bool) + + +def test_get_debug_mode_true(monkeypatch: pytest.MonkeyPatch) -> None: + """get_debug_mode() returns True when ENV_DEBUG=true.""" + monkeypatch.setenv("ENV_DEBUG", "true") + assert get_debug_mode() is True + + +def test_get_debug_mode_true_variants(monkeypatch: pytest.MonkeyPatch) -> None: + """get_debug_mode() accepts '1' and 'yes' as truthy values.""" + for value in ("1", "yes", "YES", "True", "TRUE"): + monkeypatch.setenv("ENV_DEBUG", value) + assert get_debug_mode() is True, f"Expected True for ENV_DEBUG={value!r}" + + +def test_get_debug_mode_false(monkeypatch: pytest.MonkeyPatch) -> None: + """get_debug_mode() returns False when ENV_DEBUG=false.""" + monkeypatch.setenv("ENV_DEBUG", "false") + assert get_debug_mode() is False + + +def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: + """get_debug_mode() returns False when ENV_DEBUG is not set.""" + monkeypatch.delenv("ENV_DEBUG", raising=False) + assert get_debug_mode() is False + + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + + +def test_env_debug_is_bool() -> None: + """ENV_DEBUG should be a bool.""" + assert isinstance(ENV_DEBUG, bool) + + +def test_app_version_is_string() -> None: + """APP_VERSION should be a string.""" + assert isinstance(APP_VERSION, str) + + +def test_app_version_semver_format() -> None: + """APP_VERSION should follow semver format X.Y.Z.""" + assert re.match(r"^\d+\.\d+\.\d+", APP_VERSION), f"Not semver: {APP_VERSION!r}" + + +def test_app_name_value() -> None: + """APP_NAME should be 'Curator'.""" + assert APP_NAME == "Curator" + + +def test_app_title_contains_name_and_version() -> None: + """APP_TITLE should contain APP_NAME and APP_VERSION.""" + assert APP_NAME in APP_TITLE + assert APP_VERSION in APP_TITLE + + +def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> None: + """APP_TITLE ends with '-DEV' when ENV_DEBUG is True.""" + import src.constants as consts + + monkeypatch.setenv("ENV_DEBUG", "true") + monkeypatch.setattr(consts, "ENV_DEBUG", True) + title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if True else "") + assert title.endswith("-DEV") + + +def test_app_title_no_dev_suffix_when_not_debug(monkeypatch: pytest.MonkeyPatch) -> None: + """APP_TITLE does not end with '-DEV' when ENV_DEBUG is False.""" + import src.constants as consts + + monkeypatch.setattr(consts, "ENV_DEBUG", False) + title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if False else "") + assert not title.endswith("-DEV") diff --git a/tests/test_csfd.py b/tests/test_csfd.py new file mode 100644 index 0000000..024ae31 --- /dev/null +++ b/tests/test_csfd.py @@ -0,0 +1,286 @@ +"""Tests for CSFD.cz scraper module.""" + +import pytest +from unittest.mock import patch, MagicMock +from src.core.csfd import ( + CSFDMovie, + fetch_movie, + search_movies, + fetch_movie_by_id, + _extract_csfd_id, + _parse_duration, + _extract_json_ld, + _extract_rating, + _extract_poster, + _extract_plot, + _extract_genres, + _extract_origin_info, + _check_dependencies, +) + + +# Sample HTML for testing +SAMPLE_JSON_LD = """ +{ + "@type": "Movie", + "name": "Test Movie", + "director": [{"@type": "Person", "name": "Test Director"}], + "actor": [{"@type": "Person", "name": "Actor 1"}, {"@type": "Person", "name": "Actor 2"}], + "aggregateRating": {"ratingValue": 85.5, "ratingCount": 1000}, + "duration": "PT120M", + "description": "A test movie description." +} +""" + +SAMPLE_HTML = """ + + + + + +
85%%
+
+ Drama / + Thriller +
+
Česko, 2020, 120 min
+
+ +
+

Full plot description.

+ + +""" % SAMPLE_JSON_LD + + +class TestCSFDMovie: + """Tests for CSFDMovie dataclass.""" + + def test_csfd_movie_basic(self): + """Test basic CSFDMovie creation.""" + movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/") + assert movie.title == "Test" + assert movie.url == "https://csfd.cz/film/123/" + assert movie.year is None + assert movie.genres == [] + assert movie.rating is None + + def test_csfd_movie_full(self): + """Test CSFDMovie with all fields.""" + movie = CSFDMovie( + title="Test Movie", + url="https://csfd.cz/film/123/", + year=2020, + genres=["Drama", "Thriller"], + directors=["Director 1"], + actors=["Actor 1", "Actor 2"], + rating=85, + rating_count=1000, + duration=120, + country="Česko", + poster_url="https://image.example.com/poster.jpg", + plot="A test movie.", + csfd_id=123 + ) + assert movie.year == 2020 + assert movie.genres == ["Drama", "Thriller"] + assert movie.rating == 85 + assert movie.duration == 120 + assert movie.csfd_id == 123 + + def test_csfd_movie_str(self): + """Test CSFDMovie string representation.""" + movie = CSFDMovie( + title="Test Movie", + url="https://csfd.cz/film/123/", + year=2020, + genres=["Drama"], + directors=["Director 1"], + rating=85 + ) + s = str(movie) + assert "Test Movie (2020)" in s + assert "85%" in s + assert "Drama" in s + assert "Director 1" in s + + def test_csfd_movie_str_minimal(self): + """Test CSFDMovie string with minimal data.""" + movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/") + s = str(movie) + assert "Test" in s + + +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_extract_csfd_id_valid(self): + """Test extracting CSFD ID from valid URL.""" + assert _extract_csfd_id("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/") == 9423 + assert _extract_csfd_id("https://www.csfd.cz/film/123456/") == 123456 + assert _extract_csfd_id("/film/999/prehled/") == 999 + + def test_extract_csfd_id_invalid(self): + """Test extracting CSFD ID from invalid URL.""" + assert _extract_csfd_id("https://www.csfd.cz/") is None + assert _extract_csfd_id("not-a-url") is None + + def test_parse_duration_valid(self): + """Test parsing ISO 8601 duration.""" + assert _parse_duration("PT97M") == 97 + assert _parse_duration("PT120M") == 120 + assert _parse_duration("PT60M") == 60 + + def test_parse_duration_invalid(self): + """Test parsing invalid duration.""" + assert _parse_duration("") is None + assert _parse_duration("invalid") is None + assert _parse_duration("PT") is None + + +class TestHTMLExtraction: + """Tests for HTML extraction functions.""" + + @pytest.fixture + def soup(self): + """Create BeautifulSoup object from sample HTML.""" + from bs4 import BeautifulSoup + return BeautifulSoup(SAMPLE_HTML, "html.parser") + + def test_extract_json_ld(self, soup): + """Test extracting data from JSON-LD.""" + data = _extract_json_ld(soup) + assert data["title"] == "Test Movie" + assert data["directors"] == ["Test Director"] + assert data["actors"] == ["Actor 1", "Actor 2"] + assert data["rating"] == 86 # Rounded from 85.5 + assert data["rating_count"] == 1000 + assert data["duration"] == 120 + + def test_extract_rating(self, soup): + """Test extracting rating from HTML.""" + rating = _extract_rating(soup) + assert rating == 85 + + def test_extract_genres(self, soup): + """Test extracting genres from HTML.""" + genres = _extract_genres(soup) + assert "Drama" in genres + assert "Thriller" in genres + + def test_extract_poster(self, soup): + """Test extracting poster URL.""" + poster = _extract_poster(soup) + assert poster == "https://image.example.com/poster.jpg" + + def test_extract_plot(self, soup): + """Test extracting plot.""" + plot = _extract_plot(soup) + assert plot == "Full plot description." + + def test_extract_origin_info(self, soup): + """Test extracting origin info (comma-separated legacy format).""" + info = _extract_origin_info(soup) + assert info["country"] == "Česko" + assert info["year"] == 2020 + assert info["duration"] == 120 + + def test_extract_origin_info_bullet_format(self): + """Test current CSFD format with inline bullet spans (no commas).""" + from bs4 import BeautifulSoup + html = ( + '
USA ' + '1999 ' + '136 min (Alternativní 131 min)
' + ) + info = _extract_origin_info(BeautifulSoup(html, "html.parser")) + assert info["country"] == "USA" + assert info["year"] == 1999 + assert info["duration"] == 136 + + def test_extract_json_ld_year_from_date_created(self): + """Year is taken from JSON-LD dateCreated when present.""" + from bs4 import BeautifulSoup + html = ( + '' + ) + data = _extract_json_ld(BeautifulSoup(html, "html.parser")) + assert data["year"] == 1999 + + +class TestFetchMovie: + """Tests for fetch_movie function.""" + + @patch("src.core.csfd.requests") + def test_fetch_movie_success(self, mock_requests): + """Test successful movie fetch.""" + mock_response = MagicMock() + mock_response.text = SAMPLE_HTML + mock_response.raise_for_status = MagicMock() + mock_requests.get.return_value = mock_response + + movie = fetch_movie("https://www.csfd.cz/film/123-test/") + + assert movie.title == "Test Movie" + assert movie.csfd_id == 123 + assert movie.rating == 86 + assert "Drama" in movie.genres + mock_requests.get.assert_called_once() + + @patch("src.core.csfd.requests") + def test_fetch_movie_network_error(self, mock_requests): + """Test network error handling.""" + import requests as real_requests + mock_requests.get.side_effect = real_requests.RequestException("Network error") + + with pytest.raises(real_requests.RequestException): + fetch_movie("https://www.csfd.cz/film/123/") + + +class TestSearchMovies: + """Tests for search_movies function.""" + + @patch("src.core.csfd.requests") + def test_search_movies(self, mock_requests): + """Test movie search.""" + search_html = """ + + Test Movie + Another Movie + + """ + mock_response = MagicMock() + mock_response.text = search_html + mock_response.raise_for_status = MagicMock() + mock_requests.get.return_value = mock_response + mock_requests.utils.quote = lambda x: x + + results = search_movies("test", limit=10) + + assert len(results) >= 1 + assert any(m.csfd_id == 123 for m in results) + + +class TestFetchMovieById: + """Tests for fetch_movie_by_id function.""" + + @patch("src.core.csfd.fetch_movie") + def test_fetch_by_id(self, mock_fetch): + """Test fetching movie by ID.""" + mock_fetch.return_value = CSFDMovie(title="Test", url="https://csfd.cz/film/9423/") + + movie = fetch_movie_by_id(9423) + + mock_fetch.assert_called_once_with("https://www.csfd.cz/film/9423/") + assert movie.title == "Test" + + +class TestDependencyCheck: + """Tests for dependency checking.""" + + def test_dependencies_available(self): + """Test that dependencies are available (they should be in test env).""" + # Should not raise + _check_dependencies() diff --git a/tests/test_file.py b/tests/test_file.py new file mode 100644 index 0000000..aa9149d --- /dev/null +++ b/tests/test_file.py @@ -0,0 +1,265 @@ +import pytest +import json +from pathlib import Path +from src.core.file import File +from src.core.tag import Tag +from src.core.tag_manager import TagManager + + +class TestFile: + """Testy pro třídu File""" + + @pytest.fixture + def temp_dir(self, tmp_path): + """Fixture pro dočasný adresář""" + return tmp_path + + @pytest.fixture + def tag_manager(self): + """Fixture pro TagManager""" + return TagManager() + + @pytest.fixture + def test_file(self, temp_dir): + """Fixture pro testovací soubor""" + test_file = temp_dir / "test.txt" + test_file.write_text("test content") + return test_file + + def test_file_creation(self, test_file, tag_manager): + """Test vytvoření File objektu""" + file_obj = File(test_file, tag_manager) + assert file_obj.file_path == test_file + assert file_obj.filename == "test.txt" + assert file_obj.new == True + + def test_file_metadata_filename(self, test_file, tag_manager): + """Test názvu metadata souboru""" + file_obj = File(test_file, tag_manager) + expected = test_file.parent / ".test.txt.!tag" + assert file_obj.metadata_filename == expected + + def test_file_initial_tags(self, test_file, tag_manager): + """Test že nový soubor má tag Stav/Nové""" + file_obj = File(test_file, tag_manager) + assert len(file_obj.tags) == 1 + assert file_obj.tags[0].full_path == "Stav/Nové" + + def test_file_metadata_saved(self, test_file, tag_manager): + """Test že metadata jsou uložena při vytvoření""" + file_obj = File(test_file, tag_manager) + assert file_obj.metadata_filename.exists() + + def test_file_save_metadata(self, test_file, tag_manager): + """Test uložení metadat""" + file_obj = File(test_file, tag_manager) + file_obj.new = False + file_obj.ignored = True + file_obj.save_metadata() + + # Načtení a kontrola + with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: + data = json.load(f) + + assert data["new"] == False + assert data["ignored"] == True + + def test_file_load_metadata(self, test_file, tag_manager): + """Test načtení metadat""" + # Vytvoření a uložení metadat + file_obj = File(test_file, tag_manager) + tag = tag_manager.add_tag("Video", "HD") + file_obj.tags.append(tag) + file_obj.date = "2025-01-15" + file_obj.save_metadata() + + # Vytvoření nového objektu - měl by načíst metadata + file_obj2 = File(test_file, tag_manager) + assert len(file_obj2.tags) == 2 # Stav/Nové + Video/HD + assert file_obj2.date == "2025-01-15" + + # Kontrola že tagy obsahují správné hodnoty + tag_paths = {tag.full_path for tag in file_obj2.tags} + assert "Video/HD" in tag_paths + assert "Stav/Nové" in tag_paths + + def test_file_set_date(self, test_file, tag_manager): + """Test nastavení data""" + file_obj = File(test_file, tag_manager) + file_obj.set_date("2025-12-25") + assert file_obj.date == "2025-12-25" + + # Kontrola že bylo uloženo + with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: + data = json.load(f) + assert data["date"] == "2025-12-25" + + def test_file_set_date_to_none(self, test_file, tag_manager): + """Test smazání data""" + file_obj = File(test_file, tag_manager) + file_obj.set_date("2025-12-25") + file_obj.set_date(None) + assert file_obj.date is None + + def test_file_set_date_empty_string(self, test_file, tag_manager): + """Test nastavení prázdného řetězce jako datum""" + file_obj = File(test_file, tag_manager) + file_obj.set_date("2025-12-25") + file_obj.set_date("") + assert file_obj.date is None + + def test_file_add_tag_object(self, test_file, tag_manager): + """Test přidání Tag objektu""" + file_obj = File(test_file, tag_manager) + tag = Tag("Video", "4K") + file_obj.add_tag(tag) + + assert tag in file_obj.tags + assert len(file_obj.tags) == 2 # Stav/Nové + Video/4K + + def test_file_add_tag_string(self, test_file, tag_manager): + """Test přidání tagu jako string""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("Audio/MP3") + + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "Audio/MP3" in tag_paths + + def test_file_add_tag_string_without_category(self, test_file, tag_manager): + """Test přidání tagu bez kategorie (použije 'default')""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("SimpleTag") + + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "default/SimpleTag" in tag_paths + + def test_file_add_duplicate_tag(self, test_file, tag_manager): + """Test že duplicitní tag není přidán""" + file_obj = File(test_file, tag_manager) + tag = Tag("Video", "HD") + file_obj.add_tag(tag) + file_obj.add_tag(tag) + + # Spočítáme kolikrát se tag vyskytuje + count = sum(1 for t in file_obj.tags if t == tag) + assert count == 1 + + def test_file_remove_tag_object(self, test_file, tag_manager): + """Test odstranění Tag objektu""" + file_obj = File(test_file, tag_manager) + tag = Tag("Video", "HD") + file_obj.add_tag(tag) + file_obj.remove_tag(tag) + + assert tag not in file_obj.tags + + def test_file_remove_tag_string(self, test_file, tag_manager): + """Test odstranění tagu jako string""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("Video/HD") + file_obj.remove_tag("Video/HD") + + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "Video/HD" not in tag_paths + + def test_file_remove_tag_string_without_category(self, test_file, tag_manager): + """Test odstranění tagu bez kategorie""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("SimpleTag") + file_obj.remove_tag("SimpleTag") + + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "default/SimpleTag" not in tag_paths + + def test_file_remove_nonexistent_tag(self, test_file, tag_manager): + """Test odstranění neexistujícího tagu (nemělo by vyhodit výjimku)""" + file_obj = File(test_file, tag_manager) + initial_count = len(file_obj.tags) + file_obj.remove_tag("Nonexistent/Tag") + assert len(file_obj.tags) == initial_count + + def test_file_without_tagmanager(self, test_file): + """Test File bez TagManager""" + file_obj = File(test_file, tagmanager=None) + assert file_obj.tagmanager is None + assert len(file_obj.tags) == 0 # Bez TagManager se nepřidá Stav/Nové + + def test_file_metadata_persistence(self, test_file, tag_manager): + """Test že metadata přežijí reload""" + # Vytvoření a úprava souboru + file_obj1 = File(test_file, tag_manager) + file_obj1.add_tag("Video/HD") + file_obj1.add_tag("Audio/Stereo") + file_obj1.set_date("2025-01-01") + file_obj1.new = False + file_obj1.ignored = True + file_obj1.save_metadata() + + # Načtení nového objektu + file_obj2 = File(test_file, tag_manager) + + # Kontrola + assert file_obj2.new == False + assert file_obj2.ignored == True + assert file_obj2.date == "2025-01-01" + + tag_paths = {tag.full_path for tag in file_obj2.tags} + assert "Video/HD" in tag_paths + assert "Audio/Stereo" in tag_paths + + def test_file_metadata_json_format(self, test_file, tag_manager): + """Test formátu JSON metadat""" + file_obj = File(test_file, tag_manager) + file_obj.add_tag("Test/Tag") + file_obj.set_date("2025-06-15") + + # Kontrola obsahu JSON + with open(file_obj.metadata_filename, "r", encoding="utf-8") as f: + data = json.load(f) + + assert "new" in data + assert "ignored" in data + assert "tags" in data + assert "date" in data + assert isinstance(data["tags"], list) + + def test_file_unicode_handling(self, temp_dir, tag_manager): + """Test správného zacházení s unicode znaky""" + test_file = temp_dir / "český_soubor.txt" + test_file.write_text("obsah") + + file_obj = File(test_file, tag_manager) + file_obj.add_tag("Kategorie/Český tag") + file_obj.save_metadata() + + # Reload a kontrola + file_obj2 = File(test_file, tag_manager) + tag_paths = {tag.full_path for tag in file_obj2.tags} + assert "Kategorie/Český tag" in tag_paths + + def test_file_complex_scenario(self, test_file, tag_manager): + """Test komplexního scénáře použití""" + file_obj = File(test_file, tag_manager) + + # Přidání více tagů + file_obj.add_tag("Video/HD") + file_obj.add_tag("Video/Stereo") + file_obj.add_tag("Stav/Zkontrolováno") + file_obj.set_date("2025-01-01") + + # Odstranění tagu + file_obj.remove_tag("Stav/Nové") + + # Kontrola stavu + tag_paths = {tag.full_path for tag in file_obj.tags} + assert "Video/HD" in tag_paths + assert "Video/Stereo" in tag_paths + assert "Stav/Zkontrolováno" in tag_paths + assert "Stav/Nové" not in tag_paths + assert file_obj.date == "2025-01-01" + + # Reload a kontrola persistence + file_obj2 = File(test_file, tag_manager) + tag_paths2 = {tag.full_path for tag in file_obj2.tags} + assert tag_paths == tag_paths2 + assert file_obj2.date == "2025-01-01" diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py new file mode 100644 index 0000000..febf74b --- /dev/null +++ b/tests/test_file_manager.py @@ -0,0 +1,612 @@ +import pytest +from src.core.file_manager import FileManager +from src.core.tag_manager import TagManager +from src.core.tag import Tag + + +class TestFileManager: + """Testy pro třídu FileManager""" + + @pytest.fixture + def tag_manager(self): + """Fixture pro TagManager""" + return TagManager() + + @pytest.fixture + def file_manager(self, tag_manager, temp_global_config): + """Fixture pro FileManager""" + return FileManager(tag_manager) + + @pytest.fixture + def temp_dir(self, tmp_path): + """Fixture pro dočasný adresář s testovacími soubory""" + # Vytvoření struktury souborů + (tmp_path / "file1.txt").write_text("content1") + (tmp_path / "file2.txt").write_text("content2") + (tmp_path / "file3.jpg").write_text("image") + + # Podsložka + subdir = tmp_path / "subdir" + subdir.mkdir() + (subdir / "file4.txt").write_text("content4") + + return tmp_path + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + """Fixture pro dočasný global config soubor""" + config_path = tmp_path / "test_config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + def test_file_manager_creation(self, file_manager, tag_manager): + """Test vytvoření FileManager""" + assert file_manager.filelist == [] + assert file_manager.folders == [] + assert file_manager.tagmanager == tag_manager + assert file_manager.global_config is not None + assert file_manager.folder_configs == {} + assert file_manager.current_folder is None + + def test_file_manager_append_folder(self, file_manager, temp_dir): + """Test přidání složky""" + file_manager.append(temp_dir) + + assert temp_dir in file_manager.folders + assert len(file_manager.filelist) > 0 + assert file_manager.current_folder == temp_dir + + def test_file_manager_append_folder_finds_all_files(self, file_manager, temp_dir): + """Test že append najde všechny soubory včetně podsložek""" + file_manager.append(temp_dir) + + # Měli bychom najít file1.txt, file2.txt, file3.jpg, subdir/file4.txt + # (ne .!tag soubory) + filenames = {f.filename for f in file_manager.filelist} + assert "file1.txt" in filenames + assert "file2.txt" in filenames + assert "file3.jpg" in filenames + assert "file4.txt" in filenames + + def test_file_manager_ignores_tag_files(self, file_manager, temp_dir): + """Test že .!tag soubory jsou ignorovány""" + # Vytvoření .!tag souboru + (temp_dir / ".file1.txt.!tag").write_text('{"tags": []}') + + file_manager.append(temp_dir) + + filenames = {f.filename for f in file_manager.filelist} + assert ".file1.txt.!tag" not in filenames + + def test_file_manager_ignores_curator_config_files(self, file_manager, temp_dir): + """Test že Curator config soubory jsou ignorovány""" + (temp_dir / ".Curator.!ftag").write_text('{}') # Folder config + (temp_dir / ".Curator.!gtag").write_text('{}') # Global config + + file_manager.append(temp_dir) + + filenames = {f.filename for f in file_manager.filelist} + assert ".Curator.!ftag" not in filenames + assert ".Curator.!gtag" not in filenames + + + def test_file_manager_updates_last_folder(self, file_manager, temp_dir): + """Test aktualizace last_folder v global configu""" + file_manager.append(temp_dir) + + assert file_manager.global_config["last_folder"] == str(temp_dir) + + def test_file_manager_updates_recent_folders(self, file_manager, temp_dir): + """Test aktualizace recent_folders""" + file_manager.append(temp_dir) + + assert str(temp_dir) in file_manager.global_config["recent_folders"] + assert file_manager.global_config["recent_folders"][0] == str(temp_dir) + + def test_file_manager_recent_folders_max_10(self, file_manager, tmp_path): + """Test že recent_folders má max 10 položek""" + for i in range(15): + folder = tmp_path / f"folder{i}" + folder.mkdir() + (folder / "file.txt").write_text("content") + file_manager.append(folder) + + assert len(file_manager.global_config["recent_folders"]) <= 10 + + def test_file_manager_loads_folder_config(self, file_manager, temp_dir): + """Test že se načte folder config při append""" + file_manager.append(temp_dir) + + assert temp_dir in file_manager.folder_configs + assert "ignore_patterns" in file_manager.folder_configs[temp_dir] + + +class TestFileManagerIgnorePatterns: + """Testy pro ignore patterns""" + + @pytest.fixture + def tag_manager(self): + return TagManager() + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + config_path = tmp_path / "test_config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + @pytest.fixture + def file_manager(self, tag_manager, temp_global_config): + return FileManager(tag_manager) + + @pytest.fixture + def temp_dir(self, tmp_path): + (tmp_path / "file1.txt").write_text("content1") + (tmp_path / "file2.txt").write_text("content2") + (tmp_path / "file3.jpg").write_text("image") + subdir = tmp_path / "subdir" + subdir.mkdir() + (subdir / "file4.txt").write_text("content4") + return tmp_path + + def test_ignore_patterns_by_extension(self, file_manager, temp_dir): + """Test ignorování souborů podle přípony""" + from src.core.config import save_folder_config + save_folder_config(temp_dir, {"ignore_patterns": ["*.jpg"], "custom_tags": {}, "recursive": True}) + + file_manager.append(temp_dir) + + filenames = {f.filename for f in file_manager.filelist} + assert "file3.jpg" not in filenames + assert "file1.txt" in filenames + + def test_ignore_patterns_path(self, file_manager, temp_dir): + """Test ignorování podle celé cesty""" + from src.core.config import save_folder_config + save_folder_config(temp_dir, {"ignore_patterns": ["*/subdir/*"], "custom_tags": {}, "recursive": True}) + + file_manager.append(temp_dir) + + filenames = {f.filename for f in file_manager.filelist} + assert "file4.txt" not in filenames + assert "file1.txt" in filenames + + def test_multiple_ignore_patterns(self, file_manager, temp_dir): + """Test více ignore patternů najednou""" + from src.core.config import save_folder_config + save_folder_config(temp_dir, {"ignore_patterns": ["*.jpg", "*/subdir/*"], "custom_tags": {}, "recursive": True}) + + file_manager.append(temp_dir) + + filenames = {f.filename for f in file_manager.filelist} + assert "file3.jpg" not in filenames + assert "file4.txt" not in filenames + assert "file1.txt" in filenames + assert "file2.txt" in filenames + + def test_set_ignore_patterns(self, file_manager, temp_dir): + """Test nastavení ignore patterns přes metodu""" + file_manager.append(temp_dir) + file_manager.set_ignore_patterns(["*.tmp", "*.log"]) + + patterns = file_manager.get_ignore_patterns() + assert patterns == ["*.tmp", "*.log"] + + def test_get_ignore_patterns_empty(self, file_manager, temp_dir): + """Test získání prázdných ignore patterns""" + file_manager.append(temp_dir) + + patterns = file_manager.get_ignore_patterns() + assert patterns == [] + + +class TestFileManagerFolderConfig: + """Testy pro folder config management""" + + @pytest.fixture + def tag_manager(self): + return TagManager() + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + config_path = tmp_path / "test_config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + @pytest.fixture + def file_manager(self, tag_manager, temp_global_config): + return FileManager(tag_manager) + + @pytest.fixture + def temp_dir(self, tmp_path): + (tmp_path / "file1.txt").write_text("content") + return tmp_path + + def test_get_folder_config_current(self, file_manager, temp_dir): + """Test získání configu pro aktuální složku""" + file_manager.append(temp_dir) + + config = file_manager.get_folder_config() + assert "ignore_patterns" in config + + def test_get_folder_config_specific(self, file_manager, temp_dir, tmp_path): + """Test získání configu pro specifickou složku""" + folder2 = tmp_path / "folder2" + folder2.mkdir() + (folder2 / "file.txt").write_text("content") + + file_manager.append(temp_dir) + file_manager.append(folder2) + + config = file_manager.get_folder_config(temp_dir) + assert config is not None + + def test_get_folder_config_no_current(self, file_manager): + """Test získání configu když není current folder""" + config = file_manager.get_folder_config() + assert config == {} + + def test_save_folder_config(self, file_manager, temp_dir): + """Test uložení folder configu""" + file_manager.append(temp_dir) + + new_config = {"ignore_patterns": ["*.test"], "custom_tags": {}, "recursive": False} + file_manager.save_folder_config(config=new_config) + + loaded = file_manager.get_folder_config() + assert loaded["ignore_patterns"] == ["*.test"] + assert loaded["recursive"] is False + + +class TestFileManagerTagOperations: + """Testy pro operace s tagy""" + + @pytest.fixture + def tag_manager(self): + return TagManager() + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + config_path = tmp_path / "test_config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + @pytest.fixture + def file_manager(self, tag_manager, temp_global_config): + return FileManager(tag_manager) + + @pytest.fixture + def temp_dir(self, tmp_path): + (tmp_path / "file1.txt").write_text("content1") + (tmp_path / "file2.txt").write_text("content2") + (tmp_path / "file3.txt").write_text("content3") + return tmp_path + + def test_assign_tag_to_file_objects_tag_object(self, file_manager, temp_dir): + """Test přiřazení Tag objektu k souborům""" + file_manager.append(temp_dir) + files = file_manager.filelist[:2] + tag = Tag("Video", "HD") + + file_manager.assign_tag_to_file_objects(files, tag) + + for f in files: + assert tag in f.tags + + def test_assign_tag_string_with_category(self, file_manager, temp_dir): + """Test přiřazení tagu jako string s kategorií""" + file_manager.append(temp_dir) + files = file_manager.filelist[:1] + + file_manager.assign_tag_to_file_objects(files, "Video/4K") + + tag_paths = {tag.full_path for tag in files[0].tags} + assert "Video/4K" in tag_paths + + def test_assign_tag_string_without_category(self, file_manager, temp_dir): + """Test přiřazení tagu bez kategorie (default)""" + file_manager.append(temp_dir) + files = file_manager.filelist[:1] + + file_manager.assign_tag_to_file_objects(files, "SimpleTag") + + tag_paths = {tag.full_path for tag in files[0].tags} + assert "default/SimpleTag" in tag_paths + + def test_assign_tag_no_duplicate(self, file_manager, temp_dir): + """Test že tag není přidán dvakrát""" + file_manager.append(temp_dir) + files = file_manager.filelist[:1] + tag = Tag("Video", "HD") + + file_manager.assign_tag_to_file_objects(files, tag) + file_manager.assign_tag_to_file_objects(files, tag) + + count = sum(1 for t in files[0].tags if t == tag) + assert count == 1 + + def test_remove_tag_from_file_objects(self, file_manager, temp_dir): + """Test odstranění tagu ze souborů""" + file_manager.append(temp_dir) + files = file_manager.filelist[:2] + tag = Tag("Video", "HD") + + file_manager.assign_tag_to_file_objects(files, tag) + file_manager.remove_tag_from_file_objects(files, tag) + + for f in files: + assert tag not in f.tags + + def test_remove_tag_string(self, file_manager, temp_dir): + """Test odstranění tagu jako string""" + file_manager.append(temp_dir) + files = file_manager.filelist[:1] + + file_manager.assign_tag_to_file_objects(files, "Video/HD") + file_manager.remove_tag_from_file_objects(files, "Video/HD") + + tag_paths = {tag.full_path for tag in files[0].tags} + assert "Video/HD" not in tag_paths + + def test_callback_on_tag_change(self, file_manager, temp_dir): + """Test callback při změně tagů""" + file_manager.append(temp_dir) + callback_calls = [] + + def callback(filelist): + callback_calls.append(len(filelist)) + + file_manager.on_files_changed = callback + file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], Tag("Test", "Tag")) + + assert len(callback_calls) == 1 + + +class TestFileManagerFiltering: + """Testy pro filtrování souborů""" + + @pytest.fixture + def tag_manager(self): + return TagManager() + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + config_path = tmp_path / "test_config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + @pytest.fixture + def file_manager(self, tag_manager, temp_global_config): + return FileManager(tag_manager) + + @pytest.fixture + def temp_dir(self, tmp_path): + (tmp_path / "file1.txt").write_text("content1") + (tmp_path / "file2.txt").write_text("content2") + (tmp_path / "file3.txt").write_text("content3") + return tmp_path + + def test_filter_empty_tags_returns_all(self, file_manager, temp_dir): + """Test filtrace bez tagů vrací všechny soubory""" + file_manager.append(temp_dir) + + filtered = file_manager.filter_files_by_tags([]) + assert len(filtered) == len(file_manager.filelist) + + def test_filter_none_returns_all(self, file_manager, temp_dir): + """Test filtrace s None vrací všechny soubory""" + file_manager.append(temp_dir) + + filtered = file_manager.filter_files_by_tags(None) + assert len(filtered) == len(file_manager.filelist) + + def test_filter_by_single_tag(self, file_manager, temp_dir): + """Test filtrace podle jednoho tagu""" + file_manager.append(temp_dir) + tag = Tag("Video", "HD") + files_to_tag = file_manager.filelist[:2] + file_manager.assign_tag_to_file_objects(files_to_tag, tag) + + filtered = file_manager.filter_files_by_tags([tag]) + assert len(filtered) == 2 + for f in filtered: + assert tag in f.tags + + def test_filter_by_multiple_tags_and_logic(self, file_manager, temp_dir): + """Test filtrace podle více tagů (AND logika)""" + file_manager.append(temp_dir) + tag1 = Tag("Video", "HD") + tag2 = Tag("Audio", "Stereo") + + # První soubor má oba tagy + file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag1) + file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], tag2) + + # Druhý soubor má jen první tag + file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], tag1) + + filtered = file_manager.filter_files_by_tags([tag1, tag2]) + assert len(filtered) == 1 + assert filtered[0] == file_manager.filelist[0] + + def test_filter_by_tag_strings(self, file_manager, temp_dir): + """Test filtrace podle tagů jako stringy""" + file_manager.append(temp_dir) + file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD") + + filtered = file_manager.filter_files_by_tags(["Video/HD"]) + assert len(filtered) == 1 + + def test_filter_no_match(self, file_manager, temp_dir): + """Test filtrace když nic neodpovídá""" + file_manager.append(temp_dir) + + filtered = file_manager.filter_files_by_tags([Tag("NonExistent", "Tag")]) + assert len(filtered) == 0 + + +class TestFileManagerLegacy: + """Testy pro zpětnou kompatibilitu""" + + @pytest.fixture + def tag_manager(self): + return TagManager() + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + config_path = tmp_path / "test_config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + @pytest.fixture + def file_manager(self, tag_manager, temp_global_config): + return FileManager(tag_manager) + + def test_config_property_returns_global(self, file_manager): + """Test že property config vrací global_config""" + assert file_manager.config is file_manager.global_config + + def test_config_property_modifiable(self, file_manager): + """Test že změny přes config property se projeví""" + file_manager.config["test_key"] = "test_value" + assert file_manager.global_config["test_key"] == "test_value" + + +class TestFileManagerEdgeCases: + """Testy pro edge cases""" + + @pytest.fixture + def tag_manager(self): + return TagManager() + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + config_path = tmp_path / "test_config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + @pytest.fixture + def file_manager(self, tag_manager, temp_global_config): + return FileManager(tag_manager) + + def test_empty_filelist_operations(self, file_manager): + """Test operací s prázdným filelistem""" + filtered = file_manager.filter_files_by_tags([Tag("Video", "HD")]) + assert filtered == [] + + # Přiřazení tagů na prázdný seznam + file_manager.assign_tag_to_file_objects([], Tag("Video", "HD")) + assert len(file_manager.filelist) == 0 + + def test_assign_tag_to_empty_list(self, file_manager): + """Test přiřazení tagu prázdnému seznamu souborů""" + file_manager.assign_tag_to_file_objects([], Tag("Test", "Tag")) + # Nemělo by vyhodit výjimku + + def test_remove_nonexistent_tag(self, file_manager, tmp_path): + """Test odstranění neexistujícího tagu""" + (tmp_path / "file.txt").write_text("content") + file_manager.append(tmp_path) + + # Nemělo by vyhodit výjimku + file_manager.remove_tag_from_file_objects(file_manager.filelist, Tag("NonExistent", "Tag")) + + def test_multiple_folders(self, file_manager, tmp_path): + """Test práce s více složkami""" + folder1 = tmp_path / "folder1" + folder2 = tmp_path / "folder2" + folder1.mkdir() + folder2.mkdir() + (folder1 / "file1.txt").write_text("content1") + (folder2 / "file2.txt").write_text("content2") + + file_manager.append(folder1) + file_manager.append(folder2) + + assert len(file_manager.folders) == 2 + filenames = {f.filename for f in file_manager.filelist} + assert "file1.txt" in filenames + assert "file2.txt" in filenames + + def test_folder_with_special_characters(self, file_manager, tmp_path): + """Test složky se speciálními znaky v názvu""" + special_folder = tmp_path / "složka s českou diakritikou" + special_folder.mkdir() + (special_folder / "soubor.txt").write_text("obsah") + + file_manager.append(special_folder) + + filenames = {f.filename for f in file_manager.filelist} + assert "soubor.txt" in filenames + + def test_file_with_special_characters(self, file_manager, tmp_path): + """Test souboru se speciálními znaky v názvu""" + (tmp_path / "soubor s mezerami.txt").write_text("content") + (tmp_path / "čeština.txt").write_text("obsah") + + file_manager.append(tmp_path) + + filenames = {f.filename for f in file_manager.filelist} + assert "soubor s mezerami.txt" in filenames + assert "čeština.txt" in filenames + + +class TestPoolManagement: + """Testy pro pool a copy-as-is složky""" + + @pytest.fixture + def temp_global_config(self, tmp_path, monkeypatch): + config_path = tmp_path / "test_config.json" + import src.core.config as config_module + monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path) + return config_path + + @pytest.fixture + def file_manager(self, temp_global_config): + return FileManager(TagManager()) + + def test_set_pool_creates_top_level_folders(self, file_manager, tmp_path): + pool = tmp_path / "pool" + file_manager.set_pool_dir(pool) + + assert (pool / "Filmy").is_dir() + assert (pool / "Seriály").is_dir() + assert file_manager.pool_dir == pool + + def test_import_movie_copies_and_indexes(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + source = tmp_path / "raw.mkv" + source.write_bytes(b"x" * 10) + + movie = file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1") + + assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv" + assert source.exists() # non-destructive copy + assert movie.title == "Matrix" + assert movie.csfd_link == "https://csfd.cz/film/1" + assert file_manager.index.get(movie.file_path) is not None + + def test_load_pool_movies_reads_from_index(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + source = tmp_path / "raw.mkv" + source.write_bytes(b"x" * 10) + file_manager.import_movie(source, "Matrix", "https://csfd.cz/film/1") + + reloaded = FileManager(TagManager()) + reloaded.set_pool_dir(tmp_path / "pool") + reloaded.load_pool_movies() + + assert len(reloaded.filelist) == 1 + assert reloaded.filelist[0].title == "Matrix" + + def test_copyasis_folders_default_and_set(self, file_manager): + assert file_manager.copyasis_folders == ["Seriály"] + + file_manager.set_copyasis_folders(["Seriály", " Dokumenty ", ""]) + assert file_manager.copyasis_folders == ["Seriály", "Dokumenty"] diff --git a/tests/test_hardlink_manager.py b/tests/test_hardlink_manager.py new file mode 100644 index 0000000..8ac8883 --- /dev/null +++ b/tests/test_hardlink_manager.py @@ -0,0 +1,628 @@ +import pytest +import os +from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure +from src.core.file import File +from src.core.tag import Tag +from src.core.tag_manager import TagManager + + +class TestHardlinkManager: + """Testy pro HardlinkManager""" + + @pytest.fixture + def tag_manager(self): + """Fixture pro TagManager""" + tm = TagManager() + # Remove default tags for cleaner tests + for cat in list(tm.tags_by_category.keys()): + tm.remove_category(cat) + return tm + + @pytest.fixture + def temp_source_dir(self, tmp_path): + """Fixture pro zdrojovou složku s testovacími soubory""" + source_dir = tmp_path / "source" + source_dir.mkdir() + (source_dir / "file1.txt").write_text("content1") + (source_dir / "file2.txt").write_text("content2") + (source_dir / "file3.txt").write_text("content3") + return source_dir + + @pytest.fixture + def temp_output_dir(self, tmp_path): + """Fixture pro výstupní složku""" + output_dir = tmp_path / "output" + output_dir.mkdir() + return output_dir + + @pytest.fixture + def files_with_tags(self, temp_source_dir, tag_manager): + """Fixture pro soubory s tagy""" + files = [] + + # File 1 with multiple tags + f1 = File(temp_source_dir / "file1.txt", tag_manager) + f1.tags.clear() # Remove default "Stav/Nové" tag + f1.add_tag(Tag("žánr", "Komedie")) + f1.add_tag(Tag("žánr", "Akční")) + f1.add_tag(Tag("rok", "1988")) + files.append(f1) + + # File 2 with one tag + f2 = File(temp_source_dir / "file2.txt", tag_manager) + f2.tags.clear() # Remove default "Stav/Nové" tag + f2.add_tag(Tag("žánr", "Drama")) + files.append(f2) + + # File 3 with no tags + f3 = File(temp_source_dir / "file3.txt", tag_manager) + f3.tags.clear() # Remove default "Stav/Nové" tag + files.append(f3) + + return files + + def test_hardlink_manager_creation(self, temp_output_dir): + """Test vytvoření HardlinkManager""" + manager = HardlinkManager(temp_output_dir) + assert manager.output_dir == temp_output_dir + assert manager.created_links == [] + assert manager.errors == [] + + def test_create_structure_basic(self, files_with_tags, temp_output_dir): + """Test základního vytvoření struktury""" + manager = HardlinkManager(temp_output_dir) + success, fail = manager.create_structure_for_files(files_with_tags) + + # File1 has 3 tags, File2 has 1 tag, File3 has 0 tags + # Should create 4 hardlinks total + assert success == 4 + assert fail == 0 + + # Check directory structure + assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists() + assert (temp_output_dir / "žánr" / "Akční" / "file1.txt").exists() + assert (temp_output_dir / "rok" / "1988" / "file1.txt").exists() + assert (temp_output_dir / "žánr" / "Drama" / "file2.txt").exists() + + def test_hardlinks_are_same_inode(self, files_with_tags, temp_output_dir, temp_source_dir): + """Test že vytvořené soubory jsou opravdu hardlinky (stejný inode)""" + manager = HardlinkManager(temp_output_dir) + manager.create_structure_for_files(files_with_tags) + + original = temp_source_dir / "file1.txt" + hardlink = temp_output_dir / "žánr" / "Komedie" / "file1.txt" + + # Same inode = hardlink + assert original.stat().st_ino == hardlink.stat().st_ino + + def test_create_structure_with_category_filter(self, files_with_tags, temp_output_dir): + """Test vytvoření struktury jen pro vybrané kategorie""" + manager = HardlinkManager(temp_output_dir) + success, fail = manager.create_structure_for_files(files_with_tags, categories=["žánr"]) + + # Only "žánr" tags should be processed (3 links) + assert success == 3 + assert fail == 0 + + assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists() + assert not (temp_output_dir / "rok").exists() + + def test_dry_run(self, files_with_tags, temp_output_dir): + """Test dry run (bez skutečného vytváření)""" + manager = HardlinkManager(temp_output_dir) + success, fail = manager.create_structure_for_files(files_with_tags, dry_run=True) + + assert success == 4 + assert fail == 0 + + # No actual files should be created + assert not (temp_output_dir / "žánr").exists() + + def test_get_preview(self, files_with_tags, temp_output_dir): + """Test náhledu co bude vytvořeno""" + manager = HardlinkManager(temp_output_dir) + preview = manager.get_preview(files_with_tags) + + assert len(preview) == 4 + + # Check that preview contains expected paths + targets = [p[1] for p in preview] + assert temp_output_dir / "žánr" / "Komedie" / "file1.txt" in targets + assert temp_output_dir / "žánr" / "Drama" / "file2.txt" in targets + + def test_get_preview_with_category_filter(self, files_with_tags, temp_output_dir): + """Test náhledu s filtrem kategorií""" + manager = HardlinkManager(temp_output_dir) + preview = manager.get_preview(files_with_tags, categories=["rok"]) + + assert len(preview) == 1 + assert preview[0][1] == temp_output_dir / "rok" / "1988" / "file1.txt" + + def test_remove_created_links(self, files_with_tags, temp_output_dir): + """Test odstranění vytvořených hardlinků""" + manager = HardlinkManager(temp_output_dir) + manager.create_structure_for_files(files_with_tags) + + # Verify links exist + assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists() + + # Remove links + removed = manager.remove_created_links() + assert removed == 4 + + # Links should be gone + assert not (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists() + + # Empty directories should also be removed + assert not (temp_output_dir / "žánr" / "Komedie").exists() + + def test_empty_files_list(self, temp_output_dir): + """Test s prázdným seznamem souborů""" + manager = HardlinkManager(temp_output_dir) + success, fail = manager.create_structure_for_files([]) + + assert success == 0 + assert fail == 0 + + def test_files_without_tags(self, temp_source_dir, temp_output_dir, tag_manager): + """Test se soubory bez tagů""" + f1 = File(temp_source_dir / "file1.txt", tag_manager) + f1.tags.clear() # Remove default tags + + manager = HardlinkManager(temp_output_dir) + success, fail = manager.create_structure_for_files([f1]) + + assert success == 0 + assert fail == 0 + + def test_duplicate_link_same_file(self, files_with_tags, temp_output_dir): + """Test že existující hardlink na stejný soubor je přeskočen""" + manager = HardlinkManager(temp_output_dir) + + # Create first time + success1, _ = manager.create_structure_for_files(files_with_tags) + + # Create second time - should skip existing + manager2 = HardlinkManager(temp_output_dir) + success2, fail2 = manager2.create_structure_for_files(files_with_tags) + + # All should be skipped (same inode) + assert success2 == 0 + assert fail2 == 0 + + def test_unique_name_on_conflict(self, temp_source_dir, temp_output_dir, tag_manager): + """Test že při konfliktu (jiný soubor) se použije unikátní jméno""" + # Create first file + f1 = File(temp_source_dir / "file1.txt", tag_manager) + f1.tags.clear() + f1.add_tag(Tag("test", "tag")) + + manager = HardlinkManager(temp_output_dir) + manager.create_structure_for_files([f1]) + + # Create different file with same name in different location + source2 = temp_source_dir / "subdir" + source2.mkdir() + (source2 / "file1.txt").write_text("different content") + + f2 = File(source2 / "file1.txt", tag_manager) + f2.tags.clear() + f2.add_tag(Tag("test", "tag")) + + # Should create file1_1.txt + manager2 = HardlinkManager(temp_output_dir) + success, fail = manager2.create_structure_for_files([f2]) + + assert success == 1 + assert (temp_output_dir / "test" / "tag" / "file1_1.txt").exists() + + def test_czech_characters_in_tags(self, temp_source_dir, temp_output_dir, tag_manager): + """Test českých znaků v názvech tagů""" + f1 = File(temp_source_dir / "file1.txt", tag_manager) + f1.tags.clear() + f1.add_tag(Tag("Žánr", "Česká komedie")) + f1.add_tag(Tag("Štítky", "Příběh")) + + manager = HardlinkManager(temp_output_dir) + success, fail = manager.create_structure_for_files([f1]) + + assert success == 2 + assert fail == 0 + assert (temp_output_dir / "Žánr" / "Česká komedie" / "file1.txt").exists() + assert (temp_output_dir / "Štítky" / "Příběh" / "file1.txt").exists() + + +class TestConvenienceFunction: + """Testy pro convenience funkci create_hardlink_structure""" + + @pytest.fixture + def tag_manager(self): + tm = TagManager() + for cat in list(tm.tags_by_category.keys()): + tm.remove_category(cat) + return tm + + @pytest.fixture + def temp_files(self, tmp_path, tag_manager): + source = tmp_path / "source" + source.mkdir() + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat", "tag")) + return [f] + + def test_create_hardlink_structure_function(self, temp_files, tmp_path): + """Test convenience funkce""" + output = tmp_path / "output" + output.mkdir() + + success, fail, errors = create_hardlink_structure(temp_files, output) + + assert success == 1 + assert fail == 0 + assert len(errors) == 0 + assert (output / "cat" / "tag" / "file.txt").exists() + + def test_create_hardlink_structure_with_categories(self, tmp_path, tag_manager): + """Test convenience funkce s filtrem kategorií""" + source = tmp_path / "source" + source.mkdir() + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("include", "yes")) + f.add_tag(Tag("exclude", "no")) + + output = tmp_path / "output" + output.mkdir() + + success, fail, errors = create_hardlink_structure([f], output, categories=["include"]) + + assert success == 1 + assert (output / "include" / "yes" / "file.txt").exists() + assert not (output / "exclude").exists() + + +class TestSyncStructure: + """Testy pro synchronizaci hardlink struktury""" + + @pytest.fixture + def tag_manager(self): + tm = TagManager() + for cat in list(tm.tags_by_category.keys()): + tm.remove_category(cat) + return tm + + @pytest.fixture + def setup_dirs(self, tmp_path): + source = tmp_path / "source" + source.mkdir() + output = tmp_path / "output" + output.mkdir() + return source, output + + def test_find_obsolete_links_empty_output(self, setup_dirs, tag_manager): + """Test find_obsolete_links s prázdným výstupem""" + source, output = setup_dirs + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat", "tag")) + + manager = HardlinkManager(output) + obsolete = manager.find_obsolete_links([f]) + + assert obsolete == [] + + def test_find_obsolete_links_detects_removed_tag(self, setup_dirs, tag_manager): + """Test že find_obsolete_links najde hardlink pro odebraný tag""" + source, output = setup_dirs + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat", "tag1")) + f.add_tag(Tag("cat", "tag2")) + + # Create structure with both tags + manager = HardlinkManager(output) + manager.create_structure_for_files([f]) + + assert (output / "cat" / "tag1" / "file.txt").exists() + assert (output / "cat" / "tag2" / "file.txt").exists() + + # Remove one tag from file + f.tags.clear() + f.add_tag(Tag("cat", "tag1")) # Only tag1 remains + + # Find obsolete + obsolete = manager.find_obsolete_links([f]) + + assert len(obsolete) == 1 + assert obsolete[0][0] == output / "cat" / "tag2" / "file.txt" + + def test_remove_obsolete_links(self, setup_dirs, tag_manager): + """Test odstranění zastaralých hardlinků""" + source, output = setup_dirs + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat", "tag1")) + f.add_tag(Tag("cat", "tag2")) + + manager = HardlinkManager(output) + manager.create_structure_for_files([f]) + + # Remove tag2 + f.tags.clear() + f.add_tag(Tag("cat", "tag1")) + + # Remove obsolete links + removed, paths = manager.remove_obsolete_links([f]) + + assert removed == 1 + assert not (output / "cat" / "tag2" / "file.txt").exists() + assert (output / "cat" / "tag1" / "file.txt").exists() + + def test_remove_obsolete_links_dry_run(self, setup_dirs, tag_manager): + """Test dry run pro remove_obsolete_links""" + source, output = setup_dirs + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat", "tag1")) + f.add_tag(Tag("cat", "tag2")) + + manager = HardlinkManager(output) + manager.create_structure_for_files([f]) + + f.tags.clear() + f.add_tag(Tag("cat", "tag1")) + + removed, paths = manager.remove_obsolete_links([f], dry_run=True) + + assert removed == 1 + # File should still exist (dry run) + assert (output / "cat" / "tag2" / "file.txt").exists() + + def test_sync_structure_creates_and_removes(self, setup_dirs, tag_manager): + """Test sync_structure vytvoří nové a odstraní staré hardlinky""" + source, output = setup_dirs + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat", "old_tag")) + + # Create initial structure + manager = HardlinkManager(output) + manager.create_structure_for_files([f]) + + assert (output / "cat" / "old_tag" / "file.txt").exists() + + # Change tags + f.tags.clear() + f.add_tag(Tag("cat", "new_tag")) + + # Sync + created, c_fail, removed, r_fail = manager.sync_structure([f]) + + assert created == 1 + assert removed == 1 + assert c_fail == 0 + assert r_fail == 0 + assert not (output / "cat" / "old_tag").exists() + assert (output / "cat" / "new_tag" / "file.txt").exists() + + def test_sync_structure_no_changes_needed(self, setup_dirs, tag_manager): + """Test sync_structure když není potřeba žádná změna""" + source, output = setup_dirs + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat", "tag")) + + manager = HardlinkManager(output) + manager.create_structure_for_files([f]) + + # Sync again without changes + created, c_fail, removed, r_fail = manager.sync_structure([f]) + + # Nothing should change (existing links are skipped) + assert removed == 0 + assert (output / "cat" / "tag" / "file.txt").exists() + + def test_find_obsolete_with_category_filter(self, setup_dirs, tag_manager): + """Test find_obsolete_links s filtrem kategorií""" + source, output = setup_dirs + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat1", "tag")) + f.add_tag(Tag("cat2", "tag")) + + manager = HardlinkManager(output) + manager.create_structure_for_files([f]) + + # Remove both tags + f.tags.clear() + + # Find obsolete only in cat1 + obsolete = manager.find_obsolete_links([f], categories=["cat1"]) + + assert len(obsolete) == 1 + assert obsolete[0][0] == output / "cat1" / "tag" / "file.txt" + + def test_removes_empty_directories(self, setup_dirs, tag_manager): + """Test že prázdné adresáře jsou odstraněny po sync""" + source, output = setup_dirs + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("category", "tag")) + + manager = HardlinkManager(output) + manager.create_structure_for_files([f]) + + # Remove all tags + f.tags.clear() + + manager.remove_obsolete_links([f]) + + # Directory should be gone + assert not (output / "category" / "tag").exists() + assert not (output / "category").exists() + + +class TestEdgeCases: + """Testy pro okrajové případy""" + + @pytest.fixture + def tag_manager(self): + tm = TagManager() + for cat in list(tm.tags_by_category.keys()): + tm.remove_category(cat) + return tm + + def test_nonexistent_output_dir_created(self, tmp_path, tag_manager): + """Test že výstupní složka je vytvořena pokud neexistuje""" + source = tmp_path / "source" + source.mkdir() + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat", "tag")) + + output = tmp_path / "output" / "nested" / "deep" + # output doesn't exist + + manager = HardlinkManager(output) + success, fail = manager.create_structure_for_files([f]) + + assert success == 1 + assert (output / "cat" / "tag" / "file.txt").exists() + + def test_special_characters_in_filename(self, tmp_path, tag_manager): + """Test souboru se speciálními znaky v názvu""" + source = tmp_path / "source" + source.mkdir() + (source / "file with spaces (2024).txt").write_text("content") + + f = File(source / "file with spaces (2024).txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("test", "tag")) + + output = tmp_path / "output" + output.mkdir() + + manager = HardlinkManager(output) + success, fail = manager.create_structure_for_files([f]) + + assert success == 1 + assert (output / "test" / "tag" / "file with spaces (2024).txt").exists() + + def test_empty_category_filter(self, tmp_path, tag_manager): + """Test s prázdným seznamem kategorií""" + source = tmp_path / "source" + source.mkdir() + (source / "file.txt").write_text("content") + + f = File(source / "file.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("cat", "tag")) + + output = tmp_path / "output" + output.mkdir() + + manager = HardlinkManager(output) + # Empty list = no categories = no links + success, fail = manager.create_structure_for_files([f], categories=[]) + + assert success == 0 + + def test_is_same_file_method(self, tmp_path): + """Test metody _is_same_file""" + file1 = tmp_path / "file1.txt" + file1.write_text("content") + + link = tmp_path / "link.txt" + os.link(file1, link) + + file2 = tmp_path / "file2.txt" + file2.write_text("different") + + manager = HardlinkManager(tmp_path) + + # Same inode + assert manager._is_same_file(file1, link) is True + + # Different inode + assert manager._is_same_file(file1, file2) is False + + # Non-existent file + assert manager._is_same_file(file1, tmp_path / "nonexistent") is False + + def test_get_unique_name_method(self, tmp_path): + """Test metody _get_unique_name""" + (tmp_path / "file.txt").write_text("1") + (tmp_path / "file_1.txt").write_text("2") + (tmp_path / "file_2.txt").write_text("3") + + manager = HardlinkManager(tmp_path) + unique = manager._get_unique_name(tmp_path / "file.txt") + + assert unique == tmp_path / "file_3.txt" + + +class TestMirrorAsIs: + """Testy pro copy-as-is zrcadlení (Seriály)""" + + def test_mirror_clones_hierarchy_with_hardlinks(self, tmp_path): + """Adresářová struktura se zrcadlí 1:1 a soubory jsou hardlinky""" + source = tmp_path / "Seriály" + (source / "Show" / "S01").mkdir(parents=True) + ep1 = source / "Show" / "S01" / "ep1.mkv" + ep2 = source / "Show" / "S01" / "ep2.mkv" + ep1.write_text("a") + ep2.write_text("b") + + output = tmp_path / "out" + manager = HardlinkManager(output) + created, failed = manager.mirror_as_is(source, "Seriály") + + assert failed == 0 + assert created == 2 + linked = output / "Seriály" / "Show" / "S01" / "ep1.mkv" + assert linked.exists() + assert linked.stat().st_ino == ep1.stat().st_ino + + def test_mirror_skips_curator_metadata(self, tmp_path): + """Metadata soubory (.!tag, .!index) se nezrcadlí""" + source = tmp_path / "Seriály" + source.mkdir() + (source / "ep1.mkv").write_text("a") + (source / ".ep1.mkv.!tag").write_text("{}") + (source / ".Curator.!index").write_text("{}") + + output = tmp_path / "out" + manager = HardlinkManager(output) + created, failed = manager.mirror_as_is(source, "Seriály") + + assert created == 1 + assert failed == 0 + assert not (output / "Seriály" / ".ep1.mkv.!tag").exists() + + def test_mirror_nonexistent_source_is_noop(self, tmp_path): + """Neexistující zdroj nic neudělá""" + manager = HardlinkManager(tmp_path / "out") + assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0) diff --git a/tests/test_media_utils.py b/tests/test_media_utils.py new file mode 100644 index 0000000..e626d7e --- /dev/null +++ b/tests/test_media_utils.py @@ -0,0 +1,75 @@ +import tempfile +from pathlib import Path +import pytest + +from src.core.media_utils import load_icon +from PIL import Image, ImageTk +import tkinter as tk + + +@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_pool_index.py b/tests/test_pool_index.py new file mode 100644 index 0000000..1316b6b --- /dev/null +++ b/tests/test_pool_index.py @@ -0,0 +1,97 @@ +import json + +from src.core.pool_index import PoolIndex, INDEX_FILENAME +from src.core.file import File +from src.core.tag_manager import TagManager + + +class TestPoolIndex: + """Testy pro unifikovaný metadata index poolu""" + + def test_empty_index_when_no_file(self, tmp_path): + index = PoolIndex(tmp_path) + assert index.records == {} + assert index.get(tmp_path / "Filmy" / "x.mkv") is None + + def test_set_and_get_by_relative_key(self, tmp_path): + index = PoolIndex(tmp_path) + path = tmp_path / "Filmy" / "Matrix.mkv" + index.set(path, {"title": "Matrix"}) + + assert index.get(path) == {"title": "Matrix"} + # key is the pool-relative POSIX path + assert "Filmy/Matrix.mkv" in index.records + + def test_set_persists_to_disk(self, tmp_path): + index = PoolIndex(tmp_path) + index.set(tmp_path / "Filmy" / "A.mkv", {"title": "A"}) + + on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8")) + assert on_disk["movies"]["Filmy/A.mkv"]["title"] == "A" + + def test_reload_reads_existing_index(self, tmp_path): + PoolIndex(tmp_path).set(tmp_path / "Filmy" / "A.mkv", {"title": "A"}) + + reloaded = PoolIndex(tmp_path) + assert reloaded.get(tmp_path / "Filmy" / "A.mkv") == {"title": "A"} + + def test_delete_removes_record(self, tmp_path): + index = PoolIndex(tmp_path) + path = tmp_path / "Filmy" / "A.mkv" + index.set(path, {"title": "A"}) + index.delete(path) + + assert index.get(path) is None + assert PoolIndex(tmp_path).get(path) is None + + def test_corrupt_index_loads_empty(self, tmp_path): + (tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8") + index = PoolIndex(tmp_path) + assert index.records == {} + + +class TestFileWithIndex: + """File používá index místo sidecar souboru, je-li injektován""" + + def test_index_backed_file_writes_no_sidecar(self, tmp_path): + index = PoolIndex(tmp_path) + movie = tmp_path / "Filmy" / "Matrix.mkv" + movie.parent.mkdir(parents=True) + movie.write_bytes(b"x") + + f = File(movie, TagManager(), index=index) + + assert not f.metadata_filename.exists() # no sidecar + assert index.get(movie) is not None # record created in index + assert f.tags[0].full_path == "Stav/Nové" + + def test_index_backed_metadata_persists_across_reload(self, tmp_path): + index = PoolIndex(tmp_path) + movie = tmp_path / "Filmy" / "Matrix.mkv" + movie.parent.mkdir(parents=True) + movie.write_bytes(b"x") + + tm = TagManager() + f = File(movie, tm, index=index) + f.title = "Matrix" + f.csfd_link = "https://csfd.cz/film/1" + f.add_tag("Žánr/Akční") + f.set_date("1999-03-31") + + # Fresh index + File read from disk + index2 = PoolIndex(tmp_path) + f2 = File(movie, TagManager(), index=index2) + assert f2.title == "Matrix" + assert f2.csfd_link == "https://csfd.cz/film/1" + assert f2.date == "1999-03-31" + assert "Žánr/Akční" in {t.full_path for t in f2.tags} + + def test_delete_metadata_removes_index_record(self, tmp_path): + index = PoolIndex(tmp_path) + movie = tmp_path / "Filmy" / "Matrix.mkv" + movie.parent.mkdir(parents=True) + movie.write_bytes(b"x") + + f = File(movie, TagManager(), index=index) + f.delete_metadata() + assert index.get(movie) is None diff --git a/tests/test_tag.py b/tests/test_tag.py new file mode 100644 index 0000000..37ccd7b --- /dev/null +++ b/tests/test_tag.py @@ -0,0 +1,106 @@ +import pytest +from src.core.tag import Tag + + +class TestTag: + """Testy pro třídu Tag""" + + def test_tag_creation(self): + """Test vytvoření tagu""" + tag = Tag("Kategorie", "Název") + assert tag.category == "Kategorie" + assert tag.name == "Název" + + def test_tag_full_path(self): + """Test full_path property""" + tag = Tag("Video", "HD") + assert tag.full_path == "Video/HD" + + def test_tag_str_representation(self): + """Test string reprezentace""" + tag = Tag("Foto", "Dovolená") + assert str(tag) == "Foto/Dovolená" + + def test_tag_repr(self): + """Test repr reprezentace""" + tag = Tag("Audio", "Hudba") + assert repr(tag) == "Tag(Audio/Hudba)" + + def test_tag_equality_same_tags(self): + """Test rovnosti stejných tagů""" + tag1 = Tag("Kategorie", "Název") + tag2 = Tag("Kategorie", "Název") + assert tag1 == tag2 + + def test_tag_equality_different_tags(self): + """Test nerovnosti různých tagů""" + tag1 = Tag("Kategorie1", "Název") + tag2 = Tag("Kategorie2", "Název") + assert tag1 != tag2 + + tag3 = Tag("Kategorie", "Název1") + tag4 = Tag("Kategorie", "Název2") + assert tag3 != tag4 + + def test_tag_equality_with_non_tag(self): + """Test porovnání s ne-Tag objektem""" + tag = Tag("Kategorie", "Název") + assert tag != "Kategorie/Název" + assert tag != 123 + assert tag != None + + def test_tag_hash(self): + """Test hashování - důležité pro použití v set/dict""" + tag1 = Tag("Kategorie", "Název") + tag2 = Tag("Kategorie", "Název") + tag3 = Tag("Jiná", "Název") + + # Stejné tagy mají stejný hash + assert hash(tag1) == hash(tag2) + # Různé tagy mají různý hash (většinou) + assert hash(tag1) != hash(tag3) + + def test_tag_in_set(self): + """Test použití tagů v set""" + tag1 = Tag("Kategorie", "Název") + tag2 = Tag("Kategorie", "Název") + tag3 = Tag("Jiná", "Název") + + tag_set = {tag1, tag2, tag3} + # tag1 a tag2 jsou stejné, takže set obsahuje pouze 2 prvky + assert len(tag_set) == 2 + assert tag1 in tag_set + assert tag3 in tag_set + + def test_tag_in_dict(self): + """Test použití tagů jako klíčů v dict""" + tag1 = Tag("Kategorie", "Název") + tag2 = Tag("Kategorie", "Název") + + tag_dict = {tag1: "hodnota1"} + tag_dict[tag2] = "hodnota2" + + # tag1 a tag2 jsou stejné, takže dict má 1 klíč + assert len(tag_dict) == 1 + assert tag_dict[tag1] == "hodnota2" + + def test_tag_with_special_characters(self): + """Test tagů se speciálními znaky""" + tag = Tag("Kategorie/Složitá", "Název s mezerami") + assert tag.category == "Kategorie/Složitá" + assert tag.name == "Název s mezerami" + assert tag.full_path == "Kategorie/Složitá/Název s mezerami" + + def test_tag_with_empty_strings(self): + """Test tagů s prázdnými řetězci""" + tag = Tag("", "") + assert tag.category == "" + assert tag.name == "" + assert tag.full_path == "/" + + def test_tag_unicode(self): + """Test tagů s unicode znaky""" + tag = Tag("Kategorie", "Čeština") + assert tag.category == "Kategorie" + assert tag.name == "Čeština" + assert tag.full_path == "Kategorie/Čeština" diff --git a/tests/test_tag_manager.py b/tests/test_tag_manager.py new file mode 100644 index 0000000..5fdcb82 --- /dev/null +++ b/tests/test_tag_manager.py @@ -0,0 +1,327 @@ +import pytest +from src.core.tag_manager import TagManager, DEFAULT_TAGS +from src.core.tag import Tag + + +class TestTagManager: + """Testy pro třídu TagManager""" + + @pytest.fixture + def tag_manager(self): + """Fixture pro vytvoření TagManager instance""" + return TagManager() + + @pytest.fixture + def empty_tag_manager(self): + """Fixture pro prázdný TagManager (bez default tagů)""" + tm = TagManager() + # Odstranit default tagy pro testy které potřebují prázdný manager + for category in list(tm.tags_by_category.keys()): + tm.remove_category(category) + return tm + + def test_tag_manager_creation_has_defaults(self, tag_manager): + """Test vytvoření TagManager obsahuje default tagy""" + assert "Hodnocení" in tag_manager.tags_by_category + assert "Barva" in tag_manager.tags_by_category + + def test_tag_manager_default_tags_count(self, tag_manager): + """Test počtu default tagů""" + # Hodnocení má 5 hvězdiček + assert len(tag_manager.tags_by_category["Hodnocení"]) == 5 + # Barva má 6 barev + assert len(tag_manager.tags_by_category["Barva"]) == 6 + + def test_add_category(self, tag_manager): + """Test přidání kategorie""" + tag_manager.add_category("Video") + assert "Video" in tag_manager.tags_by_category + assert tag_manager.tags_by_category["Video"] == set() + + def test_add_category_duplicate(self, empty_tag_manager): + """Test přidání duplicitní kategorie""" + empty_tag_manager.add_category("Video") + empty_tag_manager.add_category("Video") + assert len(empty_tag_manager.tags_by_category) == 1 + + def test_remove_category(self, tag_manager): + """Test odstranění kategorie""" + tag_manager.add_category("Video") + tag_manager.remove_category("Video") + assert "Video" not in tag_manager.tags_by_category + + def test_remove_nonexistent_category(self, tag_manager): + """Test odstranění neexistující kategorie""" + # Nemělo by vyhodit výjimku + tag_manager.remove_category("Neexistující") + assert "Neexistující" not in tag_manager.tags_by_category + + def test_add_tag(self, tag_manager): + """Test přidání tagu""" + tag = tag_manager.add_tag("Video", "HD") + assert isinstance(tag, Tag) + assert tag.category == "Video" + assert tag.name == "HD" + assert "Video" in tag_manager.tags_by_category + assert tag in tag_manager.tags_by_category["Video"] + + def test_add_tag_creates_category(self, tag_manager): + """Test že add_tag vytvoří kategorii pokud neexistuje""" + tag = tag_manager.add_tag("NovaKategorie", "Tag") + assert "NovaKategorie" in tag_manager.tags_by_category + + def test_add_multiple_tags_same_category(self, tag_manager): + """Test přidání více tagů do stejné kategorie""" + tag1 = tag_manager.add_tag("Video", "HD") + tag2 = tag_manager.add_tag("Video", "4K") + tag3 = tag_manager.add_tag("Video", "SD") + + assert len(tag_manager.tags_by_category["Video"]) == 3 + assert tag1 in tag_manager.tags_by_category["Video"] + assert tag2 in tag_manager.tags_by_category["Video"] + assert tag3 in tag_manager.tags_by_category["Video"] + + def test_add_duplicate_tag(self, tag_manager): + """Test přidání duplicitního tagu (set zabrání duplicitám)""" + tag1 = tag_manager.add_tag("Video", "HD") + tag2 = tag_manager.add_tag("Video", "HD") + + assert len(tag_manager.tags_by_category["Video"]) == 1 + assert tag1 == tag2 + + def test_remove_tag(self, tag_manager): + """Test odstranění tagu - když je poslední, kategorie se smaže""" + tag_manager.add_tag("Video", "HD") + tag_manager.remove_tag("Video", "HD") + + # Kategorie by měla být smazána (podle implementace v tag_manager.py) + assert "Video" not in tag_manager.tags_by_category + + def test_remove_tag_removes_empty_category(self, tag_manager): + """Test že odstranění posledního tagu odstraní i kategorii""" + tag_manager.add_tag("Video", "HD") + tag_manager.remove_tag("Video", "HD") + + assert "Video" not in tag_manager.tags_by_category + + def test_remove_tag_keeps_category_with_other_tags(self, tag_manager): + """Test že odstranění tagu neodstraní kategorii s dalšími tagy""" + tag_manager.add_tag("Video", "HD") + tag_manager.add_tag("Video", "4K") + tag_manager.remove_tag("Video", "HD") + + assert "Video" in tag_manager.tags_by_category + assert len(tag_manager.tags_by_category["Video"]) == 1 + + def test_remove_nonexistent_tag(self, tag_manager): + """Test odstranění neexistujícího tagu""" + tag_manager.add_category("Video") + # Nemělo by vyhodit výjimku + tag_manager.remove_tag("Video", "Neexistující") + + def test_remove_tag_from_nonexistent_category(self, tag_manager): + """Test odstranění tagu z neexistující kategorie""" + # Nemělo by vyhodit výjimku + tag_manager.remove_tag("Neexistující", "Tag") + + def test_get_all_tags_empty(self, empty_tag_manager): + """Test získání všech tagů (prázdný manager)""" + tags = empty_tag_manager.get_all_tags() + assert tags == [] + + def test_get_all_tags(self, empty_tag_manager): + """Test získání všech tagů""" + empty_tag_manager.add_tag("Video", "HD") + empty_tag_manager.add_tag("Video", "4K") + empty_tag_manager.add_tag("Audio", "MP3") + + tags = empty_tag_manager.get_all_tags() + assert len(tags) == 3 + assert "Video/HD" in tags + assert "Video/4K" in tags + assert "Audio/MP3" in tags + + def test_get_all_tags_includes_defaults(self, tag_manager): + """Test že get_all_tags obsahuje default tagy""" + tags = tag_manager.get_all_tags() + # Minimálně 11 default tagů (5 hodnocení + 6 barev) + assert len(tags) >= 11 + + def test_get_categories_empty(self, empty_tag_manager): + """Test získání kategorií (prázdný manager)""" + categories = empty_tag_manager.get_categories() + assert categories == [] + + def test_get_categories(self, empty_tag_manager): + """Test získání kategorií""" + empty_tag_manager.add_tag("Video", "HD") + empty_tag_manager.add_tag("Audio", "MP3") + empty_tag_manager.add_tag("Foto", "RAW") + + categories = empty_tag_manager.get_categories() + assert len(categories) == 3 + assert "Video" in categories + assert "Audio" in categories + assert "Foto" in categories + + def test_get_categories_includes_defaults(self, tag_manager): + """Test že get_categories obsahuje default kategorie""" + categories = tag_manager.get_categories() + assert "Hodnocení" in categories + assert "Barva" in categories + + def test_get_tags_in_category_empty(self, tag_manager): + """Test získání tagů z prázdné kategorie""" + tag_manager.add_category("Video") + tags = tag_manager.get_tags_in_category("Video") + assert tags == [] + + def test_get_tags_in_category(self, tag_manager): + """Test získání tagů z kategorie""" + tag_manager.add_tag("Video", "HD") + tag_manager.add_tag("Video", "4K") + tag_manager.add_tag("Audio", "MP3") + + video_tags = tag_manager.get_tags_in_category("Video") + assert len(video_tags) == 2 + + # Kontrola že obsahují správné tagy (pořadí není garantováno) + tag_names = {tag.name for tag in video_tags} + assert "HD" in tag_names + assert "4K" in tag_names + + def test_get_tags_in_nonexistent_category(self, tag_manager): + """Test získání tagů z neexistující kategorie""" + tags = tag_manager.get_tags_in_category("Neexistující") + assert tags == [] + + def test_complex_scenario(self, empty_tag_manager): + """Test komplexního scénáře použití""" + tm = empty_tag_manager + + # Přidání několika kategorií a tagů + tm.add_tag("Video", "HD") + tm.add_tag("Video", "4K") + tm.add_tag("Audio", "MP3") + tm.add_tag("Audio", "FLAC") + tm.add_tag("Foto", "RAW") + + # Kontrola stavu + assert len(tm.get_categories()) == 3 + assert len(tm.get_all_tags()) == 5 + + # Odstranění některých tagů + tm.remove_tag("Video", "HD") + assert len(tm.get_tags_in_category("Video")) == 1 + + # Odstranění celé kategorie + tm.remove_category("Foto") + assert "Foto" not in tm.get_categories() + assert len(tm.get_all_tags()) == 3 + + def test_tag_uniqueness_in_set(self, tag_manager): + """Test že tagy jsou správně ukládány jako set (bez duplicit)""" + tag_manager.add_tag("Video", "HD") + tag_manager.add_tag("Video", "HD") + tag_manager.add_tag("Video", "HD") + + # I když přidáme 3x, v setu je jen 1 + assert len(tag_manager.tags_by_category["Video"]) == 1 + + +class TestDefaultTags: + """Testy pro defaultní tagy""" + + def test_default_tags_constant_exists(self): + """Test že DEFAULT_TAGS konstanta existuje""" + assert DEFAULT_TAGS is not None + assert isinstance(DEFAULT_TAGS, dict) + + def test_default_tags_has_hodnoceni(self): + """Test že DEFAULT_TAGS obsahuje Hodnocení""" + assert "Hodnocení" in DEFAULT_TAGS + assert len(DEFAULT_TAGS["Hodnocení"]) == 5 + + def test_default_tags_has_barva(self): + """Test že DEFAULT_TAGS obsahuje Barva""" + assert "Barva" in DEFAULT_TAGS + assert len(DEFAULT_TAGS["Barva"]) == 6 + + def test_hodnoceni_stars_content(self): + """Test obsahu hvězdiček v Hodnocení""" + stars = DEFAULT_TAGS["Hodnocení"] + assert "⭐" in stars + assert "⭐⭐⭐⭐⭐" in stars + + def test_barva_colors_content(self): + """Test obsahu barev v Barva""" + colors = DEFAULT_TAGS["Barva"] + # Kontrolujeme že obsahuje některé barvy + color_names = " ".join(colors) + assert "Červená" in color_names + assert "Zelená" in color_names + assert "Modrá" in color_names + + def test_tag_manager_loads_all_default_tags(self): + """Test že TagManager načte všechny default tagy""" + tm = TagManager() + + for category, tag_names in DEFAULT_TAGS.items(): + assert category in tm.tags_by_category + tags_in_category = tm.get_tags_in_category(category) + assert len(tags_in_category) == len(tag_names) + + def test_can_add_custom_tags_alongside_defaults(self): + """Test že lze přidat vlastní tagy vedle defaultních""" + tm = TagManager() + initial_count = len(tm.get_all_tags()) + + tm.add_tag("Custom", "MyTag") + + assert len(tm.get_all_tags()) == initial_count + 1 + assert "Custom" in tm.get_categories() + + def test_can_remove_default_category(self): + """Test že lze odstranit default kategorii""" + tm = TagManager() + tm.remove_category("Hodnocení") + + assert "Hodnocení" not in tm.tags_by_category + + def test_hodnoceni_tags_are_sorted_by_stars(self): + """Test že tagy v Hodnocení jsou seřazeny od 1 do 5 hvězd""" + tm = TagManager() + tags = tm.get_tags_in_category("Hodnocení") + + tag_names = [t.name for t in tags] + assert tag_names == ["⭐", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"] + + def test_barva_tags_are_sorted_in_predefined_order(self): + """Test že tagy v Barva jsou seřazeny v předdefinovaném pořadí""" + tm = TagManager() + tags = tm.get_tags_in_category("Barva") + + tag_names = [t.name for t in tags] + expected = ["🔴 Červená", "🟠 Oranžová", "🟡 Žlutá", "🟢 Zelená", "🔵 Modrá", "🟣 Fialová"] + assert tag_names == expected + + def test_custom_category_tags_sorted_alphabetically(self): + """Test že tagy v custom kategorii jsou seřazeny abecedně""" + tm = TagManager() + tm.add_tag("Video", "HD") + tm.add_tag("Video", "4K") + tm.add_tag("Video", "SD") + + tags = tm.get_tags_in_category("Video") + tag_names = [t.name for t in tags] + + assert tag_names == ["4K", "HD", "SD"] + + def test_can_add_tag_to_default_category(self): + """Test že lze přidat tag do default kategorie""" + tm = TagManager() + initial_count = len(tm.get_tags_in_category("Hodnocení")) + + tm.add_tag("Hodnocení", "Custom Rating") + + assert len(tm.get_tags_in_category("Hodnocení")) == initial_count + 1 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..55d42d2 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,178 @@ +import pytest +from pathlib import Path +from src.core.utils import list_files + + +class TestUtils: + """Testy pro utils funkce""" + + @pytest.fixture + def temp_dir(self, tmp_path): + """Fixture pro dočasný adresář s testovací strukturou""" + # Vytvoření souborů v root + (tmp_path / "file1.txt").write_text("content1") + (tmp_path / "file2.jpg").write_text("image") + + # Podsložka + subdir1 = tmp_path / "subdir1" + subdir1.mkdir() + (subdir1 / "file3.txt").write_text("content3") + (subdir1 / "file4.png").write_text("image2") + + # Vnořená podsložka + subdir2 = subdir1 / "subdir2" + subdir2.mkdir() + (subdir2 / "file5.txt").write_text("content5") + + # Prázdná složka + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + + return tmp_path + + def test_list_files_basic(self, temp_dir): + """Test základního listování souborů""" + files = list_files(temp_dir) + assert isinstance(files, list) + assert len(files) > 0 + assert all(isinstance(f, Path) for f in files) + + def test_list_files_finds_all_files(self, temp_dir): + """Test že najde všechny soubory včetně vnořených""" + files = list_files(temp_dir) + filenames = {f.name for f in files} + + assert "file1.txt" in filenames + assert "file2.jpg" in filenames + assert "file3.txt" in filenames + assert "file4.png" in filenames + assert "file5.txt" in filenames + assert len(filenames) == 5 + + def test_list_files_recursive(self, temp_dir): + """Test rekurzivního procházení složek""" + files = list_files(temp_dir) + + # Kontrola cest - měly by obsahovat subdir1 a subdir2 + file_paths = [str(f) for f in files] + assert any("subdir1" in path for path in file_paths) + assert any("subdir2" in path for path in file_paths) + + def test_list_files_only_files_no_directories(self, temp_dir): + """Test že vrací pouze soubory, ne složky""" + files = list_files(temp_dir) + + # Všechny výsledky by měly být soubory + assert all(f.is_file() for f in files) + + # Složky by neměly být ve výsledcích + filenames = {f.name for f in files} + assert "subdir1" not in filenames + assert "subdir2" not in filenames + assert "empty" not in filenames + + def test_list_files_with_string_path(self, temp_dir): + """Test s cestou jako string""" + files = list_files(str(temp_dir)) + assert len(files) == 5 + + def test_list_files_with_path_object(self, temp_dir): + """Test s cestou jako Path objekt""" + files = list_files(temp_dir) + assert len(files) == 5 + + def test_list_files_empty_directory(self, temp_dir): + """Test prázdné složky""" + empty_dir = temp_dir / "empty" + files = list_files(empty_dir) + assert files == [] + + def test_list_files_nonexistent_directory(self): + """Test neexistující složky""" + with pytest.raises(NotADirectoryError) as exc_info: + list_files("/nonexistent/path") + assert "není platná složka" in str(exc_info.value) + + def test_list_files_file_not_directory(self, temp_dir): + """Test když je zadán soubor místo složky""" + file_path = temp_dir / "file1.txt" + with pytest.raises(NotADirectoryError) as exc_info: + list_files(file_path) + assert "není platná složka" in str(exc_info.value) + + def test_list_files_returns_absolute_paths(self, temp_dir): + """Test že vrací absolutní cesty""" + files = list_files(temp_dir) + assert all(f.is_absolute() for f in files) + + def test_list_files_different_extensions(self, temp_dir): + """Test s různými příponami""" + files = list_files(temp_dir) + extensions = {f.suffix for f in files} + + assert ".txt" in extensions + assert ".jpg" in extensions + assert ".png" in extensions + + def test_list_files_hidden_files(self, temp_dir): + """Test se skrytými soubory (začínající tečkou)""" + # Vytvoření skrytého souboru + (temp_dir / ".hidden").write_text("hidden content") + + files = list_files(temp_dir) + filenames = {f.name for f in files} + + # Skryté soubory by měly být také nalezeny + assert ".hidden" in filenames + + def test_list_files_special_characters_in_names(self, temp_dir): + """Test se speciálními znaky v názvech""" + # Vytvoření souborů se spec. znaky + (temp_dir / "soubor s mezerami.txt").write_text("content") + (temp_dir / "český_název.txt").write_text("content") + + files = list_files(temp_dir) + filenames = {f.name for f in files} + + assert "soubor s mezerami.txt" in filenames + assert "český_název.txt" in filenames + + def test_list_files_symlinks(self, temp_dir): + """Test se symbolickými linky (pokud OS podporuje)""" + try: + # Vytvoření symlinku + target = temp_dir / "file1.txt" + link = temp_dir / "link_to_file1.txt" + link.symlink_to(target) + + files = list_files(temp_dir) + # Symlink by měl být také nalezen a považován za soubor + filenames = {f.name for f in files} + assert "link_to_file1.txt" in filenames or "file1.txt" in filenames + except OSError: + # Pokud OS nepodporuje symlinky, přeskočíme + pytest.skip("OS does not support symlinks") + + def test_list_files_large_directory_structure(self, tmp_path): + """Test s větší strukturou složek""" + # Vytvoření více vnořených úrovní + for i in range(3): + level_dir = tmp_path / f"level{i}" + level_dir.mkdir() + for j in range(5): + (level_dir / f"file_{i}_{j}.txt").write_text(f"content {i} {j}") + + files = list_files(tmp_path) + # Měli bychom najít 3 * 5 = 15 souborů + assert len(files) == 15 + + def test_list_files_preserves_path_structure(self, temp_dir): + """Test že zachovává strukturu cest""" + files = list_files(temp_dir) + + # Najdeme soubor v subdir2 + file5 = [f for f in files if f.name == "file5.txt"][0] + + # Cesta by měla obsahovat obě složky + assert "subdir1" in str(file5) + assert "subdir2" in str(file5)