diff --git a/server/RdtClient.Data/Models/Internal/DbSettings.cs b/server/RdtClient.Data/Models/Internal/DbSettings.cs
index c94f27a..6df3281 100644
--- a/server/RdtClient.Data/Models/Internal/DbSettings.cs
+++ b/server/RdtClient.Data/Models/Internal/DbSettings.cs
@@ -176,6 +176,10 @@ http://127.0.0.1:6800/jsonrpc.")]
[DisplayName("Log level")]
[Description("Only set when trying to debug a download client, can generate a lot of logs.")]
public DownloadClientLogLevel LogLevel { get; set; } = DownloadClientLogLevel.None;
+
+ [DisplayName("Sanitize filenames")]
+ [Description("Strip characters from filenames and torrent directory names that cause issues on Linux containers (square brackets, curly braces, control characters) and collapse multiple consecutive spaces. Recommended on; disable only if you need to preserve the exact filenames from the debrid provider.")]
+ public Boolean SanitizeFilenames { get; set; } = true;
}
public class DbSettingsProvider
diff --git a/server/RdtClient.Service.Test/Helpers/FilenameSanitizerTest.cs b/server/RdtClient.Service.Test/Helpers/FilenameSanitizerTest.cs
new file mode 100644
index 0000000..c48e46b
--- /dev/null
+++ b/server/RdtClient.Service.Test/Helpers/FilenameSanitizerTest.cs
@@ -0,0 +1,147 @@
+using RdtClient.Service.Helpers;
+
+namespace RdtClient.Service.Test.Helpers;
+
+public class FilenameSanitizerTest
+{
+ [Theory]
+ [InlineData(
+ "Fixer.Upper.S04E11.Space.in.the.Suburbs.720p.HDTV.x264-W4F[eztv].mkv",
+ "Fixer.Upper.S04E11.Space.in.the.Suburbs.720p.HDTV.x264-W4Feztv.mkv")]
+ [InlineData(
+ "The.Bear.S03E01.Tomorrow.1080p.DSNP.WEB-DL.DDP5.1.H.264-NTb[EZTVx.to].mkv",
+ "The.Bear.S03E01.Tomorrow.1080p.DSNP.WEB-DL.DDP5.1.H.264-NTbEZTVx.to.mkv")]
+ public void SanitizeFilename_StripsSquareBrackets(String input, String expected)
+ {
+ var result = FilenameSanitizer.SanitizeFilename(input);
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void SanitizeFilename_StripsCurlyBraces()
+ {
+ var result = FilenameSanitizer.SanitizeFilename("Movie{2024}.mkv");
+ Assert.Equal("Movie2024.mkv", result);
+ }
+
+ [Theory]
+ [InlineData("Movie Name 2024.mkv", "Movie Name 2024.mkv")]
+ [InlineData("Too Many Spaces.mkv", "Too Many Spaces.mkv")]
+ public void SanitizeFilename_CollapsesMultipleSpaces(String input, String expected)
+ {
+ var result = FilenameSanitizer.SanitizeFilename(input);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData(" Leading.mkv", "Leading.mkv")]
+ [InlineData("Trailing.mkv ", "Trailing.mkv")]
+ [InlineData(" Both Sides ", "Both Sides")]
+ public void SanitizeFilename_TrimsWhitespace(String input, String expected)
+ {
+ var result = FilenameSanitizer.SanitizeFilename(input);
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void SanitizeFilename_StripsControlCharacters()
+ {
+ var input = "MovieName.mkv";
+ var result = FilenameSanitizer.SanitizeFilename(input);
+ Assert.Equal("MovieName.mkv", result);
+ }
+
+ [Fact]
+ public void SanitizeFilename_StripsDeleteCharacter()
+ {
+ var input = "MovieName.mkv";
+ var result = FilenameSanitizer.SanitizeFilename(input);
+ Assert.Equal("MovieName.mkv", result);
+ }
+
+ [Fact]
+ public void SanitizeFilename_StripsExtendedControlCharacters()
+ {
+ var input = "MovieName.mkv";
+ var result = FilenameSanitizer.SanitizeFilename(input);
+ Assert.Equal("MovieName.mkv", result);
+ }
+
+ [Fact]
+ public void SanitizeFilename_LeavesCleanFilenamesUnchanged()
+ {
+ const String clean = "fixer.upper.s05e17.chip.and.jos.breakfast.joint.hdtv.x264-w4f.mkv";
+ var result = FilenameSanitizer.SanitizeFilename(clean);
+ Assert.Equal(clean, result);
+ }
+
+ [Fact]
+ public void SanitizeFilename_PreservesParentheses()
+ {
+ const String input = "Movie (2024) 1080p.mkv";
+ var result = FilenameSanitizer.SanitizeFilename(input);
+ Assert.Equal(input, result);
+ }
+
+ [Theory]
+ [InlineData("", "")]
+ public void SanitizeFilename_HandlesEmpty(String input, String expected)
+ {
+ var result = FilenameSanitizer.SanitizeFilename(input);
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void SanitizeFilename_CombinedIssues()
+ {
+ var result = FilenameSanitizer.SanitizeFilename("Some Show S01E01 720p HDTV [eztv].mkv");
+ Assert.Equal("Some Show S01E01 720p HDTV eztv.mkv", result);
+ }
+
+ [Fact]
+ public void SanitizeFilename_StripsBracketsEvenWhenSpacedApart()
+ {
+ var result = FilenameSanitizer.SanitizeFilename("Some Show [2024] 1080p.mkv");
+ Assert.Equal("Some Show 2024 1080p.mkv", result);
+ }
+
+ [Fact]
+ public void SanitizePath_SanitizesFilenameSegment()
+ {
+ var path = Path.Combine(Path.DirectorySeparatorChar + "data", "downloads", "tv-sonarr", "Fixer.Upper.S04E11[eztv].mkv");
+ var expected = Path.Combine(Path.DirectorySeparatorChar + "data", "downloads", "tv-sonarr", "Fixer.Upper.S04E11eztv.mkv");
+
+ var result = FilenameSanitizer.SanitizePath(path);
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void SanitizePath_SanitizesDirectoryNames()
+ {
+ var path = Path.Combine(Path.DirectorySeparatorChar + "data", "downloads", "Some Show [2024]", "Episode[eztv].mkv");
+ var expected = Path.Combine(Path.DirectorySeparatorChar + "data", "downloads", "Some Show 2024", "Episodeeztv.mkv");
+
+ var result = FilenameSanitizer.SanitizePath(path);
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void SanitizePath_PreservesDirectorySeparators()
+ {
+ var path = Path.Combine(Path.DirectorySeparatorChar + "data", "downloads", "clean", "file.mkv");
+
+ var result = FilenameSanitizer.SanitizePath(path);
+
+ Assert.Equal(path, result);
+ }
+
+ [Theory]
+ [InlineData("")]
+ public void SanitizePath_HandlesEmpty(String input)
+ {
+ var result = FilenameSanitizer.SanitizePath(input);
+ Assert.Equal(input, result);
+ }
+}
diff --git a/server/RdtClient.Service/Helpers/DownloadHelper.cs b/server/RdtClient.Service/Helpers/DownloadHelper.cs
index bc5b578..c88b1b2 100644
--- a/server/RdtClient.Service/Helpers/DownloadHelper.cs
+++ b/server/RdtClient.Service/Helpers/DownloadHelper.cs
@@ -15,18 +15,19 @@ public static class DownloadHelper
return null;
}
- var directory = RemoveInvalidPathChars(torrent.RdName);
+ var originalDirectory = RemoveInvalidPathChars(torrent.RdName);
+ var directory = FilenameSanitizer.SanitizeFilenameIfEnabled(originalDirectory);
var torrentPath = Path.Combine(downloadPath, directory);
- var fileName = GetFileName(download);
+ var originalFileName = GetOriginalFileName(download);
- if (fileName == null)
+ if (originalFileName == null)
{
return null;
}
- var matchingTorrentFiles = torrent.Files.Where(m => m.Path.EndsWith(fileName)).Where(m => !String.IsNullOrWhiteSpace(m.Path)).ToList();
+ var matchingTorrentFiles = torrent.Files.Where(m => m.Path.EndsWith(originalFileName)).Where(m => !String.IsNullOrWhiteSpace(m.Path)).ToList();
if (matchingTorrentFiles.Count > 0)
{
@@ -38,10 +39,12 @@ public static class DownloadHelper
{
subPath = subPath.Trim('/').Trim('\\');
- subPath = StripTorrentNamePrefix(subPath, directory);
+ subPath = StripTorrentNamePrefix(subPath, originalDirectory);
if (!String.IsNullOrWhiteSpace(subPath))
{
+ subPath = FilenameSanitizer.SanitizePathIfEnabled(subPath);
+
torrentPath = Path.Combine(torrentPath, subPath);
}
}
@@ -54,6 +57,8 @@ public static class DownloadHelper
fileSystem.Directory.CreateDirectory(torrentPath);
}
+ var fileName = FilenameSanitizer.SanitizeFilenameIfEnabled(originalFileName);
+
var filePath = Path.Combine(torrentPath, fileName);
return filePath;
@@ -107,6 +112,13 @@ public static class DownloadHelper
}
public static String? GetFileName(Download download)
+ {
+ var cleaned = GetOriginalFileName(download);
+
+ return cleaned == null ? null : FilenameSanitizer.SanitizeFilenameIfEnabled(cleaned);
+ }
+
+ private static String? GetOriginalFileName(Download download)
{
if (String.IsNullOrWhiteSpace(download.Link))
{
diff --git a/server/RdtClient.Service/Helpers/FilenameSanitizer.cs b/server/RdtClient.Service/Helpers/FilenameSanitizer.cs
new file mode 100644
index 0000000..d3a28ad
--- /dev/null
+++ b/server/RdtClient.Service/Helpers/FilenameSanitizer.cs
@@ -0,0 +1,99 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using RdtClient.Service.Services;
+
+namespace RdtClient.Service.Helpers;
+
+///
+/// Sanitizes filenames to prevent issues with special characters on Linux containers.
+/// Path.GetInvalidFileNameChars() on Linux only returns NUL and '/', so characters
+/// like [ ] { } that cause problems with shell globbing, URI handling, and various
+/// download clients pass through the built-in filter unchanged.
+///
+public static partial class FilenameSanitizer
+{
+ [GeneratedRegex(@" {2,}")]
+ private static partial Regex CreateMultipleSpacesRegex();
+
+ private static readonly Regex MultipleSpaces = CreateMultipleSpacesRegex();
+
+ ///
+ /// Returns whether sanitization is enabled in the current settings.
+ ///
+ public static Boolean IsEnabled => Settings.Get.DownloadClient.SanitizeFilenames;
+
+ ///
+ /// Sanitizes a filename if enabled in settings; otherwise returns it unchanged.
+ ///
+ public static String SanitizeFilenameIfEnabled(String filename)
+ {
+ return IsEnabled ? SanitizeFilename(filename) : filename;
+ }
+
+ ///
+ /// Sanitizes a full path if enabled in settings; otherwise returns it unchanged.
+ ///
+ public static String SanitizePathIfEnabled(String filePath)
+ {
+ return IsEnabled ? SanitizePath(filePath) : filePath;
+ }
+
+ ///
+ /// Sanitizes a filename by stripping problematic characters and normalizing whitespace.
+ /// Does NOT touch directory separators — only a single filename segment.
+ ///
+ public static String SanitizeFilename(String filename)
+ {
+ if (String.IsNullOrEmpty(filename))
+ {
+ return filename;
+ }
+
+ var sb = new StringBuilder(filename.Length);
+
+ foreach (var c in filename)
+ {
+ if (Char.IsControl(c))
+ {
+ continue;
+ }
+
+ if (c is '[' or ']' or '{' or '}')
+ {
+ continue;
+ }
+
+ sb.Append(c);
+ }
+
+ var result = sb.ToString();
+ result = MultipleSpaces.Replace(result, " ");
+ result = result.Trim();
+
+ return result;
+ }
+
+ ///
+ /// Sanitizes each segment of a full file path, preserving directory separators.
+ ///
+ public static String SanitizePath(String filePath)
+ {
+ if (String.IsNullOrEmpty(filePath))
+ {
+ return filePath;
+ }
+
+ var separator = Path.DirectorySeparatorChar;
+ var segments = filePath.Split(separator);
+
+ for (var i = 0; i < segments.Length; i++)
+ {
+ if (!String.IsNullOrEmpty(segments[i]))
+ {
+ segments[i] = SanitizeFilename(segments[i]);
+ }
+ }
+
+ return String.Join(separator, segments);
+ }
+}
diff --git a/server/RdtClient.Service/Services/DownloadClient.cs b/server/RdtClient.Service/Services/DownloadClient.cs
index bb07570..68b7bf8 100644
--- a/server/RdtClient.Service/Services/DownloadClient.cs
+++ b/server/RdtClient.Service/Services/DownloadClient.cs
@@ -66,9 +66,9 @@ public class DownloadClient(Download download, Torrent torrent, String destinati
Downloader = Type switch
{
Data.Enums.DownloadClient.Bezzad => new BezzadDownloader(download.Link, filePath),
- Data.Enums.DownloadClient.Aria2c => new Aria2cDownloader(download.RemoteId, download.Link, filePath, downloadPath, category),
+ Data.Enums.DownloadClient.Aria2c => new Aria2cDownloader(download.RemoteId, download.Link, filePath, FilenameSanitizer.SanitizePathIfEnabled(downloadPath), category),
Data.Enums.DownloadClient.Symlink => new SymlinkDownloader(download.Link, filePath, downloadPath, torrent.ClientKind),
- Data.Enums.DownloadClient.DownloadStation => await DownloadStationDownloader.Init(download.RemoteId, download.Link, filePath, downloadPath, category),
+ Data.Enums.DownloadClient.DownloadStation => await DownloadStationDownloader.Init(download.RemoteId, download.Link, filePath, FilenameSanitizer.SanitizePathIfEnabled(downloadPath), category),
_ => throw new($"Unknown download client {Type}")
};
diff --git a/server/RdtClient.Service/Services/QBittorrent.cs b/server/RdtClient.Service/Services/QBittorrent.cs
index 3a646bb..9f8fc24 100644
--- a/server/RdtClient.Service/Services/QBittorrent.cs
+++ b/server/RdtClient.Service/Services/QBittorrent.cs
@@ -2,6 +2,7 @@
using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.QBittorrent;
+using RdtClient.Service.Helpers;
namespace RdtClient.Service.Services;
@@ -210,11 +211,11 @@ public class QBittorrent(ILogger logger, Settings settings, Authent
// Alldebrid stores single file torrents at the root folder.
if (torrent.ClientKind == Provider.AllDebrid && torrent.Files.Count == 1)
{
- torrentPath = Path.Combine(downloadPath, torrent.Files[0].Path);
+ torrentPath = Path.Combine(downloadPath, FilenameSanitizer.SanitizePathIfEnabled(torrent.Files[0].Path));
}
else
{
- torrentPath = Path.Combine(downloadPath, torrent.RdName) + Path.DirectorySeparatorChar;
+ torrentPath = Path.Combine(downloadPath, FilenameSanitizer.SanitizeFilenameIfEnabled(torrent.RdName)) + Path.DirectorySeparatorChar;
}
}
diff --git a/server/RdtClient.Service/Services/Sabnzbd.cs b/server/RdtClient.Service/Services/Sabnzbd.cs
index 71cce9a..f8bf1f7 100644
--- a/server/RdtClient.Service/Services/Sabnzbd.cs
+++ b/server/RdtClient.Service/Services/Sabnzbd.cs
@@ -104,7 +104,7 @@ public class Sabnzbd(ILogger logger, Torrents torrents, AppSettings app
if (!String.IsNullOrWhiteSpace(t.RdName))
{
- path = Path.Combine(path, t.RdName);
+ path = Path.Combine(path, FilenameSanitizer.SanitizeFilenameIfEnabled(t.RdName));
}
return new SabnzbdHistorySlot
diff --git a/server/RdtClient.Service/Services/Torrents.cs b/server/RdtClient.Service/Services/Torrents.cs
index e997f1b..35b905b 100644
--- a/server/RdtClient.Service/Services/Torrents.cs
+++ b/server/RdtClient.Service/Services/Torrents.cs
@@ -560,7 +560,7 @@ public class Torrents(
if (deleteLocalFiles && !String.IsNullOrWhiteSpace(torrent.RdName))
{
var downloadPath = DownloadPath(torrent);
- downloadPath = Path.Combine(downloadPath, torrent.RdName);
+ downloadPath = Path.Combine(downloadPath, FilenameSanitizer.SanitizeFilenameIfEnabled(torrent.RdName));
Log($"Deleting local files in {downloadPath}", torrent);
@@ -939,7 +939,7 @@ public class Torrents(
Log($"Parsing external program {fileName} with arguments {arguments}", torrent);
var downloadPath = DownloadPath(torrent);
- var torrentPath = Path.Combine(downloadPath, torrent.RdName ?? "Unknown");
+ var torrentPath = Path.Combine(downloadPath, FilenameSanitizer.SanitizeFilenameIfEnabled(torrent.RdName ?? "Unknown"));
var filePath = torrentPath;
diff --git a/server/RdtClient.Service/Services/UnpackClient.cs b/server/RdtClient.Service/Services/UnpackClient.cs
index 193fe26..f6f7e13 100644
--- a/server/RdtClient.Service/Services/UnpackClient.cs
+++ b/server/RdtClient.Service/Services/UnpackClient.cs
@@ -63,7 +63,7 @@ public class UnpackClient(Download download, String destinationPath)
if (!archiveEntries.Any(m => m.StartsWith(_torrent.RdName + @"\")) && !archiveEntries.Any(m => m.StartsWith(_torrent.RdName + "/")))
{
- extractPath = Path.Combine(destinationPath, _torrent.RdName!);
+ extractPath = Path.Combine(destinationPath, FilenameSanitizer.SanitizeFilenameIfEnabled(_torrent.RdName!));
}
if (archiveEntries.Any(m => m.Contains(".r00")))