Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 26 additions & 9 deletions backend/mediaserver/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,20 +91,37 @@ 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

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) :]
3 changes: 2 additions & 1 deletion backend/mediaserver/jellyfin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
]

Expand Down
3 changes: 2 additions & 1 deletion backend/mediaserver/kodi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
]

Expand Down
3 changes: 2 additions & 1 deletion backend/mediaserver/plex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
]

Expand Down
3 changes: 2 additions & 1 deletion backend/mediaserver/shoko.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
]

Expand Down
72 changes: 72 additions & 0 deletions backend/tests/test_media_server_path_mapping.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 2 additions & 2 deletions frontend/src/i18n/locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down