Add ČSFD Anubis bypass, drop legacy preset tags, rename Země → Země původu

This commit is contained in:
2026-06-12 20:30:14 +02:00
parent 22a14b1e41
commit 86c689b9f1
14 changed files with 349 additions and 146 deletions
+1 -1
View File
@@ -34,4 +34,4 @@ build/
AGENTS.md AGENTS.md
CLAUDE.md CLAUDE.md
DESIGN_DOCUMENT.md DESIGN_DOCUMENT.md
.claude/ .claude/
+24 -1
View File
@@ -44,7 +44,7 @@ Each version entry uses these sections (include only those that apply):
- Project `README.md` (overview, concepts, workflow, run/build instructions). - Project `README.md` (overview, concepts, workflow, run/build instructions).
- **ČSFD scraping** (`csfd.py`, ported from the Tagger devel branch): fetches - **ČSFD scraping** (`csfd.py`, ported from the Tagger devel branch): fetches
movie data from a ČSFD link (JSON-LD + HTML parsing). `File.apply_csfd_tags` movie data from a ČSFD link (JSON-LD + HTML parsing). `File.apply_csfd_tags`
assigns Žánr / Rok / Země tags and caches the fetched data in the metadata. assigns Žánr / Rok / Země původu tags and caches the fetched data in the metadata.
The GUI auto-fetches on import when a link is given and offers "Načíst tagy The GUI auto-fetches on import when a link is given and offers "Načíst tagy
z ČSFD" for selected movies. z ČSFD" for selected movies.
- App startup injects `truststore` so HTTPS uses the OS certificate store — - App startup injects `truststore` so HTTPS uses the OS certificate store —
@@ -56,12 +56,35 @@ Each version entry uses these sections (include only those that apply):
- ČSFD parsing updated for the current site HTML: year is read from JSON-LD - ČSFD parsing updated for the current site HTML: year is read from JSON-LD
`dateCreated`, and the origin line (now bullet-separated, no commas) is `dateCreated`, and the origin line (now bullet-separated, no commas) is
tokenized so country / year / duration are extracted correctly. tokenized so country / year / duration are extracted correctly.
- **ČSFD anti-bot wall (Anubis):** ČSFD now serves a proof-of-work challenge
page instead of the movie, so fetches returned a film with no genres/year
("načteno 0 tagů"). `csfd.py` now detects the Anubis challenge, solves the
SHA-256 proof-of-work the way the bundled worker JS does, and replays the
request through a `requests.Session` (reused across a batch so only the first
fetch pays the PoW cost). Žánr / Rok / Země původu tags load again.
- "Assign tags" dialog crashed on PySide6/Qt6 — `Qt.ItemIsTristate` was renamed
to `Qt.ItemIsAutoTristate`.
### Changed ### Changed
- ČSFD country tag category renamed **Země → Země původu**. Added
`scripts/migrate_tag_category.py` to rewrite the category in an existing pool
index (backs up `.Curator.!index` first); run against the live pool.
- Filmotéka tree now also builds the **Země původu** branch — it was missing
from `FILMOTEKA_CATEGORIES`, so the country level was never generated. Tree
categories are now Rok / Žánr / Země původu / Hodnocení.
- Movie table trimmed to **Název / Štítky / Velikost** — the Datum and ČSFD
columns were dropped (a ČSFD link is a prerequisite, so its indicator was
always the same).
- All references to "Tagger" renamed to "Curator" (code, spec, config filenames - All references to "Tagger" renamed to "Curator" (code, spec, config filenames
`.Curator.!gtag` / `.Curator.!ftag`, tests). `.Curator.!gtag` / `.Curator.!ftag`, tests).
- `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility). - `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility).
### Removed
- Legacy Tagger predefined tags: the always-available **Hodnocení** (⭐ rating)
and **Barva** (color) categories in `TagManager`, and the automatic
**Stav/Nové** tag assigned to every newly imported file. `DEFAULT_TAGS` is now
empty; the pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu).
### Dependencies ### Dependencies
- Added `pyside6` (GUI), `requests` + `beautifulsoup4` (ČSFD scraping), - Added `pyside6` (GUI), `requests` + `beautifulsoup4` (ČSFD scraping),
`truststore` (OS cert store for HTTPS). Declared `python-dotenv`, `pillow`, `truststore` (OS cert store for HTTPS). Declared `python-dotenv`, `pillow`,
+14 -4
View File
@@ -67,7 +67,7 @@ movie table, and one-click Filmotéka generation.
and files are never moved manually, so it is not exposed to path drift. and files are never moved manually, so it is not exposed to path drift.
- **Import dialog:** collects only **Title** + **ČSFD link**. The file is renamed - **Import dialog:** collects only **Title** + **ČSFD link**. The file is renamed
to `Title.ext`. When a ČSFD link is given, Curator fetches the movie and assigns to `Title.ext`. When a ČSFD link is given, Curator fetches the movie and assigns
Žánr / Rok / Země tags automatically; further tags can be added via the UI. Žánr / Rok / Země původu tags automatically; further tags can be added via the UI.
- **Genres:** a movie can have **multiple genres**, so it appears under each of - **Genres:** a movie can have **multiple genres**, so it appears under each of
its genre branches in the Filmotéka (multiple hardlinks). its genre branches in the Filmotéka (multiple hardlinks).
- **Pool layout:** two top-level folders — **Filmy** and **Seriály**. Movies are - **Pool layout:** two top-level folders — **Filmy** and **Seriály**. Movies are
@@ -84,7 +84,7 @@ movie table, and one-click Filmotéka generation.
the source is left in place. the source is left in place.
- **Filmotéka tree:** **one level per category**`output/Category/Tag/film` - **Filmotéka tree:** **one level per category**`output/Category/Tag/film`
(hardlink), same shape as the current hardlink manager. For now the tree is (hardlink), same shape as the current hardlink manager. For now the tree is
built from these categories: **Rok**, **Žánr**, **Hodnocení**. built from these categories: **Rok**, **Žánr**, **Země původu**, **Hodnocení**.
## Tasks ## Tasks
@@ -96,7 +96,8 @@ movie table, and one-click Filmotéka generation.
- Filmy / Seriály top-level folder handling in the pool - Filmy / Seriály top-level folder handling in the pool
- "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext - "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext
- Remove-from-pool (delete file + its metadata) - Remove-from-pool (delete file + its metadata)
- Generate the Filmotéka hardlink tree from the pool (Rok / Žánr / Hodnocení) - Generate the Filmotéka hardlink tree from the pool (Rok / Žánr / Země původu /
Hodnocení)
- Filmotéka fully regenerable from the pool alone (delete output = no loss) - Filmotéka fully regenerable from the pool alone (delete output = no loss)
- GUI reframed around the Filmotéka and rewritten in PySide6 - GUI reframed around the Filmotéka and rewritten in PySide6
- Seriály "copy-as-is" mirror: pool/Seriály cloned 1:1 into the output as - Seriály "copy-as-is" mirror: pool/Seriály cloned 1:1 into the output as
@@ -108,10 +109,19 @@ movie table, and one-click Filmotéka generation.
from the GUI); each is mirrored 1:1 during Filmotéka generation (Seriály default) from the GUI); each is mirrored 1:1 during Filmotéka generation (Seriály default)
- README.md written (overview, concepts, workflow, run/build instructions) - README.md written (overview, concepts, workflow, run/build instructions)
- ČSFD scraping (`csfd.py`, ported from Tagger devel): `File.apply_csfd_tags` - ČSFD scraping (`csfd.py`, ported from Tagger devel): `File.apply_csfd_tags`
fetches a movie and assigns Žánr / Rok / Země tags (cached in metadata); wired fetches a movie and assigns Žánr / Rok / Země původu tags (cached in metadata); wired
into the GUI (auto-fetch on import with a ČSFD link, plus "Načíst tagy z ČSFD"). into the GUI (auto-fetch on import with a ČSFD link, plus "Načíst tagy z ČSFD").
Parsing updated for current ČSFD HTML and verified live against Matrix Parsing updated for current ČSFD HTML and verified live against Matrix
(film/9499); HTTPS uses the OS cert store via `truststore` (corporate SSL) (film/9499); HTTPS uses the OS cert store via `truststore` (corporate SSL)
- ČSFD Anubis anti-bot wall handled: `csfd.py` detects the proof-of-work
challenge page, solves it (SHA-256 PoW matching the bundled worker JS) and
replays via a shared `requests.Session`, so Žánr / Rok / Země původu tags load again
(the "nalezeno 1 film, načteno 0 tagů" symptom). Verified live (Matrix 1999)
- Removed the inherited Tagger predefined tags: `DEFAULT_TAGS` is now empty
(no Hodnocení ⭐ / Barva categories) and new files no longer get an automatic
`Stav/Nové` tag. Tags now come from ČSFD (Žánr / Rok / Země původu) and manual edits.
Note: `Hodnocení` is still listed in `FILMOTEKA_CATEGORIES`, so that branch is
simply empty until something assigns a Hodnocení tag again
- Fixed template cruft: `src/constants.py` made consistent (Curator values, - Fixed template cruft: `src/constants.py` made consistent (Curator values,
`get_version`/`get_debug_mode` API) and `test_constants.py` aligned; removed `get_version`/`get_debug_mode` API) and `test_constants.py` aligned; removed
the imported `tagger/` devel dump the imported `tagger/` devel dump
+1 -1
View File
@@ -27,7 +27,7 @@ hardlink-tree machinery is inherited and extended into a full library workflow.
2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The 2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The
file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and
recorded in the index. If a ČSFD link is given, Curator fetches the movie from recorded in the index. If a ČSFD link is given, Curator fetches the movie from
[ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země** tags [ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země původu** tags
automatically (use "Načíst tagy z ČSFD" to (re)fetch later). automatically (use "Načíst tagy z ČSFD" to (re)fetch later).
3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI. 3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI.
4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree 4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree
+107
View File
@@ -0,0 +1,107 @@
"""One-off migration: rename a tag category inside a pool's metadata index.
Tags are stored in ``<pool>/.Curator.!index`` as ``"Category/Name"`` strings.
This rewrites every tag whose category matches ``--old`` to use ``--new``,
leaving the tag name untouched. A timestamped backup of the index is written
before saving.
Usage:
poetry run python scripts/migrate_tag_category.py <pool_dir> \
--old "Země" --new "Země původu"
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import sys
import json
import shutil
import argparse
from pathlib import Path
from datetime import datetime
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config # noqa: E402
from src.core.pool_index import INDEX_FILENAME # noqa: E402
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
"""Return (rewritten tags, number of tags changed) for one record."""
prefix = f"{old}/"
changed = 0
result: list[str] = []
for tag in tags:
if isinstance(tag, str) and tag.startswith(prefix):
result.append(f"{new}/{tag[len(prefix):]}")
changed += 1
else:
result.append(tag)
return result, changed
def migrate(index_path: Path, old: str, new: str) -> int:
"""Rewrite the category in place and return the number of tags changed."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_changed = 0
affected_records = 0
for key, record in movies.items():
tags = record.get("tags", [])
new_tags, changed = _rename_in_tags(tags, old, new)
if changed:
record["tags"] = new_tags
total_changed += changed
affected_records += 1
logger.debug(f"{key}: {changed} tag(s) renamed")
if total_changed == 0:
logger.info(f"No '{old}/…' tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Migrated '{old}''{new}': {total_changed} tag(s) "
f"across {affected_records} record(s)"
)
return total_changed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument("--old", default="Země", help="Category to rename from")
parser.add_argument("--new", default="Země původu", help="Category to rename to")
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.old, args.new)
if __name__ == "__main__":
main()
+116 -5
View File
@@ -8,10 +8,14 @@ from __future__ import annotations
import re import re
import json import json
import time
import hashlib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from urllib.parse import urljoin from urllib.parse import urljoin
from loguru import logger
try: try:
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -34,6 +38,16 @@ HEADERS = {
"Accept-Language": "cs,en;q=0.9", "Accept-Language": "cs,en;q=0.9",
} }
# Anubis is the proof-of-work anti-bot wall ČSFD now puts in front of every page.
# A plain request gets a 200 with a JS challenge page (title "Ujišťujeme se, že
# nejste robot!") instead of the movie, so JSON-LD/genres/year all parse empty.
# We detect that page, solve the PoW the way the bundled worker JS does, and
# replay the request through the same session to obtain the auth cookie.
ANUBIS_CHALLENGE_MARKER = 'id="anubis_challenge"'
ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
ANUBIS_MAX_NONCE = 50_000_000
@dataclass @dataclass
class CSFDMovie: class CSFDMovie:
@@ -123,12 +137,103 @@ def _parse_duration(duration_str: str) -> Optional[int]:
return int(match.group(1)) if match else None return int(match.group(1)) if match else None
def fetch_movie(url: str) -> CSFDMovie: def _extract_json_blob(html: str, element_id: str):
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
match = re.search(
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
html,
re.S,
)
if not match:
return None
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
return None
def _solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]:
"""Brute-force the Anubis proof-of-work.
Mirrors the bundled ``sha256-purejs`` worker: find the smallest ``nonce``
such that ``sha256(random_data + str(nonce))`` has ``difficulty`` leading
zero nibbles. Returns ``(hash_hex, nonce, elapsed_ms)``.
"""
full_zero_bytes = difficulty // 2
needs_half_byte = difficulty % 2 != 0
start = time.monotonic()
for nonce in range(ANUBIS_MAX_NONCE):
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).digest()
if any(digest[i] != 0 for i in range(full_zero_bytes)):
continue
if needs_half_byte and digest[full_zero_bytes] >> 4 != 0:
continue
elapsed_ms = int((time.monotonic() - start) * 1000)
return digest.hex(), nonce, elapsed_ms
raise ValueError(
f"Anubis PoW unsolved within {ANUBIS_MAX_NONCE} attempts (difficulty {difficulty})"
)
def _solve_anubis_challenge(session, html: str, url: str):
"""Solve the Anubis challenge in ``html`` and return the real page response.
Posts the proof-of-work back to the pass-challenge endpoint through
``session`` (which stores the resulting auth cookie) and follows the
redirect to the originally requested page.
"""
payload = _extract_json_blob(html, "anubis_challenge")
if not payload:
raise ValueError("ČSFD anti-bot stránka bez čitelné Anubis challenge")
rules = payload.get("rules", {})
challenge = payload.get("challenge", {})
random_data = challenge.get("randomData")
difficulty = int(rules.get("difficulty", 1))
if not random_data:
raise ValueError("Anubis challenge neobsahuje randomData")
base_prefix = _extract_json_blob(html, "anubis_base_prefix") or ""
logger.debug(f"Solving Anubis challenge (difficulty {difficulty}) for {url}")
hash_hex, nonce, elapsed_ms = _solve_anubis_pow(random_data, difficulty)
logger.debug(f"Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms")
pass_url = urljoin(CSFD_BASE_URL, f"{base_prefix}{ANUBIS_PASS_PATH}")
response = session.get(
pass_url,
params={
"id": challenge.get("id"),
"response": hash_hex,
"nonce": nonce,
"redir": url,
"elapsedTime": elapsed_ms,
},
headers=HEADERS,
timeout=10,
)
response.raise_for_status()
if ANUBIS_CHALLENGE_MARKER in response.text:
raise ValueError("ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto)")
return response
def _get_page(session, url: str):
"""GET ``url`` through ``session``, transparently clearing an Anubis wall."""
response = session.get(url, headers=HEADERS, timeout=10)
response.raise_for_status()
if ANUBIS_CHALLENGE_MARKER in response.text:
response = _solve_anubis_challenge(session, response.text, url)
return response
def fetch_movie(url: str, session=None) -> CSFDMovie:
""" """
Fetch movie information from CSFD.cz URL. Fetch movie information from CSFD.cz URL.
Args: Args:
url: Full CSFD.cz movie URL (e.g., https://www.csfd.cz/film/9423-pane-vy-jste-vdova/) url: Full CSFD.cz movie URL (e.g., https://www.csfd.cz/film/9423-pane-vy-jste-vdova/)
session: Optional ``requests.Session`` to reuse (keeps the Anubis auth
cookie across calls so only the first fetch pays the PoW cost).
Returns: Returns:
CSFDMovie object with extracted data CSFDMovie object with extracted data
@@ -140,8 +245,14 @@ def fetch_movie(url: str) -> CSFDMovie:
""" """
_check_dependencies() _check_dependencies()
response = requests.get(url, headers=HEADERS, timeout=10) own_session = session is None
response.raise_for_status() if own_session:
session = requests.Session()
try:
response = _get_page(session, url)
finally:
if own_session:
session.close()
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
@@ -378,8 +489,8 @@ def search_movies(query: str, limit: int = 10) -> list[CSFDMovie]:
_check_dependencies() _check_dependencies()
search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}" search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}"
response = requests.get(search_url, headers=HEADERS, timeout=10) with requests.Session() as session:
response.raise_for_status() response = _get_page(session, search_url)
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
results = [] results = []
+2 -5
View File
@@ -51,9 +51,6 @@ class File:
self.title = None self.title = None
self.csfd_link = None self.csfd_link = None
self.csfd_cache = None self.csfd_cache = None
if self.tagmanager:
tag = self.tagmanager.add_tag("Stav", "Nové")
self.tags.append(tag)
def _build_record(self) -> dict: def _build_record(self) -> dict:
data = { data = {
@@ -142,7 +139,7 @@ class File:
def apply_csfd_tags( def apply_csfd_tags(
self, add_genres: bool = True, add_year: bool = True, add_country: bool = True self, add_genres: bool = True, add_year: bool = True, add_country: bool = True
) -> dict: ) -> dict:
"""Načte informace z CSFD a přiřadí tagy (Žánr, Rok, Země); cachuje data. """Načte informace z CSFD a přiřadí tagy (Žánr, Rok, Země původu); cachuje data.
Returns: Returns:
dict s klíči 'success', 'movie'/'error', 'tags_added' dict s klíči 'success', 'movie'/'error', 'tags_added'
@@ -173,7 +170,7 @@ class File:
if add_year and movie.year: if add_year and movie.year:
_add("Rok", str(movie.year)) _add("Rok", str(movie.year))
if add_country and movie.country: if add_country and movie.country:
_add("Země", movie.country) _add("Země původu", movie.country)
# Use the CSFD title if we don't have one yet # Use the CSFD title if we don't have one yet
if movie.title and not self.title: if movie.title and not self.title:
+8 -8
View File
@@ -1,15 +1,15 @@
from .tag import Tag from .tag import Tag
# Default tags that are always available (order in list = display order) # Default tags that are always available (order in list = display order).
DEFAULT_TAGS = { # The legacy Tagger presets (Hodnocení / Barva) were removed for Curator; the
"Hodnocení": ["", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"], # pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu). Add entries here to
"Barva": ["🔴 Červená", "🟠 Oranžová", "🟡 Žlutá", "🟢 Zelená", "🔵 Modrá", "🟣 Fialová"], # reintroduce always-available predefined tags.
} DEFAULT_TAGS: dict[str, list[str]] = {}
# Tag sort order for default categories (preserves display order) # Tag sort order for default categories (preserves display order)
DEFAULT_TAG_ORDER = { DEFAULT_TAG_ORDER: dict[str, dict[str, int]] = {
"Hodnocení": {name: i for i, name in enumerate(DEFAULT_TAGS["Hodnocení"])}, category: {name: i for i, name in enumerate(names)}
"Barva": {name: i for i, name in enumerate(DEFAULT_TAGS["Barva"])}, for category, names in DEFAULT_TAGS.items()
} }
+7 -8
View File
@@ -31,7 +31,7 @@ from src.core.constants import APP_NAME, VERSION
from src.core.hardlink_manager import HardlinkManager from src.core.hardlink_manager import HardlinkManager
# Categories that drive the generated Filmotéka tree (see PROJECT.md) # Categories that drive the generated Filmotéka tree (see PROJECT.md)
FILMOTEKA_CATEGORIES = ["Rok", "Žánr", "Hodnocení"] FILMOTEKA_CATEGORIES = ["Rok", "Žánr", "Země původu", "Hodnocení"]
class ImportMovieDialog(QDialog): class ImportMovieDialog(QDialog):
@@ -101,7 +101,7 @@ class AssignTagsDialog(QDialog):
else: else:
state = Qt.PartiallyChecked state = Qt.PartiallyChecked
item = QTreeWidgetItem([tag.name]) item = QTreeWidgetItem([tag.name])
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsTristate) item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsAutoTristate)
item.setCheckState(0, state) item.setCheckState(0, state)
cat_item.addChild(item) cat_item.addChild(item)
self._items.append((tag.full_path, item)) self._items.append((tag.full_path, item))
@@ -213,8 +213,8 @@ class QtApp(QMainWindow):
search_row.addWidget(import_btn) search_row.addWidget(import_btn)
main_layout.addLayout(search_row) main_layout.addLayout(search_row)
self.table = QTableWidget(0, 5) self.table = QTableWidget(0, 3)
self.table.setHorizontalHeaderLabels(["Název", "Datum", "Štítky", "Velikost", "ČSFD"]) self.table.setHorizontalHeaderLabels(["Název", "Štítky", "Velikost"])
self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
@@ -223,8 +223,8 @@ class QtApp(QMainWindow):
self.table.doubleClicked.connect(lambda _: self.open_movies()) self.table.doubleClicked.connect(lambda _: self.open_movies())
self.table.itemSelectionChanged.connect(self._update_selection_status) self.table.itemSelectionChanged.connect(self._update_selection_status)
header = self.table.horizontalHeader() header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch) header.setSectionResizeMode(0, QHeaderView.Stretch) # Název
header.setSectionResizeMode(2, QHeaderView.Stretch) header.setSectionResizeMode(1, QHeaderView.Stretch) # Štítky
main_layout.addWidget(self.table) main_layout.addWidget(self.table)
splitter.addWidget(main) splitter.addWidget(main)
@@ -300,8 +300,7 @@ class QtApp(QMainWindow):
size = self._format_size(f.file_path.stat().st_size) size = self._format_size(f.file_path.stat().st_size)
except OSError: except OSError:
size = "?" size = "?"
csfd = "🔗" if f.csfd_link else "" for col, value in enumerate([name, tags, size]):
for col, value in enumerate([name, f.date or "", tags, size, csfd]):
self.table.setItem(row, col, QTableWidgetItem(value)) self.table.setItem(row, col, QTableWidgetItem(value))
self.refresh_sidebar() self.refresh_sidebar()
+35 -4
View File
@@ -16,9 +16,19 @@ from src.core.csfd import (
_extract_genres, _extract_genres,
_extract_origin_info, _extract_origin_info,
_check_dependencies, _check_dependencies,
_solve_anubis_pow,
) )
def _mock_session(mock_requests):
"""Wire ``mock_requests`` so ``requests.Session()`` (also as a context
manager) yields a single configurable session mock and return it."""
session = MagicMock()
session.__enter__.return_value = session
mock_requests.Session.return_value = session
return session
# Sample HTML for testing # Sample HTML for testing
SAMPLE_JSON_LD = """ SAMPLE_JSON_LD = """
{ {
@@ -219,7 +229,8 @@ class TestFetchMovie:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.text = SAMPLE_HTML mock_response.text = SAMPLE_HTML
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_requests.get.return_value = mock_response session = _mock_session(mock_requests)
session.get.return_value = mock_response
movie = fetch_movie("https://www.csfd.cz/film/123-test/") movie = fetch_movie("https://www.csfd.cz/film/123-test/")
@@ -227,13 +238,14 @@ class TestFetchMovie:
assert movie.csfd_id == 123 assert movie.csfd_id == 123
assert movie.rating == 86 assert movie.rating == 86
assert "Drama" in movie.genres assert "Drama" in movie.genres
mock_requests.get.assert_called_once() session.get.assert_called_once()
@patch("src.core.csfd.requests") @patch("src.core.csfd.requests")
def test_fetch_movie_network_error(self, mock_requests): def test_fetch_movie_network_error(self, mock_requests):
"""Test network error handling.""" """Test network error handling."""
import requests as real_requests import requests as real_requests
mock_requests.get.side_effect = real_requests.RequestException("Network error") session = _mock_session(mock_requests)
session.get.side_effect = real_requests.RequestException("Network error")
with pytest.raises(real_requests.RequestException): with pytest.raises(real_requests.RequestException):
fetch_movie("https://www.csfd.cz/film/123/") fetch_movie("https://www.csfd.cz/film/123/")
@@ -254,7 +266,8 @@ class TestSearchMovies:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.text = search_html mock_response.text = search_html
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_requests.get.return_value = mock_response session = _mock_session(mock_requests)
session.get.return_value = mock_response
mock_requests.utils.quote = lambda x: x mock_requests.utils.quote = lambda x: x
results = search_movies("test", limit=10) results = search_movies("test", limit=10)
@@ -277,6 +290,24 @@ class TestFetchMovieById:
assert movie.title == "Test" assert movie.title == "Test"
class TestAnubisPoW:
"""Tests for the Anubis proof-of-work solver."""
def test_solve_pow_difficulty_one(self):
"""Difficulty 1 requires a single leading zero nibble in the hash."""
import hashlib
random_data = "abc123"
hash_hex, nonce, _ = _solve_anubis_pow(random_data, difficulty=1)
assert hash_hex[0] == "0"
assert hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest() == hash_hex
def test_solve_pow_difficulty_two(self):
"""Difficulty 2 requires two leading zero nibbles (one zero byte)."""
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
assert hash_hex[:2] == "00"
class TestDependencyCheck: class TestDependencyCheck:
"""Tests for dependency checking.""" """Tests for dependency checking."""
+5 -7
View File
@@ -40,10 +40,9 @@ class TestFile:
assert file_obj.metadata_filename == expected assert file_obj.metadata_filename == expected
def test_file_initial_tags(self, test_file, tag_manager): def test_file_initial_tags(self, test_file, tag_manager):
"""Test že nový soubor má tag Stav/Nové""" """Test že nový soubor nežádné automatické tagy (Stav/Nové odstraněn)"""
file_obj = File(test_file, tag_manager) file_obj = File(test_file, tag_manager)
assert len(file_obj.tags) == 1 assert file_obj.tags == []
assert file_obj.tags[0].full_path == "Stav/Nové"
def test_file_metadata_saved(self, test_file, tag_manager): def test_file_metadata_saved(self, test_file, tag_manager):
"""Test že metadata jsou uložena při vytvoření""" """Test že metadata jsou uložena při vytvoření"""
@@ -75,13 +74,12 @@ class TestFile:
# Vytvoření nového objektu - měl by načíst metadata # Vytvoření nového objektu - měl by načíst metadata
file_obj2 = File(test_file, tag_manager) file_obj2 = File(test_file, tag_manager)
assert len(file_obj2.tags) == 2 # Stav/Nové + Video/HD assert len(file_obj2.tags) == 1 # Video/HD
assert file_obj2.date == "2025-01-15" assert file_obj2.date == "2025-01-15"
# Kontrola že tagy obsahují správné hodnoty # Kontrola že tagy obsahují správné hodnoty
tag_paths = {tag.full_path for tag in file_obj2.tags} tag_paths = {tag.full_path for tag in file_obj2.tags}
assert "Video/HD" in tag_paths assert "Video/HD" in tag_paths
assert "Stav/Nové" in tag_paths
def test_file_set_date(self, test_file, tag_manager): def test_file_set_date(self, test_file, tag_manager):
"""Test nastavení data""" """Test nastavení data"""
@@ -115,7 +113,7 @@ class TestFile:
file_obj.add_tag(tag) file_obj.add_tag(tag)
assert tag in file_obj.tags assert tag in file_obj.tags
assert len(file_obj.tags) == 2 # Stav/Nové + Video/4K assert len(file_obj.tags) == 1 # Video/4K
def test_file_add_tag_string(self, test_file, tag_manager): def test_file_add_tag_string(self, test_file, tag_manager):
"""Test přidání tagu jako string""" """Test přidání tagu jako string"""
@@ -182,7 +180,7 @@ class TestFile:
"""Test File bez TagManager""" """Test File bez TagManager"""
file_obj = File(test_file, tagmanager=None) file_obj = File(test_file, tagmanager=None)
assert file_obj.tagmanager is None assert file_obj.tagmanager is None
assert len(file_obj.tags) == 0 # Bez TagManager se nepřidá Stav/Nové assert len(file_obj.tags) == 0 # nový soubor nemá žádné automatické tagy
def test_file_metadata_persistence(self, test_file, tag_manager): def test_file_metadata_persistence(self, test_file, tag_manager):
"""Test že metadata přežijí reload""" """Test že metadata přežijí reload"""
+3 -3
View File
@@ -42,7 +42,7 @@ class TestHardlinkManager:
# File 1 with multiple tags # File 1 with multiple tags
f1 = File(temp_source_dir / "file1.txt", tag_manager) f1 = File(temp_source_dir / "file1.txt", tag_manager)
f1.tags.clear() # Remove default "Stav/Nové" tag f1.tags.clear() # ensure a clean tag set
f1.add_tag(Tag("žánr", "Komedie")) f1.add_tag(Tag("žánr", "Komedie"))
f1.add_tag(Tag("žánr", "Akční")) f1.add_tag(Tag("žánr", "Akční"))
f1.add_tag(Tag("rok", "1988")) f1.add_tag(Tag("rok", "1988"))
@@ -50,13 +50,13 @@ class TestHardlinkManager:
# File 2 with one tag # File 2 with one tag
f2 = File(temp_source_dir / "file2.txt", tag_manager) f2 = File(temp_source_dir / "file2.txt", tag_manager)
f2.tags.clear() # Remove default "Stav/Nové" tag f2.tags.clear() # ensure a clean tag set
f2.add_tag(Tag("žánr", "Drama")) f2.add_tag(Tag("žánr", "Drama"))
files.append(f2) files.append(f2)
# File 3 with no tags # File 3 with no tags
f3 = File(temp_source_dir / "file3.txt", tag_manager) f3 = File(temp_source_dir / "file3.txt", tag_manager)
f3.tags.clear() # Remove default "Stav/Nové" tag f3.tags.clear() # ensure a clean tag set
files.append(f3) files.append(f3)
return files return files
+1 -1
View File
@@ -63,7 +63,7 @@ class TestFileWithIndex:
assert not f.metadata_filename.exists() # no sidecar assert not f.metadata_filename.exists() # no sidecar
assert index.get(movie) is not None # record created in index assert index.get(movie) is not None # record created in index
assert f.tags[0].full_path == "Stav/Nové" assert f.tags == [] # no automatic tags
def test_index_backed_metadata_persists_across_reload(self, tmp_path): def test_index_backed_metadata_persists_across_reload(self, tmp_path):
index = PoolIndex(tmp_path) index = PoolIndex(tmp_path)
+25 -98
View File
@@ -13,24 +13,12 @@ class TestTagManager:
@pytest.fixture @pytest.fixture
def empty_tag_manager(self): def empty_tag_manager(self):
"""Fixture pro prázdný TagManager (bez default tagů)""" """Fixture pro prázdný TagManager (alias k tag_manager, žádné default tagy)"""
tm = TagManager() return TagManager()
# Odstranit default tagy pro testy které potřebují prázdný manager
for category in list(tm.tags_by_category.keys()):
tm.remove_category(category)
return tm
def test_tag_manager_creation_has_defaults(self, tag_manager): def test_tag_manager_creation_has_no_defaults(self, tag_manager):
"""Test vytvoření TagManager obsahuje default tagy""" """Test že nový TagManager nemá žádné předdefinované tagy"""
assert "Hodnocení" in tag_manager.tags_by_category assert tag_manager.tags_by_category == {}
assert "Barva" in tag_manager.tags_by_category
def test_tag_manager_default_tags_count(self, tag_manager):
"""Test počtu default tagů"""
# Hodnocení má 5 hvězdiček
assert len(tag_manager.tags_by_category["Hodnocení"]) == 5
# Barva má 6 barev
assert len(tag_manager.tags_by_category["Barva"]) == 6
def test_add_category(self, tag_manager): def test_add_category(self, tag_manager):
"""Test přidání kategorie""" """Test přidání kategorie"""
@@ -141,11 +129,9 @@ class TestTagManager:
assert "Video/4K" in tags assert "Video/4K" in tags
assert "Audio/MP3" in tags assert "Audio/MP3" in tags
def test_get_all_tags_includes_defaults(self, tag_manager): def test_get_all_tags_empty_on_fresh_manager(self, tag_manager):
"""Test že get_all_tags obsahuje default tagy""" """Test že čerstvý TagManager nemá žádné tagy (bez defaultů)"""
tags = tag_manager.get_all_tags() assert tag_manager.get_all_tags() == []
# Minimálně 11 default tagů (5 hodnocení + 6 barev)
assert len(tags) >= 11
def test_get_categories_empty(self, empty_tag_manager): def test_get_categories_empty(self, empty_tag_manager):
"""Test získání kategorií (prázdný manager)""" """Test získání kategorií (prázdný manager)"""
@@ -164,11 +150,9 @@ class TestTagManager:
assert "Audio" in categories assert "Audio" in categories
assert "Foto" in categories assert "Foto" in categories
def test_get_categories_includes_defaults(self, tag_manager): def test_get_categories_empty_on_fresh_manager(self, tag_manager):
"""Test že get_categories obsahuje default kategorie""" """Test že čerstvý TagManager nemá žádné kategorie (bez defaultů)"""
categories = tag_manager.get_categories() assert tag_manager.get_categories() == []
assert "Hodnocení" in categories
assert "Barva" in categories
def test_get_tags_in_category_empty(self, tag_manager): def test_get_tags_in_category_empty(self, tag_manager):
"""Test získání tagů z prázdné kategorie""" """Test získání tagů z prázdné kategorie"""
@@ -230,81 +214,33 @@ class TestTagManager:
class TestDefaultTags: class TestDefaultTags:
"""Testy pro defaultní tagy""" """Testy pro defaultní tagy (legacy Tagger presety byly odstraněny)"""
def test_default_tags_constant_exists(self): def test_default_tags_constant_exists(self):
"""Test že DEFAULT_TAGS konstanta existuje""" """Test že DEFAULT_TAGS konstanta existuje a je prázdná"""
assert DEFAULT_TAGS is not None
assert isinstance(DEFAULT_TAGS, dict) assert isinstance(DEFAULT_TAGS, dict)
assert DEFAULT_TAGS == {}
def test_default_tags_has_hodnoceni(self): def test_legacy_presets_removed(self):
"""Test že DEFAULT_TAGS obsahuje Hodnocení""" """Test že staré předdefinované kategorie (Hodnocení, Barva) jsou pryč"""
assert "Hodnocení" in DEFAULT_TAGS assert "Hodnocení" not in DEFAULT_TAGS
assert len(DEFAULT_TAGS["Hodnocení"]) == 5 assert "Barva" not in DEFAULT_TAGS
def test_default_tags_has_barva(self): def test_tag_manager_starts_empty(self):
"""Test že DEFAULT_TAGS obsahuje Barva""" """Test že TagManager bez defaultů startuje prázdný"""
assert "Barva" in DEFAULT_TAGS
assert len(DEFAULT_TAGS["Barva"]) == 6
def test_hodnoceni_stars_content(self):
"""Test obsahu hvězdiček v Hodnocení"""
stars = DEFAULT_TAGS["Hodnocení"]
assert "" in stars
assert "⭐⭐⭐⭐⭐" in stars
def test_barva_colors_content(self):
"""Test obsahu barev v Barva"""
colors = DEFAULT_TAGS["Barva"]
# Kontrolujeme že obsahuje některé barvy
color_names = " ".join(colors)
assert "Červená" in color_names
assert "Zelená" in color_names
assert "Modrá" in color_names
def test_tag_manager_loads_all_default_tags(self):
"""Test že TagManager načte všechny default tagy"""
tm = TagManager() tm = TagManager()
assert tm.get_all_tags() == []
assert tm.get_categories() == []
for category, tag_names in DEFAULT_TAGS.items(): def test_can_add_custom_tags(self):
assert category in tm.tags_by_category """Test že lze přidat vlastní tagy do prázdného manageru"""
tags_in_category = tm.get_tags_in_category(category)
assert len(tags_in_category) == len(tag_names)
def test_can_add_custom_tags_alongside_defaults(self):
"""Test že lze přidat vlastní tagy vedle defaultních"""
tm = TagManager() tm = TagManager()
initial_count = len(tm.get_all_tags())
tm.add_tag("Custom", "MyTag") tm.add_tag("Custom", "MyTag")
assert len(tm.get_all_tags()) == initial_count + 1 assert tm.get_all_tags() == ["Custom/MyTag"]
assert "Custom" in tm.get_categories() assert "Custom" in tm.get_categories()
def test_can_remove_default_category(self):
"""Test že lze odstranit default kategorii"""
tm = TagManager()
tm.remove_category("Hodnocení")
assert "Hodnocení" not in tm.tags_by_category
def test_hodnoceni_tags_are_sorted_by_stars(self):
"""Test že tagy v Hodnocení jsou seřazeny od 1 do 5 hvězd"""
tm = TagManager()
tags = tm.get_tags_in_category("Hodnocení")
tag_names = [t.name for t in tags]
assert tag_names == ["", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"]
def test_barva_tags_are_sorted_in_predefined_order(self):
"""Test že tagy v Barva jsou seřazeny v předdefinovaném pořadí"""
tm = TagManager()
tags = tm.get_tags_in_category("Barva")
tag_names = [t.name for t in tags]
expected = ["🔴 Červená", "🟠 Oranžová", "🟡 Žlutá", "🟢 Zelená", "🔵 Modrá", "🟣 Fialová"]
assert tag_names == expected
def test_custom_category_tags_sorted_alphabetically(self): def test_custom_category_tags_sorted_alphabetically(self):
"""Test že tagy v custom kategorii jsou seřazeny abecedně""" """Test že tagy v custom kategorii jsou seřazeny abecedně"""
tm = TagManager() tm = TagManager()
@@ -316,12 +252,3 @@ class TestDefaultTags:
tag_names = [t.name for t in tags] tag_names = [t.name for t in tags]
assert tag_names == ["4K", "HD", "SD"] assert tag_names == ["4K", "HD", "SD"]
def test_can_add_tag_to_default_category(self):
"""Test že lze přidat tag do default kategorie"""
tm = TagManager()
initial_count = len(tm.get_tags_in_category("Hodnocení"))
tm.add_tag("Hodnocení", "Custom Rating")
assert len(tm.get_tags_in_category("Hodnocení")) == initial_count + 1