Add per-movie attributes and per-category filename templates

This commit is contained in:
2026-06-16 17:39:39 +02:00
parent b3a61f9e86
commit a71b209539
19 changed files with 1064 additions and 111 deletions
+1
View File
@@ -63,6 +63,7 @@ class TestGlobalConfig:
"pool_dir": None,
"filmoteka_dir": None,
"copyasis_folders": ["Seriály"],
"tag_schema": DEFAULT_GLOBAL_CONFIG["tag_schema"],
}
save_global_config(test_config)
+20
View File
@@ -182,6 +182,26 @@ class TestHelperFunctions:
assert rating_band(90) == "90100 %"
assert rating_band(95) == "90100 %"
assert rating_band(100) == "90100 %"
def test_csfd_field_values_are_exact_no_transform(self):
from src.core.csfd import csfd_field_values
movie = CSFDMovie(title="X", url="u", year=1999, rating=86,
genres=["Akční", "Sci-Fi"], countries=["USA", "Kanada"])
assert csfd_field_values(movie, "genres") == ["Akční", "Sci-Fi"]
assert csfd_field_values(movie, "countries") == ["USA", "Kanada"]
assert csfd_field_values(movie, "year") == ["1999"]
# rating tag carries the EXACT value (transform happens only for folders)
assert csfd_field_values(movie, "rating") == ["86"]
# missing field / value → empty
assert csfd_field_values(CSFDMovie(title="X", url="u"), "rating") == []
assert csfd_field_values(movie, "genres") == csfd_field_values(movie, "genres")
def test_apply_transform_decade_band(self):
from src.core.csfd import apply_transform
assert apply_transform("86", "decade_band") == "8089 %"
assert apply_transform("90", "decade_band") == "90100 %"
assert apply_transform("Akční", None) == "Akční" # identity for non-rating
assert apply_transform("USA", "identity") == "USA"
assert _parse_duration("PT") is None
+64 -3
View File
@@ -297,7 +297,7 @@ class TestApplyCsfdTags:
assert "Žánr/Sci-Fi" in paths
assert "Rok/1999" in paths
assert "Země původu/USA" in paths
assert "Hodnocení/90100 %" in paths
assert "Hodnocení/90" in paths # exact value; folder grouping happens later
def test_apply_csfd_tags_does_not_tag_directors_or_actors(self, movie_file):
"""Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc)."""
@@ -319,14 +319,75 @@ class TestApplyCsfdTags:
assert cached.directors == ["Lana Wachowski"]
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"]
def test_apply_csfd_tags_can_skip_rating(self, movie_file):
def test_name_context_fields(self, movie_file):
movie_file.title = "Dr. No"
movie_file.csfd_cache = {"year": 1962, "rating": 75}
ctx = movie_file.name_context()
assert ctx["title"] == "Dr. No"
assert ctx["year"] == 1962
assert ctx["rating"] == 75
assert ctx["ext"] == movie_file.file_path.suffix
assert ctx["filename"] == movie_file.filename
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
def test_name_context_year_from_tag_when_no_cache(self, movie_file):
movie_file.csfd_cache = None
movie_file.add_tag("Rok/1999")
assert movie_file.name_context()["year"] == "1999"
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"
assert movie_file.name_context()["collection_sort"] == "03"
# reload (from index) keeps it
reloaded = File(movie_file.file_path, movie_file.tagmanager,
index=movie_file.index)
assert reloaded.attributes["collection_sort"] == "03"
def test_set_attribute_empty_removes_it(self, movie_file):
movie_file.set_attribute("collection_sort", "03")
movie_file.set_attribute("collection_sort", None)
assert "collection_sort" not in movie_file.attributes
def test_attribute_usable_in_filename_template(self, movie_file):
movie_file.title = "Dr. No"
movie_file.set_attribute("collection_sort", "01")
ctx = movie_file.name_context()
assert "{collection_sort} - {title}{ext}".format(**ctx) == \
f"01 - Dr. No{ctx['ext']}"
def test_apply_csfd_tags_honors_custom_schema(self, movie_file):
"""A schema without the rating entry produces no Hodnocení tags."""
from unittest.mock import patch
from src.core.csfd import CSFDMovie
schema = [{"category": "Žánr", "csfd_field": "genres",
"transform": None, "filmoteka_root": ""}]
movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"])
with patch("src.core.csfd.fetch_movie", return_value=movie):
movie_file.apply_csfd_tags(add_rating=False)
movie_file.apply_csfd_tags(schema)
paths = {t.full_path for t in movie_file.tags}
assert "Žánr/Drama" in paths
assert not any(p.startswith("Hodnocení/") for p in paths)
def test_apply_csfd_tags_preserves_user_tags_on_refetch(self, movie_file):
"""Re-fetching regenerates only ČSFD tags; user-added tags survive."""
from unittest.mock import patch
from src.core.csfd import CSFDMovie
movie_file.add_tag("Sbírka/Oblíbené") # user tag
first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"])
with patch("src.core.csfd.fetch_movie", return_value=first):
movie_file.apply_csfd_tags()
# different movie on re-fetch
second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"])
with patch("src.core.csfd.fetch_movie", return_value=second):
movie_file.apply_csfd_tags()
paths = {t.full_path for t in movie_file.tags}
assert "Sbírka/Oblíbené" in paths # user tag kept
assert "Žánr/Drama" in paths # new ČSFD tag
assert "Rok/2009" in paths
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
assert "Rok/1999" not in paths
+64
View File
@@ -603,6 +603,70 @@ class TestPoolManagement:
assert movie.file_path.exists()
assert not source.exists() # moved, not copied
def test_filmoteka_category_roots_from_schema(self, file_manager):
file_manager.set_tag_schema([
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"},
{"category": "Herec", "csfd_field": "actors", "transform": None, "filmoteka_root": None},
])
roots = file_manager.filmoteka_category_roots()
assert roots == {"Žánr": "", "Rok": "Dle roku"} # None-root excluded
assert "Herec" not in roots
def test_import_movie_suffix_keeps_both(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"a")
(tmp_path / "b.mkv").write_bytes(b"b")
first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
second = file_manager.import_movie(tmp_path / "b.mkv", "Matrix") # default suffix
assert first.file_path.name == "Matrix.mkv"
assert second.file_path.name == "Matrix_1.mkv"
assert len(file_manager.filelist) == 2
def test_import_movie_replace_evicts_existing(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"a")
(tmp_path / "b.mkv").write_bytes(b"bb")
first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
first.add_tag("Žánr/Akční")
old_path = first.file_path
second = file_manager.import_movie(
tmp_path / "b.mkv", "Matrix", csfd_link="x", on_conflict="replace")
assert second.file_path.name == "Matrix.mkv"
assert second.file_path == old_path # same name reused
assert second.file_path.read_bytes() == b"bb" # new content in place
assert [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"]
# index record reflects the new import (fresh metadata, old tags dropped)
record = file_manager.index.get(second.file_path)
assert record is not None
assert record["csfd_link"] == "x"
assert record["tags"] == []
assert second.tags == []
def test_import_movie_replace_across_extensions(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"a")
(tmp_path / "b.mp4").write_bytes(b"b")
file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
file_manager.import_movie(tmp_path / "b.mp4", "Matrix", on_conflict="replace")
names = [f.file_path.name for f in file_manager.filelist]
assert names == ["Matrix.mp4"]
assert not (tmp_path / "pool" / "Filmy" / "Matrix.mkv").exists()
def test_import_movie_skip_returns_none(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"a")
(tmp_path / "b.mkv").write_bytes(b"b")
file_manager.import_movie(tmp_path / "a.mkv", "Matrix")
result = file_manager.import_movie(tmp_path / "b.mkv", "Matrix", on_conflict="skip")
assert result is None
assert len(file_manager.filelist) == 1
def test_rename_movie_renames_file_and_reindexes(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
source = tmp_path / "raw.mkv"
+61
View File
@@ -141,6 +141,67 @@ class TestHardlinkManager:
# The mirror (not a managed tag folder) is left alone
assert mirror_link.exists()
def test_category_transform_groups_folder_by_band(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""Exact tag value, but the folder name goes through the transform."""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("Hodnocení", "90")) # exact rating tag
manager = HardlinkManager(temp_output_dir)
manager.create_structure_for_files(
[f],
category_roots={"Hodnocení": "Dle hodnocení"},
category_transforms={"Hodnocení": "decade_band"},
)
# folder is the band; the file lands inside it
assert (temp_output_dir / "Dle hodnocení" / "90100 %" / "file1.txt").exists()
# not a per-exact-value folder
assert not (temp_output_dir / "Dle hodnocení" / "90").exists()
def test_filename_template_applies_only_in_that_category(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""A per-category template renames the hardlink only inside its folder."""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.title = "Dr. No"
f.csfd_cache = {"year": 1962}
f.add_tag(Tag("Kolekce", "James Bond"))
f.add_tag(Tag("žánr", "Akční"))
manager = HardlinkManager(temp_output_dir)
manager.create_structure_for_files(
[f],
category_roots={"Kolekce": "Dle kolekce", "žánr": ""},
category_filename_templates={"Kolekce": "{year} - {title}{ext}"},
)
# Templated name inside the collection folder
assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists()
# Other categories keep the pool filename
assert (temp_output_dir / "Akční" / "file1.txt").exists()
def test_filename_template_cleanup_is_consistent(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""sync twice with a template leaves no stale/duplicate templated link."""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.title = "Dr. No"
f.csfd_cache = {"year": 1962}
f.add_tag(Tag("Kolekce", "James Bond"))
roots = {"Kolekce": "Dle kolekce"}
templates = {"Kolekce": "{year} - {title}{ext}"}
manager = HardlinkManager(temp_output_dir)
manager.sync_structure([f], category_roots=roots, category_filename_templates=templates)
created, _, removed, _ = manager.sync_structure(
[f], category_roots=roots, category_filename_templates=templates)
folder = temp_output_dir / "Dle kolekce" / "James Bond"
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
assert removed == 0 # nothing treated as obsolete on the second run
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)