Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a71b209539 | |||
| b3a61f9e86 | |||
| 86c689b9f1 | |||
| 22a14b1e41 |
@@ -0,0 +1,38 @@
|
|||||||
|
# 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/
|
||||||
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
## 1.5.0 — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Free-form per-movie attributes** (`attributes` in the index, set via *Filmy →
|
||||||
|
Nastavit atribut…* / context menu): arbitrary `key → value` pairs (e.g.
|
||||||
|
`collection_sort`) that are exposed to Filmotéka filename templates through
|
||||||
|
`File.name_context`. So a Kolekce template `"{collection_sort} - {title}{ext}"`
|
||||||
|
orders the folder as `01 - Dr. No.mkv`, `02 - …`. Empty value removes the attr.
|
||||||
|
- **Per-category filename template** in the tag schema (`filename_template`,
|
||||||
|
editable in *Nastavení → Tag schéma…*): the hardlink inside that category's
|
||||||
|
folders is named from the template (fields `title`/`year`/`rating`/`ext`/
|
||||||
|
`stem`/`filename`), e.g. a *Kolekce* with `"{year} - {title}{ext}"` yields
|
||||||
|
`Dle kolekce/James Bond/1962 - Dr. No.mkv` while every other folder (and the
|
||||||
|
pool) keeps the plain filename. `HardlinkManager` takes
|
||||||
|
`category_filename_templates`; `File.name_context()` supplies the fields.
|
||||||
|
- The "Přiřadit štítky" dialog now lists **all tag-schema categories** (including
|
||||||
|
empty user ones like *Kolekce*) and has a **"➕ Nový štítek…"** button to create
|
||||||
|
a tag value (e.g. *Kolekce/James Bond*) on the spot and assign it. Tag values
|
||||||
|
are created on first assignment — the schema editor defines categories + rules,
|
||||||
|
this dialog defines/assigns the individual values.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tag schema `transform` now shapes only the **Filmotéka folder name**, not the
|
||||||
|
tag value. ČSFD tags keep the **exact value** (rating → `Hodnocení/90`) while
|
||||||
|
the grouping transform is applied when building folders (→ `Dle hodnocení/
|
||||||
|
90–100 %`). `csfd_field_values` no longer transforms; `HardlinkManager` takes a
|
||||||
|
`category_transforms` map (`FileManager.filmoteka_category_transforms`) and
|
||||||
|
`apply_transform`/`decade_band` run at folder-generation time.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Sidebar filter tree no longer re-expands every category on each check/uncheck:
|
||||||
|
the expanded/collapsed state is preserved across the rebuild (new categories
|
||||||
|
still default to expanded).
|
||||||
|
|
||||||
|
## 1.3.0 — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Configurable tag schema** (`tag_schema` in the global config, edited via
|
||||||
|
*Nastavení → Tag schéma…*): a single source of truth for which tag categories
|
||||||
|
exist, which ČSFD field feeds each (with an optional value transform, e.g.
|
||||||
|
`decade_band` for the rating), and how each maps into the Filmotéka tree
|
||||||
|
(`""` = output root, `"Dle …"` = grouping folder, none = filter-only, no
|
||||||
|
folders). Replaces the hard-coded `FILMOTEKA_CATEGORY_ROOTS` and the fixed
|
||||||
|
category list in `apply_csfd_tags`; `HardlinkManager` roots are now derived
|
||||||
|
from the schema (`FileManager.filmoteka_category_roots`).
|
||||||
|
- **Tag provenance (ČSFD vs user)**: each file tracks which tags came from ČSFD
|
||||||
|
(`csfd_tags` in the index). `apply_csfd_tags` now **regenerates only the ČSFD
|
||||||
|
tags** from the schema and leaves user-added tags untouched — so changing a
|
||||||
|
movie's ČSFD link refreshes its ČSFD tags without wiping your own.
|
||||||
|
|
||||||
|
## 1.2.0 — 2026-06-16
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **Multi-file "Import movies" flow**: pick several videos at once and give each
|
||||||
|
its own Title + ČSFD link (one row per file, more addable in the dialog); a
|
||||||
|
copy/move toggle chooses whether sources are copied (default, non-destructive)
|
||||||
|
or moved into `pool/Filmy` as `Title.ext`. Imported movies are indexed and, if
|
||||||
|
a ČSFD link is set, enriched with tags right away.
|
||||||
|
- **Auto-find ČSFD links** in the import dialog ("🔎 Najít ČSFD odkazy"): for
|
||||||
|
every row without a link it cleans the filename into a query
|
||||||
|
(`clean_filename_to_query` strips resolution/codec/source/group, keeps the
|
||||||
|
year) and fills in the first ČSFD search hit (`find_csfd_url` →
|
||||||
|
`search_movies`, reusing one Anubis session). Existing links are never
|
||||||
|
overwritten; results are a suggestion the user can review before importing.
|
||||||
|
- Import dialog now **highlights in red any title that already exists in the
|
||||||
|
pool** (live as you type) and lets you **remove a single row** (✕ per row), so
|
||||||
|
you can drop one file without discarding the whole batch and re-picking.
|
||||||
|
- **Conflict resolution on import**: when a row is left red (name already in the
|
||||||
|
pool), import asks whether to **replace** the existing movie(s) with the new
|
||||||
|
file, **keep both** (numeric suffix), or **cancel**. `FileManager.import_movie`
|
||||||
|
gained an `on_conflict` policy (`suffix` / `replace` / `skip`); `replace`
|
||||||
|
evicts the existing same-named movie (file + index metadata) first.
|
||||||
|
- `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 `<pool>/.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ě původu / Hodnocení** tags and caches the fetched
|
||||||
|
data (incl. directors and the first 10 actors) in the metadata. The rating is
|
||||||
|
bucketed into ten-point bands (`rating_band`, e.g. `80–89 %`, `90–100 %`).
|
||||||
|
**Directors and actors are collected but intentionally not turned into tags or
|
||||||
|
Filmotéka folders** — there would be far too many. The GUI auto-fetches on
|
||||||
|
import when a link is given and offers "Načíst tagy z ČSFD" for selected movies.
|
||||||
|
- **Rename a pooled movie** from the app ("Přejmenovat…" in the Movie menu /
|
||||||
|
context menu, F2): `FileManager.rename_movie` renames the physical file in
|
||||||
|
pool/Filmy to `<new name>.<ext>` (extension preserved), moves its metadata to
|
||||||
|
the new index key, and syncs `title`/`filename`. Refuses empty names, names
|
||||||
|
with path separators, and collisions with an existing pooled file.
|
||||||
|
- 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.
|
||||||
|
- **ČSFD anti-bot wall (Anubis):** ČSFD now serves a proof-of-work challenge
|
||||||
|
page instead of the movie, so fetches returned a film with no genres/year
|
||||||
|
("načteno 0 tagů"). `csfd.py` now detects the Anubis challenge, solves the
|
||||||
|
SHA-256 proof-of-work the way the bundled worker JS does, and replays the
|
||||||
|
request through a `requests.Session` (reused across a batch so only the first
|
||||||
|
fetch pays the PoW cost). Žánr / Rok / Země původu tags load again.
|
||||||
|
- "Assign tags" dialog crashed on PySide6/Qt6 — `Qt.ItemIsTristate` was renamed
|
||||||
|
to `Qt.ItemIsAutoTristate`.
|
||||||
|
- Sidebar tag-filter checkboxes never appeared checked: every toggle triggered a
|
||||||
|
table refresh that rebuilt the tree from scratch (all unchecked), wiping the
|
||||||
|
click. The active filter is now kept in a separate model (`_active_filter`) and
|
||||||
|
restored on rebuild. The count after each tag is also now filter-aware — it
|
||||||
|
shows how many of the currently filtered movies carry that tag (i.e. how many
|
||||||
|
would remain if it were checked), instead of always the pool-wide total. The
|
||||||
|
refresh is deferred via `QTimer.singleShot` so the tree is not rebuilt inside
|
||||||
|
its own `itemChanged` signal (which deleted the item Qt was still processing
|
||||||
|
and crashed the app with SIGSEGV on a real click).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- ČSFD country tag category renamed **Země → Země původu**. Added
|
||||||
|
`scripts/migrate_tag_category.py` to rewrite the category in an existing pool
|
||||||
|
index (backs up `.Curator.!index` first); run against the live pool.
|
||||||
|
- Filmotéka tree **relaid out**: genre folders now sit **directly at the output
|
||||||
|
root** (next to the copy-as-is Seriály mirror), with year tags grouped under a
|
||||||
|
**`Dle roku`** folder and country tags under **`Dle země původu`**.
|
||||||
|
`HardlinkManager` gained a category → root-folder map (`category_roots`,
|
||||||
|
empty root = tag folders at the output root) and now restricts obsolete-link
|
||||||
|
cleanup to the tag-tree's own top-level folders, so copy-as-is mirrors are
|
||||||
|
never touched. The tree also groups the ČSFD rating under `Dle hodnocení`.
|
||||||
|
- ČSFD origin is now parsed as **multiple countries**: a co-production like
|
||||||
|
"USA / Velká Británie" becomes a separate **Země původu** tag per country
|
||||||
|
(so the film is filed under each), instead of one combined tag. `CSFDMovie`
|
||||||
|
gained `countries: list[str]` (replacing the single `country`); the csfd cache
|
||||||
|
schema bumped to v2 (legacy single-country caches are split on read).
|
||||||
|
- Movie table trimmed to **Název / Štítky / Velikost** — the Datum and ČSFD
|
||||||
|
columns were dropped (a ČSFD link is a prerequisite, so its indicator was
|
||||||
|
always the same).
|
||||||
|
- 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).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Duplicate `src/core/constants.py` (hard-coded stale `VERSION = "v1.0.3"`). The
|
||||||
|
GUI window title imported from it, so it showed the wrong version. All imports
|
||||||
|
now use `src/constants.py` (version derived from `pyproject.toml`), which also
|
||||||
|
absorbed `APP_VIEWPORT`; the window title uses `APP_TITLE` → "Curator vX.Y.Z".
|
||||||
|
- Legacy Tagger predefined tags: the always-available **Hodnocení** (⭐ rating)
|
||||||
|
and **Barva** (color) categories in `TagManager`, and the automatic
|
||||||
|
**Stav/Nové** tag assigned to every newly imported file. `DEFAULT_TAGS` is now
|
||||||
|
empty; the pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu).
|
||||||
|
|
||||||
|
### 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).
|
||||||
@@ -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()
|
||||||
@@ -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',
|
||||||
|
)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# 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**
|
||||||
|
(`<pool>/.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:** **multi-file** — pick several videos at once and give each
|
||||||
|
its own **Title** + **ČSFD link** (one row per file, more can be added from the
|
||||||
|
dialog), or auto-filled with **"Najít ČSFD odkazy"** (cleans each filename into
|
||||||
|
a query and fills the first ČSFD search hit; existing links are kept). A single
|
||||||
|
**copy/move** toggle decides whether the sources are copied (default) or moved
|
||||||
|
into the pool. Each file is renamed to `Title.ext`. When a
|
||||||
|
ČSFD link is given, Curator fetches the movie and assigns Žánr / Rok / Země
|
||||||
|
původu / Hodnocení (ten-point band) tags automatically; further tags can be
|
||||||
|
added via the UI. Directors and the first 10 actors are fetched and cached too,
|
||||||
|
but **deliberately not turned into tags/folders** (there would be too many).
|
||||||
|
- **Genres / countries:** a movie can have **multiple genres** and, for a
|
||||||
|
co-production, **multiple countries of origin** (ČSFD writes them
|
||||||
|
slash-separated, e.g. "USA / Velká Británie"). Each becomes its own tag, so the
|
||||||
|
film appears under every matching genre and country branch 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 copy vs move:** by default the original file is **copied** into the
|
||||||
|
pool (non-destructive); the import dialog also offers a **move** option that
|
||||||
|
relocates the source into the pool instead.
|
||||||
|
- **Filmotéka tree layout:** driven by a category → root-folder map
|
||||||
|
(`FILMOTEKA_CATEGORY_ROOTS`). At the output root sit the **genre folders
|
||||||
|
directly** (`output/Akční/film`, …), next to the copy-as-is mirrors
|
||||||
|
(**Seriály**), plus two grouping folders: **`Dle roku`** (`output/Dle
|
||||||
|
roku/<rok>/film`) and **`Dle země původu`** (`output/Dle země
|
||||||
|
původu/<země>/film`), plus `Dle hodnocení`. Each is a hardlink.
|
||||||
|
`HardlinkManager` supports an empty root (tag folders placed directly at the
|
||||||
|
output root) and restricts obsolete cleanup to the tag-tree's own top-level
|
||||||
|
folders so mirrors are never touched.
|
||||||
|
- **Tag schema (config-driven, not hard-coded):** the categories, their ČSFD
|
||||||
|
source field + transform, and their Filmotéka folder mapping all live in
|
||||||
|
`tag_schema` in the global config (default `config.DEFAULT_TAG_SCHEMA`, edited
|
||||||
|
via *Nastavení → Tag schéma…*). Both `apply_csfd_tags` (which fields → tags)
|
||||||
|
and the Filmotéka layout (`FileManager.filmoteka_category_roots`) read from it,
|
||||||
|
so adding a category or changing a folder rule needs no code change. A category
|
||||||
|
can be made filter-only (no folders) by setting its `filmoteka_root` to null.
|
||||||
|
The `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep
|
||||||
|
the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90–100 %`);
|
||||||
|
it is applied at Filmotéka generation via `filmoteka_category_transforms`.
|
||||||
|
- **Per-category filename template** (`filename_template` in a schema entry): the
|
||||||
|
hardlink name **inside that category's folders only** is rendered from the
|
||||||
|
movie's metadata (`File.name_context`: title/year/rating/ext/stem/filename plus
|
||||||
|
any free-form attributes), e.g. a Kolekce with `"{collection_sort} - {title}{ext}"`.
|
||||||
|
Other folders and the pool file keep the plain name; applied via
|
||||||
|
`filmoteka_category_filename_templates`.
|
||||||
|
- **Free-form per-movie attributes** (`File.attributes`, set in the GUI): arbitrary
|
||||||
|
`key → value` metadata stored in the index and merged into `name_context`, so
|
||||||
|
custom fields like `collection_sort` can drive filename templates.
|
||||||
|
- **Tag provenance (ČSFD vs user):** each file records which tags came from ČSFD
|
||||||
|
(`csfd_tags`). Re-fetching regenerates only those; user-added tags are kept, so
|
||||||
|
changing a movie's ČSFD link refreshes ČSFD tags without losing manual ones.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- Rename a pooled movie from the app (`FileManager.rename_movie`): renames the
|
||||||
|
file in pool/Filmy and moves its metadata to the new index key
|
||||||
|
- Remove-from-pool (delete file + its metadata)
|
||||||
|
- Generate the Filmotéka hardlink tree from the pool (Rok / Žánr / Země původu /
|
||||||
|
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ě původu 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)
|
||||||
|
- ČSFD Anubis anti-bot wall handled: `csfd.py` detects the proof-of-work
|
||||||
|
challenge page, solves it (SHA-256 PoW matching the bundled worker JS) and
|
||||||
|
replays via a shared `requests.Session`, so Žánr / Rok / Země původu tags load again
|
||||||
|
(the "nalezeno 1 film, načteno 0 tagů" symptom). Verified live (Matrix 1999)
|
||||||
|
- Removed the inherited Tagger predefined tags: `DEFAULT_TAGS` is now empty
|
||||||
|
(no Hodnocení ⭐ / Barva categories) and new files no longer get an automatic
|
||||||
|
`Stav/Nové` tag. Tags now come from ČSFD (Žánr / Rok / Země původu) and manual edits.
|
||||||
|
Note: `Hodnocení` is still listed in `FILMOTEKA_CATEGORIES`, so that branch is
|
||||||
|
simply empty until something assigns a Hodnocení tag again
|
||||||
|
- 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
|
||||||
@@ -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ě původu** tags
|
||||||
|
automatically (use "Načíst tagy z ČSFD" to (re)fetch later).
|
||||||
|
3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI.
|
||||||
|
4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree
|
||||||
|
(from the `Rok` / `Žánr` / `Hodnocení` categories); copy-as-is folders such as
|
||||||
|
`Seriály` are mirrored 1:1 as hardlinks.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[project]
|
||||||
|
name = "curator"
|
||||||
|
version = "1.5.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)"
|
||||||
|
]
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal PySide6 GUI for filtering magnet lists from ``rargb_magnets.py``.
|
||||||
|
|
||||||
|
Just a text box on top and a list below — type to filter live (same syntax as
|
||||||
|
the CLI: space-separated AND terms, ``-term`` to exclude). Double-click or press
|
||||||
|
Enter on a row to copy its magnet link to the clipboard.
|
||||||
|
|
||||||
|
python tools/filter_magnets_gui.py [files/glob/dir ...]
|
||||||
|
|
||||||
|
With no arguments it loads ``magnets_*.txt`` from the current directory. The
|
||||||
|
loading/filtering logic is reused from ``filter_magnets.py`` in this folder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Reuse the CLI tool's parsing/filtering (same folder).
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from filter_magnets import Entry, load_entries, apply_filter, resolve_inputs # noqa: E402
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt # noqa: E402
|
||||||
|
from PySide6.QtWidgets import ( # noqa: E402
|
||||||
|
QApplication, QWidget, QVBoxLayout, QLineEdit, QListWidget, QListWidgetItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MagnetFilter(QWidget):
|
||||||
|
def __init__(self, entries: list[Entry]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.entries = entries
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
|
||||||
|
self.search = QLineEdit()
|
||||||
|
self.search.setPlaceholderText("filtr… (např. 1080p 2022 -hindi) — ↵/dvojklik = kopírovat magnet")
|
||||||
|
self.search.setClearButtonEnabled(True)
|
||||||
|
self.search.textChanged.connect(self._refilter)
|
||||||
|
layout.addWidget(self.search)
|
||||||
|
|
||||||
|
self.list = QListWidget()
|
||||||
|
self.list.itemActivated.connect(self._copy) # Enter / double-click
|
||||||
|
layout.addWidget(self.list)
|
||||||
|
|
||||||
|
self.resize(820, 600)
|
||||||
|
self._refilter("")
|
||||||
|
self.search.setFocus()
|
||||||
|
|
||||||
|
def _refilter(self, text: str) -> None:
|
||||||
|
self.list.clear()
|
||||||
|
for entry in apply_filter(self.entries, text):
|
||||||
|
short = entry.magnet.split("&", 1)[0] # only the part before the first &
|
||||||
|
item = QListWidgetItem(f"{entry.name}\n{short}")
|
||||||
|
item.setData(Qt.UserRole, short)
|
||||||
|
item.setToolTip(short)
|
||||||
|
self.list.addItem(item)
|
||||||
|
self._update_title()
|
||||||
|
|
||||||
|
def _copy(self, item: QListWidgetItem) -> None:
|
||||||
|
QApplication.clipboard().setText(item.data(Qt.UserRole))
|
||||||
|
self._update_title(copied=item.text())
|
||||||
|
|
||||||
|
def _update_title(self, copied: str | None = None) -> None:
|
||||||
|
base = f"Magnet filtr — {self.list.count()} / {len(self.entries)}"
|
||||||
|
self.setWindowTitle(f"{base} ✓ zkopírováno" if copied else base)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
paths = [p for p in resolve_inputs(sys.argv[1:]) if p.exists()]
|
||||||
|
if not paths:
|
||||||
|
print("Žádné vstupní soubory (magnets_*.txt) nenalezeny.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
entries = load_entries(paths)
|
||||||
|
if not entries:
|
||||||
|
print("Vstupní soubory neobsahují žádné magnet odkazy.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
window = MagnetFilter(entries)
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""One-off migration: rename a tag category inside a pool's metadata index.
|
||||||
|
|
||||||
|
Tags are stored in ``<pool>/.Curator.!index`` as ``"Category/Name"`` strings.
|
||||||
|
This rewrites every tag whose category matches ``--old`` to use ``--new``,
|
||||||
|
leaving the tag name untouched. A timestamped backup of the index is written
|
||||||
|
before saving.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
poetry run python scripts/migrate_tag_category.py <pool_dir> \
|
||||||
|
--old "Země" --new "Země původu"
|
||||||
|
|
||||||
|
If ``<pool_dir>`` is omitted, the pool from the global config is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.core.config import load_global_config # noqa: E402
|
||||||
|
from src.core.pool_index import INDEX_FILENAME # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
|
||||||
|
"""Return (rewritten tags, number of tags changed) for one record."""
|
||||||
|
prefix = f"{old}/"
|
||||||
|
changed = 0
|
||||||
|
result: list[str] = []
|
||||||
|
for tag in tags:
|
||||||
|
if isinstance(tag, str) and tag.startswith(prefix):
|
||||||
|
result.append(f"{new}/{tag[len(prefix):]}")
|
||||||
|
changed += 1
|
||||||
|
else:
|
||||||
|
result.append(tag)
|
||||||
|
return result, changed
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(index_path: Path, old: str, new: str) -> int:
|
||||||
|
"""Rewrite the category in place and return the number of tags changed."""
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
movies: dict[str, dict] = data.get("movies", {})
|
||||||
|
total_changed = 0
|
||||||
|
affected_records = 0
|
||||||
|
for key, record in movies.items():
|
||||||
|
tags = record.get("tags", [])
|
||||||
|
new_tags, changed = _rename_in_tags(tags, old, new)
|
||||||
|
if changed:
|
||||||
|
record["tags"] = new_tags
|
||||||
|
total_changed += changed
|
||||||
|
affected_records += 1
|
||||||
|
logger.debug(f"{key}: {changed} tag(s) renamed")
|
||||||
|
|
||||||
|
if total_changed == 0:
|
||||||
|
logger.info(f"No '{old}/…' tags found — nothing to migrate")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backup = index_path.with_suffix(
|
||||||
|
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
)
|
||||||
|
shutil.copy2(index_path, backup)
|
||||||
|
logger.info(f"Backup written: {backup}")
|
||||||
|
|
||||||
|
with open(index_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Migrated '{old}' → '{new}': {total_changed} tag(s) "
|
||||||
|
f"across {affected_records} record(s)"
|
||||||
|
)
|
||||||
|
return total_changed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"pool_dir",
|
||||||
|
nargs="?",
|
||||||
|
help="Pool root (default: pool_dir from the global config)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--old", default="Země", help="Category to rename from")
|
||||||
|
parser.add_argument("--new", default="Země původu", help="Category to rename to")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
|
||||||
|
if not pool_dir:
|
||||||
|
parser.error("No pool_dir given and none configured in the global config")
|
||||||
|
|
||||||
|
index_path = Path(pool_dir) / INDEX_FILENAME
|
||||||
|
if not index_path.exists():
|
||||||
|
parser.error(f"No index found at {index_path}")
|
||||||
|
|
||||||
|
migrate(index_path, args.old, args.new)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Standalone scraper: collect magnet links from a rargb.to search.
|
||||||
|
|
||||||
|
Given a search query it walks every results page
|
||||||
|
(``https://rargb.to/search/?search=<query>`` and ``/search/<N>/?search=<query>``),
|
||||||
|
opens each torrent's detail page and saves its magnet link.
|
||||||
|
|
||||||
|
This is a self-contained tool — it only needs ``requests`` and
|
||||||
|
``beautifulsoup4`` and does not import anything from the Curator project.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python scripts/rargb_magnets.py "ubuntu 24.04"
|
||||||
|
python scripts/rargb_magnets.py test --output test_magnets.txt --max-pages 3
|
||||||
|
python scripts/rargb_magnets.py test --tsv # also write name<TAB>magnet
|
||||||
|
|
||||||
|
Be considerate: a polite delay is inserted between requests by default. Use the
|
||||||
|
results responsibly and respect the target site's terms and your local law.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote, urljoin
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
BASE_URL = "https://rargb.to"
|
||||||
|
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": "en-US,en;q=0.9",
|
||||||
|
}
|
||||||
|
MAGNET_RE = re.compile(r"magnet:\?[^\"'\s<>]+")
|
||||||
|
|
||||||
|
|
||||||
|
def search_page_url(query: str, page: int) -> str:
|
||||||
|
"""URL of the N-th results page for a query (page 1 has no number)."""
|
||||||
|
q = quote(query)
|
||||||
|
if page <= 1:
|
||||||
|
return f"{BASE_URL}/search/?search={q}"
|
||||||
|
return f"{BASE_URL}/search/{page}/?search={q}"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(session: requests.Session, url: str, timeout: float, retries: int) -> str | None:
|
||||||
|
"""GET ``url`` and return the HTML, or None after exhausting retries."""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = session.get(url, headers=HEADERS, timeout=timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.text
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
wait = attempt * 2
|
||||||
|
print(f" ! chyba ({attempt}/{retries}) u {url}: {exc} — čekám {wait}s",
|
||||||
|
file=sys.stderr)
|
||||||
|
time.sleep(wait)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_result_links(html: str) -> list[tuple[str, str]]:
|
||||||
|
"""Return (name, detail_url) for each result row on a search page."""
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
results: list[tuple[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for row in soup.select("tr.lista2"):
|
||||||
|
link = row.find("a", href=re.compile(r"^/torrent/"))
|
||||||
|
if not link:
|
||||||
|
continue
|
||||||
|
href = link.get("href")
|
||||||
|
if not href or href in seen:
|
||||||
|
continue
|
||||||
|
seen.add(href)
|
||||||
|
name = link.get("title") or link.get_text(strip=True) or href
|
||||||
|
results.append((name.strip(), urljoin(BASE_URL, href)))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def parse_last_page(html: str) -> int:
|
||||||
|
"""Best-effort highest page number from the pager (1 if none found)."""
|
||||||
|
pages = [int(n) for n in re.findall(r"/search/(\d+)/\?search=", html)]
|
||||||
|
return max(pages) if pages else 1
|
||||||
|
|
||||||
|
|
||||||
|
def extract_magnet(html: str) -> str | None:
|
||||||
|
"""First magnet link found on a torrent detail page, or None."""
|
||||||
|
match = MAGNET_RE.search(html)
|
||||||
|
return match.group(0) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def scrape(query: str, max_pages: int | None, delay: float,
|
||||||
|
timeout: float, retries: int) -> list[tuple[str, str]]:
|
||||||
|
"""Walk all result pages and return a de-duplicated [(name, magnet)] list."""
|
||||||
|
session = requests.Session()
|
||||||
|
collected: list[tuple[str, str]] = []
|
||||||
|
seen_magnets: set[str] = set()
|
||||||
|
seen_details: set[str] = set()
|
||||||
|
|
||||||
|
first_html = fetch(session, search_page_url(query, 1), timeout, retries)
|
||||||
|
if first_html is None:
|
||||||
|
print("Nepodařilo se načíst první stránku výsledků.", file=sys.stderr)
|
||||||
|
return collected
|
||||||
|
|
||||||
|
last_page = parse_last_page(first_html)
|
||||||
|
if max_pages is not None:
|
||||||
|
last_page = min(last_page, max_pages)
|
||||||
|
print(f"Dotaz: {query!r} — stránek k projití: ~{last_page}")
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
html = first_html if page == 1 else fetch(
|
||||||
|
session, search_page_url(query, page), timeout, retries)
|
||||||
|
if html is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
rows = parse_result_links(html)
|
||||||
|
new_rows = [(n, u) for n, u in rows if u not in seen_details]
|
||||||
|
if not new_rows:
|
||||||
|
# No fresh results → past the last real page; stop.
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"[strana {page}] nalezeno položek: {len(new_rows)}")
|
||||||
|
for name, detail_url in new_rows:
|
||||||
|
seen_details.add(detail_url)
|
||||||
|
time.sleep(delay)
|
||||||
|
detail_html = fetch(session, detail_url, timeout, retries)
|
||||||
|
if detail_html is None:
|
||||||
|
print(f" - {name}: detail se nenačetl", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
magnet = extract_magnet(detail_html)
|
||||||
|
if not magnet:
|
||||||
|
print(f" - {name}: magnet nenalezen", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
if magnet in seen_magnets:
|
||||||
|
continue
|
||||||
|
seen_magnets.add(magnet)
|
||||||
|
collected.append((name, magnet))
|
||||||
|
print(f" + {name}")
|
||||||
|
|
||||||
|
if max_pages is not None and page >= max_pages:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
if page > last_page:
|
||||||
|
# Probe one page past the detected last page in case the pager was
|
||||||
|
# windowed; the empty-results check above will stop us if it's truly
|
||||||
|
# the end.
|
||||||
|
last_page = page
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Vyparsuje magnet odkazy z vyhledávání na rargb.to.")
|
||||||
|
parser.add_argument("query", help="Vyhledávací dotaz (např. \"ubuntu 24.04\")")
|
||||||
|
parser.add_argument("-o", "--output", type=Path,
|
||||||
|
help="Výstupní soubor (výchozí: magnets_<dotaz>.txt)")
|
||||||
|
parser.add_argument("--max-pages", type=int, default=None,
|
||||||
|
help="Maximální počet stránek (výchozí: všechny)")
|
||||||
|
parser.add_argument("--delay", type=float, default=1.0,
|
||||||
|
help="Prodleva mezi requesty v sekundách (výchozí: 1.0)")
|
||||||
|
parser.add_argument("--timeout", type=float, default=20.0,
|
||||||
|
help="Timeout requestu v sekundách (výchozí: 20)")
|
||||||
|
parser.add_argument("--retries", type=int, default=3,
|
||||||
|
help="Počet pokusů při chybě (výchozí: 3)")
|
||||||
|
parser.add_argument("--tsv", action="store_true",
|
||||||
|
help="Uložit i <název>\\t<magnet> vedle čistých magnetů")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
output = args.output or Path(
|
||||||
|
f"magnets_{re.sub(r'[^A-Za-z0-9._-]+', '_', args.query).strip('_')}.txt")
|
||||||
|
|
||||||
|
results = scrape(args.query, args.max_pages, args.delay, args.timeout, args.retries)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
print("Nenalezeny žádné magnet odkazy.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
output.write_text("".join(f"{magnet}\n" for _, magnet in results), encoding="utf-8")
|
||||||
|
print(f"\nUloženo {len(results)} magnet odkazů do: {output}")
|
||||||
|
|
||||||
|
if args.tsv:
|
||||||
|
tsv_path = output.with_suffix(".tsv")
|
||||||
|
tsv_path.write_text(
|
||||||
|
"".join(f"{name}\t{magnet}\n" for name, magnet in results), encoding="utf-8")
|
||||||
|
print(f"Uloženo také název+magnet do: {tsv_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""One-off migration: split combined country tags in a pool's metadata index.
|
||||||
|
|
||||||
|
Before multi-country support, a co-production fetched from ČSFD was stored as a
|
||||||
|
single ``"Země původu/USA / Velká Británie"`` tag. This rewrites each such tag
|
||||||
|
into one tag per country (``"Země původu/USA"`` + ``"Země původu/Velká
|
||||||
|
Británie"``), de-duplicating within each record. A timestamped backup of the
|
||||||
|
index is written before saving.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
poetry run python scripts/split_country_tags.py [<pool_dir>] [--category "Země původu"]
|
||||||
|
|
||||||
|
If ``<pool_dir>`` is omitted, the pool from the global config is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.core.config import load_global_config # noqa: E402
|
||||||
|
from src.core.pool_index import INDEX_FILENAME # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
|
||||||
|
"""Return (rewritten tags, number of combined tags split) for one record.
|
||||||
|
|
||||||
|
Order is preserved; duplicates produced by the split are dropped.
|
||||||
|
"""
|
||||||
|
prefix = f"{category}/"
|
||||||
|
result: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
split_count = 0
|
||||||
|
|
||||||
|
def _add(tag: str) -> None:
|
||||||
|
if tag not in seen:
|
||||||
|
seen.add(tag)
|
||||||
|
result.append(tag)
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
if isinstance(tag, str) and tag.startswith(prefix) and "/" in tag[len(prefix):]:
|
||||||
|
value = tag[len(prefix):]
|
||||||
|
countries = [c.strip() for c in value.split("/") if c.strip()]
|
||||||
|
for country in countries:
|
||||||
|
_add(f"{prefix}{country}")
|
||||||
|
split_count += 1
|
||||||
|
else:
|
||||||
|
_add(tag)
|
||||||
|
return result, split_count
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(index_path: Path, category: str) -> int:
|
||||||
|
"""Split combined ``category`` tags in place; return number of tags split."""
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
movies: dict[str, dict] = data.get("movies", {})
|
||||||
|
total_split = 0
|
||||||
|
affected = 0
|
||||||
|
for key, record in movies.items():
|
||||||
|
tags = record.get("tags", [])
|
||||||
|
new_tags, split_count = _split_record_tags(tags, category)
|
||||||
|
if split_count:
|
||||||
|
record["tags"] = new_tags
|
||||||
|
total_split += split_count
|
||||||
|
affected += 1
|
||||||
|
logger.debug(f"{key}: {split_count} combined tag(s) split")
|
||||||
|
|
||||||
|
if total_split == 0:
|
||||||
|
logger.info(f"No combined '{category}/…' tags found — nothing to migrate")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backup = index_path.with_suffix(
|
||||||
|
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
)
|
||||||
|
shutil.copy2(index_path, backup)
|
||||||
|
logger.info(f"Backup written: {backup}")
|
||||||
|
|
||||||
|
with open(index_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Split {total_split} combined '{category}' tag(s) across {affected} record(s)"
|
||||||
|
)
|
||||||
|
return total_split
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"pool_dir",
|
||||||
|
nargs="?",
|
||||||
|
help="Pool root (default: pool_dir from the global config)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--category", default="Země původu", help="Tag category to split"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
|
||||||
|
if not pool_dir:
|
||||||
|
parser.error("No pool_dir given and none configured in the global config")
|
||||||
|
|
||||||
|
index_path = Path(pool_dir) / INDEX_FILENAME
|
||||||
|
if not index_path.exists():
|
||||||
|
parser.error(f"No index found at {index_path}")
|
||||||
|
|
||||||
|
migrate(index_path, args.category)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""One-off migration: drop the old decade-band rating tags from a pool index.
|
||||||
|
|
||||||
|
Earlier the ČSFD rating was stored bucketed (e.g. ``Hodnocení/90–100 %``). Now
|
||||||
|
the tag carries the exact value (``Hodnocení/90``) and the band is only a folder.
|
||||||
|
This removes the legacy band tags (``Hodnocení/<x>–<y> %``) so re-fetching from
|
||||||
|
ČSFD leaves only the exact ratings. Exact rating tags are kept. A timestamped
|
||||||
|
backup of the index is written first.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
poetry run python scripts/strip_rating_bands.py [<pool_dir>] [--category "Hodnocení"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.core.config import load_global_config # noqa: E402
|
||||||
|
from src.core.pool_index import INDEX_FILENAME # noqa: E402
|
||||||
|
|
||||||
|
# A band tag looks like "Hodnocení/90–100 %" — value has a dash range and a "%".
|
||||||
|
_BAND_RE = re.compile(r"\d+\s*[–-]\s*\d+\s*%")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_bands(tags: list[str], category: str) -> tuple[list[str], int]:
|
||||||
|
"""Return (kept tags, removed count), dropping ``category`` band tags."""
|
||||||
|
prefix = f"{category}/"
|
||||||
|
kept = [
|
||||||
|
t for t in tags
|
||||||
|
if not (isinstance(t, str) and t.startswith(prefix) and _BAND_RE.search(t))
|
||||||
|
]
|
||||||
|
return kept, len(tags) - len(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(index_path: Path, category: str) -> int:
|
||||||
|
"""Remove band rating tags in place; return number of tags removed."""
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
movies: dict[str, dict] = data.get("movies", {})
|
||||||
|
total_removed = 0
|
||||||
|
affected = 0
|
||||||
|
for key, record in movies.items():
|
||||||
|
tags = record.get("tags", [])
|
||||||
|
kept, removed = _strip_bands(tags, category)
|
||||||
|
if removed:
|
||||||
|
record["tags"] = kept
|
||||||
|
# also drop them from the ČSFD provenance set, if present
|
||||||
|
if isinstance(record.get("csfd_tags"), list):
|
||||||
|
record["csfd_tags"] = [
|
||||||
|
t for t in record["csfd_tags"]
|
||||||
|
if not (t.startswith(f"{category}/") and _BAND_RE.search(t))
|
||||||
|
]
|
||||||
|
total_removed += removed
|
||||||
|
affected += 1
|
||||||
|
logger.debug(f"{key}: removed {removed} band tag(s)")
|
||||||
|
|
||||||
|
if total_removed == 0:
|
||||||
|
logger.info(f"No '{category}/…–… %' band tags found — nothing to migrate")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backup = index_path.with_suffix(
|
||||||
|
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
)
|
||||||
|
shutil.copy2(index_path, backup)
|
||||||
|
logger.info(f"Backup written: {backup}")
|
||||||
|
|
||||||
|
with open(index_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Removed {total_removed} band '{category}' tag(s) across {affected} record(s)"
|
||||||
|
)
|
||||||
|
return total_removed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"pool_dir",
|
||||||
|
nargs="?",
|
||||||
|
help="Pool root (default: pool_dir from the global config)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--category", default="Hodnocení", help="Rating category")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
|
||||||
|
if not pool_dir:
|
||||||
|
parser.error("No pool_dir given and none configured in the global config")
|
||||||
|
|
||||||
|
index_path = Path(pool_dir) / INDEX_FILENAME
|
||||||
|
if not index_path.exists():
|
||||||
|
parser.error(f"No index found at {index_path}")
|
||||||
|
|
||||||
|
migrate(index_path, args.category)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""One-off migration: drop all tags of given categories from a pool's index.
|
||||||
|
|
||||||
|
Used to remove tag categories that turned out to be a bad idea (e.g. Režie /
|
||||||
|
Herec produced far too many folders). Cached ČSFD data is left intact — only the
|
||||||
|
``tags`` lists are pruned. A timestamped backup of the index is written first.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
poetry run python scripts/strip_tag_categories.py [<pool_dir>] \
|
||||||
|
--categories "Režie" "Herec"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.core.config import load_global_config # noqa: E402
|
||||||
|
from src.core.pool_index import INDEX_FILENAME # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _strip(tags: list[str], prefixes: tuple[str, ...]) -> tuple[list[str], int]:
|
||||||
|
"""Return (kept tags, number removed) dropping tags under any prefix."""
|
||||||
|
kept = [t for t in tags if not (isinstance(t, str) and t.startswith(prefixes))]
|
||||||
|
return kept, len(tags) - len(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(index_path: Path, categories: list[str]) -> int:
|
||||||
|
"""Remove all tags of ``categories`` in place; return number of tags removed."""
|
||||||
|
prefixes = tuple(f"{c}/" for c in categories)
|
||||||
|
|
||||||
|
with open(index_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
movies: dict[str, dict] = data.get("movies", {})
|
||||||
|
total_removed = 0
|
||||||
|
affected = 0
|
||||||
|
for key, record in movies.items():
|
||||||
|
tags = record.get("tags", [])
|
||||||
|
kept, removed = _strip(tags, prefixes)
|
||||||
|
if removed:
|
||||||
|
record["tags"] = kept
|
||||||
|
total_removed += removed
|
||||||
|
affected += 1
|
||||||
|
logger.debug(f"{key}: removed {removed} tag(s)")
|
||||||
|
|
||||||
|
if total_removed == 0:
|
||||||
|
logger.info(f"No tags in {categories} found — nothing to migrate")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backup = index_path.with_suffix(
|
||||||
|
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
)
|
||||||
|
shutil.copy2(index_path, backup)
|
||||||
|
logger.info(f"Backup written: {backup}")
|
||||||
|
|
||||||
|
with open(index_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Removed {total_removed} tag(s) of {categories} across {affected} record(s)"
|
||||||
|
)
|
||||||
|
return total_removed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"pool_dir",
|
||||||
|
nargs="?",
|
||||||
|
help="Pool root (default: pool_dir from the global config)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--categories",
|
||||||
|
nargs="+",
|
||||||
|
default=["Režie", "Herec"],
|
||||||
|
help="Tag categories to strip",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
|
||||||
|
if not pool_dir:
|
||||||
|
parser.error("No pool_dir given and none configured in the global config")
|
||||||
|
|
||||||
|
index_path = Path(pool_dir) / INDEX_FILENAME
|
||||||
|
if not index_path.exists():
|
||||||
|
parser.error(f"No index found at {index_path}")
|
||||||
|
|
||||||
|
migrate(index_path, args.categories)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Auto-generated — do not edit manually."""
|
||||||
|
__version__ = "1.5.0"
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
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`).
|
||||||
|
|
||||||
|
It is the single source of truth for app metadata used across the GUI and build
|
||||||
|
tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Default main-window geometry (used when no saved geometry exists)
|
||||||
|
APP_VIEWPORT: str = "1000x700"
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Tag schema: the single source of truth for which tag categories exist, how
|
||||||
|
# they are derived from ČSFD, and how they map to the Filmotéka folder tree.
|
||||||
|
# Each entry:
|
||||||
|
# category — tag category name (e.g. "Žánr")
|
||||||
|
# csfd_field — CSFDMovie attribute feeding it (genres / year / countries /
|
||||||
|
# rating / directors / actors), or None for a user-only category
|
||||||
|
# transform — named value transform: None (str) or "decade_band" (rating)
|
||||||
|
# filmoteka_root — folder under the output: "" = at the root (e.g. genres),
|
||||||
|
# "Dle X" = grouping folder, None = filterable but no folders
|
||||||
|
# filename_template — optional per-category hardlink name used only inside this
|
||||||
|
# category's folders (e.g. "{year} - {title}{ext}"); fields:
|
||||||
|
# title / year / rating / ext / stem / filename. Absent/None =
|
||||||
|
# keep the pool filename. Pool files are never renamed by this.
|
||||||
|
DEFAULT_TAG_SCHEMA = [
|
||||||
|
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
|
||||||
|
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"},
|
||||||
|
{"category": "Země původu", "csfd_field": "countries", "transform": None,
|
||||||
|
"filmoteka_root": "Dle země původu"},
|
||||||
|
{"category": "Hodnocení", "csfd_field": "rating", "transform": "decade_band",
|
||||||
|
"filmoteka_root": "Dle hodnocení"},
|
||||||
|
]
|
||||||
|
|
||||||
|
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)
|
||||||
|
"tag_schema": DEFAULT_TAG_SCHEMA, # tag categories + ČSFD/Filmotéka rules
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Anubis is the proof-of-work anti-bot wall ČSFD now puts in front of every page.
|
||||||
|
# A plain request gets a 200 with a JS challenge page (title "Ujišťujeme se, že
|
||||||
|
# nejste robot!") instead of the movie, so JSON-LD/genres/year all parse empty.
|
||||||
|
# We detect that page, solve the PoW the way the bundled worker JS does, and
|
||||||
|
# replay the request through the same session to obtain the auth cookie.
|
||||||
|
ANUBIS_CHALLENGE_MARKER = 'id="anubis_challenge"'
|
||||||
|
ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
||||||
|
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
|
||||||
|
ANUBIS_MAX_NONCE = 50_000_000
|
||||||
|
|
||||||
|
# Keep only the top-billed cast from a movie's actor list.
|
||||||
|
MAX_ACTORS = 10
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
# A movie can be a co-production, so the origin is a list of countries
|
||||||
|
# (ČSFD writes them slash-separated, e.g. "Japonsko / USA").
|
||||||
|
countries: list[str] = field(default_factory=list)
|
||||||
|
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,
|
||||||
|
"countries": self.countries,
|
||||||
|
"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)."""
|
||||||
|
countries = data.get("countries")
|
||||||
|
if countries is None:
|
||||||
|
# Legacy cache stored a single "country" string (possibly slash-joined)
|
||||||
|
countries = _split_countries(data.get("country"))
|
||||||
|
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"),
|
||||||
|
countries=countries,
|
||||||
|
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.countries:
|
||||||
|
parts.append(f"Země původu: {', '.join(self.countries)}")
|
||||||
|
if self.directors:
|
||||||
|
parts.append(f"Režie: {', '.join(self.directors)}")
|
||||||
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def rating_band(rating: int) -> str:
|
||||||
|
"""Bucket a 0–100 ČSFD rating into a ten-point band label (e.g. "80–89 %").
|
||||||
|
|
||||||
|
The top bucket spans 90–100 % so a perfect 100 still lands in a band.
|
||||||
|
"""
|
||||||
|
low = min((rating // 10) * 10, 90)
|
||||||
|
high = 100 if low == 90 else low + 9
|
||||||
|
return f"{low}–{high} %"
|
||||||
|
|
||||||
|
|
||||||
|
def _decade_band(value) -> str:
|
||||||
|
"""``decade_band`` transform: pull the leading integer and bucket it."""
|
||||||
|
match = re.search(r"\d+", str(value))
|
||||||
|
return rating_band(int(match.group())) if match else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
# Named value transforms usable from the tag schema (config-driven, extensible).
|
||||||
|
# These are applied when building Filmotéka FOLDER names — the tag itself keeps
|
||||||
|
# the exact value (e.g. rating 90 → tag "Hodnocení/90", folder "…/90–100 %").
|
||||||
|
TAG_TRANSFORMS = {
|
||||||
|
None: str,
|
||||||
|
"identity": str,
|
||||||
|
"decade_band": _decade_band,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_transform(value: str, transform: Optional[str]) -> str:
|
||||||
|
"""Apply a named tag-schema transform to a value, returning a string."""
|
||||||
|
fn = TAG_TRANSFORMS.get(transform, str)
|
||||||
|
try:
|
||||||
|
return str(fn(value))
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def csfd_field_values(movie: "CSFDMovie", field: str) -> list[str]:
|
||||||
|
"""Exact tag values for a CSFDMovie attribute (scalar or list), as strings.
|
||||||
|
|
||||||
|
No transform is applied here — tags carry the precise value; any grouping
|
||||||
|
transform happens later, when the Filmotéka folder names are built.
|
||||||
|
Unknown field / missing value yields ``[]``.
|
||||||
|
"""
|
||||||
|
raw = getattr(movie, field, None)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return []
|
||||||
|
items = list(raw) if isinstance(raw, (list, tuple)) else [raw]
|
||||||
|
values = [str(item).strip() for item in items if item is not None and item != ""]
|
||||||
|
return [v for v in values if v]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_countries(text: Optional[str]) -> list[str]:
|
||||||
|
"""Split a ČSFD origin country string into individual countries.
|
||||||
|
|
||||||
|
ČSFD writes co-productions slash-separated, e.g. ``"Japonsko / USA"`` →
|
||||||
|
``["Japonsko", "USA"]``. ``None``/empty yields an empty list.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
return [part.strip() for part in text.split("/") if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
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 _extract_json_blob(html: str, element_id: str):
|
||||||
|
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
|
||||||
|
match = re.search(
|
||||||
|
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
|
||||||
|
html,
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]:
|
||||||
|
"""Brute-force the Anubis proof-of-work.
|
||||||
|
|
||||||
|
Mirrors the bundled ``sha256-purejs`` worker: find the smallest ``nonce``
|
||||||
|
such that ``sha256(random_data + str(nonce))`` has ``difficulty`` leading
|
||||||
|
zero nibbles. Returns ``(hash_hex, nonce, elapsed_ms)``.
|
||||||
|
"""
|
||||||
|
full_zero_bytes = difficulty // 2
|
||||||
|
needs_half_byte = difficulty % 2 != 0
|
||||||
|
start = time.monotonic()
|
||||||
|
for nonce in range(ANUBIS_MAX_NONCE):
|
||||||
|
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).digest()
|
||||||
|
if any(digest[i] != 0 for i in range(full_zero_bytes)):
|
||||||
|
continue
|
||||||
|
if needs_half_byte and digest[full_zero_bytes] >> 4 != 0:
|
||||||
|
continue
|
||||||
|
elapsed_ms = int((time.monotonic() - start) * 1000)
|
||||||
|
return digest.hex(), nonce, elapsed_ms
|
||||||
|
raise ValueError(
|
||||||
|
f"Anubis PoW unsolved within {ANUBIS_MAX_NONCE} attempts (difficulty {difficulty})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_anubis_challenge(session, html: str, url: str):
|
||||||
|
"""Solve the Anubis challenge in ``html`` and return the real page response.
|
||||||
|
|
||||||
|
Posts the proof-of-work back to the pass-challenge endpoint through
|
||||||
|
``session`` (which stores the resulting auth cookie) and follows the
|
||||||
|
redirect to the originally requested page.
|
||||||
|
"""
|
||||||
|
payload = _extract_json_blob(html, "anubis_challenge")
|
||||||
|
if not payload:
|
||||||
|
raise ValueError("ČSFD anti-bot stránka bez čitelné Anubis challenge")
|
||||||
|
|
||||||
|
rules = payload.get("rules", {})
|
||||||
|
challenge = payload.get("challenge", {})
|
||||||
|
random_data = challenge.get("randomData")
|
||||||
|
difficulty = int(rules.get("difficulty", 1))
|
||||||
|
if not random_data:
|
||||||
|
raise ValueError("Anubis challenge neobsahuje randomData")
|
||||||
|
|
||||||
|
base_prefix = _extract_json_blob(html, "anubis_base_prefix") or ""
|
||||||
|
logger.debug(f"Solving Anubis challenge (difficulty {difficulty}) for {url}")
|
||||||
|
hash_hex, nonce, elapsed_ms = _solve_anubis_pow(random_data, difficulty)
|
||||||
|
logger.debug(f"Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms")
|
||||||
|
|
||||||
|
pass_url = urljoin(CSFD_BASE_URL, f"{base_prefix}{ANUBIS_PASS_PATH}")
|
||||||
|
response = session.get(
|
||||||
|
pass_url,
|
||||||
|
params={
|
||||||
|
"id": challenge.get("id"),
|
||||||
|
"response": hash_hex,
|
||||||
|
"nonce": nonce,
|
||||||
|
"redir": url,
|
||||||
|
"elapsedTime": elapsed_ms,
|
||||||
|
},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
if ANUBIS_CHALLENGE_MARKER in response.text:
|
||||||
|
raise ValueError("ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto)")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _get_page(session, url: str):
|
||||||
|
"""GET ``url`` through ``session``, transparently clearing an Anubis wall."""
|
||||||
|
response = session.get(url, headers=HEADERS, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
if ANUBIS_CHALLENGE_MARKER in response.text:
|
||||||
|
response = _solve_anubis_challenge(session, response.text, url)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_movie(url: str, session=None) -> 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/)
|
||||||
|
session: Optional ``requests.Session`` to reuse (keeps the Anubis auth
|
||||||
|
cookie across calls so only the first fetch pays the PoW cost).
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = requests.Session()
|
||||||
|
try:
|
||||||
|
response = _get_page(session, url)
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
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 countries and year from origin info
|
||||||
|
origin_info = _extract_origin_info(soup)
|
||||||
|
if origin_info:
|
||||||
|
if not movie_data.get("countries"):
|
||||||
|
movie_data["countries"] = origin_info.get("countries", [])
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Keep only the leading cast (ČSFD lists them in billing order)
|
||||||
|
movie_data["actors"] = movie_data.get("actors", [])[:MAX_ACTORS]
|
||||||
|
|
||||||
|
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,
|
||||||
|
"countries": [],
|
||||||
|
"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 countries, year, duration from the origin info line.
|
||||||
|
|
||||||
|
CSFD separates the values with inline bullet ``<span>`` 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. The country segment of a co-production is
|
||||||
|
slash-separated (e.g. "USA / Velká Británie") and is split into a list.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
# Countries: first alphabetic token that doesn't start with a digit;
|
||||||
|
# may list several slash-separated countries for a co-production.
|
||||||
|
if "countries" not in info and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
|
||||||
|
info["countries"] = _split_countries(token)
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def search_movies(query: str, limit: int = 10, session=None) -> list[CSFDMovie]:
|
||||||
|
"""
|
||||||
|
Search for movies on CSFD.cz.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
session: Optional ``requests.Session`` to reuse (keeps the Anubis auth
|
||||||
|
cookie across calls so only the first lookup pays the PoW cost).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of CSFDMovie objects with basic info (title, url, year)
|
||||||
|
"""
|
||||||
|
_check_dependencies()
|
||||||
|
|
||||||
|
search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}"
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = requests.Session()
|
||||||
|
try:
|
||||||
|
response = _get_page(session, search_url)
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# Release-name tokens that mark the end of the actual title in a filename.
|
||||||
|
_RELEASE_MARKERS = {
|
||||||
|
"bluray", "blu-ray", "brrip", "bdrip", "bdremux", "remux", "webrip", "web",
|
||||||
|
"web-dl", "webdl", "hdtv", "dvdrip", "dvd", "dvd5", "dvd9", "hdrip", "cam",
|
||||||
|
"ts", "tc", "x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx",
|
||||||
|
"aac", "ac3", "eac3", "dts", "dd5", "ddp5", "truehd", "atmos", "flac",
|
||||||
|
"10bit", "8bit", "hdr", "hdr10", "dolby", "sdr", "proper", "repack",
|
||||||
|
"extended", "unrated", "remastered", "imax", "multi", "dual", "complete",
|
||||||
|
"internal", "limited", "uncut",
|
||||||
|
}
|
||||||
|
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
||||||
|
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_filename_to_query(filename: str) -> str:
|
||||||
|
"""Turn a (possibly release-named) filename into a ČSFD search query.
|
||||||
|
|
||||||
|
Strips the path/extension, splits on common separators and keeps the words
|
||||||
|
before the first release marker (year, resolution, codec, source, …). The
|
||||||
|
detected year is appended back as a disambiguator. Example::
|
||||||
|
|
||||||
|
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
stem = Path(filename).stem
|
||||||
|
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
|
||||||
|
|
||||||
|
title_words: list[str] = []
|
||||||
|
year: Optional[str] = None
|
||||||
|
for token in tokens:
|
||||||
|
bare = token.strip("()[]{}")
|
||||||
|
if _YEAR_RE.match(bare):
|
||||||
|
year = bare
|
||||||
|
break
|
||||||
|
if _RESOLUTION_RE.match(bare) or bare.lower() in _RELEASE_MARKERS:
|
||||||
|
break
|
||||||
|
# also stop at a release group glued with a dash (e.g. "x264-GROUP")
|
||||||
|
title_words.append(token)
|
||||||
|
|
||||||
|
# If nothing survived (title started with a marker), fall back to the stem.
|
||||||
|
title = " ".join(title_words).strip() or re.sub(r"[.\s_]+", " ", stem).strip()
|
||||||
|
return f"{title} {year}".strip() if year else title
|
||||||
|
|
||||||
|
|
||||||
|
def find_csfd_url(query: str, session=None) -> Optional[str]:
|
||||||
|
"""Return the first ČSFD film URL matching a query, or None.
|
||||||
|
|
||||||
|
Thin wrapper over :func:`search_movies` that takes the top result. Pass a
|
||||||
|
shared ``session`` to reuse the Anubis auth cookie across several lookups.
|
||||||
|
"""
|
||||||
|
if not query.strip():
|
||||||
|
return None
|
||||||
|
results = search_movies(query, limit=1, session=session)
|
||||||
|
return results[0].url if results else None
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
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.
|
||||||
|
# v2: country (str) → countries (list[str]) for co-productions.
|
||||||
|
CSFD_CACHE_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
# full_paths of tags that came from ČSFD (vs. user-added); only these are
|
||||||
|
# regenerated when the ČSFD link changes — user tags are preserved.
|
||||||
|
self.csfd_tag_paths: set[str] = set()
|
||||||
|
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
||||||
|
# Filmotéka filename templates via name_context.
|
||||||
|
self.attributes: dict[str, str] = {}
|
||||||
|
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
|
||||||
|
self.csfd_tag_paths = set()
|
||||||
|
self.attributes = {}
|
||||||
|
|
||||||
|
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],
|
||||||
|
# which of the tags came from ČSFD (provenance)
|
||||||
|
"csfd_tags": sorted(self.csfd_tag_paths),
|
||||||
|
# date může být None
|
||||||
|
"date": self.date,
|
||||||
|
"title": self.title,
|
||||||
|
"csfd_link": self.csfd_link,
|
||||||
|
"attributes": self.attributes,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
# Legacy records have no provenance → treated as all-user (empty set)
|
||||||
|
self.csfd_tag_paths = set(data.get("csfd_tags", []))
|
||||||
|
self.attributes = dict(data.get("attributes", {}))
|
||||||
|
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 name_context(self) -> dict:
|
||||||
|
"""Fields for a Filmotéka filename template (see config tag schema).
|
||||||
|
|
||||||
|
Provides title / year / rating / ext / stem / filename from the movie's
|
||||||
|
metadata (year & rating come from the ČSFD cache, year falling back to a
|
||||||
|
``Rok`` tag). Missing values render as an empty string.
|
||||||
|
"""
|
||||||
|
cache = self.csfd_cache or {}
|
||||||
|
year = cache.get("year")
|
||||||
|
if year is None:
|
||||||
|
for t in self.tags:
|
||||||
|
if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit():
|
||||||
|
year = t.name
|
||||||
|
break
|
||||||
|
rating = cache.get("rating")
|
||||||
|
# user attributes first; core fields take precedence over same-named keys
|
||||||
|
context = dict(self.attributes)
|
||||||
|
context.update({
|
||||||
|
"title": self.title or self.file_path.stem,
|
||||||
|
"year": "" if year is None else year,
|
||||||
|
"rating": "" if rating is None else rating,
|
||||||
|
"ext": self.file_path.suffix,
|
||||||
|
"stem": self.file_path.stem,
|
||||||
|
"filename": self.filename,
|
||||||
|
})
|
||||||
|
return context
|
||||||
|
|
||||||
|
def set_attribute(self, key: str, value: str | None) -> None:
|
||||||
|
"""Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it."""
|
||||||
|
key = key.strip()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
if value:
|
||||||
|
self.attributes[key] = value
|
||||||
|
else:
|
||||||
|
self.attributes.pop(key, None)
|
||||||
|
self.save_metadata()
|
||||||
|
|
||||||
|
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 relocate(self, new_path: Path) -> None:
|
||||||
|
"""Point this File at a new path, moving its metadata along.
|
||||||
|
|
||||||
|
The physical file must already have been moved/renamed by the caller.
|
||||||
|
Drops the metadata under the old path (index key or sidecar) and rebinds
|
||||||
|
to the new path; call ``save_metadata()`` afterwards to write it back.
|
||||||
|
"""
|
||||||
|
old_metadata_filename = self.metadata_filename
|
||||||
|
if self.index is not None:
|
||||||
|
self.index.delete(self.file_path)
|
||||||
|
self.file_path = Path(new_path)
|
||||||
|
self.filename = self.file_path.name
|
||||||
|
self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag"
|
||||||
|
if self.index is None and old_metadata_filename.exists():
|
||||||
|
old_metadata_filename.rename(self.metadata_filename)
|
||||||
|
|
||||||
|
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, schema: list[dict] | None = None) -> dict:
|
||||||
|
"""Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu.
|
||||||
|
|
||||||
|
``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro
|
||||||
|
každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu
|
||||||
|
(s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance
|
||||||
|
ČSFD) zůstávají nedotčené — proto změna ČSFD odkazu nepřepíše vlastní tagy.
|
||||||
|
Režie/herci se stále cachují, tag se z nich tvoří jen je-li v schématu.
|
||||||
|
|
||||||
|
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": []}
|
||||||
|
|
||||||
|
if schema is None:
|
||||||
|
from .config import DEFAULT_TAG_SCHEMA
|
||||||
|
schema = DEFAULT_TAG_SCHEMA
|
||||||
|
|
||||||
|
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": []}
|
||||||
|
|
||||||
|
from .csfd import csfd_field_values
|
||||||
|
|
||||||
|
# Drop previous ČSFD-sourced tags; keep user-added ones.
|
||||||
|
self.tags = [t for t in self.tags if t.full_path not in self.csfd_tag_paths]
|
||||||
|
self.csfd_tag_paths = set()
|
||||||
|
|
||||||
|
tags_added: list[str] = []
|
||||||
|
for entry in schema:
|
||||||
|
field = entry.get("csfd_field")
|
||||||
|
if not field:
|
||||||
|
continue # user-only category
|
||||||
|
category = entry["category"]
|
||||||
|
for value in csfd_field_values(movie, field):
|
||||||
|
tag_obj = (self.tagmanager.add_tag(category, value)
|
||||||
|
if self.tagmanager else Tag(category, value))
|
||||||
|
if tag_obj not in self.tags:
|
||||||
|
self.tags.append(tag_obj)
|
||||||
|
tags_added.append(tag_obj.full_path)
|
||||||
|
self.csfd_tag_paths.add(tag_obj.full_path)
|
||||||
|
|
||||||
|
# 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()
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
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 tag_schema(self) -> list[dict]:
|
||||||
|
"""Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA)."""
|
||||||
|
from src.core.config import DEFAULT_TAG_SCHEMA
|
||||||
|
return self.global_config.get("tag_schema", DEFAULT_TAG_SCHEMA)
|
||||||
|
|
||||||
|
def set_tag_schema(self, schema: list[dict]) -> None:
|
||||||
|
"""Set the tag schema and persist it."""
|
||||||
|
self.global_config["tag_schema"] = schema
|
||||||
|
save_global_config(self.global_config)
|
||||||
|
|
||||||
|
def filmoteka_category_roots(self) -> dict[str, str]:
|
||||||
|
"""Category → output root-folder map derived from the tag schema.
|
||||||
|
|
||||||
|
Categories with ``filmoteka_root`` set to None are filterable but get no
|
||||||
|
folders in the generated tree.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
e["category"]: e["filmoteka_root"]
|
||||||
|
for e in self.tag_schema
|
||||||
|
if e.get("filmoteka_root") is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
def filmoteka_category_transforms(self) -> dict[str, str]:
|
||||||
|
"""Category → folder transform-name map (for grouping, e.g. rating bands).
|
||||||
|
|
||||||
|
Tags keep their exact value; this transform only shapes the folder name
|
||||||
|
in the generated tree. Categories without a transform are omitted.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
e["category"]: e["transform"]
|
||||||
|
for e in self.tag_schema
|
||||||
|
if e.get("filmoteka_root") is not None and e.get("transform")
|
||||||
|
}
|
||||||
|
|
||||||
|
def filmoteka_category_filename_templates(self) -> dict[str, str]:
|
||||||
|
"""Category → hardlink-name template map (applied inside that category)."""
|
||||||
|
return {
|
||||||
|
e["category"]: e["filename_template"]
|
||||||
|
for e in self.tag_schema
|
||||||
|
if e.get("filmoteka_root") is not None and e.get("filename_template")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 pooled_with_stem(self, title: str) -> list[File]:
|
||||||
|
"""Pooled movies whose filename stem matches ``title`` (case-insensitive)."""
|
||||||
|
stem = title.strip().lower()
|
||||||
|
return [f for f in self.filelist if Path(f.filename).stem.lower() == stem]
|
||||||
|
|
||||||
|
def _evict(self, file_obj: File) -> None:
|
||||||
|
"""Delete a pooled movie: its metadata, the file, and the list entry."""
|
||||||
|
file_obj.delete_metadata()
|
||||||
|
if file_obj.file_path.exists():
|
||||||
|
file_obj.file_path.unlink()
|
||||||
|
if file_obj in self.filelist:
|
||||||
|
self.filelist.remove(file_obj)
|
||||||
|
|
||||||
|
def import_movie(
|
||||||
|
self, source: Path, title: str, csfd_link: str | None = None,
|
||||||
|
move: bool = False, on_conflict: str = "suffix",
|
||||||
|
) -> File | None:
|
||||||
|
"""Bring a video file into pool/Filmy as 'Title.ext' and index its metadata.
|
||||||
|
|
||||||
|
By default the original is **copied** (non-destructive). With ``move=True``
|
||||||
|
the source file is moved into the pool instead, leaving nothing behind.
|
||||||
|
|
||||||
|
``on_conflict`` decides what happens when a pooled movie of the same name
|
||||||
|
already exists:
|
||||||
|
|
||||||
|
- ``"suffix"`` (default): keep both, the new file gets a ``_N`` suffix.
|
||||||
|
- ``"replace"``: evict the existing same-named movie(s) (file + metadata)
|
||||||
|
and import the new one under the plain name.
|
||||||
|
- ``"skip"``: do not import; return ``None``.
|
||||||
|
"""
|
||||||
|
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}"
|
||||||
|
|
||||||
|
existing = self.pooled_with_stem(safe_title)
|
||||||
|
conflict = bool(existing) or target.exists()
|
||||||
|
|
||||||
|
if conflict and on_conflict == "skip":
|
||||||
|
return None
|
||||||
|
if conflict and on_conflict == "replace":
|
||||||
|
for f in existing:
|
||||||
|
self._evict(f)
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
else:
|
||||||
|
# "suffix": never clobber an existing exact filename
|
||||||
|
counter = 1
|
||||||
|
while target.exists():
|
||||||
|
target = movies / f"{safe_title}_{counter}{source.suffix}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
if move:
|
||||||
|
shutil.move(str(source), str(target))
|
||||||
|
else:
|
||||||
|
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 rename_movie(self, file_obj: File, new_title: str) -> File:
|
||||||
|
"""Rename a pooled movie's file to ``<new_title>.<ext>`` and reindex it.
|
||||||
|
|
||||||
|
Renames the physical file in pool/Filmy (keeping its extension), moves
|
||||||
|
the metadata to the new key, and syncs ``title``/``filename``. The
|
||||||
|
extension is preserved; ``new_title`` is the bare name without it.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: empty name or a name containing a path separator.
|
||||||
|
FileExistsError: another pooled file already uses that name.
|
||||||
|
"""
|
||||||
|
new_title = new_title.strip()
|
||||||
|
if not new_title:
|
||||||
|
raise ValueError("Název nesmí být prázdný.")
|
||||||
|
if "/" in new_title or "\\" in new_title:
|
||||||
|
raise ValueError("Název nesmí obsahovat lomítka.")
|
||||||
|
|
||||||
|
old_path = file_obj.file_path
|
||||||
|
new_path = old_path.with_name(f"{new_title}{old_path.suffix}")
|
||||||
|
if new_path == old_path:
|
||||||
|
return file_obj # no change
|
||||||
|
|
||||||
|
if new_path.exists():
|
||||||
|
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
|
||||||
|
|
||||||
|
old_path.rename(new_path)
|
||||||
|
file_obj.relocate(new_path)
|
||||||
|
file_obj.title = new_title
|
||||||
|
file_obj.save_metadata()
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
"""
|
||||||
|
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, Dict, Set
|
||||||
|
from .file import File
|
||||||
|
|
||||||
|
|
||||||
|
class _SafeDict(dict):
|
||||||
|
"""dict for str.format_map that leaves unknown fields as an empty string."""
|
||||||
|
|
||||||
|
def __missing__(self, key): # noqa: ANN001
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class HardlinkManager:
|
||||||
|
"""Manager for creating hardlink-based directory structures from tagged files.
|
||||||
|
|
||||||
|
The output layout is driven by a *category → root folder* mapping
|
||||||
|
(``category_roots``). Each tag is placed at
|
||||||
|
``output/<root>/<tag_name>/<file>``; an empty root means the tag's own
|
||||||
|
folders sit directly at the output root (e.g. genre folders next to the
|
||||||
|
"Dle roku" / "Dle země původu" folders). The legacy ``categories`` list
|
||||||
|
(folder == category name) is still accepted and treated as the identity
|
||||||
|
mapping ``{cat: cat}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 _resolve_roots(
|
||||||
|
self,
|
||||||
|
categories: Optional[List[str]],
|
||||||
|
category_roots: Optional[Dict[str, str]],
|
||||||
|
) -> Optional[Dict[str, str]]:
|
||||||
|
"""Normalize the two filter styles into a category → root-folder map.
|
||||||
|
|
||||||
|
``None`` means "all categories", folder == category name.
|
||||||
|
"""
|
||||||
|
if category_roots is not None:
|
||||||
|
return dict(category_roots)
|
||||||
|
if categories is not None:
|
||||||
|
return {cat: cat for cat in categories}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _link_name(
|
||||||
|
self, file_obj: File, tag, templates: Optional[Dict[str, str]]
|
||||||
|
) -> str:
|
||||||
|
"""Hardlink filename for a tag — a per-category template or the pool name.
|
||||||
|
|
||||||
|
Applies ``templates[tag.category]`` (e.g. ``"{year} - {title}{ext}"``) to
|
||||||
|
the file's ``name_context``; path separators are flattened. Any failure or
|
||||||
|
empty result falls back to the pool filename.
|
||||||
|
"""
|
||||||
|
template = templates.get(tag.category) if templates else None
|
||||||
|
if not template:
|
||||||
|
return file_obj.filename
|
||||||
|
try:
|
||||||
|
rendered = template.format_map(_SafeDict(file_obj.name_context()))
|
||||||
|
except (ValueError, KeyError, IndexError, AttributeError):
|
||||||
|
return file_obj.filename
|
||||||
|
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
|
||||||
|
return rendered or file_obj.filename
|
||||||
|
|
||||||
|
def _folder_value(self, tag, transforms: Optional[Dict[str, str]]) -> str:
|
||||||
|
"""Folder name for a tag — its value run through the category transform.
|
||||||
|
|
||||||
|
The tag keeps its exact value; the grouping (e.g. rating → ten-point
|
||||||
|
band) only happens here, when naming the folder.
|
||||||
|
"""
|
||||||
|
if transforms and tag.category in transforms:
|
||||||
|
from .csfd import apply_transform
|
||||||
|
return apply_transform(tag.name, transforms[tag.category])
|
||||||
|
return tag.name
|
||||||
|
|
||||||
|
def _target_dir(
|
||||||
|
self, tag, roots: Optional[Dict[str, str]],
|
||||||
|
transforms: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Output directory for a tag, or None if its category is excluded."""
|
||||||
|
if roots is None:
|
||||||
|
folder = tag.category
|
||||||
|
elif tag.category in roots:
|
||||||
|
folder = roots[tag.category]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
base = self.output_dir / folder if folder else self.output_dir
|
||||||
|
return base / self._folder_value(tag, transforms)
|
||||||
|
|
||||||
|
def _managed_top_dirs(
|
||||||
|
self, files: List[File], roots: Optional[Dict[str, str]],
|
||||||
|
transforms: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Optional[Set[str]]:
|
||||||
|
"""Top-level output folders owned by the tag tree (None = all of them).
|
||||||
|
|
||||||
|
For a category with a non-empty root the root folder is owned; for a
|
||||||
|
category placed at the output root (empty root, e.g. genres) each of its
|
||||||
|
(transformed) tag values is its own top-level folder. This lets cleanup
|
||||||
|
skip unrelated root entries such as the copy-as-is mirror (Seriály).
|
||||||
|
"""
|
||||||
|
if roots is None:
|
||||||
|
return None
|
||||||
|
tops: Set[str] = set()
|
||||||
|
for cat, folder in roots.items():
|
||||||
|
if folder:
|
||||||
|
tops.add(folder)
|
||||||
|
else:
|
||||||
|
for file_obj in files:
|
||||||
|
for tag in file_obj.tags:
|
||||||
|
if tag.category == cat:
|
||||||
|
tops.add(self._folder_value(tag, transforms))
|
||||||
|
return tops
|
||||||
|
|
||||||
|
def create_structure_for_files(
|
||||||
|
self,
|
||||||
|
files: List[File],
|
||||||
|
categories: Optional[List[str]] = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
category_roots: Optional[Dict[str, str]] = None,
|
||||||
|
category_transforms: Optional[Dict[str, str]] = None,
|
||||||
|
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||||
|
) -> 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
|
||||||
|
category_roots: Optional category → root-folder map (see class doc);
|
||||||
|
overrides ``categories`` when given.
|
||||||
|
category_transforms: Optional category → transform-name map applied to
|
||||||
|
the tag value when naming its folder (e.g. rating → decade band).
|
||||||
|
category_filename_templates: Optional category → hardlink-name template
|
||||||
|
applied only inside that category's folders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (successful_links, failed_links)
|
||||||
|
"""
|
||||||
|
self.created_links = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
roots = self._resolve_roots(categories, category_roots)
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
for file_obj in files:
|
||||||
|
if not file_obj.tags:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for tag in file_obj.tags:
|
||||||
|
# Resolve the target dir; None means this category is excluded
|
||||||
|
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||||
|
if target_dir is None:
|
||||||
|
continue
|
||||||
|
target_file = target_dir / self._link_name(
|
||||||
|
file_obj, tag, category_filename_templates)
|
||||||
|
|
||||||
|
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,
|
||||||
|
category_roots: Optional[Dict[str, str]] = None,
|
||||||
|
category_transforms: Optional[Dict[str, str]] = None,
|
||||||
|
category_filename_templates: Optional[Dict[str, 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
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (source_path, target_path)
|
||||||
|
"""
|
||||||
|
roots = self._resolve_roots(categories, category_roots)
|
||||||
|
preview = []
|
||||||
|
|
||||||
|
for file_obj in files:
|
||||||
|
if not file_obj.tags:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for tag in file_obj.tags:
|
||||||
|
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||||
|
if target_dir is None:
|
||||||
|
continue
|
||||||
|
target_file = target_dir / self._link_name(
|
||||||
|
file_obj, tag, category_filename_templates)
|
||||||
|
|
||||||
|
preview.append((file_obj.file_path, target_file))
|
||||||
|
|
||||||
|
return preview
|
||||||
|
|
||||||
|
def find_obsolete_links(
|
||||||
|
self,
|
||||||
|
files: List[File],
|
||||||
|
categories: Optional[List[str]] = None,
|
||||||
|
category_roots: Optional[Dict[str, str]] = None,
|
||||||
|
category_transforms: Optional[Dict[str, str]] = None,
|
||||||
|
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||||
|
) -> List[Tuple[Path, Path]]:
|
||||||
|
"""
|
||||||
|
Find hardlinks in the output directory that no longer match file tags.
|
||||||
|
|
||||||
|
Scans the managed parts of the output directory for hardlinks that point
|
||||||
|
to source files but whose path no longer matches the file's current tags.
|
||||||
|
Only the tag-tree's own top-level folders are scanned, so copy-as-is
|
||||||
|
mirrors (e.g. Seriály) are left untouched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
files: List of File objects (source files)
|
||||||
|
categories: Optional list of categories to check (None = all)
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (link_path, source_path) for obsolete links
|
||||||
|
"""
|
||||||
|
obsolete: List[Tuple[Path, Path]] = []
|
||||||
|
|
||||||
|
if not self.output_dir.exists():
|
||||||
|
return obsolete
|
||||||
|
|
||||||
|
roots = self._resolve_roots(categories, category_roots)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||||
|
if target_dir is None:
|
||||||
|
continue
|
||||||
|
expected_paths[inode].add(target_dir / self._link_name(
|
||||||
|
file_obj, tag, category_filename_templates))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors)
|
||||||
|
top_dirs = self._managed_top_dirs(files, roots, category_transforms)
|
||||||
|
for top in self.output_dir.iterdir():
|
||||||
|
if not top.is_dir():
|
||||||
|
continue
|
||||||
|
if top_dirs is not None and top.name not in top_dirs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Depth-agnostic: genres sit one level deep, "Dle roku"/"Dle země
|
||||||
|
# původu" two levels deep — walk all files under the managed folder.
|
||||||
|
for link_file in top.rglob("*"):
|
||||||
|
if not link_file.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
link_inode = link_file.stat().st_ino
|
||||||
|
if link_inode in expected_paths:
|
||||||
|
if link_file not in expected_paths[link_inode]:
|
||||||
|
obsolete.append((link_file, inode_to_file[link_inode].file_path))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return obsolete
|
||||||
|
|
||||||
|
def remove_obsolete_links(
|
||||||
|
self,
|
||||||
|
files: List[File],
|
||||||
|
categories: Optional[List[str]] = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
category_roots: Optional[Dict[str, str]] = None,
|
||||||
|
category_transforms: Optional[Dict[str, str]] = None,
|
||||||
|
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||||
|
) -> 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
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (removed_count, list_of_removed_paths)
|
||||||
|
"""
|
||||||
|
obsolete = self.find_obsolete_links(
|
||||||
|
files, categories, category_roots, category_transforms,
|
||||||
|
category_filename_templates)
|
||||||
|
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,
|
||||||
|
category_roots: Optional[Dict[str, str]] = None,
|
||||||
|
category_transforms: Optional[Dict[str, str]] = None,
|
||||||
|
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||||
|
) -> 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
|
||||||
|
category_roots: Optional category → root-folder map (overrides
|
||||||
|
``categories`` when given).
|
||||||
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
|
||||||
|
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, category_roots, category_transforms,
|
||||||
|
category_filename_templates))
|
||||||
|
|
||||||
|
# Remove obsolete links
|
||||||
|
removed, removed_paths = self.remove_obsolete_links(
|
||||||
|
files, categories, dry_run, category_roots, category_transforms,
|
||||||
|
category_filename_templates
|
||||||
|
)
|
||||||
|
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, category_roots, category_transforms,
|
||||||
|
category_filename_templates
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
@@ -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 ``<pool>/.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()
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from .tag import Tag
|
||||||
|
|
||||||
|
# Default tags that are always available (order in list = display order).
|
||||||
|
# The legacy Tagger presets (Hodnocení / Barva) were removed for Curator; the
|
||||||
|
# pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu). Add entries here to
|
||||||
|
# reintroduce always-available predefined tags.
|
||||||
|
DEFAULT_TAGS: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
# Tag sort order for default categories (preserves display order)
|
||||||
|
DEFAULT_TAG_ORDER: dict[str, dict[str, int]] = {
|
||||||
|
category: {name: i for i, name in enumerate(names)}
|
||||||
|
for category, names in DEFAULT_TAGS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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()]
|
||||||
|
After Width: | Height: | Size: 596 B |
|
After Width: | Height: | Size: 892 B |
|
After Width: | Height: | Size: 618 B |
|
After Width: | Height: | Size: 961 B |
|
After Width: | Height: | Size: 710 B |
|
After Width: | Height: | Size: 716 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
# Tests package
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
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"],
|
||||||
|
"tag_schema": DEFAULT_GLOBAL_CONFIG["tag_schema"],
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
"""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,
|
||||||
|
_solve_anubis_pow,
|
||||||
|
_split_countries,
|
||||||
|
rating_band,
|
||||||
|
clean_filename_to_query,
|
||||||
|
find_csfd_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_session(mock_requests):
|
||||||
|
"""Wire ``mock_requests`` so ``requests.Session()`` (also as a context
|
||||||
|
manager) yields a single configurable session mock and return it."""
|
||||||
|
session = MagicMock()
|
||||||
|
session.__enter__.return_value = session
|
||||||
|
mock_requests.Session.return_value = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# 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 = """
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script type="application/ld+json">%s</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="film-rating-average">85%%</div>
|
||||||
|
<div class="genres">
|
||||||
|
<a href="/zanry/1/">Drama</a> /
|
||||||
|
<a href="/zanry/2/">Thriller</a>
|
||||||
|
</div>
|
||||||
|
<div class="origin">Česko, 2020, 120 min</div>
|
||||||
|
<div class="film-poster">
|
||||||
|
<img src="//image.example.com/poster.jpg">
|
||||||
|
</div>
|
||||||
|
<div class="plot-full"><p>Full plot description.</p></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""" % 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,
|
||||||
|
countries=["Č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.countries == ["Česko"]
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_split_countries_single(self):
|
||||||
|
"""A single country yields a one-item list."""
|
||||||
|
assert _split_countries("USA") == ["USA"]
|
||||||
|
|
||||||
|
def test_split_countries_multiple(self):
|
||||||
|
"""Slash-separated co-production countries are split and trimmed."""
|
||||||
|
assert _split_countries("USA / Velká Británie") == ["USA", "Velká Británie"]
|
||||||
|
assert _split_countries("Japonsko/USA") == ["Japonsko", "USA"]
|
||||||
|
|
||||||
|
def test_split_countries_empty(self):
|
||||||
|
"""None/empty yields an empty list."""
|
||||||
|
assert _split_countries(None) == []
|
||||||
|
assert _split_countries("") == []
|
||||||
|
|
||||||
|
def test_from_dict_migrates_legacy_country(self):
|
||||||
|
"""Legacy cache with a single 'country' string maps to countries list."""
|
||||||
|
movie = CSFDMovie.from_dict({"title": "X", "country": "USA / Kanada"})
|
||||||
|
assert movie.countries == ["USA", "Kanada"]
|
||||||
|
|
||||||
|
def test_from_dict_uses_countries_when_present(self):
|
||||||
|
"""New cache with 'countries' is used verbatim."""
|
||||||
|
movie = CSFDMovie.from_dict({"title": "X", "countries": ["Japonsko", "USA"]})
|
||||||
|
assert movie.countries == ["Japonsko", "USA"]
|
||||||
|
|
||||||
|
def test_rating_band_buckets(self):
|
||||||
|
"""Rating is bucketed into ten-point bands, top band spans 90–100 %."""
|
||||||
|
assert rating_band(0) == "0–9 %"
|
||||||
|
assert rating_band(86) == "80–89 %"
|
||||||
|
assert rating_band(90) == "90–100 %"
|
||||||
|
assert rating_band(95) == "90–100 %"
|
||||||
|
assert rating_band(100) == "90–100 %"
|
||||||
|
|
||||||
|
def test_csfd_field_values_are_exact_no_transform(self):
|
||||||
|
from src.core.csfd import csfd_field_values
|
||||||
|
movie = CSFDMovie(title="X", url="u", year=1999, rating=86,
|
||||||
|
genres=["Akční", "Sci-Fi"], countries=["USA", "Kanada"])
|
||||||
|
assert csfd_field_values(movie, "genres") == ["Akční", "Sci-Fi"]
|
||||||
|
assert csfd_field_values(movie, "countries") == ["USA", "Kanada"]
|
||||||
|
assert csfd_field_values(movie, "year") == ["1999"]
|
||||||
|
# rating tag carries the EXACT value (transform happens only for folders)
|
||||||
|
assert csfd_field_values(movie, "rating") == ["86"]
|
||||||
|
# missing field / value → empty
|
||||||
|
assert csfd_field_values(CSFDMovie(title="X", url="u"), "rating") == []
|
||||||
|
assert csfd_field_values(movie, "genres") == csfd_field_values(movie, "genres")
|
||||||
|
|
||||||
|
def test_apply_transform_decade_band(self):
|
||||||
|
from src.core.csfd import apply_transform
|
||||||
|
assert apply_transform("86", "decade_band") == "80–89 %"
|
||||||
|
assert apply_transform("90", "decade_band") == "90–100 %"
|
||||||
|
assert apply_transform("Akční", None) == "Akční" # identity for non-rating
|
||||||
|
assert apply_transform("USA", "identity") == "USA"
|
||||||
|
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["countries"] == ["Č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 = (
|
||||||
|
'<div class="origin">USA <span class="bullet"></span>'
|
||||||
|
'<span>1999 <span class="bullet"></span> </span>'
|
||||||
|
'136 min (Alternativní 131 min)</div>'
|
||||||
|
)
|
||||||
|
info = _extract_origin_info(BeautifulSoup(html, "html.parser"))
|
||||||
|
assert info["countries"] == ["USA"]
|
||||||
|
assert info["year"] == 1999
|
||||||
|
assert info["duration"] == 136
|
||||||
|
|
||||||
|
def test_extract_origin_info_multiple_countries(self):
|
||||||
|
"""A co-production lists several slash-separated countries."""
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
html = (
|
||||||
|
'<div class="origin">USA / Velká Británie '
|
||||||
|
'<span class="bullet"></span><span>2009 </span>'
|
||||||
|
'<span class="bullet"></span> 166 min</div>'
|
||||||
|
)
|
||||||
|
info = _extract_origin_info(BeautifulSoup(html, "html.parser"))
|
||||||
|
assert info["countries"] == ["USA", "Velká Británie"]
|
||||||
|
assert info["year"] == 2009
|
||||||
|
assert info["duration"] == 166
|
||||||
|
|
||||||
|
def test_extract_json_ld_year_from_date_created(self):
|
||||||
|
"""Year is taken from JSON-LD dateCreated when present."""
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
html = (
|
||||||
|
'<script type="application/ld+json">'
|
||||||
|
'{"@type": "Movie", "name": "Matrix", "dateCreated": 1999}'
|
||||||
|
'</script>'
|
||||||
|
)
|
||||||
|
data = _extract_json_ld(BeautifulSoup(html, "html.parser"))
|
||||||
|
assert data["year"] == 1999
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanFilenameToQuery:
|
||||||
|
"""Tests for turning a filename into a ČSFD search query."""
|
||||||
|
|
||||||
|
def test_strips_release_tags_and_keeps_year(self):
|
||||||
|
assert clean_filename_to_query(
|
||||||
|
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv") == "Matrix 1999"
|
||||||
|
|
||||||
|
def test_handles_spaces_and_parens_year(self):
|
||||||
|
assert clean_filename_to_query(
|
||||||
|
"Forrest Gump (1994) 2160p HDR.mkv") == "Forrest Gump 1994"
|
||||||
|
|
||||||
|
def test_no_year_no_markers(self):
|
||||||
|
assert clean_filename_to_query("Amelie.mkv") == "Amelie"
|
||||||
|
|
||||||
|
def test_underscores_and_resolution(self):
|
||||||
|
assert clean_filename_to_query("Sam_doma_720p.mkv") == "Sam doma"
|
||||||
|
|
||||||
|
def test_falls_back_to_stem_when_starting_with_marker(self):
|
||||||
|
# No real title words before the marker → fall back to the cleaned stem
|
||||||
|
assert clean_filename_to_query("1080p.mkv") == "1080p"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindCsfdUrl:
|
||||||
|
"""Tests for find_csfd_url (search is mocked)."""
|
||||||
|
|
||||||
|
def test_returns_first_result_url(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
movies = [
|
||||||
|
CSFDMovie(title="Matrix", url="https://www.csfd.cz/film/9499-matrix/"),
|
||||||
|
CSFDMovie(title="Matrix Reloaded", url="https://www.csfd.cz/film/9497-x/"),
|
||||||
|
]
|
||||||
|
with patch("src.core.csfd.search_movies", return_value=movies):
|
||||||
|
assert find_csfd_url("Matrix 1999") == "https://www.csfd.cz/film/9499-matrix/"
|
||||||
|
|
||||||
|
def test_returns_none_for_empty_query(self):
|
||||||
|
assert find_csfd_url(" ") is None
|
||||||
|
|
||||||
|
def test_returns_none_when_no_results(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch("src.core.csfd.search_movies", return_value=[]):
|
||||||
|
assert find_csfd_url("nonexistent film") is None
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
session = _mock_session(mock_requests)
|
||||||
|
session.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
|
||||||
|
session.get.assert_called_once()
|
||||||
|
|
||||||
|
@patch("src.core.csfd.requests")
|
||||||
|
def test_fetch_movie_caps_actors_at_ten(self, mock_requests):
|
||||||
|
"""Only the first MAX_ACTORS (10) of a long cast are kept."""
|
||||||
|
import json as _json
|
||||||
|
actors = [{"@type": "Person", "name": f"Actor {i}"} for i in range(25)]
|
||||||
|
json_ld = _json.dumps({
|
||||||
|
"@type": "Movie", "name": "Crowded", "actor": actors,
|
||||||
|
"director": [{"@type": "Person", "name": "Dir"}],
|
||||||
|
"aggregateRating": {"ratingValue": 70, "ratingCount": 5},
|
||||||
|
})
|
||||||
|
html = f'<html><head><script type="application/ld+json">{json_ld}</script></head></html>'
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = html
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
session = _mock_session(mock_requests)
|
||||||
|
session.get.return_value = mock_response
|
||||||
|
|
||||||
|
movie = fetch_movie("https://www.csfd.cz/film/1-crowded/")
|
||||||
|
|
||||||
|
assert movie.directors == ["Dir"]
|
||||||
|
assert movie.rating == 70
|
||||||
|
assert len(movie.actors) == 10
|
||||||
|
assert movie.actors[0] == "Actor 0"
|
||||||
|
assert movie.actors[-1] == "Actor 9"
|
||||||
|
|
||||||
|
@patch("src.core.csfd.requests")
|
||||||
|
def test_fetch_movie_network_error(self, mock_requests):
|
||||||
|
"""Test network error handling."""
|
||||||
|
import requests as real_requests
|
||||||
|
session = _mock_session(mock_requests)
|
||||||
|
session.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 = """
|
||||||
|
<html><body>
|
||||||
|
<a href="/film/123-test/" class="film-title-name">Test Movie</a>
|
||||||
|
<a href="/film/456-another/" class="film-title-name">Another Movie</a>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = search_html
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
session = _mock_session(mock_requests)
|
||||||
|
session.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 TestAnubisPoW:
|
||||||
|
"""Tests for the Anubis proof-of-work solver."""
|
||||||
|
|
||||||
|
def test_solve_pow_difficulty_one(self):
|
||||||
|
"""Difficulty 1 requires a single leading zero nibble in the hash."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
random_data = "abc123"
|
||||||
|
hash_hex, nonce, _ = _solve_anubis_pow(random_data, difficulty=1)
|
||||||
|
assert hash_hex[0] == "0"
|
||||||
|
assert hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest() == hash_hex
|
||||||
|
|
||||||
|
def test_solve_pow_difficulty_two(self):
|
||||||
|
"""Difficulty 2 requires two leading zero nibbles (one zero byte)."""
|
||||||
|
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
|
||||||
|
assert hash_hex[:2] == "00"
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
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 nemá žádné automatické tagy (Stav/Nové odstraněn)"""
|
||||||
|
file_obj = File(test_file, tag_manager)
|
||||||
|
assert file_obj.tags == []
|
||||||
|
|
||||||
|
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) == 1 # 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
|
||||||
|
|
||||||
|
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) == 1 # 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 # nový soubor nemá žádné automatické tagy
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyCsfdTags:
|
||||||
|
"""Tests for File.apply_csfd_tags tag assignment (CSFD fetch is mocked)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tag_manager(self):
|
||||||
|
return TagManager()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def movie_file(self, tmp_path, tag_manager):
|
||||||
|
path = tmp_path / "Matrix.mkv"
|
||||||
|
path.write_text("x")
|
||||||
|
f = File(path, tag_manager)
|
||||||
|
f.set_csfd_link("https://www.csfd.cz/film/9499-matrix/")
|
||||||
|
return f
|
||||||
|
|
||||||
|
def test_apply_csfd_tags_assigns_expected_categories(self, movie_file):
|
||||||
|
from unittest.mock import patch
|
||||||
|
from src.core.csfd import CSFDMovie
|
||||||
|
|
||||||
|
movie = CSFDMovie(
|
||||||
|
title="Matrix", url="u", year=1999, genres=["Akční", "Sci-Fi"],
|
||||||
|
directors=["Lana Wachowski", "Lilly Wachowski"],
|
||||||
|
actors=["Keanu Reeves", "Laurence Fishburne"],
|
||||||
|
rating=90, countries=["USA"],
|
||||||
|
)
|
||||||
|
with patch("src.core.csfd.fetch_movie", return_value=movie):
|
||||||
|
result = movie_file.apply_csfd_tags()
|
||||||
|
|
||||||
|
assert result["success"]
|
||||||
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
|
assert "Žánr/Akční" in paths
|
||||||
|
assert "Žánr/Sci-Fi" in paths
|
||||||
|
assert "Rok/1999" in paths
|
||||||
|
assert "Země původu/USA" in paths
|
||||||
|
assert "Hodnocení/90" in paths # exact value; folder grouping happens later
|
||||||
|
|
||||||
|
def test_apply_csfd_tags_does_not_tag_directors_or_actors(self, movie_file):
|
||||||
|
"""Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc)."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
from src.core.csfd import CSFDMovie
|
||||||
|
|
||||||
|
movie = CSFDMovie(
|
||||||
|
title="Matrix", url="u", directors=["Lana Wachowski"],
|
||||||
|
actors=["Keanu Reeves", "Laurence Fishburne"], genres=["Drama"],
|
||||||
|
)
|
||||||
|
with patch("src.core.csfd.fetch_movie", return_value=movie):
|
||||||
|
movie_file.apply_csfd_tags()
|
||||||
|
|
||||||
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
|
assert not any(p.startswith("Režie/") for p in paths)
|
||||||
|
assert not any(p.startswith("Herec/") for p in paths)
|
||||||
|
# …but the data is kept in the cache
|
||||||
|
cached = movie_file.get_cached_movie()
|
||||||
|
assert cached.directors == ["Lana Wachowski"]
|
||||||
|
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
|
||||||
|
|
||||||
|
def test_name_context_fields(self, movie_file):
|
||||||
|
movie_file.title = "Dr. No"
|
||||||
|
movie_file.csfd_cache = {"year": 1962, "rating": 75}
|
||||||
|
ctx = movie_file.name_context()
|
||||||
|
assert ctx["title"] == "Dr. No"
|
||||||
|
assert ctx["year"] == 1962
|
||||||
|
assert ctx["rating"] == 75
|
||||||
|
assert ctx["ext"] == movie_file.file_path.suffix
|
||||||
|
assert ctx["filename"] == movie_file.filename
|
||||||
|
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
|
||||||
|
|
||||||
|
def test_name_context_year_from_tag_when_no_cache(self, movie_file):
|
||||||
|
movie_file.csfd_cache = None
|
||||||
|
movie_file.add_tag("Rok/1999")
|
||||||
|
assert movie_file.name_context()["year"] == "1999"
|
||||||
|
|
||||||
|
def test_set_attribute_persists_and_in_context(self, movie_file):
|
||||||
|
movie_file.set_attribute("collection_sort", "03")
|
||||||
|
assert movie_file.attributes["collection_sort"] == "03"
|
||||||
|
assert movie_file.name_context()["collection_sort"] == "03"
|
||||||
|
# reload (from index) keeps it
|
||||||
|
reloaded = File(movie_file.file_path, movie_file.tagmanager,
|
||||||
|
index=movie_file.index)
|
||||||
|
assert reloaded.attributes["collection_sort"] == "03"
|
||||||
|
|
||||||
|
def test_set_attribute_empty_removes_it(self, movie_file):
|
||||||
|
movie_file.set_attribute("collection_sort", "03")
|
||||||
|
movie_file.set_attribute("collection_sort", None)
|
||||||
|
assert "collection_sort" not in movie_file.attributes
|
||||||
|
|
||||||
|
def test_attribute_usable_in_filename_template(self, movie_file):
|
||||||
|
movie_file.title = "Dr. No"
|
||||||
|
movie_file.set_attribute("collection_sort", "01")
|
||||||
|
ctx = movie_file.name_context()
|
||||||
|
assert "{collection_sort} - {title}{ext}".format(**ctx) == \
|
||||||
|
f"01 - Dr. No{ctx['ext']}"
|
||||||
|
|
||||||
|
def test_apply_csfd_tags_honors_custom_schema(self, movie_file):
|
||||||
|
"""A schema without the rating entry produces no Hodnocení tags."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
from src.core.csfd import CSFDMovie
|
||||||
|
|
||||||
|
schema = [{"category": "Žánr", "csfd_field": "genres",
|
||||||
|
"transform": None, "filmoteka_root": ""}]
|
||||||
|
movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"])
|
||||||
|
with patch("src.core.csfd.fetch_movie", return_value=movie):
|
||||||
|
movie_file.apply_csfd_tags(schema)
|
||||||
|
|
||||||
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
|
assert "Žánr/Drama" in paths
|
||||||
|
assert not any(p.startswith("Hodnocení/") for p in paths)
|
||||||
|
|
||||||
|
def test_apply_csfd_tags_preserves_user_tags_on_refetch(self, movie_file):
|
||||||
|
"""Re-fetching regenerates only ČSFD tags; user-added tags survive."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
from src.core.csfd import CSFDMovie
|
||||||
|
|
||||||
|
movie_file.add_tag("Sbírka/Oblíbené") # user tag
|
||||||
|
first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"])
|
||||||
|
with patch("src.core.csfd.fetch_movie", return_value=first):
|
||||||
|
movie_file.apply_csfd_tags()
|
||||||
|
# different movie on re-fetch
|
||||||
|
second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"])
|
||||||
|
with patch("src.core.csfd.fetch_movie", return_value=second):
|
||||||
|
movie_file.apply_csfd_tags()
|
||||||
|
|
||||||
|
paths = {t.full_path for t in movie_file.tags}
|
||||||
|
assert "Sbírka/Oblíbené" in paths # user tag kept
|
||||||
|
assert "Žánr/Drama" in paths # new ČSFD tag
|
||||||
|
assert "Rok/2009" in paths
|
||||||
|
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
|
||||||
|
assert "Rok/1999" not in paths
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
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_import_movie_move_removes_source(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", move=True)
|
||||||
|
|
||||||
|
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
|
||||||
|
assert movie.file_path.exists()
|
||||||
|
assert not source.exists() # moved, not copied
|
||||||
|
|
||||||
|
def test_filmoteka_category_roots_from_schema(self, file_manager):
|
||||||
|
file_manager.set_tag_schema([
|
||||||
|
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
|
||||||
|
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"},
|
||||||
|
{"category": "Herec", "csfd_field": "actors", "transform": None, "filmoteka_root": None},
|
||||||
|
])
|
||||||
|
roots = file_manager.filmoteka_category_roots()
|
||||||
|
assert roots == {"Žánr": "", "Rok": "Dle roku"} # None-root excluded
|
||||||
|
assert "Herec" not in roots
|
||||||
|
|
||||||
|
def test_import_movie_suffix_keeps_both(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"b")
|
||||||
|
first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
|
second = file_manager.import_movie(tmp_path / "b.mkv", "Matrix") # default suffix
|
||||||
|
|
||||||
|
assert first.file_path.name == "Matrix.mkv"
|
||||||
|
assert second.file_path.name == "Matrix_1.mkv"
|
||||||
|
assert len(file_manager.filelist) == 2
|
||||||
|
|
||||||
|
def test_import_movie_replace_evicts_existing(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"bb")
|
||||||
|
first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
|
first.add_tag("Žánr/Akční")
|
||||||
|
old_path = first.file_path
|
||||||
|
|
||||||
|
second = file_manager.import_movie(
|
||||||
|
tmp_path / "b.mkv", "Matrix", csfd_link="x", on_conflict="replace")
|
||||||
|
|
||||||
|
assert second.file_path.name == "Matrix.mkv"
|
||||||
|
assert second.file_path == old_path # same name reused
|
||||||
|
assert second.file_path.read_bytes() == b"bb" # new content in place
|
||||||
|
assert [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"]
|
||||||
|
# index record reflects the new import (fresh metadata, old tags dropped)
|
||||||
|
record = file_manager.index.get(second.file_path)
|
||||||
|
assert record is not None
|
||||||
|
assert record["csfd_link"] == "x"
|
||||||
|
assert record["tags"] == []
|
||||||
|
assert second.tags == []
|
||||||
|
|
||||||
|
def test_import_movie_replace_across_extensions(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mp4").write_bytes(b"b")
|
||||||
|
file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
|
file_manager.import_movie(tmp_path / "b.mp4", "Matrix", on_conflict="replace")
|
||||||
|
|
||||||
|
names = [f.file_path.name for f in file_manager.filelist]
|
||||||
|
assert names == ["Matrix.mp4"]
|
||||||
|
assert not (tmp_path / "pool" / "Filmy" / "Matrix.mkv").exists()
|
||||||
|
|
||||||
|
def test_import_movie_skip_returns_none(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"b")
|
||||||
|
file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
|
||||||
|
result = file_manager.import_movie(tmp_path / "b.mkv", "Matrix", on_conflict="skip")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert len(file_manager.filelist) == 1
|
||||||
|
|
||||||
|
def test_rename_movie_renames_file_and_reindexes(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")
|
||||||
|
movie.add_tag("Žánr/Sci-Fi")
|
||||||
|
old_path = movie.file_path
|
||||||
|
|
||||||
|
file_manager.rename_movie(movie, "Matrix Reloaded")
|
||||||
|
|
||||||
|
new_path = tmp_path / "pool" / "Filmy" / "Matrix Reloaded.mkv"
|
||||||
|
assert movie.file_path == new_path
|
||||||
|
assert new_path.exists()
|
||||||
|
assert not old_path.exists()
|
||||||
|
assert movie.title == "Matrix Reloaded"
|
||||||
|
# metadata moved to the new key, old key gone, tags preserved
|
||||||
|
assert file_manager.index.get(new_path) is not None
|
||||||
|
assert file_manager.index.get(old_path) is None
|
||||||
|
# a fresh manager reading the index sees the renamed file with its tags
|
||||||
|
reloaded = FileManager(TagManager())
|
||||||
|
reloaded.set_pool_dir(tmp_path / "pool")
|
||||||
|
reloaded.load_pool_movies()
|
||||||
|
assert [f.filename for f in reloaded.filelist] == ["Matrix Reloaded.mkv"]
|
||||||
|
assert "Žánr/Sci-Fi" in {t.full_path for t in reloaded.filelist[0].tags}
|
||||||
|
|
||||||
|
def test_rename_movie_preserves_extension(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
source = tmp_path / "raw.mp4"
|
||||||
|
source.write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(source, "Film")
|
||||||
|
|
||||||
|
file_manager.rename_movie(movie, "Jiný název")
|
||||||
|
|
||||||
|
assert movie.file_path.name == "Jiný název.mp4"
|
||||||
|
|
||||||
|
def test_rename_movie_rejects_existing_name(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"b")
|
||||||
|
first = file_manager.import_movie(tmp_path / "a.mkv", "Already")
|
||||||
|
second = file_manager.import_movie(tmp_path / "b.mkv", "Other")
|
||||||
|
|
||||||
|
with pytest.raises(FileExistsError):
|
||||||
|
file_manager.rename_movie(second, "Already")
|
||||||
|
# second movie is left untouched
|
||||||
|
assert second.file_path.name == "Other.mkv"
|
||||||
|
assert first.file_path.exists()
|
||||||
|
|
||||||
|
def test_rename_movie_rejects_empty_name(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Name")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
file_manager.rename_movie(movie, " ")
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
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() # ensure a clean tag set
|
||||||
|
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() # ensure a clean tag set
|
||||||
|
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() # ensure a clean tag set
|
||||||
|
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_create_structure_with_category_roots(self, files_with_tags, temp_output_dir):
|
||||||
|
"""category_roots: genres sit at the output root, rok under 'Dle roku'."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
roots = {"žánr": "", "rok": "Dle roku"}
|
||||||
|
manager.create_structure_for_files(files_with_tags, category_roots=roots)
|
||||||
|
|
||||||
|
# Genres directly at the output root (no "žánr" wrapper folder)
|
||||||
|
assert (temp_output_dir / "Komedie" / "file1.txt").exists()
|
||||||
|
assert (temp_output_dir / "Akční" / "file1.txt").exists()
|
||||||
|
assert (temp_output_dir / "Drama" / "file2.txt").exists()
|
||||||
|
assert not (temp_output_dir / "žánr").exists()
|
||||||
|
|
||||||
|
# Rok grouped under its own "Dle roku" folder
|
||||||
|
assert (temp_output_dir / "Dle roku" / "1988" / "file1.txt").exists()
|
||||||
|
|
||||||
|
def test_sync_with_roots_leaves_unmanaged_mirror_untouched(
|
||||||
|
self, files_with_tags, temp_source_dir, temp_output_dir
|
||||||
|
):
|
||||||
|
"""Cleanup must not delete links in a copy-as-is mirror (e.g. Seriály)."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
roots = {"žánr": "", "rok": "Dle roku"}
|
||||||
|
manager.create_structure_for_files(files_with_tags, category_roots=roots)
|
||||||
|
|
||||||
|
# Simulate a copy-as-is mirror holding a hardlink to a source file
|
||||||
|
mirror = temp_output_dir / "Seriály"
|
||||||
|
mirror.mkdir()
|
||||||
|
mirror_link = mirror / "file1.txt"
|
||||||
|
os.link(temp_source_dir / "file1.txt", mirror_link)
|
||||||
|
|
||||||
|
manager.sync_structure(files_with_tags, category_roots=roots)
|
||||||
|
|
||||||
|
# The mirror (not a managed tag folder) is left alone
|
||||||
|
assert mirror_link.exists()
|
||||||
|
|
||||||
|
def test_category_transform_groups_folder_by_band(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""Exact tag value, but the folder name goes through the transform."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("Hodnocení", "90")) # exact rating tag
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.create_structure_for_files(
|
||||||
|
[f],
|
||||||
|
category_roots={"Hodnocení": "Dle hodnocení"},
|
||||||
|
category_transforms={"Hodnocení": "decade_band"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# folder is the band; the file lands inside it
|
||||||
|
assert (temp_output_dir / "Dle hodnocení" / "90–100 %" / "file1.txt").exists()
|
||||||
|
# not a per-exact-value folder
|
||||||
|
assert not (temp_output_dir / "Dle hodnocení" / "90").exists()
|
||||||
|
|
||||||
|
def test_filename_template_applies_only_in_that_category(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""A per-category template renames the hardlink only inside its folder."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Dr. No"
|
||||||
|
f.csfd_cache = {"year": 1962}
|
||||||
|
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||||
|
f.add_tag(Tag("žánr", "Akční"))
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.create_structure_for_files(
|
||||||
|
[f],
|
||||||
|
category_roots={"Kolekce": "Dle kolekce", "žánr": ""},
|
||||||
|
category_filename_templates={"Kolekce": "{year} - {title}{ext}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Templated name inside the collection folder
|
||||||
|
assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists()
|
||||||
|
# Other categories keep the pool filename
|
||||||
|
assert (temp_output_dir / "Akční" / "file1.txt").exists()
|
||||||
|
|
||||||
|
def test_filename_template_cleanup_is_consistent(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""sync twice with a template leaves no stale/duplicate templated link."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.title = "Dr. No"
|
||||||
|
f.csfd_cache = {"year": 1962}
|
||||||
|
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||||
|
roots = {"Kolekce": "Dle kolekce"}
|
||||||
|
templates = {"Kolekce": "{year} - {title}{ext}"}
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.sync_structure([f], category_roots=roots, category_filename_templates=templates)
|
||||||
|
created, _, removed, _ = manager.sync_structure(
|
||||||
|
[f], category_roots=roots, category_filename_templates=templates)
|
||||||
|
|
||||||
|
folder = temp_output_dir / "Dle kolekce" / "James Bond"
|
||||||
|
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
|
||||||
|
assert removed == 0 # nothing treated as obsolete on the second run
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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)
|
||||||
@@ -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 == [] # no automatic tags
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
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 (alias k tag_manager, žádné default tagy)"""
|
||||||
|
return TagManager()
|
||||||
|
|
||||||
|
def test_tag_manager_creation_has_no_defaults(self, tag_manager):
|
||||||
|
"""Test že nový TagManager nemá žádné předdefinované tagy"""
|
||||||
|
assert tag_manager.tags_by_category == {}
|
||||||
|
|
||||||
|
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_empty_on_fresh_manager(self, tag_manager):
|
||||||
|
"""Test že čerstvý TagManager nemá žádné tagy (bez defaultů)"""
|
||||||
|
assert tag_manager.get_all_tags() == []
|
||||||
|
|
||||||
|
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_empty_on_fresh_manager(self, tag_manager):
|
||||||
|
"""Test že čerstvý TagManager nemá žádné kategorie (bez defaultů)"""
|
||||||
|
assert tag_manager.get_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 (legacy Tagger presety byly odstraněny)"""
|
||||||
|
|
||||||
|
def test_default_tags_constant_exists(self):
|
||||||
|
"""Test že DEFAULT_TAGS konstanta existuje a je prázdná"""
|
||||||
|
assert isinstance(DEFAULT_TAGS, dict)
|
||||||
|
assert DEFAULT_TAGS == {}
|
||||||
|
|
||||||
|
def test_legacy_presets_removed(self):
|
||||||
|
"""Test že staré předdefinované kategorie (Hodnocení, Barva) jsou pryč"""
|
||||||
|
assert "Hodnocení" not in DEFAULT_TAGS
|
||||||
|
assert "Barva" not in DEFAULT_TAGS
|
||||||
|
|
||||||
|
def test_tag_manager_starts_empty(self):
|
||||||
|
"""Test že TagManager bez defaultů startuje prázdný"""
|
||||||
|
tm = TagManager()
|
||||||
|
assert tm.get_all_tags() == []
|
||||||
|
assert tm.get_categories() == []
|
||||||
|
|
||||||
|
def test_can_add_custom_tags(self):
|
||||||
|
"""Test že lze přidat vlastní tagy do prázdného manageru"""
|
||||||
|
tm = TagManager()
|
||||||
|
|
||||||
|
tm.add_tag("Custom", "MyTag")
|
||||||
|
|
||||||
|
assert tm.get_all_tags() == ["Custom/MyTag"]
|
||||||
|
assert "Custom" in tm.get_categories()
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -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)
|
||||||