Add statistics and data-consistency menus, recently-added folder

This commit is contained in:
2026-07-02 06:10:14 +02:00
parent b5c2023212
commit f0f38d4257
17 changed files with 498 additions and 78 deletions
+5 -3
View File
@@ -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 = {
+25
View File
@@ -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).
+80
View File
@@ -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)
+55 -40
View File
@@ -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