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:
parent
145ea9a8ff
commit
1098bac18f
9 changed files with 276 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
147
server/RdtClient.Service.Test/Helpers/FilenameSanitizerTest.cs
Normal file
147
server/RdtClient.Service.Test/Helpers/FilenameSanitizerTest.cs
Normal 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 = "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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
99
server/RdtClient.Service/Helpers/FilenameSanitizer.cs
Normal file
99
server/RdtClient.Service/Helpers/FilenameSanitizer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
|
|
|||
Loading…
Reference in a new issue