diff --git a/.Curator.!gtag.bak-20260701-170242 b/.Curator.!gtag.bak-20260701-170242 new file mode 100644 index 0000000..79ef87f --- /dev/null +++ b/.Curator.!gtag.bak-20260701-170242 @@ -0,0 +1,50 @@ +{ + "window_geometry": "1669x887", + "window_maximized": false, + "last_folder": null, + "sidebar_width": 250, + "recent_folders": [], + "pool_dir": "/mnt/RHD-SRV2/Filmotéka", + "filmoteka_dir": "/mnt/RHD-SRV2/Filmy", + "copyasis_folders": [ + "Seriály", + "Dokumenty" + ], + "tag_schema": [ + { + "category": "Žánr", + "csfd_field": "genres", + "transform": null, + "filmoteka_root": "", + "filename_template": null + }, + { + "category": "Rok", + "csfd_field": "year", + "transform": null, + "filmoteka_root": "Dle roku", + "filename_template": null + }, + { + "category": "Země původu", + "csfd_field": "countries", + "transform": null, + "filmoteka_root": "Dle země původu", + "filename_template": null + }, + { + "category": "Hodnocení", + "csfd_field": "rating", + "transform": "decade_band", + "filmoteka_root": "Dle hodnocení", + "filename_template": null + }, + { + "category": "Kolekce", + "csfd_field": null, + "transform": null, + "filmoteka_root": "Dle kolekce", + "filename_template": "{Sort} - {title}{ext}" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a59bfda..0368982 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,13 +21,43 @@ Each version entry uses these sections (include only those that apply): ## Unreleased +## 1.7.0 — 2026-07-02 + ### Added +- **Nastavení → "Statistiky…"**: a library overview dialog — total movies, total + size, average ČSFD rating, with/without ČSFD link, untagged count, and a + per-category tag breakdown (counts sorted by frequency). + `FileManager.statistics()` aggregates it. +- **Testy menu → "Kontrola konzistence dat…"**: compares the pool index against + the actual files in pool/Filmy and reports **metadata without a file** (movie + deleted directly) and **files without metadata** (dropped into the pool without + importing). Read-only diagnosis (`FileManager.check_data_consistency`). +- **"- Nově přidané"** special folder in the Filmotéka: the **10** most recently + added movies as hardlinks (while **"- Tipy dne"** holds **15** random ones). + Import now records an **`added`** timestamp on each movie (`File.added`); + ordering falls back to the file's **mtime** for older items — not ctime, which + the hardlink generation itself bumps (`File.added_timestamp`, + `HardlinkManager.generate_recently_added`). - Standalone **`scripts/tipy_dne.py`** to regenerate the "Tipy dne" folder independently of the app (deployable on the server / cron): reads the pool index, picks N random movies (default 15), clears the tips folder and recreates the hardlinks. Stdlib only; paths via `--pool`/`--output` or the global config. Ships with a systemd oneshot `tipy-dne.service` + daily `tipy-dne.timer`. +### Changed +- **Special folders are prefixed with "- "** so DLNA/TV browsers sort them ahead + of the genre folders at the output root: the grouping roots (now `- Dle roku`, + `- Dle země původu`, `- Dle hodnocení`, …) plus `- Tipy dne` and `- Nově + přidané`. Genre folders stay at the root, listed after the special ones. + +### Fixed +- Obsolete-link cleanup now also removes **orphan links** in the tag tree — + hardlinks pointing at an inode no longer in the pool (movie re-imported / + replaced) — not just links whose current pool inode moved. Within a managed + folder, any file that isn't an expected link is swept, so leftovers from a + renamed layout (e.g. an old unprefixed `Dle roku`) are cleaned up on the next + generation instead of lingering forever. + ## 1.6.0 — 2026-06-16 ### Added diff --git a/Curator.py b/Curator.py index c09c57b..6284b00 100644 --- a/Curator.py +++ b/Curator.py @@ -32,3 +32,4 @@ def main() -> None: if __name__ == "__main__": main() + diff --git a/PROJECT.md b/PROJECT.md index 4a6a8bf..6a9516c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -124,6 +124,17 @@ movie table, and one-click Filmotéka generation. - **Tag provenance (ČSFD vs user):** each file records which tags came from ČSFD (`csfd_tags`). Re-fetching regenerates only those; user-added tags are kept, so changing a movie's ČSFD link refreshes ČSFD tags without losing manual ones. +- **Special folders + "- " prefix:** besides the tag tree, generation produces + `- Tipy dne` (15 random) and `- Nově přidané` (10 newest by `File.added`, mtime + fallback — ctime is bumped by hardlinking). All grouping folders and these + specials are prefixed with **"- "** so + DLNA/TV browsers sort them before the genre folders at the output root. Specials + are `reserved_subfolders` so cleanup never touches them; renaming old unprefixed + folders is handled automatically (they become stale root-level folders and are + swept on the next generation). +- **Date added:** `import_movie` stamps `File.added` (ISO); older items without it + fall back to the file's mtime for the "recently added" ordering (ctime is + unreliable — creating the Filmotéka hardlinks bumps it). ## Tasks diff --git a/pyproject.toml b/pyproject.toml index 4dfbbc6..33a0143 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "curator" -version = "1.6.0" +version = "1.7.0" description = "" authors = [ {name = "jan.doubravsky@gmail.com"} diff --git a/scripts/tipy-dne.service b/scripts/tipy-dne.service index a4f475d..e1f13ed 100644 --- a/scripts/tipy-dne.service +++ b/scripts/tipy-dne.service @@ -1,27 +1,12 @@ -# systemd oneshot service for the standalone "Tipy dne" generator. -# Runs scripts/tipy_dne.py once; schedule it with the matching tipy-dne.timer. -# -# Install (as root): -# cp scripts/tipy_dne.py /opt/curator/tipy_dne.py -# cp scripts/tipy-dne.service scripts/tipy-dne.timer /etc/systemd/system/ -# # edit User/paths below to match the server, then: -# systemctl daemon-reload -# systemctl enable --now tipy-dne.timer -# systemctl start tipy-dne.service # run once now to test -# -# Adjust: User/Group, the script path, and --pool / --output to your server. - [Unit] -Description=Generate the "Tipy dne" Filmotéka folder +Description=Generate the "Tipy dne" Filmoteka folder After=local-fs.target remote-fs.target +RequiresMountsFor=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0 [Service] Type=oneshot User=honza Group=honza -ExecStart=/usr/bin/python3 /opt/curator/tipy_dne.py \ - --pool /mnt/RHD-SRV2/Filmoteka \ - --output /mnt/RHD-SRV2/Filmy/Filmoteka_v2 \ - --count 15 +ExecStart=/usr/bin/python3 /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka/Tools/tipy_dne.py --pool /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka --output /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmy --count 15 Nice=10 NoNewPrivileges=true diff --git a/scripts/tipy-dne.timer b/scripts/tipy-dne.timer index d72d97a..995e52e 100644 --- a/scripts/tipy-dne.timer +++ b/scripts/tipy-dne.timer @@ -1,13 +1,9 @@ -# systemd timer for tipy-dne.service — regenerates "Tipy dne" daily. -# Enable with: systemctl enable --now tipy-dne.timer - [Unit] -Description=Daily regeneration of the "Tipy dne" Filmotéka folder +Description=Daily regeneration of the "Tipy dne" Filmoteka folder [Timer] -# Every day at 06:00 (server local time). Change as needed, e.g. "hourly". -OnCalendar=*-*-* 06:00:00 -# Catch up if the machine was off at the scheduled time. +# Kazdy den o pulnoci (mistni cas serveru = CEST/CET) +OnCalendar=*-*-* 00:00:00 Persistent=true [Install] diff --git a/scripts/tipy_dne.py b/scripts/tipy_dne.py index 4cb6ddb..d6040b5 100644 --- a/scripts/tipy_dne.py +++ b/scripts/tipy_dne.py @@ -25,7 +25,7 @@ from pathlib import Path INDEX_FILENAME = ".Curator.!index" GLOBAL_CONFIG_FILENAME = ".Curator.!gtag" -DEFAULT_SUBFOLDER = "Tipy dne" +DEFAULT_SUBFOLDER = "- Tipy dne" # "- " prefix sorts it first in DLNA/TV browsers DEFAULT_COUNT = 15 diff --git a/src/_version.py b/src/_version.py index a1c6637..6d8f6bb 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "1.6.0" +__version__ = "1.7.0" diff --git a/src/core/config.py b/src/core/config.py index 6d7d04b..8e6b660 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -33,13 +33,15 @@ FOLDER_CONFIG_NAME = ".Curator.!ftag" # category's folders (e.g. "{year} - {title}{ext}"); fields: # title / year / rating / ext / stem / filename. Absent/None = # keep the pool filename. Pool files are never renamed by this. +# Grouping folders are prefixed with "- " so DLNA/TV browsers sort the special +# folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root. DEFAULT_TAG_SCHEMA = [ {"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""}, - {"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"}, + {"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"}, {"category": "Země původu", "csfd_field": "countries", "transform": None, - "filmoteka_root": "Dle země původu"}, + "filmoteka_root": "- Dle země původu"}, {"category": "Hodnocení", "csfd_field": "rating", "transform": "decade_band", - "filmoteka_root": "Dle hodnocení"}, + "filmoteka_root": "- Dle hodnocení"}, ] DEFAULT_GLOBAL_CONFIG = { diff --git a/src/core/file.py b/src/core/file.py index 5ac2c76..efe71f9 100644 --- a/src/core/file.py +++ b/src/core/file.py @@ -24,6 +24,9 @@ class File: # movie-library fields self.title: str | None = None self.csfd_link: str | None = None + # ISO timestamp of when the movie was added to the pool (set on import); + # None for older/unknown → falls back to the file's ctime. + self.added: str | None = None # Cached CSFD data — avoids re-fetching on every open self.csfd_cache: dict | None = None # full_paths of tags that came from ČSFD (vs. user-added); only these are @@ -60,6 +63,7 @@ class File: self.csfd_cache = None self.csfd_tag_paths = set() self.attributes = {} + self.added = None def _build_record(self) -> dict: data = { @@ -74,6 +78,7 @@ class File: "title": self.title, "csfd_link": self.csfd_link, "attributes": self.attributes, + "added": self.added, } if self.csfd_cache is not None: data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache} @@ -89,6 +94,7 @@ class File: # Legacy records have no provenance → treated as all-user (empty set) self.csfd_tag_paths = set(data.get("csfd_tags", [])) self.attributes = dict(data.get("attributes", {})) + self.added = data.get("added", None) raw_cache = data.get("csfd_cache") if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION: self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"} @@ -117,6 +123,25 @@ class File: data = json.load(f) self._apply_record(data) + def added_timestamp(self) -> float: + """Epoch seconds for 'date added' — the stored ``added`` or the file mtime. + + Used to order the "recently added" folder (newer = larger). The fallback + is **mtime**, not ctime: creating the Filmotéka hardlinks bumps the source + inode's ctime, which would make ctime-based ordering meaningless, whereas + mtime (preserved from the source on import) stays stable. + """ + if self.added: + try: + from datetime import datetime + return datetime.fromisoformat(self.added).timestamp() + except ValueError: + pass + try: + return self.file_path.stat().st_mtime + except OSError: + return 0.0 + def name_context(self) -> dict: """Fields for a Filmotéka filename template (see config tag schema). diff --git a/src/core/file_manager.py b/src/core/file_manager.py index ea05414..2dd7aeb 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -1,4 +1,6 @@ from pathlib import Path +from datetime import datetime +from collections import Counter import shutil from .file import File from .tag_manager import TagManager @@ -136,6 +138,83 @@ class FileManager: file_obj = File(each, self.tagmanager, index=self.index) self.filelist.append(file_obj) + def check_data_consistency(self) -> dict: + """Compare the pool index against the actual files in pool/Filmy. + + Detects drift such as a movie file deleted directly (metadata left behind) + or a file dropped into the pool without importing (no metadata). + + Returns a dict with: + ok — False if the pool isn't set (with 'error') + missing — index keys whose file no longer exists on disk + untracked — pool files (pool-relative) with no index entry + index_count / disk_count — totals for the summary + """ + pool = self.pool_dir + movies = self.movies_dir + if not (pool and movies and movies.is_dir()): + return {"ok": False, "error": "Pool není nastaven.", + "missing": [], "untracked": []} + + index = self.index or PoolIndex(pool) + index_keys = set(index.records.keys()) + + disk_keys = set() + for f in list_files(movies): + if f.name.endswith(METADATA_SUFFIXES): + continue + disk_keys.add(f.relative_to(pool).as_posix()) + + missing = sorted(k for k in index_keys if not (pool / k).is_file()) + untracked = sorted(disk_keys - index_keys) + return { + "ok": True, + "missing": missing, + "untracked": untracked, + "index_count": len(index_keys), + "disk_count": len(disk_keys), + } + + def statistics(self) -> dict: + """Aggregate stats over the loaded pool movies for the stats view. + + Returns a dict with totals and, per tag category, the tag counts sorted + by frequency (descending). + """ + files = self.filelist + total_size = 0 + with_csfd = 0 + untagged = 0 + ratings: list[int] = [] + categories: dict[str, Counter] = {} + + for f in files: + try: + total_size += f.file_path.stat().st_size + except OSError: + pass + if f.csfd_link: + with_csfd += 1 + if not f.tags: + untagged += 1 + for tag in f.tags: + categories.setdefault(tag.category, Counter())[tag.name] += 1 + rating = (f.csfd_cache or {}).get("rating") + if isinstance(rating, (int, float)): + ratings.append(rating) + + return { + "count": len(files), + "total_size": total_size, + "with_csfd": with_csfd, + "without_csfd": len(files) - with_csfd, + "untagged": untagged, + "avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None, + "categories": { + cat: counter.most_common() for cat, counter in categories.items() + }, + } + def pooled_with_stem(self, title: str) -> list[File]: """Pooled movies whose filename stem matches ``title`` (case-insensitive).""" stem = title.strip().lower() @@ -204,6 +283,7 @@ class FileManager: file_obj = File(target, self.tagmanager, index=self.index) file_obj.title = safe_title file_obj.csfd_link = csfd_link or None + file_obj.added = datetime.now().isoformat(timespec="seconds") file_obj.save_metadata() self.filelist.append(file_obj) diff --git a/src/core/hardlink_manager.py b/src/core/hardlink_manager.py index 8c00d40..08d8d9f 100644 --- a/src/core/hardlink_manager.py +++ b/src/core/hardlink_manager.py @@ -274,32 +274,20 @@ class HardlinkManager: return success_count, fail_count - def generate_random_tips( - self, - files: List[File], - count: int = 10, - subfolder: str = "Tipy dne", - dry_run: bool = False, + def _fill_special_folder( + self, chosen: List[File], subfolder: str, dry_run: bool ) -> Tuple[int, int]: - """(Re)fill ``subfolder`` with ``count`` random files as hardlinks. - - The folder is emptied first so each run picks a fresh random selection. - ``files`` should be the pool movies only (copy-as-is mirrors excluded). - - Returns: - Tuple of (created_links, failed_links) - """ + """Empty ``subfolder`` and hardlink ``chosen`` files into it.""" base = self.output_dir / subfolder if not dry_run and base.exists(): for child in base.iterdir(): - if child.is_file(): + if child.is_file() or child.is_symlink(): try: child.unlink() except OSError: pass - chosen = random.sample(files, min(count, len(files))) created = 0 fail = 0 for file_obj in chosen: @@ -318,9 +306,41 @@ class HardlinkManager: except OSError as e: self.errors.append((file_obj.file_path, str(e))) fail += 1 - return created, fail + def generate_recently_added( + self, + files: List[File], + count: int = 10, + subfolder: str = "- Nově přidané", + dry_run: bool = False, + ) -> Tuple[int, int]: + """(Re)fill ``subfolder`` with the ``count`` most recently added movies. + + Ordering is by ``File.added_timestamp()`` (import time, ctime fallback), + newest first. Returns (created_links, failed_links). + """ + chosen = sorted(files, key=lambda f: f.added_timestamp(), reverse=True)[:count] + return self._fill_special_folder(chosen, subfolder, dry_run) + + def generate_random_tips( + self, + files: List[File], + count: int = 10, + subfolder: str = "Tipy dne", + dry_run: bool = False, + ) -> Tuple[int, int]: + """(Re)fill ``subfolder`` with ``count`` random files as hardlinks. + + The folder is emptied first so each run picks a fresh random selection. + ``files`` should be the pool movies only (copy-as-is mirrors excluded). + + Returns: + Tuple of (created_links, failed_links) + """ + chosen = random.sample(files, min(count, len(files))) + return self._fill_special_folder(chosen, subfolder, dry_run) + def _is_same_file(self, path1: Path, path2: Path) -> bool: """Check if two paths point to the same file (same inode).""" try: @@ -458,23 +478,20 @@ class HardlinkManager: except OSError: continue - # Build expected paths for each file based on current tags - expected_paths: dict[int, set[Path]] = {} + # Every link the tag tree *should* contain, given current files+tags. + expected_all: set[Path] = set() for file_obj in files: - try: - inode = file_obj.file_path.stat().st_ino - expected_paths[inode] = set() + for tag in file_obj.tags: + target_dir = self._target_dir(tag, roots, category_transforms) + if target_dir is None: + continue + expected_all.add(target_dir / self._link_name( + file_obj, tag, category_filename_templates)) - for tag in file_obj.tags: - target_dir = self._target_dir(tag, roots, category_transforms) - if target_dir is None: - continue - expected_paths[inode].add(target_dir / self._link_name( - file_obj, tag, category_filename_templates)) - except OSError: - continue - - # Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors) + # Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors). + # Inside them, any file that isn't an expected link is obsolete — whether + # a tag was removed, the movie was replaced (orphan inode), or the folder + # itself is a leftover from a renamed layout. top_dirs = self._managed_top_dirs( files, roots, category_transforms, reserved_subfolders) for top in self.output_dir.iterdir(): @@ -483,18 +500,16 @@ class HardlinkManager: if top_dirs is not None and top.name not in top_dirs: continue - # Depth-agnostic: genres sit one level deep, "Dle roku"/"Dle země - # původu" two levels deep — walk all files under the managed folder. for link_file in top.rglob("*"): if not link_file.is_file(): continue - try: - link_inode = link_file.stat().st_ino - if link_inode in expected_paths: - if link_file not in expected_paths[link_inode]: - obsolete.append((link_file, inode_to_file[link_inode].file_path)) - except OSError: + if link_file in expected_all: continue + try: + source = inode_to_file[link_file.stat().st_ino].file_path + except (OSError, KeyError): + source = link_file # orphan (no current pool movie) + obsolete.append((link_file, source)) return obsolete diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index bc99a25..4721911 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -30,6 +30,12 @@ from src.core.tag import Tag from src.constants import APP_TITLE from src.core.hardlink_manager import HardlinkManager +# Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV +# browsers sort them ahead of the genre folders at the output root. +TIPS_SUBFOLDER = "- Tipy dne" # random selection, refreshed each run +RECENT_SUBFOLDER = "- Nově přidané" # most recently added movies +TIPS_COUNT = 15 # random tips +RECENT_COUNT = 10 # newest by date added class ImportMoviesDialog(QDialog): @@ -408,6 +414,44 @@ class TagSchemaDialog(QDialog): self.accept() +class StatsDialog(QDialog): + """Read-only overview of the pool: totals + per-category tag breakdown.""" + + def __init__(self, parent: QWidget, stats: dict, size_text: str) -> None: + super().__init__(parent) + self.setWindowTitle("Statistiky knihovny") + self.setMinimumSize(460, 560) + + layout = QVBoxLayout(self) + avg = stats["avg_rating"] + summary = ( + f"Filmů v poolu: {stats['count']}
" + f"Celková velikost: {size_text}
" + f"Průměrné hodnocení ČSFD: " + f"{str(avg) + ' %' if avg is not None else '—'}
" + f"S ČSFD odkazem: {stats['with_csfd']}  ·  " + f"bez: {stats['without_csfd']}
" + f"Bez štítků: {stats['untagged']}" + ) + layout.addWidget(QLabel(summary)) + + tree = QTreeWidget() + tree.setHeaderLabels(["Kategorie / štítek", "Počet"]) + tree.setColumnWidth(0, 300) + for category in sorted(stats["categories"]): + entries = stats["categories"][category] + cat_item = QTreeWidgetItem([f"{category} ({len(entries)})", ""]) + tree.addTopLevelItem(cat_item) + for name, count in entries: + cat_item.addChild(QTreeWidgetItem([name, str(count)])) + layout.addWidget(tree) + + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(self.reject) + buttons.accepted.connect(self.accept) + layout.addWidget(buttons) + + class QtApp(QMainWindow): def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None: super().__init__() @@ -470,6 +514,11 @@ class QtApp(QMainWindow): settings_menu = bar.addMenu("&Nastavení") self._add_action(settings_menu, "Tag schéma…", self.edit_tag_schema) + self._add_action(settings_menu, "Statistiky…", self.show_statistics) + + tests_menu = bar.addMenu("&Testy") + self._add_action(tests_menu, "Kontrola konzistence dat…", self.check_consistency) + def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction: action = QAction(text, self) if shortcut: @@ -957,7 +1006,8 @@ class QtApp(QMainWindow): QMessageBox.information(self, "Filmotéka", "Pool je prázdný.") return manager = HardlinkManager(out) - reserved = set(self.filehandler.copyasis_folders) | {"Tipy dne"} + reserved = (set(self.filehandler.copyasis_folders) + | {TIPS_SUBFOLDER, RECENT_SUBFOLDER}) created, create_fail, removed, remove_fail = manager.sync_structure( files, category_roots=self.filehandler.filmoteka_category_roots(), @@ -978,22 +1028,26 @@ class QtApp(QMainWindow): mirrored += m_created mirror_fail += m_failed - # "Tipy dne": 10 random pool movies (copy-as-is folders excluded) - tips, tips_fail = manager.generate_random_tips(files, count=10) + # Special folders (pool only; copy-as-is excluded): random tips + newest + tips, tips_fail = manager.generate_random_tips( + files, count=TIPS_COUNT, subfolder=TIPS_SUBFOLDER) + recent, recent_fail = manager.generate_recently_added( + files, count=RECENT_COUNT, subfolder=RECENT_SUBFOLDER) msg = ( f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n" f"Copy-as-is — zrcadleno: {mirrored}\n" - f"Tipy dne — náhodně: {tips}" + f"Tipy dne — náhodně: {tips}, Nově přidané: {recent}" ) - if create_fail or remove_fail or mirror_fail or tips_fail: - msg += f"\nSelhalo: {create_fail + remove_fail + mirror_fail + tips_fail}" + failed = create_fail + remove_fail + mirror_fail + tips_fail + recent_fail + if failed: + msg += f"\nSelhalo: {failed}" QMessageBox.warning(self, "Filmotéka dokončena s chybami", msg) else: QMessageBox.information(self, "Filmotéka vygenerována", msg) self.status.showMessage( f"Filmotéka: filmy +{created}/-{removed}, copy-as-is +{mirrored}, " - f"tipy +{tips}", 5000 + f"tipy +{tips}, nově +{recent}", 5000 ) def edit_copyasis_folders(self) -> None: @@ -1021,6 +1075,56 @@ class QtApp(QMainWindow): 8000, ) + def show_statistics(self) -> None: + if not self.filehandler.filelist: + QMessageBox.information(self, "Statistiky", "Pool je prázdný.") + return + stats = self.filehandler.statistics() + StatsDialog(self, stats, self._format_size(stats["total_size"])).exec() + + def check_consistency(self) -> None: + """Test: does the pool index match the actual files in pool/Filmy?""" + if not self.filehandler.movies_dir: + QMessageBox.information(self, "Testy", "Nejprve nastavte pool.") + return + result = self.filehandler.check_data_consistency() + if not result["ok"]: + QMessageBox.warning(self, "Kontrola konzistence", result["error"]) + return + + missing, untracked = result["missing"], result["untracked"] + if not missing and not untracked: + QMessageBox.information( + self, "Kontrola konzistence dat", + f"✅ Vše v pořádku — index a pool si odpovídají.\n\n" + f"Záznamů v indexu: {result['index_count']}\n" + f"Souborů v poolu: {result['disk_count']}", + ) + return + + detail: list[str] = [] + if missing: + detail.append( + f"■ Metadata bez souboru ({len(missing)}) — soubor byl smazán " + "přímo, záznam v indexu zůstal:") + detail += [f" • {k}" for k in missing] + detail.append("") + if untracked: + detail.append( + f"■ Soubory bez metadat ({len(untracked)}) — přidané do poolu " + "mimo import:") + detail += [f" • {k}" for k in untracked] + + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning) + box.setWindowTitle("Kontrola konzistence — nalezeny nesrovnalosti") + box.setText( + f"Metadata bez souboru: {len(missing)}\n" + f"Soubory bez metadat: {len(untracked)}\n\n" + "Podrobnosti zobrazíš přes „Show Details…\".") + box.setDetailedText("\n".join(detail)) + box.exec() + def closeEvent(self, event) -> None: # noqa: N802 — Qt override self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}" from src.core.config import save_global_config diff --git a/tests/test_file.py b/tests/test_file.py index bdc40dd..d95e9de 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -335,6 +335,18 @@ class TestApplyCsfdTags: movie_file.add_tag("Rok/1999") assert movie_file.name_context()["year"] == "1999" + def test_added_timestamp_uses_stored_added(self, movie_file): + movie_file.added = "2026-06-16T12:00:00" + from datetime import datetime + assert movie_file.added_timestamp() == datetime.fromisoformat( + "2026-06-16T12:00:00").timestamp() + + def test_added_timestamp_falls_back_to_mtime(self, movie_file): + movie_file.added = None + import os + expected = os.stat(movie_file.file_path).st_mtime + assert movie_file.added_timestamp() == expected + def test_set_attribute_persists_and_in_context(self, movie_file): movie_file.set_attribute("collection_sort", "03") assert movie_file.attributes["collection_sort"] == "03" diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index 78357ad..88853d7 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -592,6 +592,72 @@ class TestPoolManagement: assert movie.csfd_link == "https://csfd.cz/film/1" assert file_manager.index.get(movie.file_path) is not None + def test_statistics_aggregates_pool(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"x" * 100) + (tmp_path / "b.mkv").write_bytes(b"y" * 50) + a = file_manager.import_movie(tmp_path / "a.mkv", "A", "https://csfd/1") + a.add_tag("Žánr/Akční") + a.add_tag("Žánr/Sci-Fi") + a.csfd_cache = {"rating": 90} + a.save_metadata() + b = file_manager.import_movie(tmp_path / "b.mkv", "B") + b.add_tag("Žánr/Akční") + + s = file_manager.statistics() + assert s["count"] == 2 + assert s["total_size"] == 150 + assert s["with_csfd"] == 1 and s["without_csfd"] == 1 + assert s["untagged"] == 0 + assert s["avg_rating"] == 90 + # Akční on both movies → count 2, sorted first + assert dict(s["categories"]["Žánr"])["Akční"] == 2 + assert s["categories"]["Žánr"][0] == ("Akční", 2) + + def test_check_data_consistency_clean(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"a") + file_manager.import_movie(tmp_path / "a.mkv", "Matrix") + + result = file_manager.check_data_consistency() + assert result["ok"] + assert result["missing"] == [] + assert result["untracked"] == [] + assert result["index_count"] == 1 and result["disk_count"] == 1 + + def test_check_data_consistency_detects_missing_file(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"a") + movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") + movie.file_path.unlink() # someone deleted the file directly + + result = file_manager.check_data_consistency() + assert result["missing"] == ["Filmy/Matrix.mkv"] + assert result["untracked"] == [] + + def test_check_data_consistency_detects_untracked_file(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"a") + file_manager.import_movie(tmp_path / "a.mkv", "Matrix") + # a file dropped into the pool without importing + (file_manager.movies_dir / "Sneaked.mkv").write_bytes(b"x") + + result = file_manager.check_data_consistency() + assert result["missing"] == [] + assert result["untracked"] == ["Filmy/Sneaked.mkv"] + + def test_import_movie_records_added_timestamp(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + source = tmp_path / "raw.mkv" + source.write_bytes(b"x") + movie = file_manager.import_movie(source, "Matrix") + + assert movie.added is not None + # parseable ISO timestamp, persisted in the index + from datetime import datetime + datetime.fromisoformat(movie.added) + assert file_manager.index.get(movie.file_path)["added"] == movie.added + def test_import_movie_move_removes_source(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mkv" diff --git a/tests/test_hardlink_manager.py b/tests/test_hardlink_manager.py index 5546f69..56fac36 100644 --- a/tests/test_hardlink_manager.py +++ b/tests/test_hardlink_manager.py @@ -143,6 +143,32 @@ class TestHardlinkManager: # The mirror (reserved folder) is left alone assert mirror_link.exists() + def test_sync_removes_orphan_link_from_replaced_movie( + self, temp_source_dir, temp_output_dir, tag_manager + ): + """A stale link pointing at a no-longer-pooled inode is cleaned up.""" + f = File(temp_source_dir / "file1.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("žánr", "Drama")) # a root-level category enables the sweep + f.add_tag(Tag("rok", "1968")) + roots = {"žánr": "", "rok": "- Dle roku"} + manager = HardlinkManager(temp_output_dir) + manager.sync_structure([f], category_roots=roots) + + # Simulate a leftover from an OLD layout: a different file (different + # inode, not in the pool) hardlinked under an old unprefixed folder. + orphan_src = temp_source_dir / "orphan.txt" + orphan_src.write_text("old data") + old_dir = temp_output_dir / "Dle roku" / "1968" + old_dir.mkdir(parents=True) + os.link(orphan_src, old_dir / "file1.txt") + + manager.sync_structure([f], category_roots=roots) + + # new prefixed folder kept, stale unprefixed one (orphan) removed + assert (temp_output_dir / "- Dle roku" / "1968" / "file1.txt").exists() + assert not (temp_output_dir / "Dle roku").exists() + def test_sync_removes_root_genre_folder_when_last_movie_drops_tag( self, temp_source_dir, temp_output_dir, tag_manager ): @@ -255,6 +281,23 @@ class TestHardlinkManager: tips = temp_output_dir / "Tipy dne" assert len(list(tips.iterdir())) == 1 # not accumulated to 2 + def test_generate_recently_added_orders_by_added( + self, files_with_tags, temp_output_dir + ): + """The newest-added movies (by added_timestamp) fill the folder.""" + # explicit added timestamps: file2 newest, file1 middle, file3 oldest + files_with_tags[0].added = "2026-01-02T00:00:00" + files_with_tags[1].added = "2026-01-03T00:00:00" + files_with_tags[2].added = "2026-01-01T00:00:00" + manager = HardlinkManager(temp_output_dir) + created, fail = manager.generate_recently_added( + files_with_tags, count=2, subfolder="- Nově přidané") + + recent = temp_output_dir / "- Nově přidané" + assert created == 2 and fail == 0 + names = {p.name for p in recent.iterdir()} + assert names == {"file2.txt", "file1.txt"} # newest two, file3 excluded + def test_dry_run(self, files_with_tags, temp_output_dir): """Test dry run (bez skutečného vytváření)""" manager = HardlinkManager(temp_output_dir)