Add statistics and data-consistency menus, recently-added folder
This commit is contained in:
@@ -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}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -21,13 +21,43 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.7.0 — 2026-07-02
|
||||||
|
|
||||||
### Added
|
### 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
|
- Standalone **`scripts/tipy_dne.py`** to regenerate the "Tipy dne" folder
|
||||||
independently of the app (deployable on the server / cron): reads the pool
|
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
|
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.
|
the hardlinks. Stdlib only; paths via `--pool`/`--output` or the global config.
|
||||||
Ships with a systemd oneshot `tipy-dne.service` + daily `tipy-dne.timer`.
|
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
|
## 1.6.0 — 2026-06-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -32,3 +32,4 @@ def main() -> None:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
+11
@@ -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
|
- **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
|
(`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.
|
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
|
## Tasks
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "curator"
|
name = "curator"
|
||||||
version = "1.6.0"
|
version = "1.7.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "jan.doubravsky@gmail.com"}
|
{name = "jan.doubravsky@gmail.com"}
|
||||||
|
|||||||
@@ -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]
|
[Unit]
|
||||||
Description=Generate the "Tipy dne" Filmotéka folder
|
Description=Generate the "Tipy dne" Filmoteka folder
|
||||||
After=local-fs.target remote-fs.target
|
After=local-fs.target remote-fs.target
|
||||||
|
RequiresMountsFor=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=honza
|
User=honza
|
||||||
Group=honza
|
Group=honza
|
||||||
ExecStart=/usr/bin/python3 /opt/curator/tipy_dne.py \
|
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
|
||||||
--pool /mnt/RHD-SRV2/Filmoteka \
|
|
||||||
--output /mnt/RHD-SRV2/Filmy/Filmoteka_v2 \
|
|
||||||
--count 15
|
|
||||||
Nice=10
|
Nice=10
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
# systemd timer for tipy-dne.service — regenerates "Tipy dne" daily.
|
|
||||||
# Enable with: systemctl enable --now tipy-dne.timer
|
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Daily regeneration of the "Tipy dne" Filmotéka folder
|
Description=Daily regeneration of the "Tipy dne" Filmoteka folder
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
# Every day at 06:00 (server local time). Change as needed, e.g. "hourly".
|
# Kazdy den o pulnoci (mistni cas serveru = CEST/CET)
|
||||||
OnCalendar=*-*-* 06:00:00
|
OnCalendar=*-*-* 00:00:00
|
||||||
# Catch up if the machine was off at the scheduled time.
|
|
||||||
Persistent=true
|
Persistent=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
INDEX_FILENAME = ".Curator.!index"
|
INDEX_FILENAME = ".Curator.!index"
|
||||||
GLOBAL_CONFIG_FILENAME = ".Curator.!gtag"
|
GLOBAL_CONFIG_FILENAME = ".Curator.!gtag"
|
||||||
DEFAULT_SUBFOLDER = "Tipy dne"
|
DEFAULT_SUBFOLDER = "- Tipy dne" # "- " prefix sorts it first in DLNA/TV browsers
|
||||||
DEFAULT_COUNT = 15
|
DEFAULT_COUNT = 15
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
"""Auto-generated — do not edit manually."""
|
"""Auto-generated — do not edit manually."""
|
||||||
__version__ = "1.6.0"
|
__version__ = "1.7.0"
|
||||||
|
|||||||
+5
-3
@@ -33,13 +33,15 @@ FOLDER_CONFIG_NAME = ".Curator.!ftag"
|
|||||||
# category's folders (e.g. "{year} - {title}{ext}"); fields:
|
# category's folders (e.g. "{year} - {title}{ext}"); fields:
|
||||||
# title / year / rating / ext / stem / filename. Absent/None =
|
# title / year / rating / ext / stem / filename. Absent/None =
|
||||||
# keep the pool filename. Pool files are never renamed by this.
|
# 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 = [
|
DEFAULT_TAG_SCHEMA = [
|
||||||
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
|
{"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,
|
{"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",
|
{"category": "Hodnocení", "csfd_field": "rating", "transform": "decade_band",
|
||||||
"filmoteka_root": "Dle hodnocení"},
|
"filmoteka_root": "- Dle hodnocení"},
|
||||||
]
|
]
|
||||||
|
|
||||||
DEFAULT_GLOBAL_CONFIG = {
|
DEFAULT_GLOBAL_CONFIG = {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class File:
|
|||||||
# movie-library fields
|
# movie-library fields
|
||||||
self.title: str | None = None
|
self.title: str | None = None
|
||||||
self.csfd_link: 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
|
# Cached CSFD data — avoids re-fetching on every open
|
||||||
self.csfd_cache: dict | None = None
|
self.csfd_cache: dict | None = None
|
||||||
# full_paths of tags that came from ČSFD (vs. user-added); only these are
|
# 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_cache = None
|
||||||
self.csfd_tag_paths = set()
|
self.csfd_tag_paths = set()
|
||||||
self.attributes = {}
|
self.attributes = {}
|
||||||
|
self.added = None
|
||||||
|
|
||||||
def _build_record(self) -> dict:
|
def _build_record(self) -> dict:
|
||||||
data = {
|
data = {
|
||||||
@@ -74,6 +78,7 @@ class File:
|
|||||||
"title": self.title,
|
"title": self.title,
|
||||||
"csfd_link": self.csfd_link,
|
"csfd_link": self.csfd_link,
|
||||||
"attributes": self.attributes,
|
"attributes": self.attributes,
|
||||||
|
"added": self.added,
|
||||||
}
|
}
|
||||||
if self.csfd_cache is not None:
|
if self.csfd_cache is not None:
|
||||||
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
|
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)
|
# Legacy records have no provenance → treated as all-user (empty set)
|
||||||
self.csfd_tag_paths = set(data.get("csfd_tags", []))
|
self.csfd_tag_paths = set(data.get("csfd_tags", []))
|
||||||
self.attributes = dict(data.get("attributes", {}))
|
self.attributes = dict(data.get("attributes", {}))
|
||||||
|
self.added = data.get("added", None)
|
||||||
raw_cache = data.get("csfd_cache")
|
raw_cache = data.get("csfd_cache")
|
||||||
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION:
|
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"}
|
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)
|
data = json.load(f)
|
||||||
self._apply_record(data)
|
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:
|
def name_context(self) -> dict:
|
||||||
"""Fields for a Filmotéka filename template (see config tag schema).
|
"""Fields for a Filmotéka filename template (see config tag schema).
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from collections import Counter
|
||||||
import shutil
|
import shutil
|
||||||
from .file import File
|
from .file import File
|
||||||
from .tag_manager import TagManager
|
from .tag_manager import TagManager
|
||||||
@@ -136,6 +138,83 @@ class FileManager:
|
|||||||
file_obj = File(each, self.tagmanager, index=self.index)
|
file_obj = File(each, self.tagmanager, index=self.index)
|
||||||
self.filelist.append(file_obj)
|
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]:
|
def pooled_with_stem(self, title: str) -> list[File]:
|
||||||
"""Pooled movies whose filename stem matches ``title`` (case-insensitive)."""
|
"""Pooled movies whose filename stem matches ``title`` (case-insensitive)."""
|
||||||
stem = title.strip().lower()
|
stem = title.strip().lower()
|
||||||
@@ -204,6 +283,7 @@ class FileManager:
|
|||||||
file_obj = File(target, self.tagmanager, index=self.index)
|
file_obj = File(target, self.tagmanager, index=self.index)
|
||||||
file_obj.title = safe_title
|
file_obj.title = safe_title
|
||||||
file_obj.csfd_link = csfd_link or None
|
file_obj.csfd_link = csfd_link or None
|
||||||
|
file_obj.added = datetime.now().isoformat(timespec="seconds")
|
||||||
file_obj.save_metadata()
|
file_obj.save_metadata()
|
||||||
|
|
||||||
self.filelist.append(file_obj)
|
self.filelist.append(file_obj)
|
||||||
|
|||||||
@@ -274,32 +274,20 @@ class HardlinkManager:
|
|||||||
|
|
||||||
return success_count, fail_count
|
return success_count, fail_count
|
||||||
|
|
||||||
def generate_random_tips(
|
def _fill_special_folder(
|
||||||
self,
|
self, chosen: List[File], subfolder: str, dry_run: bool
|
||||||
files: List[File],
|
|
||||||
count: int = 10,
|
|
||||||
subfolder: str = "Tipy dne",
|
|
||||||
dry_run: bool = False,
|
|
||||||
) -> Tuple[int, int]:
|
) -> Tuple[int, int]:
|
||||||
"""(Re)fill ``subfolder`` with ``count`` random files as hardlinks.
|
"""Empty ``subfolder`` and hardlink ``chosen`` files into it."""
|
||||||
|
|
||||||
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)
|
|
||||||
"""
|
|
||||||
base = self.output_dir / subfolder
|
base = self.output_dir / subfolder
|
||||||
|
|
||||||
if not dry_run and base.exists():
|
if not dry_run and base.exists():
|
||||||
for child in base.iterdir():
|
for child in base.iterdir():
|
||||||
if child.is_file():
|
if child.is_file() or child.is_symlink():
|
||||||
try:
|
try:
|
||||||
child.unlink()
|
child.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
chosen = random.sample(files, min(count, len(files)))
|
|
||||||
created = 0
|
created = 0
|
||||||
fail = 0
|
fail = 0
|
||||||
for file_obj in chosen:
|
for file_obj in chosen:
|
||||||
@@ -318,9 +306,41 @@ class HardlinkManager:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.errors.append((file_obj.file_path, str(e)))
|
self.errors.append((file_obj.file_path, str(e)))
|
||||||
fail += 1
|
fail += 1
|
||||||
|
|
||||||
return created, fail
|
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:
|
def _is_same_file(self, path1: Path, path2: Path) -> bool:
|
||||||
"""Check if two paths point to the same file (same inode)."""
|
"""Check if two paths point to the same file (same inode)."""
|
||||||
try:
|
try:
|
||||||
@@ -458,23 +478,20 @@ class HardlinkManager:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Build expected paths for each file based on current tags
|
# Every link the tag tree *should* contain, given current files+tags.
|
||||||
expected_paths: dict[int, set[Path]] = {}
|
expected_all: set[Path] = set()
|
||||||
for file_obj in files:
|
for file_obj in files:
|
||||||
try:
|
|
||||||
inode = file_obj.file_path.stat().st_ino
|
|
||||||
expected_paths[inode] = set()
|
|
||||||
|
|
||||||
for tag in file_obj.tags:
|
for tag in file_obj.tags:
|
||||||
target_dir = self._target_dir(tag, roots, category_transforms)
|
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||||
if target_dir is None:
|
if target_dir is None:
|
||||||
continue
|
continue
|
||||||
expected_paths[inode].add(target_dir / self._link_name(
|
expected_all.add(target_dir / self._link_name(
|
||||||
file_obj, tag, category_filename_templates))
|
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(
|
top_dirs = self._managed_top_dirs(
|
||||||
files, roots, category_transforms, reserved_subfolders)
|
files, roots, category_transforms, reserved_subfolders)
|
||||||
for top in self.output_dir.iterdir():
|
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:
|
if top_dirs is not None and top.name not in top_dirs:
|
||||||
continue
|
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("*"):
|
for link_file in top.rglob("*"):
|
||||||
if not link_file.is_file():
|
if not link_file.is_file():
|
||||||
continue
|
continue
|
||||||
try:
|
if link_file in expected_all:
|
||||||
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:
|
|
||||||
continue
|
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
|
return obsolete
|
||||||
|
|
||||||
|
|||||||
+111
-7
@@ -30,6 +30,12 @@ from src.core.tag import Tag
|
|||||||
from src.constants import APP_TITLE
|
from src.constants import APP_TITLE
|
||||||
from src.core.hardlink_manager import HardlinkManager
|
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):
|
class ImportMoviesDialog(QDialog):
|
||||||
@@ -408,6 +414,44 @@ class TagSchemaDialog(QDialog):
|
|||||||
self.accept()
|
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"<b>Filmů v poolu:</b> {stats['count']}<br>"
|
||||||
|
f"<b>Celková velikost:</b> {size_text}<br>"
|
||||||
|
f"<b>Průměrné hodnocení ČSFD:</b> "
|
||||||
|
f"{str(avg) + ' %' if avg is not None else '—'}<br>"
|
||||||
|
f"<b>S ČSFD odkazem:</b> {stats['with_csfd']} · "
|
||||||
|
f"<b>bez:</b> {stats['without_csfd']}<br>"
|
||||||
|
f"<b>Bez štítků:</b> {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):
|
class QtApp(QMainWindow):
|
||||||
def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None:
|
def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -470,6 +514,11 @@ class QtApp(QMainWindow):
|
|||||||
settings_menu = bar.addMenu("&Nastavení")
|
settings_menu = bar.addMenu("&Nastavení")
|
||||||
self._add_action(settings_menu, "Tag schéma…", self.edit_tag_schema)
|
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:
|
def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction:
|
||||||
action = QAction(text, self)
|
action = QAction(text, self)
|
||||||
if shortcut:
|
if shortcut:
|
||||||
@@ -957,7 +1006,8 @@ class QtApp(QMainWindow):
|
|||||||
QMessageBox.information(self, "Filmotéka", "Pool je prázdný.")
|
QMessageBox.information(self, "Filmotéka", "Pool je prázdný.")
|
||||||
return
|
return
|
||||||
manager = HardlinkManager(out)
|
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(
|
created, create_fail, removed, remove_fail = manager.sync_structure(
|
||||||
files,
|
files,
|
||||||
category_roots=self.filehandler.filmoteka_category_roots(),
|
category_roots=self.filehandler.filmoteka_category_roots(),
|
||||||
@@ -978,22 +1028,26 @@ class QtApp(QMainWindow):
|
|||||||
mirrored += m_created
|
mirrored += m_created
|
||||||
mirror_fail += m_failed
|
mirror_fail += m_failed
|
||||||
|
|
||||||
# "Tipy dne": 10 random pool movies (copy-as-is folders excluded)
|
# Special folders (pool only; copy-as-is excluded): random tips + newest
|
||||||
tips, tips_fail = manager.generate_random_tips(files, count=10)
|
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 = (
|
msg = (
|
||||||
f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n"
|
f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n"
|
||||||
f"Copy-as-is — zrcadleno: {mirrored}\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:
|
failed = create_fail + remove_fail + mirror_fail + tips_fail + recent_fail
|
||||||
msg += f"\nSelhalo: {create_fail + remove_fail + mirror_fail + tips_fail}"
|
if failed:
|
||||||
|
msg += f"\nSelhalo: {failed}"
|
||||||
QMessageBox.warning(self, "Filmotéka dokončena s chybami", msg)
|
QMessageBox.warning(self, "Filmotéka dokončena s chybami", msg)
|
||||||
else:
|
else:
|
||||||
QMessageBox.information(self, "Filmotéka vygenerována", msg)
|
QMessageBox.information(self, "Filmotéka vygenerována", msg)
|
||||||
self.status.showMessage(
|
self.status.showMessage(
|
||||||
f"Filmotéka: filmy +{created}/-{removed}, copy-as-is +{mirrored}, "
|
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:
|
def edit_copyasis_folders(self) -> None:
|
||||||
@@ -1021,6 +1075,56 @@ class QtApp(QMainWindow):
|
|||||||
8000,
|
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
|
def closeEvent(self, event) -> None: # noqa: N802 — Qt override
|
||||||
self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}"
|
self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}"
|
||||||
from src.core.config import save_global_config
|
from src.core.config import save_global_config
|
||||||
|
|||||||
@@ -335,6 +335,18 @@ class TestApplyCsfdTags:
|
|||||||
movie_file.add_tag("Rok/1999")
|
movie_file.add_tag("Rok/1999")
|
||||||
assert movie_file.name_context()["year"] == "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):
|
def test_set_attribute_persists_and_in_context(self, movie_file):
|
||||||
movie_file.set_attribute("collection_sort", "03")
|
movie_file.set_attribute("collection_sort", "03")
|
||||||
assert movie_file.attributes["collection_sort"] == "03"
|
assert movie_file.attributes["collection_sort"] == "03"
|
||||||
|
|||||||
@@ -592,6 +592,72 @@ class TestPoolManagement:
|
|||||||
assert movie.csfd_link == "https://csfd.cz/film/1"
|
assert movie.csfd_link == "https://csfd.cz/film/1"
|
||||||
assert file_manager.index.get(movie.file_path) is not None
|
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):
|
def test_import_movie_move_removes_source(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
source = tmp_path / "raw.mkv"
|
source = tmp_path / "raw.mkv"
|
||||||
|
|||||||
@@ -143,6 +143,32 @@ class TestHardlinkManager:
|
|||||||
# The mirror (reserved folder) is left alone
|
# The mirror (reserved folder) is left alone
|
||||||
assert mirror_link.exists()
|
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(
|
def test_sync_removes_root_genre_folder_when_last_movie_drops_tag(
|
||||||
self, temp_source_dir, temp_output_dir, tag_manager
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
):
|
):
|
||||||
@@ -255,6 +281,23 @@ class TestHardlinkManager:
|
|||||||
tips = temp_output_dir / "Tipy dne"
|
tips = temp_output_dir / "Tipy dne"
|
||||||
assert len(list(tips.iterdir())) == 1 # not accumulated to 2
|
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):
|
def test_dry_run(self, files_with_tags, temp_output_dir):
|
||||||
"""Test dry run (bez skutečného vytváření)"""
|
"""Test dry run (bez skutečného vytváření)"""
|
||||||
manager = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user