diff --git a/CHANGELOG.md b/CHANGELOG.md index 76bf7d50..08595bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to Sublarr are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Media server path mapping takes several pairs** - The path mapping of a + Plex, Jellyfin, Kodi or Shoko server held one `from:to` pair, so a server + that mounts each library under its own root (`/media/tv` as `/data/tv`, + `/media/movies` as `/data/films`) could only be mapped for one of them, + and every subtitle for the other ended in a full library scan. Pairs can now + be separated with `;`, and the longest matching one is applied. A single + pair works as before. + ## [1.16.0] - 2026-10-09 ### Added diff --git a/backend/mediaserver/base.py b/backend/mediaserver/base.py index 8fcff697..9f4e39a3 100644 --- a/backend/mediaserver/base.py +++ b/backend/mediaserver/base.py @@ -91,8 +91,17 @@ def get_config_fields(self) -> list[dict]: def apply_path_mapping(self, file_path: str) -> str: """Transform file_path using optional path_mapping from config. - If path_mapping is set as "from_path:to_path", replaces from_path - prefix with to_path. If not set, returns file_path unchanged. + path_mapping holds one or more "from_path:to_path" pairs separated by + ";", e.g. "/media/tv:/data/tv;/media/movies:/data/films". The pair + whose from_path is the longest prefix of file_path is applied, so a + specific pair wins over a general one in any order. A single pair + behaves as it always has. If no pair matches, or none is set, + file_path is returned unchanged. + + One pair cannot describe a server that mounts each library under its + own root, or under a name that differs in more than the prefix, and a + path the mapping misses ends in a full library scan instead of a + partial one. Args: file_path: Original file path @@ -100,11 +109,19 @@ def apply_path_mapping(self, file_path: str) -> str: Returns: Mapped file path """ - path_mapping = self.config.get("path_mapping", "") - if not path_mapping or ":" not in path_mapping: + path_mapping = self.config.get("path_mapping", "") or "" + best = None + for pair in path_mapping.split(";"): + pair = pair.strip() + if ":" not in pair: + continue + from_path, to_path = pair.split(":", 1) + if not file_path.startswith(from_path): + continue + if best is None or len(from_path) > len(best[0]): + best = (from_path, to_path) + + if best is None: return file_path - - from_path, to_path = path_mapping.split(":", 1) - if file_path.startswith(from_path): - return to_path + file_path[len(from_path) :] - return file_path + from_path, to_path = best + return to_path + file_path[len(from_path) :] diff --git a/backend/mediaserver/jellyfin.py b/backend/mediaserver/jellyfin.py index 43fd127c..944548dd 100644 --- a/backend/mediaserver/jellyfin.py +++ b/backend/mediaserver/jellyfin.py @@ -56,7 +56,8 @@ class JellyfinEmbyServer(MediaServer): "type": "text", "required": False, "default": "", - "help": "from_path:to_path for Docker volume mapping (e.g. /media:/data)", + "help": "from_path:to_path for Docker volume mapping (e.g. /media:/data). " + "Separate several pairs with ; and the longest matching from_path wins.", }, ] diff --git a/backend/mediaserver/kodi.py b/backend/mediaserver/kodi.py index f5a9d9be..66975463 100644 --- a/backend/mediaserver/kodi.py +++ b/backend/mediaserver/kodi.py @@ -53,7 +53,8 @@ class KodiServer(MediaServer): "type": "text", "required": False, "default": "", - "help": "from_path:to_path for Docker volume mapping (e.g. /media:/data)", + "help": "from_path:to_path for Docker volume mapping (e.g. /media:/data). " + "Separate several pairs with ; and the longest matching from_path wins.", }, ] diff --git a/backend/mediaserver/plex.py b/backend/mediaserver/plex.py index 8046919c..4ea8313d 100644 --- a/backend/mediaserver/plex.py +++ b/backend/mediaserver/plex.py @@ -54,7 +54,8 @@ class PlexServer(MediaServer): "type": "text", "required": False, "default": "", - "help": "from_path:to_path for Docker volume mapping (e.g. /media:/data)", + "help": "from_path:to_path for Docker volume mapping (e.g. /media:/data). " + "Separate several pairs with ; and the longest matching from_path wins.", }, ] diff --git a/backend/mediaserver/shoko.py b/backend/mediaserver/shoko.py index c9d18842..7de7882b 100644 --- a/backend/mediaserver/shoko.py +++ b/backend/mediaserver/shoko.py @@ -63,7 +63,8 @@ class ShokoServer(MediaServer): "required": False, "default": "", "help": "from_path:to_path if Sublarr and Shoko see the media under " - "different roots (e.g. /media:/data). Leave blank if the paths match.", + "different roots (e.g. /media:/data). Separate several pairs with ; and " + "the longest matching from_path wins. Leave blank if the paths match.", }, ] diff --git a/backend/tests/test_media_server_path_mapping.py b/backend/tests/test_media_server_path_mapping.py new file mode 100644 index 00000000..0664c2ea --- /dev/null +++ b/backend/tests/test_media_server_path_mapping.py @@ -0,0 +1,72 @@ +"""A media server path mapping can hold several pairs. + +A server that mounts each library under its own root, such as Plex with +``/media/tv`` as ``/data/tv`` and ``/media/movies`` as ``/data/films``, cannot +be described by one ``from:to`` pair. With one pair, every subtitle for the +unmapped library missed the partial scan and fell back to a full scan of +that library. Pairs are separated by ``;`` and the longest matching +``from_path`` is applied. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from mediaserver.plex import PlexServer + +TWO_ROOTS = "/media/tv:/data/tv;/media/movies:/data/films" + + +def _mapper(path_mapping): + return PlexServer(url="http://plex", token="t", path_mapping=path_mapping) + + +@pytest.mark.parametrize( + "mapping, path, expected", + [ + # One pair keeps its old behaviour. + ("/media:/data", "/media/tv/Show/ep.mkv", "/data/tv/Show/ep.mkv"), + ("/media:/data", "/other/ep.mkv", "/other/ep.mkv"), + ("", "/media/tv/Show/ep.mkv", "/media/tv/Show/ep.mkv"), + # Each pair maps its own root. + (TWO_ROOTS, "/media/tv/Show/ep.mkv", "/data/tv/Show/ep.mkv"), + (TWO_ROOTS, "/media/movies/Film (2020)/Film.mkv", "/data/films/Film (2020)/Film.mkv"), + (TWO_ROOTS, "/media/music/song.flac", "/media/music/song.flac"), + # Spaces around pairs and a trailing separator are ignored. + ( + " /media/tv:/data/tv ; /media/movies:/data/films ;", + "/media/movies/F/F.mkv", + "/data/films/F/F.mkv", + ), + ], +) +def test_maps_each_path_with_its_own_pair(mapping, path, expected): + assert _mapper(mapping).apply_path_mapping(path) == expected + + +@pytest.mark.parametrize( + "mapping", + ["/media:/data;/media/anime:/anime", "/media/anime:/anime;/media:/data"], +) +def test_the_longest_matching_pair_wins_in_any_order(mapping): + mapper = _mapper(mapping) + assert mapper.apply_path_mapping("/media/anime/Show/ep.mkv") == "/anime/Show/ep.mkv" + assert mapper.apply_path_mapping("/media/tv/Show/ep.mkv") == "/data/tv/Show/ep.mkv" + + +def _section(kind, title, *locations): + return MagicMock(type=kind, title=title, locations=list(locations)) + + +def test_a_second_library_root_gets_a_partial_scan_not_a_full_one(): + movies = _section("movie", "Movies", "/data/films") + tv = _section("show", "TV Shows", "/data/tv") + server = MagicMock() + server.library.sections.return_value = [movies, tv] + backend = _mapper(TWO_ROOTS) + with patch.object(backend, "_get_server", return_value=server): + result = backend.refresh_item("/media/movies/Film (2020)/Film.mkv", "movie") + + assert result.success + movies.update.assert_called_once_with(path="/data/films/Film (2020)") + tv.update.assert_not_called() diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index 1b45cc38..ab3fc539 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -1205,8 +1205,8 @@ "field_name": "Name", "field_name_desc": "Anzeigename für diese Instanz", "field_path_mapping": "Pfad-Mapping", - "field_path_mapping_desc": "Container-Pfad → Medienserver-Pfad Mapping, z.B. /media:/data", - "field_path_mapping_help": "Containerpfade auf Medienserverpfade abbilden, z.B. /media:/data", + "field_path_mapping_desc": "Container-Pfad → Medienserver-Pfad Mapping, z.B. /media:/data. Mehrere Paare mit ; trennen", + "field_path_mapping_help": "Containerpfade auf Medienserverpfade abbilden, z.B. /media/tv:/data/tv;/media/movies:/data/films. Der längste passende Containerpfad gewinnt.", "field_enabled": "Aktiviert", "field_enabled_desc": "{{name}}-Integration für Bibliotheks-Benachrichtigungen aktivieren", "field_desc_url": "Adresse der {{name}}-Instanz inkl. Port, z.B. http://localhost:8096", diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 48810334..a29f0678 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -1205,8 +1205,8 @@ "field_name": "Name", "field_name_desc": "Display name for this instance", "field_path_mapping": "Path Mapping", - "field_path_mapping_desc": "Container path → media server path mapping, e.g. /media:/data", - "field_path_mapping_help": "Map container paths to media server paths, e.g. /media:/data", + "field_path_mapping_desc": "Container path → media server path mapping, e.g. /media:/data. Separate several pairs with ;", + "field_path_mapping_help": "Map container paths to media server paths, e.g. /media/tv:/data/tv;/media/movies:/data/films. The longest matching container path wins.", "field_enabled": "Enabled", "field_enabled_desc": "Enable {{name}} integration for library refresh notifications", "field_desc_url": "{{name}} instance URL including port, e.g. http://localhost:8096",