From 1098bac18f2cf9e172052de5a9f4b12e50ff9dec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 06:19:25 +0000 Subject: [PATCH] feat: strip problematic characters from downloaded filenames Downloads fail on Linux containers when filenames contain square brackets or multiple consecutive spaces. On Linux, .NET's Path.GetInvalidFileNameChars() only returns NUL and '/', so characters like [ ] { } that cause issues with shell globbing, URI handling, and various download clients pass through the existing filter unchanged. Add FilenameSanitizer that: - Strips square brackets, curly braces, and control characters - Collapses multiple consecutive spaces into one - Trims leading and trailing whitespace - Preserves parentheses and all other characters Apply sanitization at filesystem boundaries: DownloadHelper (where the Real-Debrid filename is turned into a local path), UnpackClient, Torrent delete / RunOnTorrentComplete, and the qBittorrent / SABnzbd status paths that *arr reads back. Aria2c and DownloadStation also sanitize their remote output paths so they match the sanitized local filePath. Symlink's rclone-mount lookup still uses the original name since that has to match the real file in the mount. Add a SanitizeFilenames toggle in DbSettings (default on) so users can disable the behaviour if they need the exact Real-Debrid name. --- .../Models/Internal/DbSettings.cs | 4 + .../Helpers/FilenameSanitizerTest.cs | 147 ++++++++++++++++++ .../Helpers/DownloadHelper.cs | 22 ++- .../Helpers/FilenameSanitizer.cs | 99 ++++++++++++ .../Services/DownloadClient.cs | 4 +- .../RdtClient.Service/Services/QBittorrent.cs | 5 +- server/RdtClient.Service/Services/Sabnzbd.cs | 2 +- server/RdtClient.Service/Services/Torrents.cs | 4 +- .../Services/UnpackClient.cs | 2 +- 9 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 server/RdtClient.Service.Test/Helpers/FilenameSanitizerTest.cs create mode 100644 server/RdtClient.Service/Helpers/FilenameSanitizer.cs 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 = "Movie€NameŸ.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")))