CSFD integration

This commit is contained in:
2025-12-30 07:54:30 +01:00
parent 028c6606e0
commit 47b39aadfe
20 changed files with 2597 additions and 129 deletions

View File

@@ -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
View File

@@ -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"

View File

@@ -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]

View File

@@ -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
View 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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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):
"""

View File

@@ -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}"

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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
View 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()

View File

@@ -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"] == []

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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