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.
This commit is contained in:
Claude 2026-04-23 06:19:25 +00:00
parent 145ea9a8ff
commit 1098bac18f
No known key found for this signature in database
9 changed files with 276 additions and 13 deletions

View file

@ -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

View file

@ -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);
}
}

View file

@ -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))
{

View file

@ -0,0 +1,99 @@
using System.Text;
using System.Text.RegularExpressions;
using RdtClient.Service.Services;
namespace RdtClient.Service.Helpers;
/// <summary>
/// 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.
/// </summary>
public static partial class FilenameSanitizer
{
[GeneratedRegex(@" {2,}")]
private static partial Regex CreateMultipleSpacesRegex();
private static readonly Regex MultipleSpaces = CreateMultipleSpacesRegex();
/// <summary>
/// Returns whether sanitization is enabled in the current settings.
/// </summary>
public static Boolean IsEnabled => Settings.Get.DownloadClient.SanitizeFilenames;
/// <summary>
/// Sanitizes a filename if enabled in settings; otherwise returns it unchanged.
/// </summary>
public static String SanitizeFilenameIfEnabled(String filename)
{
return IsEnabled ? SanitizeFilename(filename) : filename;
}
/// <summary>
/// Sanitizes a full path if enabled in settings; otherwise returns it unchanged.
/// </summary>
public static String SanitizePathIfEnabled(String filePath)
{
return IsEnabled ? SanitizePath(filePath) : filePath;
}
/// <summary>
/// Sanitizes a filename by stripping problematic characters and normalizing whitespace.
/// Does NOT touch directory separators — only a single filename segment.
/// </summary>
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;
}
/// <summary>
/// Sanitizes each segment of a full file path, preserving directory separators.
/// </summary>
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);
}
}

View file

@ -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}")
};

View file

@ -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<QBittorrent> 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;
}
}

View file

@ -104,7 +104,7 @@ public class Sabnzbd(ILogger<Sabnzbd> 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

View file

@ -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;

View file

@ -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")))