From 6efee036a826027391433b7d0c954ebc1a75c679 Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Tue, 11 Aug 2020 00:07:26 +0200 Subject: [PATCH] Fixed: Include extension when calculating maximum episode title length when renaming files Fixed: Option to override max filename length with MAX_NAME environment variable Closes #3888 --- src/NzbDrone.Common/Disk/LongPathSupport.cs | 53 +++++++++++++++++++ .../MoveEpisodeFileFixture.cs | 6 +-- .../OrganizerTests/BuildFilePathFixture.cs | 30 +++++++++-- .../TruncatedEpisodeTitlesFixture.cs | 17 ++++++ .../MediaFiles/EpisodeFileMovingService.cs | 9 ++-- .../MediaFiles/RenameEpisodeFileService.cs | 3 +- .../Organizer/FileNameBuilder.cs | 41 +++++++++----- .../Organizer/FileNameSampleService.cs | 2 +- 8 files changed, 130 insertions(+), 31 deletions(-) diff --git a/src/NzbDrone.Common/Disk/LongPathSupport.cs b/src/NzbDrone.Common/Disk/LongPathSupport.cs index ef4bd3f7c..81a056ea1 100644 --- a/src/NzbDrone.Common/Disk/LongPathSupport.cs +++ b/src/NzbDrone.Common/Disk/LongPathSupport.cs @@ -1,15 +1,68 @@ using System; +using System.IO; +using NzbDrone.Common.EnvironmentInfo; namespace NzbDrone.Common.Disk { public static class LongPathSupport { + private static int MAX_PATH; + private static int MAX_NAME; + public static void Enable() { // Mono has an issue with enabling long path support via app.config. // This works for both mono and .net on Windows. AppContext.SetSwitch("Switch.System.IO.UseLegacyPathHandling", false); AppContext.SetSwitch("Switch.System.IO.BlockLongPaths", false); + + DetectLongPathLimits(); + } + + private static void DetectLongPathLimits() + { + if (!int.TryParse(Environment.GetEnvironmentVariable("MAX_PATH"), out MAX_PATH)) + { + if (OsInfo.IsLinux) + { + MAX_PATH = 4096; + } + else + { + try + { + Path.GetDirectoryName($@"C:\{new string('a', 300)}\ab"); + MAX_PATH = 4096; + } + catch + { + MAX_PATH = 260; + } + } + } + + if (!int.TryParse(Environment.GetEnvironmentVariable("MAX_NAME"), out MAX_NAME)) + { + MAX_NAME = 255; + } + } + + public static int MaxFilePathLength + { + get + { + if (MAX_PATH == 0) DetectLongPathLimits(); + return MAX_PATH; + } + } + + public static int MaxFileNameLength + { + get + { + if (MAX_NAME == 0) DetectLongPathLimits(); + return MAX_NAME; + } } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeFileMovingServiceTests/MoveEpisodeFileFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeFileMovingServiceTests/MoveEpisodeFileFixture.cs index 727f9ae4d..298e524a9 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeFileMovingServiceTests/MoveEpisodeFileFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeFileMovingServiceTests/MoveEpisodeFileFixture.cs @@ -42,11 +42,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeFileMovingServiceTests .Build(); Mocker.GetMock() - .Setup(s => s.BuildFileName(It.IsAny>(), It.IsAny(), It.IsAny(), null, null)) - .Returns("File Name"); - - Mocker.GetMock() - .Setup(s => s.BuildFilePath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(s => s.BuildFilePath(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) .Returns(@"C:\Test\TV\Series\Season 01\File Name.avi".AsOsAgnostic()); Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs index 20b73bcb8..3dc61cc9b 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs @@ -1,9 +1,11 @@ +using System.Linq; using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; +using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Organizer; -using NzbDrone.Core.Tv; using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.OrganizerTests @@ -32,16 +34,26 @@ namespace NzbDrone.Core.Test.OrganizerTests [TestCase("30 Rock - S00E05 - Episode Title", 0, true, "Season {season}", @"C:\Test\30 Rock\MySpecials\30 Rock - S00E05 - Episode Title.mkv")] public void CalculateFilePath_SeasonFolder_SingleNumber(string filename, int seasonNumber, bool useSeasonFolder, string seasonFolderFormat, string expectedPath) { + var fakeEpisodes = Builder.CreateListOfSize(1) + .All() + .With(s => s.Title = "Episode Title") + .With(s => s.SeasonNumber = seasonNumber) + .With(s => s.EpisodeNumber = 5) + .Build().ToList(); var fakeSeries = Builder.CreateNew() .With(s => s.Title = "30 Rock") .With(s => s.Path = @"C:\Test\30 Rock".AsOsAgnostic()) .With(s => s.SeasonFolder = useSeasonFolder) + .With(s => s.SeriesType = SeriesTypes.Standard) + .Build(); + var fakeEpisodeFile = Builder.CreateNew() + .With(s => s.SceneName = filename) .Build(); namingConfig.SeasonFolderFormat = seasonFolderFormat; namingConfig.SpecialsFolderFormat = "MySpecials"; - Subject.BuildFilePath(fakeSeries, seasonNumber, filename, ".mkv").Should().Be(expectedPath.AsOsAgnostic()); + Subject.BuildFilePath(fakeEpisodes, fakeSeries, fakeEpisodeFile, ".mkv").Should().Be(expectedPath.AsOsAgnostic()); } [Test] @@ -51,15 +63,25 @@ namespace NzbDrone.Core.Test.OrganizerTests var seasonNumber = 1; var expectedPath = @"C:\Test\NCIS - Los Angeles\NCIS - Los Angeles Season 1\S01E05 - Episode Title.mkv"; + var fakeEpisodes = Builder.CreateListOfSize(1) + .All() + .With(s => s.Title = "Episode Title") + .With(s => s.SeasonNumber = seasonNumber) + .With(s => s.EpisodeNumber = 5) + .Build().ToList(); var fakeSeries = Builder.CreateNew() .With(s => s.Title = "NCIS: Los Angeles") .With(s => s.Path = @"C:\Test\NCIS - Los Angeles".AsOsAgnostic()) .With(s => s.SeasonFolder = true) + .With(s => s.SeriesType = SeriesTypes.Standard) + .Build(); + var fakeEpisodeFile = Builder.CreateNew() + .With(s => s.SceneName = filename) .Build(); namingConfig.SeasonFolderFormat = "{Series Title} Season {season:0}"; - - Subject.BuildFilePath(fakeSeries, seasonNumber, filename, ".mkv").Should().Be(expectedPath.AsOsAgnostic()); + + Subject.BuildFilePath(fakeEpisodes, fakeSeries, fakeEpisodeFile, ".mkv").Should().Be(expectedPath.AsOsAgnostic()); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedEpisodeTitlesFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedEpisodeTitlesFixture.cs index 5fb88e7c8..1e73c0e95 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedEpisodeTitlesFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedEpisodeTitlesFixture.cs @@ -93,6 +93,23 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests _episodeFile.Quality.Revision.Version = 2; } + [Test] + public void should_truncate_with_extension() + { + _series.Title = "The Fantastic Life of Mr. Sisko"; + + _episodes[0].SeasonNumber = 2; + _episodes[0].EpisodeNumber = 18; + _episodes[0].Title = "This title has to be 197 characters in length, combined with the series title, quality and episode number it becomes 254ish and the extension puts it above the 255 limit and triggers the truncation"; + _episodeFile.Quality.Quality = Quality.Bluray1080p; + _episodes = _episodes.Take(1).ToList(); + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"; + + var result = Subject.BuildFileName(_episodes, _series, _episodeFile, ".mkv"); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("The Fantastic Life of Mr. Sisko - S02E18 - This title has to be 197 characters in length, combined with the series title, quality and episode number it becomes 254ish and the extension puts it above the 255 limit and triggers the trunc... Bluray-1080p.mkv"); + } + [Test] public void should_truncate_with_ellipsis_between_first_and_last_episode_titles() { diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs index 5ffed5567..123d11484 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs @@ -59,8 +59,7 @@ namespace NzbDrone.Core.MediaFiles public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, Series series) { var episodes = _episodeService.GetEpisodesByFileId(episodeFile.Id); - var newFileName = _buildFileNames.BuildFileName(episodes, series, episodeFile); - var filePath = _buildFileNames.BuildFilePath(series, episodes.First().SeasonNumber, newFileName, Path.GetExtension(episodeFile.RelativePath)); + var filePath = _buildFileNames.BuildFilePath(episodes, series, episodeFile, Path.GetExtension(episodeFile.RelativePath)); EnsureEpisodeFolder(episodeFile, series, episodes.Select(v => v.SeasonNumber).First(), filePath); @@ -71,8 +70,7 @@ namespace NzbDrone.Core.MediaFiles public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode) { - var newFileName = _buildFileNames.BuildFileName(localEpisode.Episodes, localEpisode.Series, episodeFile); - var filePath = _buildFileNames.BuildFilePath(localEpisode.Series, localEpisode.SeasonNumber, newFileName, Path.GetExtension(localEpisode.Path)); + var filePath = _buildFileNames.BuildFilePath(localEpisode.Episodes, localEpisode.Series, episodeFile, Path.GetExtension(localEpisode.Path)); EnsureEpisodeFolder(episodeFile, localEpisode, filePath); @@ -83,8 +81,7 @@ namespace NzbDrone.Core.MediaFiles public EpisodeFile CopyEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode) { - var newFileName = _buildFileNames.BuildFileName(localEpisode.Episodes, localEpisode.Series, episodeFile); - var filePath = _buildFileNames.BuildFilePath(localEpisode.Series, localEpisode.SeasonNumber, newFileName, Path.GetExtension(localEpisode.Path)); + var filePath = _buildFileNames.BuildFilePath(localEpisode.Episodes, localEpisode.Series, episodeFile, Path.GetExtension(localEpisode.Path)); EnsureEpisodeFolder(episodeFile, localEpisode, filePath); diff --git a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs index 3ccee5b82..9ffdaa26c 100644 --- a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs @@ -90,8 +90,7 @@ namespace NzbDrone.Core.MediaFiles } var seasonNumber = episodesInFile.First().SeasonNumber; - var newName = _filenameBuilder.BuildFileName(episodesInFile, series, file); - var newPath = _filenameBuilder.BuildFilePath(series, seasonNumber, newName, Path.GetExtension(episodeFilePath)); + var newPath = _filenameBuilder.BuildFilePath(episodesInFile, series, file, Path.GetExtension(episodeFilePath)); if (!episodeFilePath.PathEquals(newPath, StringComparison.Ordinal)) { diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 5d77ba747..95ff59636 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -4,9 +4,12 @@ using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; +using FluentMigrator.Builders.Create.Column; using NLog; using NzbDrone.Common.Cache; +using NzbDrone.Common.Disk; using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.MediaInfo; @@ -18,8 +21,8 @@ namespace NzbDrone.Core.Organizer { public interface IBuildFileNames { - string BuildFileName(List episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig = null, List preferredWords = null); - string BuildFilePath(Series series, int seasonNumber, string fileName, string extension); + string BuildFileName(List episodes, Series series, EpisodeFile episodeFile, string extension = "", NamingConfig namingConfig = null, List preferredWords = null); + string BuildFilePath(List episodes, Series series, EpisodeFile episodeFile, string extension, NamingConfig namingConfig = null, List preferredWords = null); string BuildSeasonPath(Series series, int seasonNumber); BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); string GetSeriesFolder(Series series, NamingConfig namingConfig = null); @@ -96,7 +99,7 @@ namespace NzbDrone.Core.Organizer _logger = logger; } - public string BuildFileName(List episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig = null, List preferredWords = null) + private string BuildFileName(List episodes, Series series, EpisodeFile episodeFile, string extension, int maxPath, NamingConfig namingConfig = null, List preferredWords = null) { if (namingConfig == null) { @@ -105,7 +108,7 @@ namespace NzbDrone.Core.Organizer if (!namingConfig.RenameEpisodes) { - return GetOriginalTitle(episodeFile); + return GetOriginalTitle(episodeFile) + extension; } if (namingConfig.StandardEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Standard) @@ -140,9 +143,9 @@ namespace NzbDrone.Core.Organizer var splitPatterns = pattern.Split(new char[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); var components = new List(); - foreach (var s in splitPatterns) + for (var i = 0; i < splitPatterns.Length; i++) { - var splitPattern = s; + var splitPattern = splitPatterns[i]; var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); splitPattern = AddSeasonEpisodeNumberingTokens(splitPattern, tokenHandlers, episodes, namingConfig); splitPattern = AddAbsoluteNumberingTokens(splitPattern, tokenHandlers, series, episodes, namingConfig); @@ -159,7 +162,12 @@ namespace NzbDrone.Core.Organizer AddPreferredWords(tokenHandlers, series, episodeFile, preferredWords); var component = ReplaceTokens(splitPattern, tokenHandlers, namingConfig, true).Trim(); - var maxEpisodeTitleLength = 255 - GetLengthWithoutEpisodeTitle(component, namingConfig); + var maxPathSegmentLength = LongPathSupport.MaxFileNameLength; + if (i == splitPatterns.Length - 1) + { + maxPathSegmentLength -= extension.Length; + } + var maxEpisodeTitleLength = maxPathSegmentLength - GetLengthWithoutEpisodeTitle(component, namingConfig); AddEpisodeTitleTokens(tokenHandlers, episodes, maxEpisodeTitleLength); component = ReplaceTokens(component, tokenHandlers, namingConfig).Trim(); @@ -171,18 +179,25 @@ namespace NzbDrone.Core.Organizer components.Add(component); } - return string.Join(Path.DirectorySeparatorChar.ToString(), components); + return string.Join(Path.DirectorySeparatorChar.ToString(), components) + extension; } - public string BuildFilePath(Series series, int seasonNumber, string fileName, string extension) + public string BuildFileName(List episodes, Series series, EpisodeFile episodeFile, string extension = "", NamingConfig namingConfig = null, List preferredWords = null) + { + return BuildFileName(episodes, series, episodeFile, extension, LongPathSupport.MaxFilePathLength, namingConfig, preferredWords); + } + + public string BuildFilePath(List episodes, Series series, EpisodeFile episodeFile, string extension, NamingConfig namingConfig = null, List preferredWords = null) { Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace(); + + var seasonPath = BuildSeasonPath(series, episodes.First().SeasonNumber); + var remainingPathLength = LongPathSupport.MaxFilePathLength - seasonPath.Length - 1; + var fileName = BuildFileName(episodes, series, episodeFile, extension, remainingPathLength, namingConfig, preferredWords); - var path = BuildSeasonPath(series, seasonNumber); - - return Path.Combine(path, fileName + extension); + return Path.Combine(seasonPath, fileName); } - + public string BuildSeasonPath(Series series, int seasonNumber) { var path = series.Path; diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index 79ec0e951..7c77c9379 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -259,7 +259,7 @@ namespace NzbDrone.Core.Organizer { try { - return _buildFileNames.BuildFileName(episodes, series, episodeFile, nameSpec, _preferredWords); + return _buildFileNames.BuildFileName(episodes, series, episodeFile, "", nameSpec, _preferredWords); } catch (NamingFormatException) {