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

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