| … | … | @@ -0,0 +1,160 @@ |
| 1 | """Library ingest: fetched copies land in the Jellyfin library, not a cache. |
| 2 | |
| 3 | A finished copy is not cache -- it is library content, and it is permanent. It |
| 4 | goes to /mnt/usb/{Shows,Movies} in the layout Jellyfin identifies on, where it is |
| 5 | indexed and served like any other media and Kodi plays it through the same |
| 6 | plugin:// path as the rest of the library. Seek, resume and pause all work there |
| 7 | natively, which is what makes the muxed-fMP4 problem stop mattering for anything |
| 8 | we have fetched. |
| 9 | |
| 10 | Naming carries the TMDB id explicitly ([tmdbid-138502]). The 2026-07-28 library |
| 11 | work found Jellyfin's own identification is ~92% right and not safe to rename |
| 12 | blindly -- but that was retrofitting unknown files. Here the title was resolved |
| 13 | FROM its TMDB id, so identity is asserted rather than inferred. |
| 14 | |
| 15 | The volume is exFAT (verified 2026-08-11: findmnt -> exfat /dev/sdb1). That |
| 16 | forbids " * / : < > ? \\ | in names, which is why the existing library holds |
| 17 | `Ghost in the Shell:STAND ALONE COMPLEX` with a FULLWIDTH colon -- an existing |
| 18 | convention this follows rather than invents. |
| 19 | |
| 20 | Run: .venv/bin/python3 -m pytest test_library_ingest.py -q |
| 21 | """ |
| 22 | |
| 23 | import os |
| 24 | |
| 25 | import pytest |
| 26 | |
| 27 | import addon |
| 28 | |
| 29 | |
| 30 | @pytest.fixture(autouse=True) |
| 31 | def tmp_library(tmp_path, monkeypatch): |
| 32 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path)) |
| 33 | yield |
| 34 | |
| 35 | |
| 36 | # --- character safety (exFAT) ----------------------------------------------- |
| 37 | |
| 38 | def test_forbidden_exfat_characters_are_replaced_not_dropped(): |
| 39 | """exFAT rejects " * / : < > ? \\ | outright. Replacing preserves |
| 40 | readability; dropping would turn "Ep: One" into "Ep One" and lose the |
| 41 | distinction from a real title.""" |
| 42 | out = addon._safe_component('A:B/C\\D*E?F"G<H>I|J') |
| 43 | for bad in '"*/:<>?\\|': |
| 44 | assert bad not in out |
| 45 | assert "A" in out and "J" in out |
| 46 | |
| 47 | |
| 48 | def test_colon_becomes_the_fullwidth_colon_already_used_in_the_library(): |
| 49 | """Matches `Ghost in the Shell:STAND ALONE COMPLEX` on the volume today.""" |
| 50 | assert addon._safe_component("Ghost in the Shell: SAC") == \ |
| 51 | "Ghost in the Shell: SAC" |
| 52 | |
| 53 | |
| 54 | def test_control_characters_and_edge_whitespace_are_stripped(): |
| 55 | assert "\n" not in addon._safe_component("Two\nLines") |
| 56 | assert addon._safe_component(" padded ") == "padded" |
| 57 | |
| 58 | |
| 59 | def test_trailing_dots_are_stripped(): |
| 60 | """A component ending in '.' is rejected or silently altered on |
| 61 | exFAT/Windows, which would break the later os.replace.""" |
| 62 | assert not addon._safe_component("Episode.").endswith(".") |
| 63 | |
| 64 | |
| 65 | def test_a_component_never_becomes_empty(): |
| 66 | """An empty component would collapse the path and put the file in the |
| 67 | parent directory.""" |
| 68 | assert addon._safe_component("///") != "" |
| 69 | assert addon._safe_component("") != "" |
| 70 | |
| 71 | |
| 72 | # --- series naming ---------------------------------------------------------- |
| 73 | |
| 74 | def test_series_path_is_the_jellyfin_layout(): |
| 75 | p = addon._library_path("series", "X-Men '97", "2024", "138502", |
| 76 | season=2, episode=7, episode_title="Bright Eyes") |
| 77 | rel = os.path.relpath(p, addon.LIBRARY_ROOT) |
| 78 | assert rel == os.path.join( |
| 79 | "Shows", |
| 80 | "X-Men '97 (2024) [tmdbid-138502]", |
| 81 | "Season 02", |
| 82 | "X-Men '97 (2024) - S02E07 - Bright Eyes.mp4", |
| 83 | ) |
| 84 | |
| 85 | |
| 86 | def test_season_and_episode_are_zero_padded(): |
| 87 | p = addon._library_path("series", "Show", "2020", "1", |
| 88 | season=1, episode=3, episode_title="T") |
| 89 | assert "Season 01" in p and "S01E03" in p |
| 90 | |
| 91 | |
| 92 | def test_double_digit_seasons_are_not_truncated(): |
| 93 | p = addon._library_path("series", "Show", "2020", "1", |
| 94 | season=12, episode=134, episode_title="T") |
| 95 | assert "Season 12" in p and "S12E134" in p |
| 96 | |
| 97 | |
| 98 | def test_a_missing_episode_title_still_produces_a_valid_name(): |
| 99 | """TMDB does not always carry an episode title. The file must still be |
| 100 | named unambiguously -- Jellyfin keys on the SxxExx token.""" |
| 101 | p = addon._library_path("series", "Show", "2020", "1", |
| 102 | season=1, episode=3, episode_title="") |
| 103 | assert p.endswith("Show (2020) - S01E03.mp4") |
| 104 | |
| 105 | |
| 106 | def test_a_slash_in_an_episode_title_cannot_create_a_directory(): |
| 107 | p = addon._library_path("series", "Show", "2020", "1", season=1, episode=1, |
| 108 | episode_title="Part 1/2") |
| 109 | assert os.path.basename(p) == "Show (2020) - S01E01 - Part 1-2.mp4" |
| 110 | |
| 111 | |
| 112 | # --- movie naming ----------------------------------------------------------- |
| 113 | |
| 114 | def test_movie_path_is_the_jellyfin_layout(): |
| 115 | p = addon._library_path("movie", "Blade Runner", "1982", "78") |
| 116 | rel = os.path.relpath(p, addon.LIBRARY_ROOT) |
| 117 | assert rel == os.path.join( |
| 118 | "Movies", |
| 119 | "Blade Runner (1982) [tmdbid-78]", |
| 120 | "Blade Runner (1982).mp4", |
| 121 | ) |
| 122 | |
| 123 | |
| 124 | def test_a_missing_year_does_not_leave_empty_parentheses(): |
| 125 | """TMDB occasionally has no release date. '( )' would both look broken and |
| 126 | change how Jellyfin parses the name.""" |
| 127 | p = addon._library_path("movie", "Untitled", "", "99") |
| 128 | assert "()" not in p and "( )" not in p |
| 129 | assert "[tmdbid-99]" in p |
| 130 | |
| 131 | |
| 132 | # --- containment ------------------------------------------------------------ |
| 133 | |
| 134 | def test_hostile_values_cannot_escape_the_library_root(): |
| 135 | for evil in ("../../etc/passwd", "..", "a/../../b", "/etc/shadow"): |
| 136 | for kind in ("series", "movie"): |
| 137 | p = os.path.realpath(addon._library_path( |
| 138 | kind, evil, "2020", evil, season=1, episode=1, |
| 139 | episode_title=evil)) |
| 140 | assert p.startswith(os.path.realpath(addon.LIBRARY_ROOT) + os.sep) |
| 141 | |
| 142 | |
| 143 | def test_the_tmdb_id_is_the_identifying_token(): |
| 144 | """Two shows sharing a name and year must not share a folder.""" |
| 145 | a = addon._library_path("series", "Ghosts", "2019", "95396", |
| 146 | season=1, episode=1, episode_title="P") |
| 147 | b = addon._library_path("series", "Ghosts", "2019", "87739", |
| 148 | season=1, episode=1, episode_title="P") |
| 149 | assert os.path.dirname(a) != os.path.dirname(b) |
| 150 | |
| 151 | |
| 152 | # --- the partial file must be on the same filesystem ------------------------ |
| 153 | |
| 154 | def test_the_part_file_is_a_sibling_of_its_destination(): |
| 155 | """os.replace() across filesystems fails with EXDEV. Writing the temp file |
| 156 | into the repo's cache dir and renaming onto /mnt/usb would fail on every |
| 157 | ingest, so the partial must sit beside its destination.""" |
| 158 | dest = addon._library_path("series", "Show", "2020", "1", |
| 159 | season=1, episode=1, episode_title="T") |
| 160 | assert os.path.dirname(addon._part_path(dest)) == os.path.dirname(dest) |