CSFD integration
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
> **DŮLEŽITÉ:** Tento soubor obsahuje VŠE co potřebuji vědět o projektu.
|
||||
> Pokud pracuji na Tagger, VŽDY nejdříve přečtu tento soubor!
|
||||
|
||||
**Poslední aktualizace:** 2025-12-28
|
||||
**Poslední aktualizace:** 2025-12-29
|
||||
**Verze:** 1.0.4
|
||||
**Status:** Stable, v aktivním vývoji
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
- Rekurzivní procházení složek
|
||||
- Hierarchické tagy (kategorie/název)
|
||||
- Filtrování podle tagů
|
||||
- Přejmenování tagů a kategorií (včetně aktualizace všech souborů)
|
||||
- Metadata uložená v JSON souborech
|
||||
- Automatická detekce rozlišení videí (ffprobe)
|
||||
- Moderní GUI (qBittorrent-style)
|
||||
- Hardlink struktura - vytváření adresářové struktury pomocí hardlinků podle tagů
|
||||
- Tříúrovňový konfigurační systém (globální, složkový, souborový)
|
||||
- CSFD.cz integrace - získávání informací o filmech z české filmové databáze
|
||||
|
||||
---
|
||||
|
||||
@@ -38,31 +40,33 @@ Tagger/
|
||||
│
|
||||
├── src/
|
||||
│ ├── core/ # Jádro aplikace (ŽÁDNÉ UI!)
|
||||
│ │ ├── tag.py # Tag value object (immutable)
|
||||
│ │ ├── tag.py # Tag value object (immutable, from_string parser)
|
||||
│ │ ├── tag_manager.py # Správa tagů a kategorií
|
||||
│ │ ├── file.py # File s metadaty
|
||||
│ │ ├── file_manager.py # Správa souborů, filtrování
|
||||
│ │ ├── config.py # Tříúrovňová konfigurace (global, folder, file)
|
||||
│ │ ├── hardlink_manager.py # Správa hardlink struktury
|
||||
│ │ ├── utils.py # list_files() - rekurzivní procházení
|
||||
│ │ ├── media_utils.py # load_icon(), ffprobe
|
||||
│ │ ├── constants.py # APP_NAME, VERSION, APP_VIEWPORT
|
||||
│ │ └── list_manager.py # Třídění (málo používaný)
|
||||
│ │ ├── media_utils.py # add_video_resolution_tag (ffprobe)
|
||||
│ │ ├── csfd.py # CSFD.cz scraper (fetch_movie, search_movies)
|
||||
│ │ └── constants.py # APP_NAME, VERSION, APP_VIEWPORT
|
||||
│ │
|
||||
│ └── ui/
|
||||
│ └── gui.py # Moderní qBittorrent-style GUI
|
||||
│ ├── gui.py # Moderní qBittorrent-style GUI
|
||||
│ └── utils.py # load_icon() - GUI utility pro ikony
|
||||
│
|
||||
├── tests/ # 189 testů, 100% core coverage
|
||||
├── tests/ # 274 testů, 100% core coverage
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Pytest fixtures
|
||||
│ ├── test_tag.py # 13 testů
|
||||
│ ├── test_tag_manager.py # 31 testů
|
||||
│ ├── test_file.py # 22 testů
|
||||
│ ├── test_file_manager.py # 40 testů
|
||||
│ ├── test_config.py # 33 testů
|
||||
│ ├── test_tag.py # 19 testů (včetně Tag.from_string)
|
||||
│ ├── test_tag_manager.py # 55 testů (včetně rename/merge tagů/kategorií)
|
||||
│ ├── test_file.py # 33 testů (včetně CSFD integrace)
|
||||
│ ├── test_file_manager.py # 78 testů (close_folder, rename/merge v souborech)
|
||||
│ ├── test_config.py # 31 testů
|
||||
│ ├── test_hardlink_manager.py # 28 testů
|
||||
│ ├── test_utils.py # 17 testů
|
||||
│ └── test_media_utils.py # 3 testy
|
||||
│ ├── test_media_utils.py # 3 testy (load_icon v src/ui/utils.py)
|
||||
│ └── test_csfd.py # 19 testů
|
||||
│
|
||||
├── src/resources/
|
||||
│ └── images/32/ # Ikony (32x32 PNG)
|
||||
@@ -232,7 +236,44 @@ class FileManager:
|
||||
self.folder_config = {}
|
||||
```
|
||||
|
||||
### 5. HardlinkManager (hardlink struktura)
|
||||
### 5. CSFD Scraper (filmové informace)
|
||||
|
||||
```python
|
||||
from src.core.csfd import fetch_movie, search_movies, CSFDMovie
|
||||
|
||||
# Načtení informací o filmu z URL
|
||||
movie = fetch_movie("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/")
|
||||
print(movie.title) # „Pane, vy jste vdova!"
|
||||
print(movie.year) # 1970
|
||||
print(movie.rating) # 82
|
||||
print(movie.genres) # ['Komedie', 'Sci-Fi']
|
||||
print(movie.directors) # ['Václav Vorlíček']
|
||||
print(movie.actors) # ['Iva Janžurová', ...]
|
||||
|
||||
# Vyhledávání filmů
|
||||
results = search_movies("Pelíšky")
|
||||
for m in results:
|
||||
print(m.title, m.csfd_id)
|
||||
```
|
||||
|
||||
**CSFDMovie atributy:**
|
||||
- `title` - název filmu
|
||||
- `url` - CSFD URL
|
||||
- `year` - rok vydání
|
||||
- `genres` - seznam žánrů
|
||||
- `directors` - seznam režisérů
|
||||
- `actors` - seznam herců
|
||||
- `rating` - hodnocení v %
|
||||
- `rating_count` - počet hodnocení
|
||||
- `duration` - délka v minutách
|
||||
- `country` - země původu
|
||||
- `poster_url` - URL plakátu
|
||||
- `plot` - popis děje
|
||||
- `csfd_id` - ID filmu na CSFD
|
||||
|
||||
**Závislosti:** `requests`, `beautifulsoup4` (instalace: `poetry add requests beautifulsoup4`)
|
||||
|
||||
### 6. HardlinkManager (hardlink struktura)
|
||||
|
||||
```python
|
||||
class HardlinkManager:
|
||||
@@ -423,10 +464,10 @@ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||
|
||||
## Metriky projektu
|
||||
|
||||
**Testy:** 189 (všechny ✅)
|
||||
**Testy:** 274 (všechny ✅)
|
||||
**Test coverage:** 100% core modulů
|
||||
**Python verze:** 3.12+
|
||||
**Dependencies:** Pillow (PIL)
|
||||
**Dependencies:** Pillow (PIL), requests, beautifulsoup4
|
||||
**Vývojové prostředí:** Poetry
|
||||
|
||||
**Performance:**
|
||||
@@ -440,7 +481,13 @@ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
||||
|
||||
### Časté problémy
|
||||
|
||||
**1. "Cannot import ImageTk"**
|
||||
**1. TreeView tagy se nezobrazují správně po načtení z CSFD**
|
||||
```
|
||||
# Opraveno: přidán update_idletasks() po refresh_sidebar()
|
||||
# Pokud stále přetrvává, zkuste F5 nebo znovu otevřít složku
|
||||
```
|
||||
|
||||
**2. "Cannot import ImageTk"**
|
||||
```bash
|
||||
# Řešení: Použij poetry environment
|
||||
poetry run python Tagger.py
|
||||
@@ -511,5 +558,5 @@ poetry run python Tagger.py
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** 2025-12-28
|
||||
**Last updated:** 2025-12-29
|
||||
**Maintainer:** Claude Opus 4.5 + honza
|
||||
|
||||
251
poetry.lock
generated
251
poetry.lock
generated
@@ -1,4 +1,162 @@
|
||||
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.14.3"
|
||||
description = "Screen-scraping library"
|
||||
optional = false
|
||||
python-versions = ">=3.7.0"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb"},
|
||||
{file = "beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86"},
|
||||
]
|
||||
|
||||
[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 = "2025.11.12"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
|
||||
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
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.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"},
|
||||
{file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
|
||||
{file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
@@ -6,17 +164,35 @@ 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 = ["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.11"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
|
||||
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
all = ["flake8 (>=7.1.1)", "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"},
|
||||
@@ -28,6 +204,7 @@ version = "25.0"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
||||
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||
@@ -39,6 +216,7 @@ version = "12.0.0"
|
||||
description = "Python Imaging Library (fork)"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b"},
|
||||
{file = "pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1"},
|
||||
@@ -147,6 +325,7 @@ 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"},
|
||||
@@ -162,6 +341,7 @@ version = "2.19.2"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||
@@ -176,6 +356,7 @@ version = "9.0.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
|
||||
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
|
||||
@@ -191,7 +372,71 @@ pygments = ">=2.7.2"
|
||||
[package.extras]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
||||
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
charset_normalizer = ">=2,<4"
|
||||
idna = ">=2.5,<4"
|
||||
urllib3 = ">=1.21.1,<3"
|
||||
|
||||
[package.extras]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.1"
|
||||
description = "A modern CSS selector implementation for Beautiful Soup."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434"},
|
||||
{file = "soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350"},
|
||||
]
|
||||
|
||||
[[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"]
|
||||
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.6.2"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
|
||||
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
|
||||
]
|
||||
|
||||
[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\""]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "d9b2c3a8467631e5de03f3a79ad641da445743ec08afb777b0fa7eef1b046045"
|
||||
content-hash = "f6001ed675bdcc993bafb4f3170aa7bfc8aa86d75d370acf0063329fccfa7dd9"
|
||||
|
||||
@@ -10,6 +10,8 @@ package-mode = false
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
pillow = "^12.0.0"
|
||||
requests = "^2.32.5"
|
||||
beautifulsoup4 = "^4.14.3"
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
@@ -96,17 +96,3 @@ def save_folder_config(folder: Path, cfg: dict):
|
||||
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)
|
||||
|
||||
375
src/core/csfd.py
Normal file
375
src/core/csfd.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
CSFD.cz scraper module for fetching movie information.
|
||||
|
||||
This module provides functionality to fetch movie data from CSFD.cz (Czech-Slovak Film Database).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from urllib.parse import urljoin
|
||||
|
||||
try:
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
HAS_DEPENDENCIES = True
|
||||
except ImportError:
|
||||
HAS_DEPENDENCIES = False
|
||||
requests = None # type: ignore
|
||||
BeautifulSoup = None # type: ignore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
CSFD_BASE_URL = "https://www.csfd.cz"
|
||||
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
|
||||
|
||||
# User agent to avoid being blocked
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept-Language": "cs,en;q=0.9",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSFDMovie:
|
||||
"""Represents movie data from CSFD.cz"""
|
||||
title: str
|
||||
url: str
|
||||
year: Optional[int] = None
|
||||
genres: list[str] = field(default_factory=list)
|
||||
directors: list[str] = field(default_factory=list)
|
||||
actors: list[str] = field(default_factory=list)
|
||||
rating: Optional[int] = None # Percentage 0-100
|
||||
rating_count: Optional[int] = None
|
||||
duration: Optional[int] = None # Minutes
|
||||
country: Optional[str] = None
|
||||
poster_url: Optional[str] = None
|
||||
plot: Optional[str] = None
|
||||
csfd_id: Optional[int] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
parts = [self.title]
|
||||
if self.year:
|
||||
parts[0] += f" ({self.year})"
|
||||
if self.rating is not None:
|
||||
parts.append(f"Hodnocení: {self.rating}%")
|
||||
if self.genres:
|
||||
parts.append(f"Žánr: {', '.join(self.genres)}")
|
||||
if self.directors:
|
||||
parts.append(f"Režie: {', '.join(self.directors)}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _check_dependencies():
|
||||
"""Check if required dependencies are installed."""
|
||||
if not HAS_DEPENDENCIES:
|
||||
raise ImportError(
|
||||
"CSFD module requires 'requests' and 'beautifulsoup4' packages. "
|
||||
"Install them with: pip install requests beautifulsoup4"
|
||||
)
|
||||
|
||||
|
||||
def _extract_csfd_id(url: str) -> Optional[int]:
|
||||
"""Extract CSFD movie ID from URL."""
|
||||
match = re.search(r"/film/(\d+)", url)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _parse_duration(duration_str: str) -> Optional[int]:
|
||||
"""Parse ISO 8601 duration (PT97M) to minutes."""
|
||||
match = re.search(r"PT(\d+)M", duration_str)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def fetch_movie(url: str) -> CSFDMovie:
|
||||
"""
|
||||
Fetch movie information from CSFD.cz URL.
|
||||
|
||||
Args:
|
||||
url: Full CSFD.cz movie URL (e.g., https://www.csfd.cz/film/9423-pane-vy-jste-vdova/)
|
||||
|
||||
Returns:
|
||||
CSFDMovie object with extracted data
|
||||
|
||||
Raises:
|
||||
ImportError: If required dependencies are not installed
|
||||
requests.RequestException: If network request fails
|
||||
ValueError: If URL is invalid or page cannot be parsed
|
||||
"""
|
||||
_check_dependencies()
|
||||
|
||||
response = requests.get(url, headers=HEADERS, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
# Try to extract JSON-LD structured data first (most reliable)
|
||||
movie_data = _extract_json_ld(soup)
|
||||
|
||||
# Extract additional data from HTML
|
||||
movie_data["url"] = url
|
||||
movie_data["csfd_id"] = _extract_csfd_id(url)
|
||||
|
||||
# Get rating from HTML if not in JSON-LD
|
||||
if movie_data.get("rating") is None:
|
||||
movie_data["rating"] = _extract_rating(soup)
|
||||
|
||||
# Get poster URL
|
||||
if movie_data.get("poster_url") is None:
|
||||
movie_data["poster_url"] = _extract_poster(soup)
|
||||
|
||||
# Get plot summary
|
||||
if movie_data.get("plot") is None:
|
||||
movie_data["plot"] = _extract_plot(soup)
|
||||
|
||||
# Get country and year from origin info
|
||||
origin_info = _extract_origin_info(soup)
|
||||
if origin_info:
|
||||
if movie_data.get("country") is None:
|
||||
movie_data["country"] = origin_info.get("country")
|
||||
if movie_data.get("year") is None:
|
||||
movie_data["year"] = origin_info.get("year")
|
||||
if movie_data.get("duration") is None:
|
||||
movie_data["duration"] = origin_info.get("duration")
|
||||
|
||||
# Get genres from HTML if not in JSON-LD
|
||||
if not movie_data.get("genres"):
|
||||
movie_data["genres"] = _extract_genres(soup)
|
||||
|
||||
return CSFDMovie(**movie_data)
|
||||
|
||||
|
||||
def _extract_json_ld(soup: BeautifulSoup) -> dict:
|
||||
"""Extract movie data from JSON-LD structured data."""
|
||||
data = {
|
||||
"title": "",
|
||||
"year": None,
|
||||
"genres": [],
|
||||
"directors": [],
|
||||
"actors": [],
|
||||
"rating": None,
|
||||
"rating_count": None,
|
||||
"duration": None,
|
||||
"country": None,
|
||||
"poster_url": None,
|
||||
"plot": None,
|
||||
}
|
||||
|
||||
# Find JSON-LD script
|
||||
script_tags = soup.find_all("script", type="application/ld+json")
|
||||
for script in script_tags:
|
||||
try:
|
||||
json_data = json.loads(script.string)
|
||||
|
||||
# Handle both single object and array
|
||||
if isinstance(json_data, list):
|
||||
for item in json_data:
|
||||
if item.get("@type") == "Movie":
|
||||
json_data = item
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
if json_data.get("@type") != "Movie":
|
||||
continue
|
||||
|
||||
# Title
|
||||
data["title"] = json_data.get("name", "")
|
||||
|
||||
# Genres
|
||||
genre = json_data.get("genre", [])
|
||||
if isinstance(genre, str):
|
||||
data["genres"] = [genre]
|
||||
else:
|
||||
data["genres"] = list(genre)
|
||||
|
||||
# Directors
|
||||
directors = json_data.get("director", [])
|
||||
if isinstance(directors, dict):
|
||||
directors = [directors]
|
||||
data["directors"] = [d.get("name", "") for d in directors if d.get("name")]
|
||||
|
||||
# Actors
|
||||
actors = json_data.get("actor", [])
|
||||
if isinstance(actors, dict):
|
||||
actors = [actors]
|
||||
data["actors"] = [a.get("name", "") for a in actors if a.get("name")]
|
||||
|
||||
# Rating
|
||||
agg_rating = json_data.get("aggregateRating", {})
|
||||
if agg_rating:
|
||||
rating_value = agg_rating.get("ratingValue")
|
||||
if rating_value is not None:
|
||||
data["rating"] = round(float(rating_value))
|
||||
data["rating_count"] = agg_rating.get("ratingCount")
|
||||
|
||||
# Duration
|
||||
duration_str = json_data.get("duration", "")
|
||||
if duration_str:
|
||||
data["duration"] = _parse_duration(duration_str)
|
||||
|
||||
# Poster
|
||||
image = json_data.get("image")
|
||||
if image:
|
||||
if isinstance(image, str):
|
||||
data["poster_url"] = image
|
||||
elif isinstance(image, dict):
|
||||
data["poster_url"] = image.get("url")
|
||||
|
||||
# Description
|
||||
data["plot"] = json_data.get("description")
|
||||
|
||||
break # Found movie data
|
||||
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
continue
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _extract_rating(soup: BeautifulSoup) -> Optional[int]:
|
||||
"""Extract rating percentage from HTML."""
|
||||
# Look for rating box
|
||||
rating_elem = soup.select_one(".film-rating-average")
|
||||
if rating_elem:
|
||||
text = rating_elem.get_text(strip=True)
|
||||
match = re.search(r"(\d+)%", text)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_poster(soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extract poster image URL from HTML."""
|
||||
# Look for poster image
|
||||
poster = soup.select_one(".film-poster img")
|
||||
if poster:
|
||||
src = poster.get("src") or poster.get("data-src")
|
||||
if src:
|
||||
if src.startswith("//"):
|
||||
return "https:" + src
|
||||
return src
|
||||
return None
|
||||
|
||||
|
||||
def _extract_plot(soup: BeautifulSoup) -> Optional[str]:
|
||||
"""Extract plot summary from HTML."""
|
||||
# Look for plot/description section
|
||||
plot_elem = soup.select_one(".plot-full p")
|
||||
if plot_elem:
|
||||
return plot_elem.get_text(strip=True)
|
||||
|
||||
# Alternative: shorter plot
|
||||
plot_elem = soup.select_one(".plot-preview p")
|
||||
if plot_elem:
|
||||
return plot_elem.get_text(strip=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_genres(soup: BeautifulSoup) -> list[str]:
|
||||
"""Extract genres from HTML."""
|
||||
genres = []
|
||||
genre_links = soup.select(".genres a")
|
||||
for link in genre_links:
|
||||
genre = link.get_text(strip=True)
|
||||
if genre:
|
||||
genres.append(genre)
|
||||
return genres
|
||||
|
||||
|
||||
def _extract_origin_info(soup: BeautifulSoup) -> dict:
|
||||
"""Extract country, year, duration from origin info line."""
|
||||
info = {}
|
||||
|
||||
# Look for origin line like "Československo, 1970, 97 min"
|
||||
origin_elem = soup.select_one(".origin")
|
||||
if origin_elem:
|
||||
text = origin_elem.get_text(strip=True)
|
||||
|
||||
# Extract year
|
||||
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", text)
|
||||
if year_match:
|
||||
info["year"] = int(year_match.group(1))
|
||||
|
||||
# Extract duration
|
||||
duration_match = re.search(r"(\d+)\s*min", text)
|
||||
if duration_match:
|
||||
info["duration"] = int(duration_match.group(1))
|
||||
|
||||
# Extract country (first part before comma)
|
||||
parts = text.split(",")
|
||||
if parts:
|
||||
info["country"] = parts[0].strip()
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def search_movies(query: str, limit: int = 10) -> list[CSFDMovie]:
|
||||
"""
|
||||
Search for movies on CSFD.cz.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of CSFDMovie objects with basic info (title, url, year)
|
||||
"""
|
||||
_check_dependencies()
|
||||
|
||||
search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}"
|
||||
response = requests.get(search_url, headers=HEADERS, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
results = []
|
||||
|
||||
# Find movie results
|
||||
movie_items = soup.select(".film-title-name, .search-result-item a[href*='/film/']")
|
||||
|
||||
for item in movie_items[:limit]:
|
||||
href = item.get("href", "")
|
||||
if "/film/" not in href:
|
||||
continue
|
||||
|
||||
title = item.get_text(strip=True)
|
||||
url = urljoin(CSFD_BASE_URL, href)
|
||||
|
||||
# Try to get year from sibling/parent
|
||||
year = None
|
||||
parent = item.find_parent(class_="article-content")
|
||||
if parent:
|
||||
year_elem = parent.select_one(".info")
|
||||
if year_elem:
|
||||
year_match = re.search(r"\((\d{4})\)", year_elem.get_text())
|
||||
if year_match:
|
||||
year = int(year_match.group(1))
|
||||
|
||||
results.append(CSFDMovie(
|
||||
title=title,
|
||||
url=url,
|
||||
year=year,
|
||||
csfd_id=_extract_csfd_id(url)
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def fetch_movie_by_id(csfd_id: int) -> CSFDMovie:
|
||||
"""
|
||||
Fetch movie by CSFD ID.
|
||||
|
||||
Args:
|
||||
csfd_id: CSFD movie ID number
|
||||
|
||||
Returns:
|
||||
CSFDMovie object with full data
|
||||
"""
|
||||
url = f"{CSFD_BASE_URL}/film/{csfd_id}/"
|
||||
return fetch_movie(url)
|
||||
@@ -13,6 +13,8 @@ class File:
|
||||
self.tagmanager = tagmanager
|
||||
# new: optional date string "YYYY-MM-DD" (assigned manually)
|
||||
self.date: str | None = None
|
||||
# CSFD.cz URL for movie info
|
||||
self.csfd_url: str | None = None
|
||||
self.get_metadata()
|
||||
|
||||
def get_metadata(self) -> None:
|
||||
@@ -21,6 +23,7 @@ class File:
|
||||
self.ignored = False
|
||||
self.tags = []
|
||||
self.date = None
|
||||
self.csfd_url = None
|
||||
if self.tagmanager:
|
||||
tag = self.tagmanager.add_tag("Stav", "Nové")
|
||||
self.tags.append(tag)
|
||||
@@ -36,6 +39,8 @@ class File:
|
||||
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
|
||||
# date může být None
|
||||
"date": self.date,
|
||||
# CSFD URL
|
||||
"csfd_url": self.csfd_url,
|
||||
}
|
||||
with open(self.metadata_filename, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
@@ -47,6 +52,7 @@ class File:
|
||||
self.ignored = data.get("ignored", False)
|
||||
self.tags = []
|
||||
self.date = data.get("date", None)
|
||||
self.csfd_url = data.get("csfd_url", None)
|
||||
|
||||
if not self.tagmanager:
|
||||
return
|
||||
@@ -66,6 +72,59 @@ class File:
|
||||
self.date = date_str
|
||||
self.save_metadata()
|
||||
|
||||
def set_csfd_url(self, url: str | None):
|
||||
"""Nastaví CSFD URL nebo None pro smazání."""
|
||||
if url is None or url == "":
|
||||
self.csfd_url = None
|
||||
else:
|
||||
self.csfd_url = url
|
||||
self.save_metadata()
|
||||
|
||||
def apply_csfd_tags(self, add_genres: bool = True, add_year: bool = True, add_country: bool = True) -> dict:
|
||||
"""
|
||||
Načte informace z CSFD a přiřadí tagy (žánr, rok, země).
|
||||
|
||||
Returns:
|
||||
dict s klíči 'success', 'movie', 'error', 'tags_added'
|
||||
"""
|
||||
if not self.csfd_url:
|
||||
return {"success": False, "error": "CSFD URL není nastavena", "tags_added": []}
|
||||
|
||||
try:
|
||||
from .csfd import fetch_movie
|
||||
movie = fetch_movie(self.csfd_url)
|
||||
except ImportError as e:
|
||||
return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
|
||||
|
||||
tags_added = []
|
||||
|
||||
if add_genres and movie.genres:
|
||||
for genre in movie.genres:
|
||||
tag_obj = self.tagmanager.add_tag("Žánr", genre) if self.tagmanager else Tag("Žánr", genre)
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(f"Žánr/{genre}")
|
||||
|
||||
if add_year and movie.year:
|
||||
year_str = str(movie.year)
|
||||
tag_obj = self.tagmanager.add_tag("Rok", year_str) if self.tagmanager else Tag("Rok", year_str)
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(f"Rok/{year_str}")
|
||||
|
||||
if add_country and movie.country:
|
||||
tag_obj = self.tagmanager.add_tag("Země", movie.country) if self.tagmanager else Tag("Země", movie.country)
|
||||
if tag_obj not in self.tags:
|
||||
self.tags.append(tag_obj)
|
||||
tags_added.append(f"Země/{movie.country}")
|
||||
|
||||
if tags_added:
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from .file import File
|
||||
from .tag import Tag
|
||||
from .tag_manager import TagManager
|
||||
from .utils import list_files
|
||||
from typing import Iterable
|
||||
@@ -98,39 +99,34 @@ class FileManager:
|
||||
config = self.get_folder_config(folder)
|
||||
return config.get("ignore_patterns", [])
|
||||
|
||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||
def assign_tag_to_files(self, files: 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 isinstance(tag, str):
|
||||
parsed = Tag.from_string(tag)
|
||||
tag_obj = self.tagmanager.add_tag(parsed.category, parsed.name)
|
||||
else:
|
||||
tag_obj = tag
|
||||
|
||||
for f in files:
|
||||
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):
|
||||
def remove_tag_from_files(self, files: 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 isinstance(tag, str):
|
||||
tag_obj = Tag.from_string(tag)
|
||||
else:
|
||||
tag_obj = tag
|
||||
|
||||
for f in files:
|
||||
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)
|
||||
|
||||
@@ -144,14 +140,11 @@ class FileManager:
|
||||
return self.filelist
|
||||
|
||||
target_full_paths = set()
|
||||
from .tag import Tag as TagClass
|
||||
for t in tags_list:
|
||||
if isinstance(t, TagClass):
|
||||
if isinstance(t, Tag):
|
||||
target_full_paths.add(t.full_path)
|
||||
elif isinstance(t, str):
|
||||
target_full_paths.add(t)
|
||||
else:
|
||||
continue
|
||||
|
||||
filtered = []
|
||||
for f in self.filelist:
|
||||
@@ -160,6 +153,210 @@ class FileManager:
|
||||
filtered.append(f)
|
||||
return filtered
|
||||
|
||||
# Backwards compatibility aliases
|
||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||
"""Deprecated: Use assign_tag_to_files instead."""
|
||||
return self.assign_tag_to_files(files_objs, tag)
|
||||
|
||||
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
|
||||
"""Deprecated: Use remove_tag_from_files instead."""
|
||||
return self.remove_tag_from_files(files_objs, tag)
|
||||
|
||||
def close_folder(self):
|
||||
"""
|
||||
Safely close current folder - save all metadata and clear state.
|
||||
|
||||
This method:
|
||||
1. Saves metadata for all files
|
||||
2. Saves folder config
|
||||
3. Clears file list, folders, and configs
|
||||
4. Notifies GUI via callback
|
||||
"""
|
||||
if not self.current_folder:
|
||||
return
|
||||
|
||||
# Save all file metadata
|
||||
for f in self.filelist:
|
||||
try:
|
||||
f.save_metadata()
|
||||
except Exception:
|
||||
pass # Ignore errors during save
|
||||
|
||||
# Save folder config
|
||||
if self.current_folder in self.folder_configs:
|
||||
self.save_folder_config(self.current_folder)
|
||||
|
||||
# Clear state
|
||||
self.filelist.clear()
|
||||
self.folders.clear()
|
||||
self.folder_configs.clear()
|
||||
self.current_folder = None
|
||||
|
||||
# Notify GUI
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed([])
|
||||
|
||||
def rename_tag_in_files(self, category: str, old_name: str, new_name: str) -> int:
|
||||
"""
|
||||
Rename a tag in all files that have it.
|
||||
|
||||
Args:
|
||||
category: The category containing the tag
|
||||
old_name: Current name of the tag
|
||||
new_name: New name for the tag
|
||||
|
||||
Returns:
|
||||
Number of files updated
|
||||
"""
|
||||
old_tag = Tag(category, old_name)
|
||||
new_tag = self.tagmanager.rename_tag(category, old_name, new_name)
|
||||
|
||||
if new_tag is None:
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
for f in self.filelist:
|
||||
if old_tag in f.tags:
|
||||
# Remove old tag and add new one
|
||||
f.tags.remove(old_tag)
|
||||
f.tags.append(new_tag)
|
||||
f.save_metadata()
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0 and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
return updated_count
|
||||
|
||||
def rename_category_in_files(self, old_category: str, new_category: str) -> int:
|
||||
"""
|
||||
Rename a category in all files that have tags from it.
|
||||
|
||||
Args:
|
||||
old_category: Current name of the category
|
||||
new_category: New name for the category
|
||||
|
||||
Returns:
|
||||
Number of files updated
|
||||
"""
|
||||
# Get all tags in old category before renaming
|
||||
old_tags = self.tagmanager.get_tags_in_category(old_category)
|
||||
if not old_tags:
|
||||
return 0
|
||||
|
||||
# Rename the category in TagManager
|
||||
if not self.tagmanager.rename_category(old_category, new_category):
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
for f in self.filelist:
|
||||
file_updated = False
|
||||
new_tags = []
|
||||
for tag in f.tags:
|
||||
if tag.category == old_category:
|
||||
# Replace with new category tag
|
||||
new_tags.append(Tag(new_category, tag.name))
|
||||
file_updated = True
|
||||
else:
|
||||
new_tags.append(tag)
|
||||
|
||||
if file_updated:
|
||||
f.tags = new_tags
|
||||
f.save_metadata()
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0 and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
return updated_count
|
||||
|
||||
def merge_tag_in_files(self, category: str, source_name: str, target_name: str) -> int:
|
||||
"""
|
||||
Merge source tag into target tag in all files.
|
||||
Files with source tag will have it replaced by target tag.
|
||||
Files that already have target tag will just have source tag removed.
|
||||
|
||||
Args:
|
||||
category: The category containing both tags
|
||||
source_name: Name of the tag to merge (will be removed)
|
||||
target_name: Name of the tag to merge into (will be kept)
|
||||
|
||||
Returns:
|
||||
Number of files updated
|
||||
"""
|
||||
source_tag = Tag(category, source_name)
|
||||
target_tag = Tag(category, target_name)
|
||||
|
||||
# Merge in TagManager first
|
||||
result_tag = self.tagmanager.merge_tag(category, source_name, target_name)
|
||||
if result_tag is None:
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
for f in self.filelist:
|
||||
if source_tag in f.tags:
|
||||
# Remove source tag
|
||||
f.tags.remove(source_tag)
|
||||
|
||||
# Add target tag if not already present
|
||||
if target_tag not in f.tags:
|
||||
f.tags.append(target_tag)
|
||||
|
||||
f.save_metadata()
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0 and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
return updated_count
|
||||
|
||||
def merge_category_in_files(self, source_category: str, target_category: str) -> int:
|
||||
"""
|
||||
Merge source category into target category in all files.
|
||||
All tags from source category will be moved to target category.
|
||||
|
||||
Args:
|
||||
source_category: Category to merge (will be removed)
|
||||
target_category: Category to merge into (will receive all tags)
|
||||
|
||||
Returns:
|
||||
Number of files updated
|
||||
"""
|
||||
# Get all tags in source category before merging
|
||||
source_tags = self.tagmanager.get_tags_in_category(source_category)
|
||||
if not source_tags:
|
||||
return 0
|
||||
|
||||
# Merge in TagManager first
|
||||
if not self.tagmanager.merge_category(source_category, target_category):
|
||||
return 0
|
||||
|
||||
updated_count = 0
|
||||
for f in self.filelist:
|
||||
file_updated = False
|
||||
new_tags = []
|
||||
for tag in f.tags:
|
||||
if tag.category == source_category:
|
||||
# Replace with target category tag
|
||||
new_tag = Tag(target_category, tag.name)
|
||||
# Only add if not already present
|
||||
if new_tag not in new_tags:
|
||||
new_tags.append(new_tag)
|
||||
file_updated = True
|
||||
else:
|
||||
if tag not in new_tags:
|
||||
new_tags.append(tag)
|
||||
|
||||
if file_updated:
|
||||
f.tags = new_tags
|
||||
f.save_metadata()
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0 and self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
return updated_count
|
||||
|
||||
# Legacy property for backwards compatibility
|
||||
@property
|
||||
def config(self):
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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)
|
||||
@@ -1,19 +1,19 @@
|
||||
# Module header
|
||||
import sys
|
||||
import subprocess
|
||||
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
|
||||
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)
|
||||
# Backwards compatibility: load_icon moved to src/ui/utils.py
|
||||
def load_icon(path):
|
||||
"""Deprecated: Use src.ui.utils.load_icon instead."""
|
||||
from src.ui.utils import load_icon as _load_icon
|
||||
return _load_icon(path)
|
||||
|
||||
|
||||
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
|
||||
"""
|
||||
|
||||
@@ -3,6 +3,27 @@ class Tag:
|
||||
self.category = category
|
||||
self.name = name
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, tag_str: str, default_category: str = "default") -> "Tag":
|
||||
"""
|
||||
Parse a tag from 'category/name' string format.
|
||||
|
||||
Args:
|
||||
tag_str: Tag string in 'category/name' format
|
||||
default_category: Category to use if no '/' in string
|
||||
|
||||
Returns:
|
||||
Tag object
|
||||
|
||||
Examples:
|
||||
Tag.from_string("Stav/Nové") -> Tag("Stav", "Nové")
|
||||
Tag.from_string("simple") -> Tag("default", "simple")
|
||||
"""
|
||||
if "/" in tag_str:
|
||||
category, name = tag_str.split("/", 1)
|
||||
return cls(category, name)
|
||||
return cls(default_category, tag_str)
|
||||
|
||||
@property
|
||||
def full_path(self):
|
||||
return f"{self.category}/{self.name}"
|
||||
|
||||
@@ -64,4 +64,144 @@ class TagManager:
|
||||
# Sort alphabetically for custom categories
|
||||
tags.sort(key=lambda t: t.name)
|
||||
|
||||
return tags
|
||||
return tags
|
||||
|
||||
def rename_tag(self, category: str, old_name: str, new_name: str) -> Tag | None:
|
||||
"""
|
||||
Rename a tag within a category.
|
||||
|
||||
Args:
|
||||
category: The category containing the tag
|
||||
old_name: Current name of the tag
|
||||
new_name: New name for the tag
|
||||
|
||||
Returns:
|
||||
The new Tag object if successful, None if tag not found or new name already exists
|
||||
"""
|
||||
if category not in self.tags_by_category:
|
||||
return None
|
||||
|
||||
old_tag = Tag(category, old_name)
|
||||
new_tag = Tag(category, new_name)
|
||||
|
||||
# Check if old tag exists
|
||||
if old_tag not in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Check if new name already exists (and is different)
|
||||
if old_name != new_name and new_tag in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Remove old tag and add new one
|
||||
self.tags_by_category[category].discard(old_tag)
|
||||
self.tags_by_category[category].add(new_tag)
|
||||
|
||||
return new_tag
|
||||
|
||||
def rename_category(self, old_category: str, new_category: str) -> bool:
|
||||
"""
|
||||
Rename a category.
|
||||
|
||||
Args:
|
||||
old_category: Current name of the category
|
||||
new_category: New name for the category
|
||||
|
||||
Returns:
|
||||
True if successful, False if category not found or new name already exists
|
||||
"""
|
||||
if old_category not in self.tags_by_category:
|
||||
return False
|
||||
|
||||
# Check if new category already exists (and is different)
|
||||
if old_category != new_category and new_category in self.tags_by_category:
|
||||
return False
|
||||
|
||||
# Get all tags from old category
|
||||
old_tags = self.tags_by_category[old_category]
|
||||
|
||||
# Create new tags with new category
|
||||
new_tags = {Tag(new_category, tag.name) for tag in old_tags}
|
||||
|
||||
# Remove old category and add new one
|
||||
del self.tags_by_category[old_category]
|
||||
self.tags_by_category[new_category] = new_tags
|
||||
|
||||
return True
|
||||
|
||||
def merge_tag(self, category: str, source_name: str, target_name: str) -> Tag | None:
|
||||
"""
|
||||
Merge source tag into target tag (removes source, keeps target).
|
||||
|
||||
Args:
|
||||
category: The category containing both tags
|
||||
source_name: Name of the tag to merge (will be removed)
|
||||
target_name: Name of the tag to merge into (will be kept)
|
||||
|
||||
Returns:
|
||||
The target Tag object if successful, None if either tag not found
|
||||
"""
|
||||
if category not in self.tags_by_category:
|
||||
return None
|
||||
|
||||
source_tag = Tag(category, source_name)
|
||||
target_tag = Tag(category, target_name)
|
||||
|
||||
# Check if source tag exists
|
||||
if source_tag not in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Check if target tag exists
|
||||
if target_tag not in self.tags_by_category[category]:
|
||||
return None
|
||||
|
||||
# Remove source tag (target already exists)
|
||||
self.tags_by_category[category].discard(source_tag)
|
||||
|
||||
# Clean up empty category
|
||||
if not self.tags_by_category[category]:
|
||||
self.remove_category(category)
|
||||
|
||||
return target_tag
|
||||
|
||||
def merge_category(self, source_category: str, target_category: str) -> bool:
|
||||
"""
|
||||
Merge source category into target category (moves all tags, removes source).
|
||||
|
||||
Args:
|
||||
source_category: Category to merge (will be removed)
|
||||
target_category: Category to merge into (will receive all tags)
|
||||
|
||||
Returns:
|
||||
True if successful, False if either category not found
|
||||
"""
|
||||
if source_category not in self.tags_by_category:
|
||||
return False
|
||||
|
||||
if target_category not in self.tags_by_category:
|
||||
return False
|
||||
|
||||
if source_category == target_category:
|
||||
return True # No-op
|
||||
|
||||
# Get all tags from source category
|
||||
source_tags = self.tags_by_category[source_category]
|
||||
|
||||
# Create new tags with target category and add to target
|
||||
for tag in source_tags:
|
||||
new_tag = Tag(target_category, tag.name)
|
||||
self.tags_by_category[target_category].add(new_tag)
|
||||
|
||||
# Remove source category
|
||||
del self.tags_by_category[source_category]
|
||||
|
||||
return True
|
||||
|
||||
def tag_exists(self, category: str, name: str) -> bool:
|
||||
"""Check if a tag exists in a category."""
|
||||
if category not in self.tags_by_category:
|
||||
return False
|
||||
return Tag(category, name) in self.tags_by_category[category]
|
||||
|
||||
def category_exists(self, category: str) -> bool:
|
||||
"""Check if a category exists."""
|
||||
return category in self.tags_by_category
|
||||
273
src/ui/gui.py
273
src/ui/gui.py
@@ -9,12 +9,12 @@ from tkinter import ttk, simpledialog, messagebox, filedialog
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from src.core.media_utils import load_icon
|
||||
from src.ui.utils import load_icon
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager, DEFAULT_TAG_ORDER
|
||||
from src.core.file import File
|
||||
from src.core.tag import Tag
|
||||
from src.core.list_manager import ListManager
|
||||
# ListManager removed - sorting implemented directly in GUI
|
||||
from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT
|
||||
from src.core.config import save_global_config
|
||||
from src.core.hardlink_manager import HardlinkManager
|
||||
@@ -219,8 +219,6 @@ class App:
|
||||
def __init__(self, filehandler: FileManager, tagmanager: TagManager):
|
||||
self.filehandler = filehandler
|
||||
self.tagmanager = tagmanager
|
||||
self.list_manager = ListManager()
|
||||
|
||||
# State
|
||||
self.states = {}
|
||||
self.file_items = {} # Treeview item_id -> File object mapping
|
||||
@@ -231,6 +229,7 @@ class App:
|
||||
self.sort_mode = "name"
|
||||
self.sort_order = "asc"
|
||||
self.category_colors = {} # category -> color mapping
|
||||
self.show_csfd_column = True # CSFD column visibility
|
||||
|
||||
self.filehandler.on_files_changed = self.update_files_from_manager
|
||||
|
||||
@@ -312,6 +311,7 @@ class App:
|
||||
# File menu
|
||||
file_menu = tk.Menu(menu_bar, tearoff=0)
|
||||
file_menu.add_command(label="Open Folder... (Ctrl+O)", command=self.open_folder_dialog)
|
||||
file_menu.add_command(label="Zavřít složku (Ctrl+W)", command=self.close_folder)
|
||||
file_menu.add_command(label="Nastavit ignorované vzory", command=self.set_ignore_patterns)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_command(label="Exit (Ctrl+Q)", command=self.root.quit)
|
||||
@@ -323,6 +323,12 @@ class App:
|
||||
variable=self.hide_ignored_var,
|
||||
command=self.toggle_hide_ignored
|
||||
)
|
||||
self.show_csfd_var = tk.BooleanVar(value=True, master=self.root)
|
||||
view_menu.add_checkbutton(
|
||||
label="Zobrazit CSFD sloupec",
|
||||
variable=self.show_csfd_var,
|
||||
command=self.toggle_csfd_column
|
||||
)
|
||||
view_menu.add_command(label="Refresh (F5)", command=self.refresh_all)
|
||||
|
||||
# Tools menu
|
||||
@@ -331,6 +337,9 @@ class App:
|
||||
tools_menu.add_command(label="Detekovat rozlišení videí", command=self.detect_video_resolution)
|
||||
tools_menu.add_command(label="Přiřadit tagy (Ctrl+T)", command=self.assign_tag_to_selected_bulk)
|
||||
tools_menu.add_separator()
|
||||
tools_menu.add_command(label="Nastavit CSFD URL...", command=self.set_csfd_url_for_selected)
|
||||
tools_menu.add_command(label="Načíst tagy z CSFD", command=self.apply_csfd_tags_for_selected)
|
||||
tools_menu.add_separator()
|
||||
tools_menu.add_command(label="Nastavit hardlink složku...", command=self.configure_hardlink_folder)
|
||||
tools_menu.add_command(label="Aktualizovat hardlink strukturu", command=self.update_hardlink_structure)
|
||||
tools_menu.add_command(label="Vytvořit hardlink strukturu...", command=self.create_hardlink_structure)
|
||||
@@ -428,22 +437,27 @@ class App:
|
||||
table_frame = tk.Frame(file_frame)
|
||||
table_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||
|
||||
# Define columns
|
||||
columns = ("name", "date", "tags", "size")
|
||||
# Define columns (including CSFD)
|
||||
columns = ("name", "date", "tags", "csfd", "size")
|
||||
self.file_table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended")
|
||||
|
||||
# Column headers with sort commands
|
||||
self.file_table.heading("name", text="📄 Název ▲", command=lambda: self.sort_by_column("name"))
|
||||
self.file_table.heading("date", text="📅 Datum", command=lambda: self.sort_by_column("date"))
|
||||
self.file_table.heading("tags", text="🏷️ Štítky")
|
||||
self.file_table.heading("csfd", text="🎬 CSFD")
|
||||
self.file_table.heading("size", text="💾 Velikost", command=lambda: self.sort_by_column("size"))
|
||||
|
||||
# Column widths
|
||||
self.file_table.column("name", width=300)
|
||||
self.file_table.column("date", width=100)
|
||||
self.file_table.column("tags", width=200)
|
||||
self.file_table.column("csfd", width=50)
|
||||
self.file_table.column("size", width=80)
|
||||
|
||||
# Load CSFD column visibility from folder config
|
||||
self._update_csfd_column_visibility()
|
||||
|
||||
# Scrollbars
|
||||
vsb = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.file_table.yview)
|
||||
hsb = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.file_table.xview)
|
||||
@@ -493,6 +507,7 @@ class App:
|
||||
# Tag context menu
|
||||
self.tag_menu = tk.Menu(self.root, tearoff=0)
|
||||
self.tag_menu.add_command(label="Nový štítek", command=self.tree_add_tag)
|
||||
self.tag_menu.add_command(label="Přejmenovat štítek", command=self.tree_rename_tag)
|
||||
self.tag_menu.add_command(label="Smazat štítek", command=self.tree_delete_tag)
|
||||
|
||||
# File context menu
|
||||
@@ -501,11 +516,15 @@ class App:
|
||||
self.file_menu.add_command(label="Přiřadit štítky (Ctrl+T)", command=self.assign_tag_to_selected_bulk)
|
||||
self.file_menu.add_command(label="Nastavit datum (Ctrl+D)", command=self.set_date_for_selected)
|
||||
self.file_menu.add_separator()
|
||||
self.file_menu.add_command(label="Nastavit CSFD URL...", command=self.set_csfd_url_for_selected)
|
||||
self.file_menu.add_command(label="Načíst tagy z CSFD", command=self.apply_csfd_tags_for_selected)
|
||||
self.file_menu.add_separator()
|
||||
self.file_menu.add_command(label="Smazat z indexu (Del)", command=self.remove_selected_files)
|
||||
|
||||
def _bind_shortcuts(self):
|
||||
"""Bind keyboard shortcuts"""
|
||||
self.root.bind("<Control-o>", lambda e: self.open_folder_dialog())
|
||||
self.root.bind("<Control-w>", lambda e: self.close_folder())
|
||||
self.root.bind("<Control-q>", lambda e: self.root.quit())
|
||||
self.root.bind("<Control-t>", lambda e: self.assign_tag_to_selected_bulk())
|
||||
self.root.bind("<Control-d>", lambda e: self.set_date_for_selected())
|
||||
@@ -570,6 +589,9 @@ class App:
|
||||
self.tag_tree.tag_configure(f"cat_{category}", foreground=color)
|
||||
self.tag_tree.tag_configure(f"tag_{category}", foreground=color)
|
||||
|
||||
# Force tree update
|
||||
self.tag_tree.update_idletasks()
|
||||
|
||||
def update_tag_counts(self, filtered_files):
|
||||
"""Update tag counts in sidebar based on filtered files"""
|
||||
if not hasattr(self, 'tag_tree_items'):
|
||||
@@ -669,6 +691,134 @@ class App:
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_label.config(text=f"Smazán tag: {name}")
|
||||
|
||||
def tree_rename_tag(self):
|
||||
"""Rename selected tag or category"""
|
||||
item = self.selected_tree_item_for_context
|
||||
if not item:
|
||||
return
|
||||
|
||||
# Don't allow renaming root
|
||||
if item == self.root_tag_id:
|
||||
return
|
||||
|
||||
parent_id = self.tag_tree.parent(item)
|
||||
current_text = self.tag_tree.item(item, "text").strip()
|
||||
|
||||
# Check if this is a category (parent is root) or a tag
|
||||
is_category = (parent_id == self.root_tag_id)
|
||||
|
||||
if is_category:
|
||||
# Renaming a category
|
||||
current_name = current_text.replace("📁 ", "")
|
||||
new_name = simpledialog.askstring(
|
||||
"Přejmenovat kategorii",
|
||||
f"Nový název kategorie '{current_name}':",
|
||||
initialvalue=current_name
|
||||
)
|
||||
if not new_name or new_name == current_name:
|
||||
return
|
||||
|
||||
# Check if new name already exists - offer merge
|
||||
if new_name in self.tagmanager.get_categories():
|
||||
merge = messagebox.askyesno(
|
||||
"Kategorie existuje",
|
||||
f"Kategorie '{new_name}' již existuje.\n\n"
|
||||
f"Chcete sloučit kategorii '{current_name}' do '{new_name}'?\n\n"
|
||||
f"Všechny štítky z '{current_name}' budou přesunuty do '{new_name}'.",
|
||||
icon="question"
|
||||
)
|
||||
if not merge:
|
||||
return
|
||||
|
||||
# Merge category in all files
|
||||
updated_count = self.filehandler.merge_category_in_files(current_name, new_name)
|
||||
|
||||
# Refresh sidebar
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
self.status_label.config(
|
||||
text=f"Kategorie sloučena: {current_name} → {new_name} ({updated_count} souborů)"
|
||||
)
|
||||
return
|
||||
|
||||
# Rename category in all files
|
||||
updated_count = self.filehandler.rename_category_in_files(current_name, new_name)
|
||||
|
||||
# Refresh sidebar
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
self.status_label.config(
|
||||
text=f"Kategorie přejmenována: {current_name} → {new_name} ({updated_count} souborů)"
|
||||
)
|
||||
else:
|
||||
# Renaming a tag
|
||||
# Get tag name (without count suffix)
|
||||
# Find the tag name from the mapping
|
||||
tag_name = None
|
||||
for full_path, (item_id, name) in self.tag_tree_items.items():
|
||||
if item_id == item:
|
||||
tag_name = name
|
||||
break
|
||||
|
||||
if tag_name is None:
|
||||
# Fallback: parse from text (remove leading spaces and count)
|
||||
tag_name = current_text.lstrip()
|
||||
# Remove count suffix like " (5)"
|
||||
import re
|
||||
tag_name = re.sub(r'\s*\(\d+\)\s*$', '', tag_name)
|
||||
|
||||
category = self.tag_tree.item(parent_id, "text").replace("📁 ", "")
|
||||
|
||||
new_name = simpledialog.askstring(
|
||||
"Přejmenovat štítek",
|
||||
f"Nový název štítku '{tag_name}':",
|
||||
initialvalue=tag_name
|
||||
)
|
||||
if not new_name or new_name == tag_name:
|
||||
return
|
||||
|
||||
# Check if new name already exists in this category - offer merge
|
||||
existing_tags = [t.name for t in self.tagmanager.get_tags_in_category(category)]
|
||||
if new_name in existing_tags:
|
||||
merge = messagebox.askyesno(
|
||||
"Štítek existuje",
|
||||
f"Štítek '{new_name}' v kategorii '{category}' již existuje.\n\n"
|
||||
f"Chcete sloučit '{tag_name}' do '{new_name}'?\n\n"
|
||||
f"Všechny soubory s '{tag_name}' budou mít tento štítek nahrazen za '{new_name}'.",
|
||||
icon="question"
|
||||
)
|
||||
if not merge:
|
||||
return
|
||||
|
||||
# Merge tag in all files
|
||||
updated_count = self.filehandler.merge_tag_in_files(category, tag_name, new_name)
|
||||
|
||||
# Refresh sidebar
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
self.status_label.config(
|
||||
text=f"Štítek sloučen: {category}/{tag_name} → {category}/{new_name} ({updated_count} souborů)"
|
||||
)
|
||||
return
|
||||
|
||||
# Rename tag in all files
|
||||
updated_count = self.filehandler.rename_tag_in_files(category, tag_name, new_name)
|
||||
|
||||
# Refresh sidebar
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
self.status_label.config(
|
||||
text=f"Štítek přejmenován: {category}/{tag_name} → {category}/{new_name} ({updated_count} souborů)"
|
||||
)
|
||||
|
||||
def get_checked_tags(self) -> List[Tag]:
|
||||
"""Get list of checked tags"""
|
||||
tags = []
|
||||
@@ -740,13 +890,16 @@ class App:
|
||||
if len(f.tags) > 3:
|
||||
tags += f" +{len(f.tags) - 3}"
|
||||
|
||||
# CSFD indicator
|
||||
csfd = "✓" if f.csfd_url else ""
|
||||
|
||||
try:
|
||||
size = f.file_path.stat().st_size
|
||||
size_str = self._format_size(size)
|
||||
except:
|
||||
size_str = "?"
|
||||
|
||||
item_id = self.file_table.insert("", "end", values=(name, date, tags, size_str))
|
||||
item_id = self.file_table.insert("", "end", values=(name, date, tags, csfd, size_str))
|
||||
self.file_items[item_id] = f
|
||||
|
||||
# Update status
|
||||
@@ -838,11 +991,27 @@ class App:
|
||||
f.tagmanager.add_tag(t.category, t.name)
|
||||
|
||||
self.status_label.config(text=f"Přidána složka: {folder_path}")
|
||||
self._update_csfd_column_visibility() # Load CSFD column setting for new folder
|
||||
self.refresh_sidebar()
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Chyba", f"Nelze přidat složku {folder}: {e}")
|
||||
|
||||
def close_folder(self):
|
||||
"""Close current folder safely"""
|
||||
if not self.filehandler.current_folder:
|
||||
self.status_label.config(text="Žádná složka není otevřena")
|
||||
return
|
||||
|
||||
folder_name = self.filehandler.current_folder.name
|
||||
|
||||
# Close folder (saves metadata and clears state)
|
||||
self.filehandler.close_folder()
|
||||
|
||||
# Refresh UI
|
||||
self.refresh_sidebar()
|
||||
self.status_label.config(text=f"Složka zavřena: {folder_name}")
|
||||
|
||||
def open_selected_files(self):
|
||||
"""Open selected files"""
|
||||
files = self.get_selected_files()
|
||||
@@ -974,6 +1143,96 @@ class App:
|
||||
self.show_full_path = not self.show_full_path
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
def toggle_csfd_column(self):
|
||||
"""Toggle CSFD column visibility"""
|
||||
self.show_csfd_column = self.show_csfd_var.get()
|
||||
self._update_csfd_column_visibility()
|
||||
|
||||
# Save to folder config
|
||||
if self.filehandler.current_folder:
|
||||
folder_config = self.filehandler.get_folder_config()
|
||||
folder_config["show_csfd_column"] = self.show_csfd_column
|
||||
self.filehandler.save_folder_config(config=folder_config)
|
||||
|
||||
def _update_csfd_column_visibility(self):
|
||||
"""Update CSFD column width based on visibility setting"""
|
||||
# Load from folder config if available
|
||||
if self.filehandler.current_folder:
|
||||
folder_config = self.filehandler.get_folder_config()
|
||||
self.show_csfd_column = folder_config.get("show_csfd_column", True)
|
||||
if hasattr(self, 'show_csfd_var'):
|
||||
self.show_csfd_var.set(self.show_csfd_column)
|
||||
|
||||
# Update column width
|
||||
if hasattr(self, 'file_table'):
|
||||
if self.show_csfd_column:
|
||||
self.file_table.column("csfd", width=50)
|
||||
else:
|
||||
self.file_table.column("csfd", width=0)
|
||||
|
||||
def set_csfd_url_for_selected(self):
|
||||
"""Set CSFD URL for selected files"""
|
||||
files = self.get_selected_files()
|
||||
if not files:
|
||||
self.status_label.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
|
||||
# Get current URL from first file
|
||||
current_url = files[0].csfd_url or ""
|
||||
|
||||
prompt = "Zadej CSFD URL (např. https://www.csfd.cz/film/9423-pane-vy-jste-vdova/):"
|
||||
url = simpledialog.askstring("Nastavit CSFD URL", prompt, initialvalue=current_url, parent=self.root)
|
||||
if url is None:
|
||||
return
|
||||
|
||||
for f in files:
|
||||
f.set_csfd_url(url if url != "" else None)
|
||||
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
self.status_label.config(text=f"CSFD URL nastaveno pro {len(files)} soubor(ů)")
|
||||
|
||||
def apply_csfd_tags_for_selected(self):
|
||||
"""Load tags from CSFD for selected files"""
|
||||
files = self.get_selected_files()
|
||||
if not files:
|
||||
self.status_label.config(text="Nebyly vybrány žádné soubory")
|
||||
return
|
||||
|
||||
# Filter files with CSFD URL
|
||||
files_with_url = [f for f in files if f.csfd_url]
|
||||
if not files_with_url:
|
||||
messagebox.showwarning("Upozornění", "Žádný z vybraných souborů nemá nastavenou CSFD URL")
|
||||
return
|
||||
|
||||
self.status_label.config(text=f"Načítám tagy z CSFD pro {len(files_with_url)} souborů...")
|
||||
self.root.update()
|
||||
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
all_tags_added = []
|
||||
|
||||
for f in files_with_url:
|
||||
result = f.apply_csfd_tags()
|
||||
if result["success"]:
|
||||
success_count += 1
|
||||
all_tags_added.extend(result["tags_added"])
|
||||
else:
|
||||
error_count += 1
|
||||
|
||||
# Refresh sidebar to show new categories
|
||||
self.refresh_sidebar()
|
||||
self.root.update_idletasks() # Force UI refresh
|
||||
self.update_files_from_manager(self.filehandler.filelist)
|
||||
|
||||
# Show result
|
||||
if error_count > 0:
|
||||
messagebox.showwarning("Dokončeno s chybami",
|
||||
f"Úspěšně: {success_count}, Chyby: {error_count}\n"
|
||||
f"Přidáno {len(all_tags_added)} tagů")
|
||||
else:
|
||||
self.status_label.config(
|
||||
text=f"Načteno z CSFD: {success_count} souborů, přidáno {len(all_tags_added)} tagů")
|
||||
|
||||
def sort_by_column(self, column: str):
|
||||
"""Sort by column header click"""
|
||||
if self.sort_mode == column:
|
||||
|
||||
19
src/ui/utils.py
Normal file
19
src/ui/utils.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
UI utility functions for Tagger GUI.
|
||||
"""
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
|
||||
def load_icon(path) -> ImageTk.PhotoImage:
|
||||
"""
|
||||
Load an icon from file and resize to 16x16.
|
||||
|
||||
Args:
|
||||
path: Path to the image file
|
||||
|
||||
Returns:
|
||||
ImageTk.PhotoImage resized to 16x16 pixels
|
||||
"""
|
||||
img = Image.open(path)
|
||||
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||
return ImageTk.PhotoImage(img)
|
||||
@@ -4,8 +4,7 @@ from pathlib import Path
|
||||
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
|
||||
get_folder_config_path, folder_has_config, FOLDER_CONFIG_NAME
|
||||
)
|
||||
|
||||
|
||||
@@ -266,36 +265,6 @@ class TestFolderConfig:
|
||||
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"""
|
||||
|
||||
|
||||
262
tests/test_csfd.py
Normal file
262
tests/test_csfd.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Tests for CSFD.cz scraper module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from src.core.csfd import (
|
||||
CSFDMovie,
|
||||
fetch_movie,
|
||||
search_movies,
|
||||
fetch_movie_by_id,
|
||||
_extract_csfd_id,
|
||||
_parse_duration,
|
||||
_extract_json_ld,
|
||||
_extract_rating,
|
||||
_extract_poster,
|
||||
_extract_plot,
|
||||
_extract_genres,
|
||||
_extract_origin_info,
|
||||
_check_dependencies,
|
||||
)
|
||||
|
||||
|
||||
# Sample HTML for testing
|
||||
SAMPLE_JSON_LD = """
|
||||
{
|
||||
"@type": "Movie",
|
||||
"name": "Test Movie",
|
||||
"director": [{"@type": "Person", "name": "Test Director"}],
|
||||
"actor": [{"@type": "Person", "name": "Actor 1"}, {"@type": "Person", "name": "Actor 2"}],
|
||||
"aggregateRating": {"ratingValue": 85.5, "ratingCount": 1000},
|
||||
"duration": "PT120M",
|
||||
"description": "A test movie description."
|
||||
}
|
||||
"""
|
||||
|
||||
SAMPLE_HTML = """
|
||||
<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,
|
||||
country="Česko",
|
||||
poster_url="https://image.example.com/poster.jpg",
|
||||
plot="A test movie.",
|
||||
csfd_id=123
|
||||
)
|
||||
assert movie.year == 2020
|
||||
assert movie.genres == ["Drama", "Thriller"]
|
||||
assert movie.rating == 85
|
||||
assert movie.duration == 120
|
||||
assert movie.csfd_id == 123
|
||||
|
||||
def test_csfd_movie_str(self):
|
||||
"""Test CSFDMovie string representation."""
|
||||
movie = CSFDMovie(
|
||||
title="Test Movie",
|
||||
url="https://csfd.cz/film/123/",
|
||||
year=2020,
|
||||
genres=["Drama"],
|
||||
directors=["Director 1"],
|
||||
rating=85
|
||||
)
|
||||
s = str(movie)
|
||||
assert "Test Movie (2020)" in s
|
||||
assert "85%" in s
|
||||
assert "Drama" in s
|
||||
assert "Director 1" in s
|
||||
|
||||
def test_csfd_movie_str_minimal(self):
|
||||
"""Test CSFDMovie string with minimal data."""
|
||||
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
|
||||
s = str(movie)
|
||||
assert "Test" in s
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Tests for helper functions."""
|
||||
|
||||
def test_extract_csfd_id_valid(self):
|
||||
"""Test extracting CSFD ID from valid URL."""
|
||||
assert _extract_csfd_id("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/") == 9423
|
||||
assert _extract_csfd_id("https://www.csfd.cz/film/123456/") == 123456
|
||||
assert _extract_csfd_id("/film/999/prehled/") == 999
|
||||
|
||||
def test_extract_csfd_id_invalid(self):
|
||||
"""Test extracting CSFD ID from invalid URL."""
|
||||
assert _extract_csfd_id("https://www.csfd.cz/") is None
|
||||
assert _extract_csfd_id("not-a-url") is None
|
||||
|
||||
def test_parse_duration_valid(self):
|
||||
"""Test parsing ISO 8601 duration."""
|
||||
assert _parse_duration("PT97M") == 97
|
||||
assert _parse_duration("PT120M") == 120
|
||||
assert _parse_duration("PT60M") == 60
|
||||
|
||||
def test_parse_duration_invalid(self):
|
||||
"""Test parsing invalid duration."""
|
||||
assert _parse_duration("") is None
|
||||
assert _parse_duration("invalid") is None
|
||||
assert _parse_duration("PT") is None
|
||||
|
||||
|
||||
class TestHTMLExtraction:
|
||||
"""Tests for HTML extraction functions."""
|
||||
|
||||
@pytest.fixture
|
||||
def soup(self):
|
||||
"""Create BeautifulSoup object from sample HTML."""
|
||||
from bs4 import BeautifulSoup
|
||||
return BeautifulSoup(SAMPLE_HTML, "html.parser")
|
||||
|
||||
def test_extract_json_ld(self, soup):
|
||||
"""Test extracting data from JSON-LD."""
|
||||
data = _extract_json_ld(soup)
|
||||
assert data["title"] == "Test Movie"
|
||||
assert data["directors"] == ["Test Director"]
|
||||
assert data["actors"] == ["Actor 1", "Actor 2"]
|
||||
assert data["rating"] == 86 # Rounded from 85.5
|
||||
assert data["rating_count"] == 1000
|
||||
assert data["duration"] == 120
|
||||
|
||||
def test_extract_rating(self, soup):
|
||||
"""Test extracting rating from HTML."""
|
||||
rating = _extract_rating(soup)
|
||||
assert rating == 85
|
||||
|
||||
def test_extract_genres(self, soup):
|
||||
"""Test extracting genres from HTML."""
|
||||
genres = _extract_genres(soup)
|
||||
assert "Drama" in genres
|
||||
assert "Thriller" in genres
|
||||
|
||||
def test_extract_poster(self, soup):
|
||||
"""Test extracting poster URL."""
|
||||
poster = _extract_poster(soup)
|
||||
assert poster == "https://image.example.com/poster.jpg"
|
||||
|
||||
def test_extract_plot(self, soup):
|
||||
"""Test extracting plot."""
|
||||
plot = _extract_plot(soup)
|
||||
assert plot == "Full plot description."
|
||||
|
||||
def test_extract_origin_info(self, soup):
|
||||
"""Test extracting origin info."""
|
||||
info = _extract_origin_info(soup)
|
||||
assert info["country"] == "Česko"
|
||||
assert info["year"] == 2020
|
||||
assert info["duration"] == 120
|
||||
|
||||
|
||||
class TestFetchMovie:
|
||||
"""Tests for fetch_movie function."""
|
||||
|
||||
@patch("src.core.csfd.requests")
|
||||
def test_fetch_movie_success(self, mock_requests):
|
||||
"""Test successful movie fetch."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = SAMPLE_HTML
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_requests.get.return_value = mock_response
|
||||
|
||||
movie = fetch_movie("https://www.csfd.cz/film/123-test/")
|
||||
|
||||
assert movie.title == "Test Movie"
|
||||
assert movie.csfd_id == 123
|
||||
assert movie.rating == 86
|
||||
assert "Drama" in movie.genres
|
||||
mock_requests.get.assert_called_once()
|
||||
|
||||
@patch("src.core.csfd.requests")
|
||||
def test_fetch_movie_network_error(self, mock_requests):
|
||||
"""Test network error handling."""
|
||||
import requests as real_requests
|
||||
mock_requests.get.side_effect = real_requests.RequestException("Network error")
|
||||
|
||||
with pytest.raises(real_requests.RequestException):
|
||||
fetch_movie("https://www.csfd.cz/film/123/")
|
||||
|
||||
|
||||
class TestSearchMovies:
|
||||
"""Tests for search_movies function."""
|
||||
|
||||
@patch("src.core.csfd.requests")
|
||||
def test_search_movies(self, mock_requests):
|
||||
"""Test movie search."""
|
||||
search_html = """
|
||||
<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()
|
||||
mock_requests.get.return_value = mock_response
|
||||
mock_requests.utils.quote = lambda x: x
|
||||
|
||||
results = search_movies("test", limit=10)
|
||||
|
||||
assert len(results) >= 1
|
||||
assert any(m.csfd_id == 123 for m in results)
|
||||
|
||||
|
||||
class TestFetchMovieById:
|
||||
"""Tests for fetch_movie_by_id function."""
|
||||
|
||||
@patch("src.core.csfd.fetch_movie")
|
||||
def test_fetch_by_id(self, mock_fetch):
|
||||
"""Test fetching movie by ID."""
|
||||
mock_fetch.return_value = CSFDMovie(title="Test", url="https://csfd.cz/film/9423/")
|
||||
|
||||
movie = fetch_movie_by_id(9423)
|
||||
|
||||
mock_fetch.assert_called_once_with("https://www.csfd.cz/film/9423/")
|
||||
assert movie.title == "Test"
|
||||
|
||||
|
||||
class TestDependencyCheck:
|
||||
"""Tests for dependency checking."""
|
||||
|
||||
def test_dependencies_available(self):
|
||||
"""Test that dependencies are available (they should be in test env)."""
|
||||
# Should not raise
|
||||
_check_dependencies()
|
||||
@@ -263,3 +263,155 @@ class TestFile:
|
||||
tag_paths2 = {tag.full_path for tag in file_obj2.tags}
|
||||
assert tag_paths == tag_paths2
|
||||
assert file_obj2.date == "2025-01-01"
|
||||
|
||||
|
||||
class TestFileCSFDIntegration:
|
||||
"""Testy pro CSFD integraci v File"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
return tmp_path
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(self, temp_dir):
|
||||
test_file = temp_dir / "film.mkv"
|
||||
test_file.write_text("video content")
|
||||
return test_file
|
||||
|
||||
def test_file_csfd_url_initial(self, test_file, tag_manager):
|
||||
"""Test že csfd_url je None při vytvoření"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
assert file_obj.csfd_url is None
|
||||
|
||||
def test_file_set_csfd_url(self, test_file, tag_manager):
|
||||
"""Test nastavení CSFD URL"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/")
|
||||
assert file_obj.csfd_url == "https://www.csfd.cz/film/9423-pane-vy-jste-vdova/"
|
||||
|
||||
def test_file_set_csfd_url_persistence(self, test_file, tag_manager):
|
||||
"""Test že CSFD URL přežije reload"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
file_obj2 = File(test_file, tag_manager)
|
||||
assert file_obj2.csfd_url == "https://www.csfd.cz/film/123/"
|
||||
|
||||
def test_file_set_csfd_url_none(self, test_file, tag_manager):
|
||||
"""Test smazání CSFD URL"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
file_obj.set_csfd_url(None)
|
||||
assert file_obj.csfd_url is None
|
||||
|
||||
def test_file_set_csfd_url_empty(self, test_file, tag_manager):
|
||||
"""Test nastavení prázdného řetězce jako CSFD URL"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
file_obj.set_csfd_url("")
|
||||
assert file_obj.csfd_url is None
|
||||
|
||||
def test_file_csfd_url_in_metadata(self, test_file, tag_manager):
|
||||
"""Test že CSFD URL je uložena v metadatech"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/999/")
|
||||
|
||||
import json
|
||||
with open(file_obj.metadata_filename, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data["csfd_url"] == "https://www.csfd.cz/film/999/"
|
||||
|
||||
def test_apply_csfd_tags_no_url(self, test_file, tag_manager):
|
||||
"""Test apply_csfd_tags bez nastaveného URL"""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
result = file_obj.apply_csfd_tags()
|
||||
|
||||
assert result["success"] is False
|
||||
assert "URL není nastavena" in result["error"]
|
||||
assert result["tags_added"] == []
|
||||
|
||||
@pytest.fixture
|
||||
def mock_csfd_movie(self):
|
||||
"""Mock CSFDMovie pro testování"""
|
||||
from unittest.mock import MagicMock
|
||||
movie = MagicMock()
|
||||
movie.title = "Test Film"
|
||||
movie.year = 2020
|
||||
movie.genres = ["Komedie", "Drama"]
|
||||
movie.country = "Česko"
|
||||
movie.rating = 85
|
||||
return movie
|
||||
|
||||
def test_apply_csfd_tags_success(self, test_file, tag_manager, mock_csfd_movie):
|
||||
"""Test úspěšného načtení tagů z CSFD"""
|
||||
from unittest.mock import patch
|
||||
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
with patch("src.core.csfd.fetch_movie", return_value=mock_csfd_movie):
|
||||
result = file_obj.apply_csfd_tags()
|
||||
|
||||
assert result["success"] is True
|
||||
assert "Žánr/Komedie" in result["tags_added"]
|
||||
assert "Žánr/Drama" in result["tags_added"]
|
||||
assert "Rok/2020" in result["tags_added"]
|
||||
assert "Země/Česko" in result["tags_added"]
|
||||
|
||||
# Kontrola že tagy jsou opravdu přidány
|
||||
tag_paths = {tag.full_path for tag in file_obj.tags}
|
||||
assert "Žánr/Komedie" in tag_paths
|
||||
assert "Žánr/Drama" in tag_paths
|
||||
assert "Rok/2020" in tag_paths
|
||||
assert "Země/Česko" in tag_paths
|
||||
|
||||
def test_apply_csfd_tags_genres_only(self, test_file, tag_manager, mock_csfd_movie):
|
||||
"""Test načtení pouze žánrů"""
|
||||
from unittest.mock import patch
|
||||
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
with patch("src.core.csfd.fetch_movie", return_value=mock_csfd_movie):
|
||||
result = file_obj.apply_csfd_tags(add_genres=True, add_year=False, add_country=False)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "Žánr/Komedie" in result["tags_added"]
|
||||
assert "Rok/2020" not in result["tags_added"]
|
||||
assert "Země/Česko" not in result["tags_added"]
|
||||
|
||||
def test_apply_csfd_tags_no_duplicate(self, test_file, tag_manager, mock_csfd_movie):
|
||||
"""Test že duplicitní tagy nejsou přidány"""
|
||||
from unittest.mock import patch
|
||||
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
# Přidáme tag ručně
|
||||
file_obj.add_tag("Žánr/Komedie")
|
||||
|
||||
with patch("src.core.csfd.fetch_movie", return_value=mock_csfd_movie):
|
||||
result = file_obj.apply_csfd_tags()
|
||||
|
||||
# Komedie by neměla být v tags_added, protože už existuje
|
||||
assert "Žánr/Komedie" not in result["tags_added"]
|
||||
assert "Žánr/Drama" in result["tags_added"]
|
||||
|
||||
def test_apply_csfd_tags_network_error(self, test_file, tag_manager):
|
||||
"""Test chyby při načítání z CSFD"""
|
||||
from unittest.mock import patch
|
||||
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.set_csfd_url("https://www.csfd.cz/film/123/")
|
||||
|
||||
with patch("src.core.csfd.fetch_movie", side_effect=Exception("Network error")):
|
||||
result = file_obj.apply_csfd_tags()
|
||||
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
assert result["tags_added"] == []
|
||||
|
||||
@@ -556,3 +556,433 @@ class TestFileManagerEdgeCases:
|
||||
filenames = {f.filename for f in file_manager.filelist}
|
||||
assert "soubor s mezerami.txt" in filenames
|
||||
assert "čeština.txt" in filenames
|
||||
|
||||
|
||||
class TestFileManagerCloseFolder:
|
||||
"""Testy pro close_folder metodu"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_path = config_dir / "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):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "file1.txt").write_text("content1")
|
||||
(data_dir / "file2.txt").write_text("content2")
|
||||
return data_dir
|
||||
|
||||
def test_close_folder_clears_state(self, file_manager, temp_dir):
|
||||
"""Test že close_folder vymaže stav"""
|
||||
file_manager.append(temp_dir)
|
||||
assert len(file_manager.filelist) == 2
|
||||
assert file_manager.current_folder == temp_dir
|
||||
|
||||
file_manager.close_folder()
|
||||
|
||||
assert len(file_manager.filelist) == 0
|
||||
assert len(file_manager.folders) == 0
|
||||
assert file_manager.current_folder is None
|
||||
assert len(file_manager.folder_configs) == 0
|
||||
|
||||
def test_close_folder_saves_metadata(self, file_manager, temp_dir):
|
||||
"""Test že close_folder uloží metadata"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Find file1.txt specifically
|
||||
file = next(f for f in file_manager.filelist if f.filename == "file1.txt")
|
||||
file.add_tag("Test/CloseTag")
|
||||
|
||||
file_manager.close_folder()
|
||||
|
||||
# Reload file and check tag persists
|
||||
from src.core.file import File
|
||||
reloaded = File(temp_dir / "file1.txt", file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Test/CloseTag" in tag_paths
|
||||
|
||||
def test_close_folder_callback(self, file_manager, temp_dir):
|
||||
"""Test že close_folder volá callback"""
|
||||
file_manager.append(temp_dir)
|
||||
callback_calls = []
|
||||
|
||||
def callback(filelist):
|
||||
callback_calls.append(len(filelist))
|
||||
|
||||
file_manager.on_files_changed = callback
|
||||
file_manager.close_folder()
|
||||
|
||||
assert len(callback_calls) == 1
|
||||
assert callback_calls[0] == 0 # Empty list after close
|
||||
|
||||
def test_close_folder_no_folder_open(self, file_manager):
|
||||
"""Test close_folder bez otevřené složky"""
|
||||
# Should not raise
|
||||
file_manager.close_folder()
|
||||
assert file_manager.current_folder is None
|
||||
|
||||
def test_close_folder_preserves_global_config(self, file_manager, temp_dir):
|
||||
"""Test že close_folder zachová global config"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.global_config["test_key"] = "test_value"
|
||||
|
||||
file_manager.close_folder()
|
||||
|
||||
assert file_manager.global_config.get("test_key") == "test_value"
|
||||
|
||||
|
||||
class TestFileManagerRenameTag:
|
||||
"""Testy pro přejmenování tagů v souborech"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_path = config_dir / "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):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "file1.txt").write_text("content1")
|
||||
(data_dir / "file2.txt").write_text("content2")
|
||||
(data_dir / "file3.txt").write_text("content3")
|
||||
return data_dir
|
||||
|
||||
def test_rename_tag_in_files_success(self, file_manager, temp_dir):
|
||||
"""Test úspěšného přejmenování tagu v souborech"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přidat tag dvěma souborům
|
||||
files_to_tag = file_manager.filelist[:2]
|
||||
file_manager.assign_tag_to_file_objects(files_to_tag, "Video/HD")
|
||||
|
||||
# Přejmenovat tag
|
||||
updated_count = file_manager.rename_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert updated_count == 2
|
||||
|
||||
# Zkontrolovat že tagy jsou přejmenovány
|
||||
for f in files_to_tag:
|
||||
tag_paths = {t.full_path for t in f.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_rename_tag_in_files_persistence(self, file_manager, temp_dir):
|
||||
"""Test že přejmenovaný tag přežije reload"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
|
||||
file_manager.rename_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
# Reload soubor
|
||||
from src.core.file import File
|
||||
reloaded = File(file.file_path, file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_rename_tag_in_files_no_match(self, file_manager, temp_dir):
|
||||
"""Test přejmenování tagu který žádný soubor nemá"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects(file_manager.filelist[:1], "Video/HD")
|
||||
|
||||
updated_count = file_manager.rename_tag_in_files("Video", "4K", "UHD")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_rename_tag_in_files_nonexistent_category(self, file_manager, temp_dir):
|
||||
"""Test přejmenování tagu v neexistující kategorii"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
updated_count = file_manager.rename_tag_in_files("NonExistent", "Tag", "NewTag")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_rename_tag_in_files_callback(self, file_manager, temp_dir):
|
||||
"""Test že přejmenování tagu volá callback"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects(file_manager.filelist[:1], "Video/HD")
|
||||
|
||||
callback_calls = []
|
||||
def callback(filelist):
|
||||
callback_calls.append(len(filelist))
|
||||
|
||||
file_manager.on_files_changed = callback
|
||||
file_manager.rename_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert len(callback_calls) == 1
|
||||
|
||||
def test_rename_tag_preserves_other_tags(self, file_manager, temp_dir):
|
||||
"""Test že přejmenování jednoho tagu neovlivní ostatní tagy souboru"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
|
||||
file_manager.assign_tag_to_file_objects([file], "Quality/High")
|
||||
|
||||
file_manager.rename_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
assert "Quality/High" in tag_paths
|
||||
|
||||
def test_rename_category_in_files_success(self, file_manager, temp_dir):
|
||||
"""Test úspěšného přejmenování kategorie v souborech"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přidat tagy ze stejné kategorie
|
||||
file_manager.assign_tag_to_file_objects(file_manager.filelist[:2], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/4K")
|
||||
|
||||
# Přejmenovat kategorii
|
||||
updated_count = file_manager.rename_category_in_files("Video", "Rozlišení")
|
||||
|
||||
assert updated_count == 2
|
||||
|
||||
# Zkontrolovat že tagy mají novou kategorii
|
||||
file1 = file_manager.filelist[0]
|
||||
tag_paths = {t.full_path for t in file1.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Rozlišení/4K" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
assert "Video/4K" not in tag_paths
|
||||
|
||||
def test_rename_category_in_files_persistence(self, file_manager, temp_dir):
|
||||
"""Test že přejmenovaná kategorie přežije reload"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
|
||||
file_manager.rename_category_in_files("Video", "Rozlišení")
|
||||
|
||||
# Reload soubor
|
||||
from src.core.file import File
|
||||
reloaded = File(file.file_path, file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_rename_category_in_files_no_match(self, file_manager, temp_dir):
|
||||
"""Test přejmenování kategorie kterou žádný soubor nemá"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects(file_manager.filelist[:1], "Video/HD")
|
||||
|
||||
updated_count = file_manager.rename_category_in_files("Audio", "Sound")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_rename_category_in_files_nonexistent(self, file_manager, temp_dir):
|
||||
"""Test přejmenování neexistující kategorie"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
updated_count = file_manager.rename_category_in_files("NonExistent", "NewName")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_rename_category_preserves_other_categories(self, file_manager, temp_dir):
|
||||
"""Test že přejmenování kategorie neovlivní jiné kategorie"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
|
||||
|
||||
file_manager.rename_category_in_files("Video", "Rozlišení")
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
|
||||
|
||||
class TestFileManagerMergeTag:
|
||||
"""Testy pro slučování tagů v souborech"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_path = config_dir / "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):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "file1.txt").write_text("content1")
|
||||
(data_dir / "file2.txt").write_text("content2")
|
||||
(data_dir / "file3.txt").write_text("content3")
|
||||
return data_dir
|
||||
|
||||
def test_merge_tag_in_files_success(self, file_manager, temp_dir):
|
||||
"""Test úspěšného sloučení tagů v souborech"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přidat oba tagy - jeden soubor má HD, druhý má FullHD
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Video/FullHD")
|
||||
|
||||
# Sloučit HD do FullHD
|
||||
updated_count = file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert updated_count == 1
|
||||
|
||||
# Soubor 0 by měl mít FullHD místo HD
|
||||
tag_paths = {t.full_path for t in file_manager.filelist[0].tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_merge_tag_in_files_file_has_both(self, file_manager, temp_dir):
|
||||
"""Test sloučení když soubor má oba tagy"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Soubor má oba tagy
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/FullHD")
|
||||
|
||||
# Sloučit HD do FullHD - HD by měl být odstraněn
|
||||
updated_count = file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert updated_count == 1
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
# FullHD by měl být jen jednou
|
||||
fullhd_count = sum(1 for t in file.tags if t.full_path == "Video/FullHD")
|
||||
assert fullhd_count == 1
|
||||
|
||||
def test_merge_tag_in_files_persistence(self, file_manager, temp_dir):
|
||||
"""Test že sloučený tag přežije reload"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Video/FullHD")
|
||||
|
||||
file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
# Reload soubor
|
||||
from src.core.file import File
|
||||
reloaded = File(file.file_path, file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_merge_tag_in_files_no_source(self, file_manager, temp_dir):
|
||||
"""Test sloučení když žádný soubor nemá zdrojový tag"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/FullHD")
|
||||
|
||||
updated_count = file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_merge_tag_preserves_other_tags(self, file_manager, temp_dir):
|
||||
"""Test že sloučení neovlivní ostatní tagy"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/FullHD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
|
||||
|
||||
file_manager.merge_tag_in_files("Video", "HD", "FullHD")
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Video/FullHD" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
|
||||
def test_merge_category_in_files_success(self, file_manager, temp_dir):
|
||||
"""Test úspěšného sloučení kategorií v souborech"""
|
||||
file_manager.append(temp_dir)
|
||||
|
||||
# Přidat tagy z různých kategorií
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Rozlišení/4K")
|
||||
|
||||
# Sloučit Video do Rozlišení
|
||||
updated_count = file_manager.merge_category_in_files("Video", "Rozlišení")
|
||||
|
||||
assert updated_count == 1
|
||||
|
||||
# Soubor 0 by měl mít Rozlišení/HD místo Video/HD
|
||||
tag_paths = {t.full_path for t in file_manager.filelist[0].tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_merge_category_in_files_persistence(self, file_manager, temp_dir):
|
||||
"""Test že sloučená kategorie přežije reload"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[1]], "Rozlišení/4K")
|
||||
|
||||
file_manager.merge_category_in_files("Video", "Rozlišení")
|
||||
|
||||
# Reload soubor
|
||||
from src.core.file import File
|
||||
reloaded = File(file.file_path, file_manager.tagmanager)
|
||||
tag_paths = {t.full_path for t in reloaded.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
def test_merge_category_no_source_files(self, file_manager, temp_dir):
|
||||
"""Test sloučení když žádný soubor nemá zdrojovou kategorii"""
|
||||
file_manager.append(temp_dir)
|
||||
file_manager.assign_tag_to_file_objects([file_manager.filelist[0]], "Rozlišení/4K")
|
||||
|
||||
updated_count = file_manager.merge_category_in_files("Video", "Rozlišení")
|
||||
|
||||
assert updated_count == 0
|
||||
|
||||
def test_merge_category_preserves_other_categories(self, file_manager, temp_dir):
|
||||
"""Test že sloučení kategorie neovlivní jiné kategorie"""
|
||||
file_manager.append(temp_dir)
|
||||
file = file_manager.filelist[0]
|
||||
file_manager.assign_tag_to_file_objects([file], "Video/HD")
|
||||
file_manager.assign_tag_to_file_objects([file], "Rozlišení/4K")
|
||||
file_manager.assign_tag_to_file_objects([file], "Audio/Stereo")
|
||||
|
||||
file_manager.merge_category_in_files("Video", "Rozlišení")
|
||||
|
||||
tag_paths = {t.full_path for t in file.tags}
|
||||
assert "Rozlišení/HD" in tag_paths
|
||||
assert "Rozlišení/4K" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
assert "Video/HD" not in tag_paths
|
||||
|
||||
@@ -2,7 +2,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from src.core.media_utils import load_icon
|
||||
from src.ui.utils import load_icon
|
||||
from PIL import Image, ImageTk
|
||||
import tkinter as tk
|
||||
|
||||
|
||||
@@ -104,3 +104,44 @@ class TestTag:
|
||||
assert tag.category == "Kategorie"
|
||||
assert tag.name == "Čeština"
|
||||
assert tag.full_path == "Kategorie/Čeština"
|
||||
|
||||
|
||||
class TestTagFromString:
|
||||
"""Testy pro Tag.from_string() class method"""
|
||||
|
||||
def test_from_string_with_category(self):
|
||||
"""Test parsování stringu s kategorií"""
|
||||
tag = Tag.from_string("Stav/Nové")
|
||||
assert tag.category == "Stav"
|
||||
assert tag.name == "Nové"
|
||||
|
||||
def test_from_string_without_category(self):
|
||||
"""Test parsování stringu bez kategorie - použije default"""
|
||||
tag = Tag.from_string("simple")
|
||||
assert tag.category == "default"
|
||||
assert tag.name == "simple"
|
||||
|
||||
def test_from_string_custom_default_category(self):
|
||||
"""Test parsování s vlastní default kategorií"""
|
||||
tag = Tag.from_string("simple", default_category="Custom")
|
||||
assert tag.category == "Custom"
|
||||
assert tag.name == "simple"
|
||||
|
||||
def test_from_string_multiple_slashes(self):
|
||||
"""Test parsování stringu s více lomítky"""
|
||||
tag = Tag.from_string("Kategorie/Název/s/lomítky")
|
||||
assert tag.category == "Kategorie"
|
||||
assert tag.name == "Název/s/lomítky"
|
||||
|
||||
def test_from_string_unicode(self):
|
||||
"""Test parsování unicode stringu"""
|
||||
tag = Tag.from_string("Žánr/Komedie")
|
||||
assert tag.category == "Žánr"
|
||||
assert tag.name == "Komedie"
|
||||
|
||||
def test_from_string_equality(self):
|
||||
"""Test že from_string vytváří ekvivalentní tag"""
|
||||
tag1 = Tag("Stav", "Nové")
|
||||
tag2 = Tag.from_string("Stav/Nové")
|
||||
assert tag1 == tag2
|
||||
assert hash(tag1) == hash(tag2)
|
||||
|
||||
@@ -325,3 +325,287 @@ class TestDefaultTags:
|
||||
tm.add_tag("Hodnocení", "Custom Rating")
|
||||
|
||||
assert len(tm.get_tags_in_category("Hodnocení")) == initial_count + 1
|
||||
|
||||
|
||||
class TestRenameTag:
|
||||
"""Testy pro přejmenování tagů a kategorií"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def empty_tag_manager(self):
|
||||
tm = TagManager()
|
||||
for category in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(category)
|
||||
return tm
|
||||
|
||||
def test_rename_tag_success(self, empty_tag_manager):
|
||||
"""Test úspěšného přejmenování tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
new_tag = tm.rename_tag("Video", "HD", "FullHD")
|
||||
|
||||
assert new_tag is not None
|
||||
assert new_tag.name == "FullHD"
|
||||
assert new_tag.category == "Video"
|
||||
# Old tag should not exist
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" not in tag_names
|
||||
assert "FullHD" in tag_names
|
||||
|
||||
def test_rename_tag_nonexistent_category(self, empty_tag_manager):
|
||||
"""Test přejmenování tagu v neexistující kategorii"""
|
||||
result = empty_tag_manager.rename_tag("Nonexistent", "Tag", "NewTag")
|
||||
assert result is None
|
||||
|
||||
def test_rename_tag_nonexistent_tag(self, empty_tag_manager):
|
||||
"""Test přejmenování neexistujícího tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.rename_tag("Video", "Nonexistent", "NewTag")
|
||||
assert result is None
|
||||
|
||||
def test_rename_tag_to_existing_name(self, empty_tag_manager):
|
||||
"""Test přejmenování tagu na existující název"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
|
||||
result = tm.rename_tag("Video", "HD", "4K")
|
||||
assert result is None
|
||||
# Original tags should still exist
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
|
||||
def test_rename_tag_same_name(self, empty_tag_manager):
|
||||
"""Test přejmenování tagu na stejný název (no-op)"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
new_tag = tm.rename_tag("Video", "HD", "HD")
|
||||
# Should succeed but effectively be a no-op
|
||||
assert new_tag is not None
|
||||
assert new_tag.name == "HD"
|
||||
|
||||
def test_rename_category_success(self, empty_tag_manager):
|
||||
"""Test úspěšného přejmenování kategorie"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
|
||||
result = tm.rename_category("Video", "Rozlišení")
|
||||
|
||||
assert result is True
|
||||
assert "Video" not in tm.get_categories()
|
||||
assert "Rozlišení" in tm.get_categories()
|
||||
# Tags should be moved to new category
|
||||
tags = tm.get_tags_in_category("Rozlišení")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
|
||||
def test_rename_category_nonexistent(self, empty_tag_manager):
|
||||
"""Test přejmenování neexistující kategorie"""
|
||||
result = empty_tag_manager.rename_category("Nonexistent", "NewName")
|
||||
assert result is False
|
||||
|
||||
def test_rename_category_to_existing_name(self, empty_tag_manager):
|
||||
"""Test přejmenování kategorie na existující název"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Audio", "MP3")
|
||||
|
||||
result = tm.rename_category("Video", "Audio")
|
||||
assert result is False
|
||||
# Original categories should still exist
|
||||
assert "Video" in tm.get_categories()
|
||||
assert "Audio" in tm.get_categories()
|
||||
|
||||
def test_rename_category_same_name(self, empty_tag_manager):
|
||||
"""Test přejmenování kategorie na stejný název"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.rename_category("Video", "Video")
|
||||
# Should succeed but effectively be a no-op
|
||||
assert result is True
|
||||
assert "Video" in tm.get_categories()
|
||||
|
||||
def test_rename_tag_preserves_other_tags(self, empty_tag_manager):
|
||||
"""Test že přejmenování jednoho tagu neovlivní ostatní"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Video", "SD")
|
||||
|
||||
tm.rename_tag("Video", "HD", "FullHD")
|
||||
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert len(tag_names) == 3
|
||||
assert "FullHD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
assert "SD" in tag_names
|
||||
assert "HD" not in tag_names
|
||||
|
||||
def test_rename_category_preserves_tags(self, empty_tag_manager):
|
||||
"""Test že přejmenování kategorie zachová všechny tagy"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Video", "SD")
|
||||
|
||||
tm.rename_category("Video", "Rozlišení")
|
||||
|
||||
tags = tm.get_tags_in_category("Rozlišení")
|
||||
assert len(tags) == 3
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
assert "SD" in tag_names
|
||||
|
||||
|
||||
class TestMergeTag:
|
||||
"""Testy pro slučování tagů a kategorií"""
|
||||
|
||||
@pytest.fixture
|
||||
def empty_tag_manager(self):
|
||||
tm = TagManager()
|
||||
for category in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(category)
|
||||
return tm
|
||||
|
||||
def test_merge_tag_success(self, empty_tag_manager):
|
||||
"""Test úspěšného sloučení tagů"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "FullHD")
|
||||
|
||||
result = tm.merge_tag("Video", "HD", "FullHD")
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "FullHD"
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "FullHD" in tag_names
|
||||
assert "HD" not in tag_names
|
||||
assert len(tag_names) == 1
|
||||
|
||||
def test_merge_tag_nonexistent_source(self, empty_tag_manager):
|
||||
"""Test sloučení neexistujícího zdrojového tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "FullHD")
|
||||
|
||||
result = tm.merge_tag("Video", "HD", "FullHD")
|
||||
assert result is None
|
||||
|
||||
def test_merge_tag_nonexistent_target(self, empty_tag_manager):
|
||||
"""Test sloučení do neexistujícího cílového tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.merge_tag("Video", "HD", "FullHD")
|
||||
assert result is None
|
||||
|
||||
def test_merge_tag_nonexistent_category(self, empty_tag_manager):
|
||||
"""Test sloučení v neexistující kategorii"""
|
||||
result = empty_tag_manager.merge_tag("Nonexistent", "HD", "FullHD")
|
||||
assert result is None
|
||||
|
||||
def test_merge_tag_preserves_other_tags(self, empty_tag_manager):
|
||||
"""Test že sloučení jednoho tagu neovlivní ostatní"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Video", "SD")
|
||||
|
||||
tm.merge_tag("Video", "HD", "4K")
|
||||
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert len(tag_names) == 2
|
||||
assert "4K" in tag_names
|
||||
assert "SD" in tag_names
|
||||
assert "HD" not in tag_names
|
||||
|
||||
def test_merge_category_success(self, empty_tag_manager):
|
||||
"""Test úspěšného sloučení kategorií"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Rozlišení", "SD")
|
||||
|
||||
result = tm.merge_category("Video", "Rozlišení")
|
||||
|
||||
assert result is True
|
||||
assert "Video" not in tm.get_categories()
|
||||
assert "Rozlišení" in tm.get_categories()
|
||||
tags = tm.get_tags_in_category("Rozlišení")
|
||||
tag_names = [t.name for t in tags]
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
assert "SD" in tag_names
|
||||
|
||||
def test_merge_category_nonexistent_source(self, empty_tag_manager):
|
||||
"""Test sloučení neexistující zdrojové kategorie"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Rozlišení", "HD")
|
||||
|
||||
result = tm.merge_category("Video", "Rozlišení")
|
||||
assert result is False
|
||||
|
||||
def test_merge_category_nonexistent_target(self, empty_tag_manager):
|
||||
"""Test sloučení do neexistující cílové kategorie"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.merge_category("Video", "Rozlišení")
|
||||
assert result is False
|
||||
|
||||
def test_merge_category_same_category(self, empty_tag_manager):
|
||||
"""Test sloučení kategorie se sebou samou"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
result = tm.merge_category("Video", "Video")
|
||||
assert result is True # No-op, should succeed
|
||||
|
||||
def test_merge_category_duplicate_tags(self, empty_tag_manager):
|
||||
"""Test sloučení kategorií s duplicitními tagy"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Rozlišení", "HD") # Same tag name in target
|
||||
|
||||
result = tm.merge_category("Video", "Rozlišení")
|
||||
|
||||
assert result is True
|
||||
tags = tm.get_tags_in_category("Rozlišení")
|
||||
tag_names = [t.name for t in tags]
|
||||
# HD should appear only once (set deduplication)
|
||||
assert tag_names.count("HD") == 1
|
||||
assert "4K" in tag_names
|
||||
|
||||
def test_tag_exists(self, empty_tag_manager):
|
||||
"""Test kontroly existence tagu"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
assert tm.tag_exists("Video", "HD") is True
|
||||
assert tm.tag_exists("Video", "4K") is False
|
||||
assert tm.tag_exists("Nonexistent", "HD") is False
|
||||
|
||||
def test_category_exists(self, empty_tag_manager):
|
||||
"""Test kontroly existence kategorie"""
|
||||
tm = empty_tag_manager
|
||||
tm.add_tag("Video", "HD")
|
||||
|
||||
assert tm.category_exists("Video") is True
|
||||
assert tm.category_exists("Nonexistent") is False
|
||||
|
||||
Reference in New Issue
Block a user