Migrate from Poetry and pyenv to uv

This commit is contained in:
2026-10-12 15:48:26 +02:00
parent c399a6aed3
commit c69f5f9166
93 changed files with 13068 additions and 2401 deletions
+6 -5
View File
@@ -1,11 +1,14 @@
"""
Konfigurace pytest - sdílené fixtures a nastavení pro všechny testy
"""
import pytest
import tempfile
import contextlib
import shutil
import tempfile
from pathlib import Path
import pytest
@pytest.fixture(scope="session")
def session_temp_dir():
@@ -22,7 +25,5 @@ def cleanup_config_files():
# Cleanup po testu
config_file = Path("config.json")
if config_file.exists():
try:
with contextlib.suppress(Exception):
config_file.unlink()
except Exception:
pass
+16 -11
View File
@@ -1,8 +1,13 @@
import pytest
import json
import pytest
from src.core.config import (
load_global_config, save_global_config, DEFAULT_GLOBAL_CONFIG,
load_config, save_config # Legacy functions
DEFAULT_GLOBAL_CONFIG,
load_config, # Legacy functions
load_global_config,
save_config,
save_global_config,
)
@@ -14,7 +19,8 @@ class TestGlobalConfig:
"""Fixture pro dočasný globální config soubor"""
config_path = tmp_path / "config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
def test_default_global_config_structure(self):
@@ -136,7 +142,8 @@ class TestLegacyFunctions:
"""Fixture pro dočasný globální config soubor"""
config_path = tmp_path / "config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
def test_load_config_legacy(self, temp_global_config):
@@ -166,14 +173,15 @@ class TestConfigEdgeCases:
"""Fixture pro dočasný globální config soubor"""
config_path = tmp_path / "config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
def test_config_path_with_spaces(self, temp_global_config):
"""Test s cestou obsahující mezery"""
test_config = {
**DEFAULT_GLOBAL_CONFIG,
"last_folder": "/path/with spaces/in name"
"last_folder": "/path/with spaces/in name",
}
save_global_config(test_config)
@@ -215,10 +223,7 @@ class TestConfigEdgeCases:
def test_config_ensure_ascii_false(self, temp_global_config):
"""Test že ensure_ascii=False funguje správně"""
test_config = {
**DEFAULT_GLOBAL_CONFIG,
"last_folder": "/cesta/čeština"
}
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/cesta/čeština"}
save_global_config(test_config)
+11 -6
View File
@@ -15,7 +15,6 @@ from src.constants import (
get_version,
)
# ---------------------------------------------------------------------------
# get_version()
# ---------------------------------------------------------------------------
@@ -42,15 +41,19 @@ def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
assert len(result) > 0
def test_get_version_unknown_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_version_unknown_fallback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
missing = tmp_path / "nonexistent.toml"
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
# Patch _version import to also fail
with patch("src.constants.Path.write_text", side_effect=OSError):
with patch.dict("sys.modules", {"src._version": None}):
result = get_version()
with (
patch("src.constants.Path.write_text", side_effect=OSError),
patch.dict("sys.modules", {"src._version": None}),
):
result = get_version()
assert isinstance(result, str)
@@ -131,7 +134,9 @@ def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> Non
assert title.endswith("-DEV")
def test_app_title_no_dev_suffix_when_not_debug(monkeypatch: pytest.MonkeyPatch) -> None:
def test_app_title_no_dev_suffix_when_not_debug(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
import src.constants as consts
+44 -20
View File
@@ -1,4 +1,5 @@
"""Tests for the lean ČSFD title-bridge scraper (`src.core.csfd`)."""
import hashlib
from unittest.mock import patch
@@ -78,12 +79,16 @@ SEARCH_HTML = """
class TestCleanFilenameToQuery:
def test_strips_release_tags_and_keeps_year(self):
assert clean_filename_to_query(
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv") == "Matrix 1999"
assert (
clean_filename_to_query("Matrix.1999.1080p.BluRay.x264-GROUP.mkv")
== "Matrix 1999"
)
def test_handles_spaces_and_parens_year(self):
assert clean_filename_to_query(
"Forrest Gump (1994) 2160p HDR.mkv") == "Forrest Gump 1994"
assert (
clean_filename_to_query("Forrest Gump (1994) 2160p HDR.mkv")
== "Forrest Gump 1994"
)
def test_no_year_no_markers(self):
assert clean_filename_to_query("Amelie.mkv") == "Amelie"
@@ -97,8 +102,9 @@ class TestCleanFilenameToQuery:
class TestExtractCsfdId:
def test_extract_from_various_urls(self):
assert _extract_csfd_id(
"https://www.csfd.cz/film/2294-vykoupeni/prehled/") == 2294
assert (
_extract_csfd_id("https://www.csfd.cz/film/2294-vykoupeni/prehled/") == 2294
)
assert _extract_csfd_id("/film/999/") == 999
def test_none_when_absent(self):
@@ -119,13 +125,17 @@ class TestAnubisPoW:
class TestParseOrigin:
def test_country_and_year_from_bullet_format(self):
from bs4 import BeautifulSoup
soup = BeautifulSoup(FILM_HTML, "html.parser")
assert _parse_origin(soup) == ("USA", "1994")
def test_first_country_of_coproduction(self):
from bs4 import BeautifulSoup
html = ('<div class="origin">Francie / Německo <span class="bullet"></span>'
'<span>2001</span> 120 min</div>')
html = (
'<div class="origin">Francie / Německo <span class="bullet"></span>'
"<span>2001</span> 120 min</div>"
)
soup = BeautifulSoup(html, "html.parser")
assert _parse_origin(soup) == ("Francie", "2001")
@@ -133,6 +143,7 @@ class TestParseOrigin:
class TestParseOriginalTitle:
def test_picks_name_matching_origin_country(self):
from bs4 import BeautifulSoup
soup = BeautifulSoup(FILM_HTML, "html.parser")
assert _parse_original_title(soup, "USA") == "The Shawshank Redemption"
@@ -140,22 +151,29 @@ class TestParseOriginalTitle:
# A Czech film lists only foreign/festival alt-titles here — none match
# the origin country, so we must NOT return one of them.
from bs4 import BeautifulSoup
html = ('<ul class="film-names">'
'<li><img class="flag" title="angličtina"/> Loners</li>'
'<li><img class="flag" title="Slovinsko"/> Samotarji</li></ul>')
html = (
'<ul class="film-names">'
'<li><img class="flag" title="angličtina"/> Loners</li>'
'<li><img class="flag" title="Slovinsko"/> Samotarji</li></ul>'
)
soup = BeautifulSoup(html, "html.parser")
assert _parse_original_title(soup, "Česko") is None
def test_cesko_matches_ceskoslovensko_flag(self):
from bs4 import BeautifulSoup
html = ('<ul class="film-names">'
'<li><img class="flag" title="Československo"/> Ostře sledované vlaky</li>'
'</ul>')
html = (
'<ul class="film-names">'
'<li><img class="flag" title="Československo"/> Ostře sledované vlaky</li>'
"</ul>"
)
soup = BeautifulSoup(html, "html.parser")
assert _parse_original_title(soup, "Česko") == "Ostře sledované vlaky"
def test_none_when_no_names(self):
from bs4 import BeautifulSoup
soup = BeautifulSoup("<div>x</div>", "html.parser")
assert _parse_original_title(soup, "USA") is None
@@ -189,10 +207,13 @@ class TestFetchFilm:
def test_wraps_network_error_in_csfd_error(self):
import requests
with patch("src.core.csfd._get_page",
side_effect=requests.RequestException("boom")):
with pytest.raises(CsfdError):
fetch_film("https://www.csfd.cz/film/1/")
with (
patch(
"src.core.csfd._get_page", side_effect=requests.RequestException("boom")
),
pytest.raises(CsfdError),
):
fetch_film("https://www.csfd.cz/film/1/")
class TestIsFilmHref:
@@ -218,7 +239,8 @@ class TestSearchFilms:
# the Seriály-section episode link is excluded
assert [f.csfd_id for f in films] == [2294, 248803]
assert films[0].url == (
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/")
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/"
)
def test_empty_query_returns_empty(self):
assert search_films(" ") == []
@@ -231,7 +253,9 @@ class TestFindCsfdUrl:
assert find_csfd_url("shawshank 1994") == "https://www.csfd.cz/film/2294-x/"
def test_retries_without_trailing_year_when_first_query_misses(self):
hit = CsfdFilm(url="https://www.csfd.cz/film/221006-vratne-lahve/", csfd_id=221006)
hit = CsfdFilm(
url="https://www.csfd.cz/film/221006-vratne-lahve/", csfd_id=221006
)
calls: list[str] = []
def fake_search(query, limit=1, session=None):
+36 -15
View File
@@ -1,4 +1,5 @@
import pytest
from src.core.file import File
from src.core.pool_index import PoolIndex
from src.core.tag import Tag
@@ -130,7 +131,9 @@ class TestFile:
file_obj.remove_tag("Video/HD")
assert "Video/HD" not in {t.full_path for t in file_obj.tags}
def test_file_remove_tag_string_without_category(self, test_file, tag_manager, index):
def test_file_remove_tag_string_without_category(
self, test_file, tag_manager, index
):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("SimpleTag")
file_obj.remove_tag("SimpleTag")
@@ -227,8 +230,13 @@ class TestApplyNfoTags:
def test_apply_nfo_assigns_expected_categories(self, movie_file):
from src.core.nfo import NfoData
nfo = NfoData(title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
countries=["USA"], rating=90)
nfo = NfoData(
title="Matrix",
year="1999",
genres=["Akční", "Sci-Fi"],
countries=["USA"],
rating=90,
)
result = movie_file.apply_nfo(nfo)
assert result["success"]
@@ -263,12 +271,16 @@ class TestApplyNfoTags:
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()
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
@@ -277,8 +289,9 @@ class TestApplyNfoTags:
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)
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):
@@ -290,15 +303,23 @@ class TestApplyNfoTags:
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']}"
assert (
"{collection_sort} - {title}{ext}".format(**ctx)
== f"01 - Dr. No{ctx['ext']}"
)
def test_apply_nfo_honors_custom_schema(self, movie_file):
"""A schema without the rating entry produces no Hodnocení tags."""
from src.core.nfo import NfoData
schema = [{"category": "Žánr", "csfd_field": "genres",
"transform": None, "filmoteka_root": ""}]
schema = [
{
"category": "Žánr",
"csfd_field": "genres",
"transform": None,
"filmoteka_root": "",
}
]
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
paths = {t.full_path for t in movie_file.tags}
@@ -314,9 +335,9 @@ class TestApplyNfoTags:
movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"]))
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 NFO tag
assert "Sbírka/Oblíbené" in paths # user tag kept
assert "Žánr/Drama" in paths # new NFO tag
assert "Rok/2009" in paths
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
assert "Rok/1999" not in paths
+1313 -139
View File
File diff suppressed because it is too large Load Diff
+51 -33
View File
@@ -1,7 +1,9 @@
import pytest
import os
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
import pytest
from src.core.file import File
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
from src.core.tag import Tag
from src.core.tag_manager import TagManager
@@ -84,7 +86,9 @@ class TestHardlinkManager:
assert (temp_output_dir / "rok" / "1988" / "file1.txt").exists()
assert (temp_output_dir / "žánr" / "Drama" / "file2.txt").exists()
def test_hardlinks_are_same_inode(self, files_with_tags, temp_output_dir, temp_source_dir):
def test_hardlinks_are_same_inode(
self, files_with_tags, temp_output_dir, temp_source_dir
):
"""Test že vytvořené soubory jsou opravdu hardlinky (stejný inode)"""
manager = HardlinkManager(temp_output_dir)
manager.create_structure_for_files(files_with_tags)
@@ -95,10 +99,14 @@ class TestHardlinkManager:
# Same inode = hardlink
assert original.stat().st_ino == hardlink.stat().st_ino
def test_create_structure_with_category_filter(self, files_with_tags, temp_output_dir):
def test_create_structure_with_category_filter(
self, files_with_tags, temp_output_dir
):
"""Test vytvoření struktury jen pro vybrané kategorie"""
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files(files_with_tags, categories=["žánr"])
success, fail = manager.create_structure_for_files(
files_with_tags, categories=["žánr"]
)
# Only "žánr" tags should be processed (3 links)
assert success == 3
@@ -107,7 +115,9 @@ class TestHardlinkManager:
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
assert not (temp_output_dir / "rok").exists()
def test_create_structure_with_category_roots(self, files_with_tags, temp_output_dir):
def test_create_structure_with_category_roots(
self, files_with_tags, temp_output_dir
):
"""category_roots: genres sit at the output root, rok under 'Dle roku'."""
manager = HardlinkManager(temp_output_dir)
roots = {"žánr": "", "rok": "Dle roku"}
@@ -137,8 +147,8 @@ class TestHardlinkManager:
os.link(temp_source_dir / "file1.txt", mirror_link)
manager.sync_structure(
files_with_tags, category_roots=roots,
reserved_subfolders={"Seriály"})
files_with_tags, category_roots=roots, reserved_subfolders={"Seriály"}
)
# The mirror (reserved folder) is left alone
assert mirror_link.exists()
@@ -208,9 +218,7 @@ class TestHardlinkManager:
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
assert (created2, removed2) == (0, 0)
def test_link_name_strips_year_by_default(
self, tmp_path, tag_manager
):
def test_link_name_strips_year_by_default(self, tmp_path, tag_manager):
"""A pooled 'Title (YYYY).ext' hardlinks into the tag tree as 'Title.ext'."""
source = tmp_path / "source"
source.mkdir()
@@ -242,9 +250,7 @@ class TestHardlinkManager:
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
def test_link_name_keeps_year_on_collision(
self, tmp_path, tag_manager
):
def test_link_name_keeps_year_on_collision(self, tmp_path, tag_manager):
"""Two same-title films in one folder keep the year to disambiguate."""
source = tmp_path / "source"
source.mkdir()
@@ -276,9 +282,7 @@ class TestHardlinkManager:
assert (output / "Dle roku" / "1972" / "Solaris.mkv").exists()
assert (output / "Dle roku" / "2002" / "Solaris.mkv").exists()
def test_clean_naming_cleanup_is_consistent(
self, tmp_path, tag_manager
):
def test_clean_naming_cleanup_is_consistent(self, tmp_path, tag_manager):
"""Clean-named links are stable: a second sync removes/creates nothing."""
source = tmp_path / "source"
source.mkdir()
@@ -349,7 +353,9 @@ class TestHardlinkManager:
)
# Templated name inside the collection folder
assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists()
assert (
temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt"
).exists()
# Other categories use the default clean title (no year, not the pool name)
assert (temp_output_dir / "Akční" / "Dr. No.txt").exists()
@@ -365,9 +371,12 @@ class TestHardlinkManager:
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)
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"]
@@ -417,7 +426,8 @@ class TestHardlinkManager:
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é")
files_with_tags, count=2, subfolder="- Nově přidané"
)
recent = temp_output_dir / "- Nově přidané"
assert created == 2 and fail == 0
@@ -427,7 +437,9 @@ class TestHardlinkManager:
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)
success, fail = manager.create_structure_for_files(files_with_tags, dry_run=True)
success, fail = manager.create_structure_for_files(
files_with_tags, dry_run=True
)
assert success == 4
assert fail == 0
@@ -497,7 +509,7 @@ class TestHardlinkManager:
manager = HardlinkManager(temp_output_dir)
# Create first time
success1, _ = manager.create_structure_for_files(files_with_tags)
_success1, _ = manager.create_structure_for_files(files_with_tags)
# Create second time - should skip existing
manager2 = HardlinkManager(temp_output_dir)
@@ -507,7 +519,9 @@ class TestHardlinkManager:
assert success2 == 0
assert fail2 == 0
def test_unique_name_on_conflict(self, temp_source_dir, temp_output_dir, tag_manager):
def test_unique_name_on_conflict(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""Two different movies with the same clean name (no year to tell them
apart) in one folder get distinct links via a numeric suffix."""
f1 = File(temp_source_dir / "file1.txt", tag_manager)
@@ -530,7 +544,9 @@ class TestHardlinkManager:
assert (folder / "file1.txt").exists()
assert (folder / "file1_2.txt").exists()
def test_czech_characters_in_tags(self, temp_source_dir, temp_output_dir, tag_manager):
def test_czech_characters_in_tags(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""Test českých znaků v názvech tagů"""
f1 = File(temp_source_dir / "file1.txt", tag_manager)
f1.tags.clear()
@@ -593,7 +609,9 @@ class TestConvenienceFunction:
output = tmp_path / "output"
output.mkdir()
success, fail, errors = create_hardlink_structure([f], output, categories=["include"])
success, _fail, _errors = create_hardlink_structure(
[f], output, categories=["include"]
)
assert success == 1
assert (output / "include" / "yes" / "file.txt").exists()
@@ -677,7 +695,7 @@ class TestSyncStructure:
f.add_tag(Tag("cat", "tag1"))
# Remove obsolete links
removed, paths = manager.remove_obsolete_links([f])
removed, _paths = manager.remove_obsolete_links([f])
assert removed == 1
assert not (output / "cat" / "tag2" / "file.txt").exists()
@@ -699,7 +717,7 @@ class TestSyncStructure:
f.tags.clear()
f.add_tag(Tag("cat", "tag1"))
removed, paths = manager.remove_obsolete_links([f], dry_run=True)
removed, _paths = manager.remove_obsolete_links([f], dry_run=True)
assert removed == 1
# File should still exist (dry run)
@@ -747,7 +765,7 @@ class TestSyncStructure:
manager.create_structure_for_files([f])
# Sync again without changes
created, c_fail, removed, r_fail = manager.sync_structure([f])
_created, _c_fail, removed, _r_fail = manager.sync_structure([f])
# Nothing should change (existing links are skipped)
assert removed == 0
@@ -821,7 +839,7 @@ class TestEdgeCases:
# output doesn't exist
manager = HardlinkManager(output)
success, fail = manager.create_structure_for_files([f])
success, _fail = manager.create_structure_for_files([f])
assert success == 1
assert (output / "cat" / "tag" / "file.txt").exists()
@@ -842,7 +860,7 @@ class TestEdgeCases:
output.mkdir()
manager = HardlinkManager(output)
success, fail = manager.create_structure_for_files([f])
success, _fail = manager.create_structure_for_files([f])
assert success == 1
assert (output / "test" / "tag" / "file with spaces (part 2).txt").exists()
@@ -862,7 +880,7 @@ class TestEdgeCases:
manager = HardlinkManager(output)
# Empty list = no categories = no links
success, fail = manager.create_structure_for_files([f], categories=[])
success, _fail = manager.create_structure_for_files([f], categories=[])
assert success == 0
+16 -11
View File
@@ -3,6 +3,7 @@
The FFmpeg subprocess call is monkeypatched so the tests don't need ffmpeg
installed and don't touch real video data.
"""
import subprocess
from pathlib import Path
from types import SimpleNamespace
@@ -23,6 +24,7 @@ def video_file(tmp_path) -> Path:
def _fake_run(returncode=0, stdout="", stderr=""):
def run(cmd, **kwargs):
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
return run
@@ -36,8 +38,10 @@ def test_deep_clean_file_is_ok(monkeypatch, video_file):
def test_deep_reports_decode_errors(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(
integrity.subprocess, "run",
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"))
integrity.subprocess,
"run",
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"),
)
result = check_video_integrity(video_file, deep=True)
assert not result.ok and result.checked
assert result.error == "[matroska @ 0x..] Read error" # first line only
@@ -46,8 +50,7 @@ def test_deep_reports_decode_errors(monkeypatch, video_file):
def test_deep_stderr_without_nonzero_rc_still_fails(monkeypatch, video_file):
"""ffmpeg can print decode errors while returning rc 0 — must still fail."""
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(
integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
result = check_video_integrity(video_file, deep=True)
assert not result.ok
@@ -99,9 +102,9 @@ def test_ffmpeg_available_reflects_which(monkeypatch):
def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
"""FileManager.scan_video_integrity aggregates per-file results."""
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.file import File
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
good = tmp_path / "good.mkv"
bad = tmp_path / "bad.mkv"
@@ -121,7 +124,8 @@ def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
seen: list[int] = []
result = fm.scan_video_integrity(
files, deep=True, on_progress=lambda d, t, f: seen.append(d))
files, deep=True, on_progress=lambda d, t, f: seen.append(d)
)
assert result["ok"] and result["checked"] == 2
assert [r.path.name for r in result["problems"]] == ["bad.mkv"]
@@ -129,9 +133,9 @@ def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
def test_scan_stops_on_cancel(monkeypatch, tmp_path):
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.file import File
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
files = []
for i in range(3):
@@ -142,7 +146,8 @@ def test_scan_stops_on_cancel(monkeypatch, tmp_path):
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
monkeypatch.setattr(
"src.core.file_manager.check_video_integrity",
lambda path, deep=True: IntegrityResult(Path(path), ok=True))
lambda path, deep=True: IntegrityResult(Path(path), ok=True),
)
fm = FileManager(TagManager())
result = fm.scan_video_integrity(files, should_cancel=lambda: True)
@@ -150,8 +155,8 @@ def test_scan_stops_on_cancel(monkeypatch, tmp_path):
def test_scan_reports_missing_ffmpeg(monkeypatch, tmp_path):
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: False)
fm = FileManager(TagManager())
+30 -14
View File
@@ -1,4 +1,5 @@
"""Tests for the Jellyfin client + pool verification. Network is monkeypatched."""
from types import SimpleNamespace
import pytest
@@ -14,8 +15,9 @@ def _configured(monkeypatch):
def _resp(status=200, payload=None):
return SimpleNamespace(status_code=status, ok=(200 <= status < 300),
json=lambda: (payload or {}))
return SimpleNamespace(
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
)
def test_base_url_strips_trailing_slash():
@@ -29,8 +31,9 @@ def test_api_available(monkeypatch):
def test_server_version(monkeypatch):
monkeypatch.setattr(jellyfin.requests, "request",
lambda *a, **k: _resp(200, {"Version": "10.9.11"}))
monkeypatch.setattr(
jellyfin.requests, "request", lambda *a, **k: _resp(200, {"Version": "10.9.11"})
)
assert jellyfin.server_version() == "10.9.11"
@@ -40,21 +43,32 @@ def test_scan_all_posts_refresh(monkeypatch):
def fake(method, url, **k):
seen["method"], seen["url"] = method, url
return _resp(200, {})
monkeypatch.setattr(jellyfin.requests, "request", fake)
jellyfin.scan_all()
assert seen["method"] == "POST" and seen["url"].endswith("/Library/Refresh")
def test_fetch_movies_maps_tmdb_id(monkeypatch):
payload = {"Items": [
{"Id": "a1", "Name": "Matrix", "ProductionYear": 1999,
"ProviderIds": {"Tmdb": "603"}, "Path": "/m/Matrix (1999) [tmdbid-603].mkv"},
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
]}
monkeypatch.setattr(jellyfin.requests, "request", lambda *a, **k: _resp(200, payload))
payload = {
"Items": [
{
"Id": "a1",
"Name": "Matrix",
"ProductionYear": 1999,
"ProviderIds": {"Tmdb": "603"},
"Path": "/m/Matrix (1999) [tmdbid-603].mkv",
},
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
]
}
monkeypatch.setattr(
jellyfin.requests, "request", lambda *a, **k: _resp(200, payload)
)
movies = jellyfin.fetch_movies()
assert movies[0] == JellyfinMovie("a1", "Matrix", 1999, 603,
"/m/Matrix (1999) [tmdbid-603].mkv")
assert movies[0] == JellyfinMovie(
"a1", "Matrix", 1999, 603, "/m/Matrix (1999) [tmdbid-603].mkv"
)
assert movies[1].tmdb_id is None
@@ -74,9 +88,11 @@ class TestVerifyAgainstJellyfin:
@pytest.fixture
def file_manager(self, tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
@@ -88,7 +104,7 @@ class TestVerifyAgainstJellyfin:
file_manager.import_movie(tmp_path / "b.mkv", "Neznámý", year=2020) # no id
jf = [
JellyfinMovie("j1", "Matrix", 1999, 603, None), # matches a by tmdb
JellyfinMovie("j1", "Matrix", 1999, 603, None), # matches a by tmdb
JellyfinMovie("j2", "Cizí film", 2011, 999, None), # jellyfin-only
]
result = file_manager.verify_against_jellyfin(jf)
+67 -29
View File
@@ -1,4 +1,5 @@
"""Tests for recovering ČSFD link / title / year from pre-index layouts."""
import json
from src.core.legacy_meta import LegacyMeta, read_legacy_meta
@@ -12,25 +13,38 @@ def _video(tmp_path, name="Matrix (1999).mkv"):
def test_reads_tagger_era_sidecar(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(json.dumps({
"title": "Matrix",
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
"csfd_cache": {"year": 1999, "rating": 90},
}), encoding="utf-8")
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps(
{
"title": "Matrix",
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
"csfd_cache": {"year": 1999, "rating": 90},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta == LegacyMeta(
csfd_url="https://www.csfd.cz/film/9499-matrix/",
title="Matrix", year="1999", source="sidecar")
title="Matrix",
year="1999",
source="sidecar",
)
def test_sidecar_new_key_and_meta_block(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(json.dumps({
"title": "Matrix",
"csfd_url": "https://www.csfd.cz/film/9499/",
"meta": {"year": "1999"},
}), encoding="utf-8")
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps(
{
"title": "Matrix",
"csfd_url": "https://www.csfd.cz/film/9499/",
"meta": {"year": "1999"},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
@@ -42,16 +56,21 @@ def test_reads_old_pool_index_by_relative_key(tmp_path):
movies_dir = pool / "Filmy"
movies_dir.mkdir(parents=True)
video = _video(movies_dir)
(pool / ".Curator.!index").write_text(json.dumps({
"settings": {},
"movies": {
"Filmy/Matrix (1999).mkv": {
"title": "Matrix",
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
"csfd_cache": {"year": 1999},
(pool / ".Curator.!index").write_text(
json.dumps(
{
"settings": {},
"movies": {
"Filmy/Matrix (1999).mkv": {
"title": "Matrix",
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
"csfd_cache": {"year": 1999},
}
},
}
},
}), encoding="utf-8")
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
@@ -66,10 +85,18 @@ def test_index_fallback_to_unique_basename_match(tmp_path):
elsewhere = tmp_path / "loose"
elsewhere.mkdir()
video = _video(elsewhere)
(pool.parent / ".Curator.!index").write_text(json.dumps({
"movies": {"whatever/Matrix (1999).mkv": {
"csfd_url": "https://www.csfd.cz/film/9499/"}},
}), encoding="utf-8")
(pool.parent / ".Curator.!index").write_text(
json.dumps(
{
"movies": {
"whatever/Matrix (1999).mkv": {
"csfd_url": "https://www.csfd.cz/film/9499/"
}
},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta is not None
@@ -79,10 +106,20 @@ def test_index_fallback_to_unique_basename_match(tmp_path):
def test_sidecar_wins_over_index(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps({"csfd_link": "https://www.csfd.cz/film/SIDECAR/"}), encoding="utf-8")
(tmp_path / ".Curator.!index").write_text(json.dumps({
"movies": {"Matrix (1999).mkv": {"csfd_link": "https://www.csfd.cz/film/INDEX/"}},
}), encoding="utf-8")
json.dumps({"csfd_link": "https://www.csfd.cz/film/SIDECAR/"}), encoding="utf-8"
)
(tmp_path / ".Curator.!index").write_text(
json.dumps(
{
"movies": {
"Matrix (1999).mkv": {
"csfd_link": "https://www.csfd.cz/film/INDEX/"
}
},
}
),
encoding="utf-8",
)
assert read_legacy_meta(video).csfd_url == "https://www.csfd.cz/film/SIDECAR/"
@@ -100,5 +137,6 @@ def test_none_on_broken_sidecar(tmp_path):
def test_record_without_useful_fields_is_none(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps({"new": True, "ignored": False, "tags": []}), encoding="utf-8")
json.dumps({"new": True, "ignored": False, "tags": []}), encoding="utf-8"
)
assert read_legacy_meta(video) is None
+3 -2
View File
@@ -1,10 +1,11 @@
import tempfile
import tkinter as tk
from pathlib import Path
import pytest
from PIL import Image, ImageTk
from src.core.media_utils import load_icon
from PIL import Image, ImageTk
import tkinter as tk
@pytest.fixture(scope="module")
+14 -7
View File
@@ -1,4 +1,5 @@
"""Tests for pool → TMDb migration (src.core.migrate + FileManager)."""
import pytest
from src.core.migrate import classify, normalize
@@ -49,9 +50,11 @@ class TestFileManagerMigrate:
@pytest.fixture
def file_manager(self, tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
@@ -61,10 +64,13 @@ class TestFileManagerMigrate:
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
assert "[tmdbid" not in movie.file_path.name # not migrated yet
import src.core.tmdb as tmdb
from src.core import tmdb
monkeypatch.setattr(
tmdb, "search_movie",
lambda q, year=None, limit=5: [TmdbResult(603, "Matrix", "1999", "movie")])
tmdb,
"search_movie",
lambda q, year=None, limit=5: [TmdbResult(603, "Matrix", "1999", "movie")],
)
proposals = file_manager.match_pool_to_tmdb()
assert len(proposals) == 1
@@ -78,11 +84,12 @@ class TestFileManagerMigrate:
(tmp_path / "a.mkv").write_bytes(b"x")
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
import src.core.tmdb as tmdb
from src.core import tmdb
called = []
monkeypatch.setattr(
tmdb, "search_movie",
lambda q, year=None, limit=5: called.append(q) or [])
tmdb, "search_movie", lambda q, year=None, limit=5: called.append(q) or []
)
proposals = file_manager.match_pool_to_tmdb()
assert proposals == [] and called == [] # already has id → not searched
+104 -35
View File
@@ -1,4 +1,5 @@
"""Tests for the movie object and the pool layout it derives (src.core.movie)."""
from pathlib import Path
import pytest
@@ -13,34 +14,54 @@ from src.core.movie import (
)
@pytest.mark.parametrize("label,expected", [
("HSBS", "HSBS"), ("hsbs", "HSBS"), ("3D HSBS", "HSBS"),
("3d.hsbs", "HSBS"), ("3D_FTAB", "FTAB"), ("3d-mvc", "MVC"),
("4K", None), ("Director's Cut", None), ("", None), (None, None),
])
@pytest.mark.parametrize(
"label,expected",
[
("HSBS", "HSBS"),
("hsbs", "HSBS"),
("3D HSBS", "HSBS"),
("3d.hsbs", "HSBS"),
("3D_FTAB", "FTAB"),
("3d-mvc", "MVC"),
("4K", None),
("Director's Cut", None),
("", None),
(None, None),
],
)
def test_normalize_3d_label(label, expected):
assert normalize_3d_label(label) == expected
class TestMediaFilename:
def test_no_label_is_the_bare_stem(self):
assert media_filename("Matrix (1999)", "", ".mkv", grouped=False) == \
"Matrix (1999).mkv"
assert (
media_filename("Matrix (1999)", "", ".mkv", grouped=False)
== "Matrix (1999).mkv"
)
def test_3d_label_uses_jellyfins_documented_form(self):
assert media_filename("Avatar (2009)", "3D HSBS", ".mkv", grouped=False) == \
"Avatar (2009).3D.HSBS.mkv"
assert (
media_filename("Avatar (2009)", "3D HSBS", ".mkv", grouped=False)
== "Avatar (2009).3D.HSBS.mkv"
)
# same spelling grouped — "." is a valid separator after the folder name
assert media_filename("Avatar (2009)", "hsbs", ".mkv", grouped=True) == \
"Avatar (2009).3D.HSBS.mkv"
assert (
media_filename("Avatar (2009)", "hsbs", ".mkv", grouped=True)
== "Avatar (2009).3D.HSBS.mkv"
)
def test_plain_label_uses_dash_form(self):
assert media_filename("Avatar (2009)", "4K", ".mkv", grouped=True) == \
"Avatar (2009) - 4K.mkv"
assert (
media_filename("Avatar (2009)", "4K", ".mkv", grouped=True)
== "Avatar (2009) - 4K.mkv"
)
def test_grouped_empty_label_gets_a_default(self):
assert media_filename("Avatar (2009)", "", ".mkv", grouped=True) == \
f"Avatar (2009) - {DEFAULT_VERSION_LABEL}.mkv"
assert (
media_filename("Avatar (2009)", "", ".mkv", grouped=True)
== f"Avatar (2009) - {DEFAULT_VERSION_LABEL}.mkv"
)
class TestIdentityKey:
@@ -59,8 +80,13 @@ class TestIdentityKey:
def _movie(**kwargs) -> Movie:
base = dict(key="tmdb-19995", title="Avatar", year="2009", tmdb_id=19995,
library="Filmy")
base = {
"key": "tmdb-19995",
"title": "Avatar",
"year": "2009",
"tmdb_id": 19995,
"library": "Filmy",
}
base.update(kwargs)
return Movie(**base)
@@ -77,50 +103,66 @@ class TestMovieShape:
assert m.is_grouped is False
def test_two_videos_are_grouped(self):
m = _movie(media=[MediaFile(Path("/a.mkv"), "3D HSBS"),
MediaFile(Path("/b.mkv"), "Výchozí")])
m = _movie(
media=[
MediaFile(Path("/a.mkv"), "3D HSBS"),
MediaFile(Path("/b.mkv"), "Výchozí"),
]
)
assert m.is_grouped is True
def test_primary_prefers_the_plain_version(self):
m = _movie(media=[MediaFile(Path("/a.mkv"), "3D HSBS"),
MediaFile(Path("/b.mkv"), "")])
m = _movie(
media=[MediaFile(Path("/a.mkv"), "3D HSBS"), MediaFile(Path("/b.mkv"), "")]
)
assert m.primary.label == ""
class TestDesiredLayout:
def test_one_video_stays_flat_in_the_library(self, tmp_path):
m = _movie(media=[MediaFile(tmp_path / "kdekoli.mkv", "")])
(media, target), = m.desired_layout(tmp_path)
((_media, target),) = m.desired_layout(tmp_path)
assert target == tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995].mkv"
def test_one_3d_video_keeps_the_3d_tag_flat(self, tmp_path):
m = _movie(media=[MediaFile(tmp_path / "x.mkv", "3D HSBS")])
(_m, target), = m.desired_layout(tmp_path)
((_m, target),) = m.desired_layout(tmp_path)
assert target.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
assert target.parent == tmp_path / "Filmy"
def test_two_videos_move_into_the_movie_folder(self, tmp_path):
m = _movie(media=[MediaFile(tmp_path / "a.mkv", ""),
MediaFile(tmp_path / "b.mkv", "3D HSBS")])
m = _movie(
media=[
MediaFile(tmp_path / "a.mkv", ""),
MediaFile(tmp_path / "b.mkv", "3D HSBS"),
]
)
targets = {mf.label: path for mf, path in m.desired_layout(tmp_path)}
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
assert targets[""] == folder / \
f"Avatar (2009) [tmdbid-19995] - {DEFAULT_VERSION_LABEL}.mkv"
assert (
targets[""]
== folder / f"Avatar (2009) [tmdbid-19995] - {DEFAULT_VERSION_LABEL}.mkv"
)
assert targets["3D HSBS"] == folder / "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
def test_layout_follows_a_retitle(self, tmp_path):
"""Renaming the movie is just a different desired layout — the thing
that used to be impossible for a grouped movie."""
m = _movie(title="Avatar: Nová verze",
media=[MediaFile(tmp_path / "a.mkv", "A"),
MediaFile(tmp_path / "b.mkv", "B")])
m = _movie(
title="Avatar: Nová verze",
media=[
MediaFile(tmp_path / "a.mkv", "A"),
MediaFile(tmp_path / "b.mkv", "B"),
],
)
folders = {path.parent for _mf, path in m.desired_layout(tmp_path)}
assert folders == {
tmp_path / "Filmy" / "Avatar: Nová verze (2009) [tmdbid-19995]"}
tmp_path / "Filmy" / "Avatar: Nová verze (2009) [tmdbid-19995]"
}
def test_layout_follows_a_library_move(self, tmp_path):
m = _movie(library="Kids", media=[MediaFile(tmp_path / "a.mkv", "")])
(_m, target), = m.desired_layout(tmp_path)
((_m, target),) = m.desired_layout(tmp_path)
assert target.parent == tmp_path / "Kids"
@@ -128,12 +170,16 @@ class TestRecordRoundTrip:
def test_to_and_from_record(self, tmp_path):
m = _movie(
media=[MediaFile(tmp_path / "Filmy" / "a.mkv", "3D HSBS")],
csfd_url="https://www.csfd.cz/film/1/", date="2026-01-01",
attributes={"collection_sort": "01"}, meta={"year": "2009"})
csfd_url="https://www.csfd.cz/film/1/",
date="2026-01-01",
attributes={"collection_sort": "01"},
meta={"year": "2009"},
)
record = m.to_record(tmp_path)
assert record["media"] == [
{"role": "video", "label": "3D HSBS", "file": "Filmy/a.mkv"}]
{"role": "video", "label": "3D HSBS", "file": "Filmy/a.mkv"}
]
back = Movie.from_record("tmdb-19995", record, tmp_path)
assert back.title == "Avatar"
@@ -148,8 +194,31 @@ class TestRecordRoundTrip:
def test_from_record_builds_tags_via_tagmanager(self, tmp_path):
from src.core.tag_manager import TagManager
record = {"title": "Avatar", "media": [], "tags": ["Žánr/Sci-Fi"]}
back = Movie.from_record("tmdb-19995", record, tmp_path, TagManager())
assert {t.full_path for t in back.tags} == {"Žánr/Sci-Fi"}
@pytest.mark.parametrize(
"label,expected",
[
("part1", 1),
("Part 2", 2),
("pt3", 3),
("CD1", 1),
("disc-2", 2),
("dvd_4", 4),
("disk12", 12),
("4K", None),
("3D HSBS", None),
("", None),
("partition", None),
],
)
def test_normalize_part_label(label, expected):
from src.core.movie import normalize_part_label
assert normalize_part_label(label) == expected
+44 -13
View File
@@ -1,4 +1,5 @@
"""Tests for the pool naming convention helpers (src.core.naming)."""
import pytest
from src.core.naming import (
@@ -9,14 +10,23 @@ from src.core.naming import (
parse_3d_format,
parse_pool_stem,
parse_tmdb_id,
three_d_format_from_filename,
year_from_filename,
)
@pytest.mark.parametrize("year,ok", [
("1968", True), (1968, True), ("968", False), ("19688", False),
("abcd", False), ("", False), (" 1999 ", True),
])
@pytest.mark.parametrize(
"year,ok",
[
("1968", True),
(1968, True),
("968", False),
("19688", False),
("abcd", False),
("", False),
(" 1999 ", True),
],
)
def test_is_valid_year(year, ok):
assert is_valid_year(year) is ok
@@ -82,8 +92,10 @@ def test_is_canonical_pool_stem():
def test_canonical_pool_stem_with_3d_format():
assert canonical_pool_stem("Avatar", 2009, 19995, "hsbs") == \
"Avatar (2009) [tmdbid-19995].3D.HSBS"
assert (
canonical_pool_stem("Avatar", 2009, 19995, "hsbs")
== "Avatar (2009) [tmdbid-19995].3D.HSBS"
)
# upper-cased regardless of input case
assert canonical_pool_stem("Avatar", 2009, None, "fsbs") == "Avatar (2009).3D.FSBS"
# empty/None → no tag at all
@@ -118,12 +130,31 @@ def test_canonical_pool_stem_3d_without_tmdb_id():
assert parse_3d_format("Avatar (2009).3D.MVC") == "MVC"
@pytest.mark.parametrize("name,year", [
("Matrix.1999.1080p.BluRay.mkv", "1999"),
("Šíleně smutná princezna (1968).mkv", "1968"),
("Movie 2001 2010 remux.mkv", "2010"), # last standalone year wins
("No Year Here.mkv", None),
("Film 12345.mkv", None), # not a bare 4-digit year
])
@pytest.mark.parametrize(
"name,year",
[
("Matrix.1999.1080p.BluRay.mkv", "1999"),
("Šíleně smutná princezna (1968).mkv", "1968"),
("Movie 2001 2010 remux.mkv", "2010"), # last standalone year wins
("No Year Here.mkv", None),
("Film 12345.mkv", None), # not a bare 4-digit year
],
)
def test_year_from_filename(name, year):
assert year_from_filename(name) == year
@pytest.mark.parametrize(
"name,fmt",
[
("Avatar.2009.3D.HSBS.BluRay.mkv", "HSBS"),
("Avatar.2009.3d.hsbs.mkv", "HSBS"),
("Avatar (2009) 3D_FTAB 1080p.mkv", "FTAB"),
("Avatar (2009) - 3D-MVC.mkv", "MVC"),
("Avatar (2009).mkv", None),
("Premium3D.HSBS.mkv", None), # "3D" glued to a word doesn't count
("Avatar.2009.HSBS.mkv", None), # no "3D" token at all
],
)
def test_three_d_format_from_filename(name, fmt):
assert three_d_format_from_filename(name) == fmt
+26 -10
View File
@@ -1,4 +1,5 @@
"""Tests for the NFO reader (src.core.nfo)."""
from src.core.nfo import NfoData, find_nfo, read_nfo
MOVIE_NFO = """<?xml version="1.0" encoding="utf-8"?>
@@ -26,14 +27,20 @@ def test_read_nfo_sibling(tmp_path):
nfo = read_nfo(video)
assert nfo == NfoData(
title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
countries=["USA"], rating=87, tmdb_id=603)
title="Matrix",
year="1999",
genres=["Akční", "Sci-Fi"],
countries=["USA"],
rating=87,
tmdb_id=603,
)
def test_rating_scaled_to_percent(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><rating>7.3</rating></movie>", encoding="utf-8")
"<movie><rating>7.3</rating></movie>", encoding="utf-8"
)
assert read_nfo(video).rating == 73
@@ -41,21 +48,25 @@ def test_rating_from_ratings_block(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><ratings><rating name='tmdb'><value>9.1</value></rating>"
"</ratings></movie>", encoding="utf-8")
"</ratings></movie>",
encoding="utf-8",
)
assert read_nfo(video).rating == 91
def test_tmdbid_from_tmdbid_tag(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><tmdbid>603</tmdbid></movie>", encoding="utf-8")
"<movie><tmdbid>603</tmdbid></movie>", encoding="utf-8"
)
assert read_nfo(video).tmdb_id == 603
def test_csfd_url_from_uniqueid_bare_id(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
'<movie><uniqueid type="csfd">9499</uniqueid></movie>', encoding="utf-8")
'<movie><uniqueid type="csfd">9499</uniqueid></movie>', encoding="utf-8"
)
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499/"
@@ -63,21 +74,25 @@ def test_csfd_url_from_uniqueid_full_url(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
'<movie><uniqueid type="csfd">https://www.csfd.cz/film/9499-matrix/'
'</uniqueid></movie>', encoding="utf-8")
"</uniqueid></movie>",
encoding="utf-8",
)
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499-matrix/"
def test_csfd_url_from_legacy_csfdid_tag(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><csfdid>12345</csfdid></movie>", encoding="utf-8")
"<movie><csfdid>12345</csfdid></movie>", encoding="utf-8"
)
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/12345/"
def test_csfd_url_absent_is_none(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><title>Matrix</title></movie>", encoding="utf-8")
"<movie><title>Matrix</title></movie>", encoding="utf-8"
)
assert read_nfo(video).csfd_url is None
@@ -115,9 +130,10 @@ def test_field_values():
def test_refresh_tags_from_nfo(tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
+55 -24
View File
@@ -1,7 +1,7 @@
import json
from src.core.pool_index import PoolIndex, INDEX_FILENAME, INDEX_VERSION
from src.core.file import File
from src.core.pool_index import INDEX_FILENAME, INDEX_VERSION, PoolIndex
from src.core.tag_manager import TagManager
@@ -53,9 +53,13 @@ class TestPoolIndex:
def test_find_movie_by_media(self, tmp_path):
index = PoolIndex(tmp_path)
index.set_movie("tmdb-603", {
"title": "Matrix",
"media": [{"role": "video", "label": "", "file": "Filmy/Matrix.mkv"}]})
index.set_movie(
"tmdb-603",
{
"title": "Matrix",
"media": [{"role": "video", "label": "", "file": "Filmy/Matrix.mkv"}],
},
)
found = index.find_movie_by_media(tmp_path / "Filmy" / "Matrix.mkv")
assert found is not None
@@ -76,16 +80,23 @@ class TestV1Migration:
def _write_v1(self, tmp_path, movies: dict) -> None:
(tmp_path / INDEX_FILENAME).write_text(
json.dumps({"settings": {"tag_schema": []}, "movies": movies}),
encoding="utf-8")
encoding="utf-8",
)
def test_flat_movie_becomes_identity_keyed_record(self, tmp_path):
self._write_v1(tmp_path, {
"Filmy/Matrix (1999) [tmdbid-603].mkv": {
"title": "Matrix", "tags": ["Žánr/Akční"],
"csfd_url": "https://www.csfd.cz/film/9499/",
"date": "1999-03-31", "added": "2026-01-01T00:00:00",
"attributes": {"collection_sort": "01"},
}})
self._write_v1(
tmp_path,
{
"Filmy/Matrix (1999) [tmdbid-603].mkv": {
"title": "Matrix",
"tags": ["Žánr/Akční"],
"csfd_url": "https://www.csfd.cz/film/9499/",
"date": "1999-03-31",
"added": "2026-01-01T00:00:00",
"attributes": {"collection_sort": "01"},
}
},
)
index = PoolIndex(tmp_path)
@@ -96,18 +107,29 @@ class TestV1Migration:
assert record["tmdb_id"] == 603
assert record["library"] == "Filmy"
assert record["media"] == [
{"role": "video", "label": "",
"file": "Filmy/Matrix (1999) [tmdbid-603].mkv"}]
{
"role": "video",
"label": "",
"file": "Filmy/Matrix (1999) [tmdbid-603].mkv",
}
]
assert record["tags"] == ["Žánr/Akční"]
assert record["csfd_url"] == "https://www.csfd.cz/film/9499/"
assert record["attributes"] == {"collection_sort": "01"}
def test_grouped_versions_merge_into_one_record(self, tmp_path):
base = "Filmy/Avatar (2009) [tmdbid-19995]"
self._write_v1(tmp_path, {
f"{base}/Avatar (2009) [tmdbid-19995] - Výchozí.mkv": {"title": "Avatar"},
f"{base}/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv": {"title": "Avatar"},
})
self._write_v1(
tmp_path,
{
f"{base}/Avatar (2009) [tmdbid-19995] - Výchozí.mkv": {
"title": "Avatar"
},
f"{base}/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv": {
"title": "Avatar"
},
},
)
index = PoolIndex(tmp_path)
@@ -117,8 +139,7 @@ class TestV1Migration:
assert record["library"] == "Filmy"
def test_movie_without_tmdbid_gets_slug_key(self, tmp_path):
self._write_v1(tmp_path, {
"Filmy/Pelíšky (1999).mkv": {"title": "Pelíšky"}})
self._write_v1(tmp_path, {"Filmy/Pelíšky (1999).mkv": {"title": "Pelíšky"}})
index = PoolIndex(tmp_path)
@@ -131,7 +152,10 @@ class TestV1Migration:
backup = tmp_path / (INDEX_FILENAME + ".v1.bak")
assert backup.exists()
assert "Filmy/A (2000).mkv" in json.loads(backup.read_text(encoding="utf-8"))["movies"]
assert (
"Filmy/A (2000).mkv"
in json.loads(backup.read_text(encoding="utf-8"))["movies"]
)
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
assert on_disk["version"] == INDEX_VERSION
@@ -141,9 +165,16 @@ class TestV1Migration:
assert index.get_setting("tag_schema") == []
def test_v2_index_is_not_migrated_again(self, tmp_path):
(tmp_path / INDEX_FILENAME).write_text(json.dumps({
"version": INDEX_VERSION, "settings": {},
"movies": {"tmdb-1": {"title": "X", "media": []}}}), encoding="utf-8")
(tmp_path / INDEX_FILENAME).write_text(
json.dumps(
{
"version": INDEX_VERSION,
"settings": {},
"movies": {"tmdb-1": {"title": "X", "media": []}},
}
),
encoding="utf-8",
)
index = PoolIndex(tmp_path)
+304 -49
View File
@@ -1,12 +1,16 @@
"""Tests for series naming/scanning (src.core.series) + FileManager episodes."""
import pytest
from src.core.series import (
Episode,
collect_episode_files,
episode_filename,
is_canonical_episode,
is_season_folder,
parse_episode,
parse_episode_code,
parse_episode_part,
sanitize_title,
scan_series,
season_folder_name,
@@ -14,14 +18,17 @@ from src.core.series import (
)
@pytest.mark.parametrize("name,expected", [
("Breaking Bad S01E02.mkv", (1, 2)),
("bb.s1e2.mkv", (1, 2)),
("Show 01x02 x264.mkv", (1, 2)),
("Series.S01E02E03.mkv", (1, 2)), # multi-ep → first
("Series S12E145.mkv", (12, 145)),
("No code here.mkv", None),
])
@pytest.mark.parametrize(
"name,expected",
[
("Breaking Bad S01E02.mkv", (1, 2)),
("bb.s1e2.mkv", (1, 2)),
("Show 01x02 x264.mkv", (1, 2)),
("Series.S01E02E03.mkv", (1, 2)), # multi-ep → first
("Series S12E145.mkv", (12, 145)),
("No code here.mkv", None),
],
)
def test_parse_episode(name, expected):
assert parse_episode(name) == expected
@@ -36,8 +43,10 @@ def test_episode_filename_is_code_plus_series_title():
assert episode_filename(1, 2, "Most!", ".mkv") == "S01E02 - Most!.mkv"
assert episode_filename(0, 3, "Most!", ".mkv") == "S00E03 - Most!.mkv"
# release string is NOT kept — only the code + series title
assert episode_filename(12, 145, "Breaking Bad", ".mkv") == \
"S12E145 - Breaking Bad.mkv"
assert (
episode_filename(12, 145, "Breaking Bad", ".mkv")
== "S12E145 - Breaking Bad.mkv"
)
# empty / unusable title → just the code
assert episode_filename(3, 4, "", ".mkv") == "S03E04.mkv"
assert episode_filename(3, 4, "/", ".mkv") == "S03E04.mkv"
@@ -45,7 +54,7 @@ def test_episode_filename_is_code_plus_series_title():
def test_sanitize_title():
assert sanitize_title("Most!") == "Most!"
assert sanitize_title('A: B / C D') == "A B C D"
assert sanitize_title("A: B / C D") == "A B C D"
assert sanitize_title(" trailing. ") == "trailing"
@@ -58,11 +67,61 @@ def test_is_season_folder():
def test_series_folder_stem():
assert series_folder_stem("Breaking Bad", 2008, 1396) == "Breaking Bad (2008) [tmdbid-1396]"
assert (
series_folder_stem("Breaking Bad", 2008, 1396)
== "Breaking Bad (2008) [tmdbid-1396]"
)
assert series_folder_stem("Neznámý", None, 42) == "Neznámý [tmdbid-42]"
assert series_folder_stem("Jen název", None, None) == "Jen název"
@pytest.mark.parametrize(
"name,expected",
[
("Show S01E02.pt2.mkv", 2),
("Show S01E01-part-1.mkv", 1),
("Show S01E01 part 1.mkv", 1),
("Show S01E01_cd1.mkv", 1),
("Show S01E01.disc2.mkv", 2),
("Show S01E01.mkv", None),
("Show Abcd2.mkv", None), # "cd2" mid-word — not a stacking suffix
("Show Part of Something.mkv", None), # no digit right after → not a match
],
)
def test_parse_episode_part(name, expected):
assert parse_episode_part(name) == expected
@pytest.mark.parametrize(
"text,expected",
[
("S01E02P3", (1, 2, 3)),
("s1e2p3", (1, 2, 3)),
("S01E02 pt3", (1, 2, 3)),
("S01E02.part.3", (1, 2, 3)),
("S01E02 cd2", (1, 2, 2)),
("S01E02", (1, 2, None)),
("1x02", (1, 2, None)),
("no code here", None),
],
)
def test_parse_episode_code(text, expected):
assert parse_episode_code(text) == expected
def test_episode_filename_with_part():
assert episode_filename(1, 2, "Most!", ".mkv", part=3) == "S01E02 - Most!.pt3.mkv"
assert episode_filename(1, 2, "", ".mkv", part=2) == "S01E02.pt2.mkv"
# no part → unchanged from the plain form
assert episode_filename(1, 2, "Most!", ".mkv", part=None) == "S01E02 - Most!.mkv"
def test_episode_code_property_includes_part():
assert Episode(None, 1, 2, part=3).code == "S01E02.pt3"
assert Episode(None, 1, 2).code == "S01E02"
assert Episode(None, None, None).code == "?"
def test_is_canonical_episode(tmp_path):
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
folder.mkdir(parents=True)
@@ -77,6 +136,17 @@ def test_is_canonical_episode(tmp_path):
assert not is_canonical_episode(wrong_body, "Breaking Bad")
def test_is_canonical_episode_with_part(tmp_path):
folder = tmp_path / "Show (2008) [tmdbid-1]" / "Season 01"
folder.mkdir(parents=True)
good = folder / "S01E02 - Show.pt1.mkv"
good.write_bytes(b"x")
old_style = folder / "S01E02-part-1.mkv" # recognised, but not our own form
old_style.write_bytes(b"x")
assert is_canonical_episode(good, "Show")
assert not is_canonical_episode(old_style, "Show")
def test_scan_series(tmp_path):
lib = tmp_path / "Seriály"
s1 = lib / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
@@ -95,20 +165,36 @@ def test_scan_series(tmp_path):
assert list(seasons.keys()) == [1] and len(seasons[1]) == 2
def test_scan_series_reads_episode_parts(tmp_path):
lib = tmp_path / "Seriály"
s1 = lib / "Show (2008) [tmdbid-1]" / "Season 01"
s1.mkdir(parents=True)
(s1 / "Show S01E02.pt1.mkv").write_bytes(b"x")
(s1 / "Show S01E02.pt2.mkv").write_bytes(b"x")
ser = scan_series(lib, "Seriály")[0]
parts = sorted(ep.part for ep in ser.episodes)
assert parts == [1, 2]
assert {ep.code for ep in ser.episodes} == {"S01E02.pt1", "S01E02.pt2"}
def test_collect_episode_files_walks_subfolders(tmp_path):
root = tmp_path / "Breaking.Bad.COMPLETE"
(root / "Season 01").mkdir(parents=True)
(root / "Season 02").mkdir(parents=True)
(root / "Season 01" / "BB S01E01.mkv").write_bytes(b"x")
(root / "Season 01" / "BB S01E02.mkv").write_bytes(b"x")
(root / "Season 01" / "BB S01E02.srt").write_bytes(b"x") # sidecar → skipped
(root / "Season 01" / "BB S01E02.srt").write_bytes(b"x") # sidecar → skipped
(root / "Season 02" / "BB S02E01.avi").write_bytes(b"x")
(root / "readme.nfo").write_bytes(b"x") # sidecar → skipped
(root / "readme.nfo").write_bytes(b"x") # sidecar → skipped
files = collect_episode_files(root)
assert [f.name for f in files] == [
"BB S01E01.mkv", "BB S01E02.mkv", "BB S02E01.avi"]
"BB S01E01.mkv",
"BB S01E02.mkv",
"BB S02E01.avi",
]
def test_collect_episode_files_missing_folder_is_empty(tmp_path):
@@ -119,9 +205,11 @@ class TestFileManagerSeries:
@pytest.fixture
def file_manager(self, tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
@@ -131,11 +219,19 @@ class TestFileManagerSeries:
(tmp_path / n).write_bytes(b"x")
result = file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S01E02.mkv"],
"Breaking Bad", year=2008, tmdb_id=1396)
"Breaking Bad",
year=2008,
tmdb_id=1396,
)
assert result["imported"] == 2
base = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
base = (
tmp_path
/ "pool"
/ "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01"
)
assert (base / "S01E01 - Breaking Bad.mkv").exists()
assert (base / "S01E02 - Breaking Bad.mkv").exists()
@@ -143,12 +239,22 @@ class TestFileManagerSeries:
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
result = file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396,
library="Hlavní")
[tmp_path / "bb.S01E01.mkv"],
"Breaking Bad",
year=2008,
tmdb_id=1396,
library="Hlavní",
)
assert result["imported"] == 1
assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
assert (
tmp_path
/ "pool"
/ "Hlavní"
/ "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01"
/ "S01E01 - Breaking Bad.mkv"
).exists()
# a mixed library is scanned for series too
found = file_manager.load_series()
assert [s.title for s in found] == ["Breaking Bad"]
@@ -156,7 +262,8 @@ class TestFileManagerSeries:
def test_import_episodes_skips_uncoded(self, file_manager, tmp_path):
(tmp_path / "random.mkv").write_bytes(b"x")
result = file_manager.import_episodes(
[tmp_path / "random.mkv"], "Breaking Bad", year=2008)
[tmp_path / "random.mkv"], "Breaking Bad", year=2008
)
assert result["imported"] == 0
assert result["skipped_no_code"] == ["random.mkv"]
@@ -165,7 +272,10 @@ class TestFileManagerSeries:
(tmp_path / "bb.S02E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S02E01.mkv"],
"Breaking Bad", year=2008, tmdb_id=1396)
"Breaking Bad",
year=2008,
tmdb_id=1396,
)
(ser,) = file_manager.load_series()
assert ser.folder.is_dir()
@@ -176,6 +286,7 @@ class TestFileManagerSeries:
def test_remove_series_refuses_folder_outside_pool(self, file_manager, tmp_path):
from src.core.series import Series
outside = tmp_path / "not-the-pool" / "Show (2020)"
outside.mkdir(parents=True)
bogus = Series(outside, "Show", "2020", None, "Seriály")
@@ -183,26 +294,141 @@ class TestFileManagerSeries:
file_manager.remove_series(bogus)
assert outside.is_dir() # untouched
def test_remove_episodes_deletes_files_and_subtitles(self, file_manager, tmp_path):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
(tmp_path / "bb.S01E02.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S01E02.mkv"],
"Breaking Bad",
year=2008,
tmdb_id=1396,
)
(ser,) = file_manager.load_series()
season = ser.seasons()[1]
e1 = next(e for e in season if e.episode == 1)
e2 = next(e for e in season if e.episode == 2)
srt = e1.path.with_suffix(".srt")
srt.write_bytes(b"x")
file_manager.remove_episodes([e1])
assert not e1.path.exists()
assert not srt.exists()
assert e2.path.exists() # untouched
assert e1.path.parent.is_dir() # not empty — e2 is still there
def test_remove_episodes_cleans_up_the_now_empty_season_folder(
self, file_manager, tmp_path
):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396
)
(ser,) = file_manager.load_series()
(ep,) = ser.episodes
season_folder = ep.path.parent
file_manager.remove_episodes([ep])
assert not ep.path.exists()
assert not season_folder.exists()
def test_remove_season_deletes_every_episode_in_it(self, file_manager, tmp_path):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
(tmp_path / "bb.S01E02.mkv").write_bytes(b"x")
(tmp_path / "bb.S02E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[
tmp_path / "bb.S01E01.mkv",
tmp_path / "bb.S01E02.mkv",
tmp_path / "bb.S02E01.mkv",
],
"Breaking Bad",
year=2008,
tmdb_id=1396,
)
(ser,) = file_manager.load_series()
season1_folder = ser.seasons()[1][0].path.parent
file_manager.remove_season(ser, 1)
assert not season1_folder.exists()
(ser_after,) = file_manager.load_series()
assert [ep.season for ep in ser_after.episodes] == [2]
def test_remove_season_raises_for_a_season_with_no_episodes(
self, file_manager, tmp_path
):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396
)
(ser,) = file_manager.load_series()
with pytest.raises(ValueError):
file_manager.remove_season(ser, 9)
def test_import_episodes_uses_explicit_codes_override(self, file_manager, tmp_path):
uncoded = tmp_path / "pilot.mkv"
uncoded.write_bytes(b"x")
misparsed = tmp_path / "bb 2008 e07.mkv" # "2008" would parse as 20x08
misparsed.write_bytes(b"x")
result = file_manager.import_episodes(
[uncoded, misparsed], "Breaking Bad", year=2008, tmdb_id=1396,
codes={uncoded: (1, 1), misparsed: (1, 7)})
[uncoded, misparsed],
"Breaking Bad",
year=2008,
tmdb_id=1396,
codes={uncoded: (1, 1), misparsed: (1, 7)},
)
assert result["imported"] == 2
base = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
base = (
tmp_path
/ "pool"
/ "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01"
)
assert (base / "S01E01 - Breaking Bad.mkv").exists()
assert (base / "S01E07 - Breaking Bad.mkv").exists()
assert result["skipped_no_code"] == []
def test_import_episodes_uses_explicit_part_override(self, file_manager, tmp_path):
tape1 = tmp_path / "tape1.mkv"
tape1.write_bytes(b"x")
tape2 = tmp_path / "tape2.mkv"
tape2.write_bytes(b"x")
result = file_manager.import_episodes(
[tape1, tape2],
"Breaking Bad",
year=2008,
tmdb_id=1396,
codes={tape1: (1, 1, 1), tape2: (1, 1, 2)},
)
assert result["imported"] == 2
base = (
tmp_path
/ "pool"
/ "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01"
)
assert (base / "S01E01 - Breaking Bad.pt1.mkv").exists()
assert (base / "S01E01 - Breaking Bad.pt2.mkv").exists()
def test_import_episodes_reads_part_from_filename(self, file_manager, tmp_path):
tape = tmp_path / "Show.S01E01.pt2.mkv"
tape.write_bytes(b"x")
result = file_manager.import_episodes([tape], "Show", year=2020, tmdb_id=5)
base = tmp_path / "pool" / "Seriály" / "Show (2020) [tmdbid-5]" / "Season 01"
assert (base / "S01E01 - Show.pt2.mkv").exists()
assert result["imported"] == 1
def test_rename_episodes_to_canonical(self, file_manager, tmp_path):
# a mis-named episode sitting in the wrong place
folder = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]")
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
folder.mkdir(parents=True)
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
@@ -213,16 +439,33 @@ class TestFileManagerSeries:
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
assert (folder / "Season 01" / "S01E03 - Breaking Bad.mkv").exists()
def test_rename_episodes_to_canonical_keeps_the_part_tag(
self, file_manager, tmp_path
):
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
folder.mkdir(parents=True)
(folder / "bb.1x03.HDTV-part2.mkv").write_bytes(b"x")
result = file_manager.rename_episodes_to_canonical(
file_manager.load_series()[0]
)
assert result["renamed"] == [
("bb.1x03.HDTV-part2.mkv", "S01E03 - Breaking Bad.pt2.mkv")
]
assert (folder / "Season 01" / "S01E03 - Breaking Bad.pt2.mkv").exists()
def test_rename_episodes_to_canonical_carries_subtitle_along(
self, file_manager, tmp_path
):
folder = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]")
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
folder.mkdir(parents=True)
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
(folder / "bb.1x03.HDTV.cs.srt").write_bytes(b"x")
result = file_manager.rename_episodes_to_canonical(file_manager.load_series()[0])
result = file_manager.rename_episodes_to_canonical(
file_manager.load_series()[0]
)
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
assert (folder / "Season 01" / "S01E03 - Breaking Bad.cs.srt").exists()
@@ -232,39 +475,51 @@ class TestFileManagerSeries:
seri = tmp_path / "pool" / "Seriály"
# series A: mis-named episodes in a non-padded season folder
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").mkdir(parents=True)
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1"
/ "fargo.1x01.1080p.WEB.mkv").write_bytes(b"x")
(
seri
/ "Fargo (2014) [tmdbid-60622]"
/ "Season 1"
/ "fargo.1x01.1080p.WEB.mkv"
).write_bytes(b"x")
# series B: one good, one without a code
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01").mkdir(parents=True)
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
/ "S01E02 - Most!.mkv").write_bytes(b"x")
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
/ "necoZvlastniho.mkv").write_bytes(b"x")
(
seri / "Most! (2019) [tmdbid-90000]" / "Season 01" / "S01E02 - Most!.mkv"
).write_bytes(b"x")
(
seri / "Most! (2019) [tmdbid-90000]" / "Season 01" / "necoZvlastniho.mkv"
).write_bytes(b"x")
result = file_manager.rename_all_series_to_canonical()
assert result["series"] == 2
assert ("Fargo", "fargo.1x01.1080p.WEB.mkv", "S01E01 - Fargo.mkv") in result["renamed"]
assert (seri / "Fargo (2014) [tmdbid-60622]" / "Season 01"
/ "S01E01 - Fargo.mkv").exists()
assert ("Fargo", "fargo.1x01.1080p.WEB.mkv", "S01E01 - Fargo.mkv") in result[
"renamed"
]
assert (
seri / "Fargo (2014) [tmdbid-60622]" / "Season 01" / "S01E01 - Fargo.mkv"
).exists()
# emptied non-padded season folder swept
assert not (seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").exists()
# the already-canonical one is untouched, the code-less one is reported
assert ("Most!", "necoZvlastniho.mkv") in result["skipped_no_code"]
assert (seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
/ "S01E02 - Most!.mkv").exists()
assert (
seri / "Most! (2019) [tmdbid-90000]" / "Season 01" / "S01E02 - Most!.mkv"
).exists()
def test_apply_series_edits_renames_folder_and_episodes(self, file_manager, tmp_path):
def test_apply_series_edits_renames_folder_and_episodes(
self, file_manager, tmp_path
):
folder = tmp_path / "pool" / "Seriály" / "Wrong Name"
(folder / "Season 01").mkdir(parents=True)
(folder / "Season 01" / "old S01E01.mkv").write_bytes(b"x")
ser = file_manager.load_series()[0]
result = file_manager.apply_series_edits(
ser, title="Breaking Bad", year=2008, tmdb_id=1396)
ser, title="Breaking Bad", year=2008, tmdb_id=1396
)
new_folder = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]")
new_folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
assert result["folder"] == new_folder
assert (new_folder / "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
assert not folder.exists()
+16 -7
View File
@@ -1,4 +1,5 @@
"""Tests for external subtitle sidecars (src.core.subtitles)."""
import pytest
from src.core.subtitles import (
@@ -21,15 +22,21 @@ class TestSubtitleFilename:
assert subtitle_filename("Matrix (1999)", ".srt") == "Matrix (1999).srt"
def test_with_language(self):
assert subtitle_filename("Matrix (1999)", ".srt", "cs") == "Matrix (1999).cs.srt"
assert (
subtitle_filename("Matrix (1999)", ".srt", "cs") == "Matrix (1999).cs.srt"
)
def test_with_language_and_flag(self):
assert subtitle_filename("Matrix (1999)", ".srt", "cs", "forced") == \
"Matrix (1999).cs.forced.srt"
assert (
subtitle_filename("Matrix (1999)", ".srt", "cs", "forced")
== "Matrix (1999).cs.forced.srt"
)
def test_uppercase_input_is_lowered(self):
assert subtitle_filename("Matrix (1999)", ".SRT", "CS", "FORCED") == \
"Matrix (1999).cs.forced.srt"
assert (
subtitle_filename("Matrix (1999)", ".SRT", "CS", "FORCED")
== "Matrix (1999).cs.forced.srt"
)
class TestFindSubtitles:
@@ -53,7 +60,7 @@ class TestFindSubtitles:
def test_ignores_unrelated_and_overlapping_stem(self, tmp_path):
video = _video(tmp_path, "Matrix.mkv")
(tmp_path / "Matrix Reloaded.srt").write_bytes(b"x") # overlapping prefix
(tmp_path / "Matrix.nfo").write_bytes(b"x") # not a subtitle ext
(tmp_path / "Matrix.nfo").write_bytes(b"x") # not a subtitle ext
(tmp_path / "poster.jpg").write_bytes(b"x")
assert find_subtitles(video) == []
@@ -67,7 +74,9 @@ class TestFindSubtitles:
def test_label_property(self):
assert SubtitleFile(path=None, language="cs").label == "cs"
assert SubtitleFile(path=None, language="cs", flag="forced").label == "cs (forced)"
assert (
SubtitleFile(path=None, language="cs", flag="forced").label == "cs (forced)"
)
assert SubtitleFile(path=None).label == "?"
-1
View File
@@ -1,4 +1,3 @@
import pytest
from src.core.tag import Tag
+3 -2
View File
@@ -1,6 +1,7 @@
import pytest
from src.core.tag_manager import TagManager, DEFAULT_TAGS
from src.core.tag import Tag
from src.core.tag_manager import DEFAULT_TAGS, TagManager
class TestTagManager:
@@ -55,7 +56,7 @@ class TestTagManager:
def test_add_tag_creates_category(self, tag_manager):
"""Test že add_tag vytvoří kategorii pokud neexistuje"""
tag = tag_manager.add_tag("NovaKategorie", "Tag")
tag_manager.add_tag("NovaKategorie", "Tag")
assert "NovaKategorie" in tag_manager.tags_by_category
def test_add_multiple_tags_same_category(self, tag_manager):
+57 -20
View File
@@ -1,4 +1,5 @@
"""Tests for the TMDb client (src.core.tmdb). Network is monkeypatched."""
from types import SimpleNamespace
import pytest
@@ -13,8 +14,9 @@ def _with_key(monkeypatch):
def _resp(status=200, payload=None):
return SimpleNamespace(status_code=status, ok=(200 <= status < 300),
json=lambda: (payload or {}))
return SimpleNamespace(
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
)
def test_api_available_reflects_key(monkeypatch):
@@ -24,36 +26,56 @@ def test_api_available_reflects_key(monkeypatch):
def test_lookup_movie_returns_title_and_year(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 603, "title": "Matrix", "release_date": "1999-03-31"}))
monkeypatch.setattr(
tmdb.requests,
"get",
lambda *a, **k: _resp(
200, {"id": 603, "title": "Matrix", "release_date": "1999-03-31"}
),
)
result = tmdb.lookup_movie(603)
assert result == TmdbResult(603, "Matrix", "1999", "movie")
def test_lookup_movie_falls_back_to_original_title(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 1, "original_title": "Solyaris", "release_date": "1972-01-01"}))
monkeypatch.setattr(
tmdb.requests,
"get",
lambda *a, **k: _resp(
200, {"id": 1, "original_title": "Solyaris", "release_date": "1972-01-01"}
),
)
assert tmdb.lookup_movie(1).title == "Solyaris"
def test_lookup_movie_missing_date_gives_no_year(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 2, "title": "Bez data", "release_date": ""}))
monkeypatch.setattr(
tmdb.requests,
"get",
lambda *a, **k: _resp(200, {"id": 2, "title": "Bez data", "release_date": ""}),
)
assert tmdb.lookup_movie(2).year is None
def test_lookup_tv_uses_name_and_first_air(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 1396, "name": "Breaking Bad", "first_air_date": "2008-01-20"}))
monkeypatch.setattr(
tmdb.requests,
"get",
lambda *a, **k: _resp(
200, {"id": 1396, "name": "Breaking Bad", "first_air_date": "2008-01-20"}
),
)
result = tmdb.lookup(1396, kind="tv")
assert result == TmdbResult(1396, "Breaking Bad", "2008", "tv")
def test_search_movie_maps_results(monkeypatch):
payload = {"results": [
{"id": 603, "title": "Matrix", "release_date": "1999-03-31"},
{"id": 605, "title": "Matrix Revolutions", "release_date": "2003-11-05"},
]}
payload = {
"results": [
{"id": 603, "title": "Matrix", "release_date": "1999-03-31"},
{"id": 605, "title": "Matrix Revolutions", "release_date": "2003-11-05"},
]
}
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
results = tmdb.search_movie("Matrix", year="1999")
assert [r.tmdb_id for r in results] == [603, 605]
@@ -63,16 +85,24 @@ def test_search_movie_maps_results(monkeypatch):
def test_search_movie_empty_query_skips_network(monkeypatch):
def boom(*a, **k):
raise AssertionError("should not hit network")
monkeypatch.setattr(tmdb.requests, "get", boom)
assert tmdb.search_movie(" ") == []
def test_movie_details_maps_fields(monkeypatch):
payload = {
"id": 603, "title": "Matrix", "release_date": "1999-03-31",
"id": 603,
"title": "Matrix",
"release_date": "1999-03-31",
"vote_average": 8.3,
"genres": [{"id": 28, "name": "Akční"}, {"id": 878, "name": "Vědeckofantastický"}],
"production_countries": [{"iso_3166_1": "US", "name": "United States of America"}],
"genres": [
{"id": 28, "name": "Akční"},
{"id": 878, "name": "Vědeckofantastický"},
],
"production_countries": [
{"iso_3166_1": "US", "name": "United States of America"}
],
}
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
d = tmdb.movie_details(603)
@@ -85,8 +115,8 @@ def test_movie_details_maps_fields(monkeypatch):
def test_refresh_tags_from_tmdb(tmp_path, monkeypatch):
monkeypatch.setattr("src.core.config.GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.core.tmdb import TmdbDetails
fm = FileManager(TagManager())
@@ -97,8 +127,14 @@ def test_refresh_tags_from_tmdb(tmp_path, monkeypatch):
fm.import_movie(tmp_path / "b.mkv", "Bez ID", year=2000) # no tmdbid
import src.core.tmdb as tmdb_mod
monkeypatch.setattr(tmdb_mod, "movie_details", lambda i: TmdbDetails(
int(i), "Matrix", "1999", ["Akční", "Sci-Fi"], ["USA"], 83))
monkeypatch.setattr(
tmdb_mod,
"movie_details",
lambda i: TmdbDetails(
int(i), "Matrix", "1999", ["Akční", "Sci-Fi"], ["USA"], 83
),
)
result = fm.refresh_tags_from_tmdb()
assert result == {"ok": True, "updated": 1, "without_id": 1, "errors": []}
@@ -124,6 +160,7 @@ def test_network_error_raises(monkeypatch):
def boom(*a, **k):
raise requests.ConnectionError("down")
monkeypatch.setattr(tmdb.requests, "get", boom)
with pytest.raises(TmdbError):
tmdb.lookup_movie(603)
+4 -2
View File
@@ -1,5 +1,7 @@
import pytest
from pathlib import Path
import pytest
from src.core.utils import list_files
@@ -171,7 +173,7 @@ class TestUtils:
files = list_files(temp_dir)
# Najdeme soubor v subdir2
file5 = [f for f in files if f.name == "file5.txt"][0]
file5 = next(f for f in files if f.name == "file5.txt")
# Cesta by měla obsahovat obě složky
assert "subdir1" in str(file5)