diff --git a/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/ReverseFixture.cs b/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/ReverseFixture.cs new file mode 100644 index 000000000..27a73cc4b --- /dev/null +++ b/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/ReverseFixture.cs @@ -0,0 +1,17 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Common.Test.ExtensionTests.StringExtensionTests +{ + [TestFixture] + public class ReverseFixture + { + [TestCase("input", "tupni")] + [TestCase("racecar", "racecar")] + public void should_reverse_string(string input, string expected) + { + input.Reverse().Should().Be(expected); + } + } +} diff --git a/src/NzbDrone.Common/Extensions/StringExtensions.cs b/src/NzbDrone.Common/Extensions/StringExtensions.cs index 75e5462f4..8a4d140f7 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -242,5 +242,14 @@ namespace NzbDrone.Common.Extensions { return input.Contains(':') ? $"[{input}]" : input; } + + public static string Reverse(this string text) + { + var array = text.ToCharArray(); + + Array.Reverse(array); + + return new string(array); + } } } diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedReleaseGroupFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedReleaseGroupFixture.cs new file mode 100644 index 000000000..266b39332 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedReleaseGroupFixture.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests +{ + [TestFixture] + + public class TruncatedReleaseGroupFixture : CoreTest + { + private Series _series; + private List _episodes; + private EpisodeFile _episodeFile; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _series = Builder + .CreateNew() + .With(s => s.Title = "Series Title") + .Build(); + + _namingConfig = NamingConfig.Default; + _namingConfig.MultiEpisodeStyle = 0; + _namingConfig.RenameEpisodes = true; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(_namingConfig); + + _episodes = new List + { + Builder.CreateNew() + .With(e => e.Title = "Episode Title 1") + .With(e => e.SeasonNumber = 1) + .With(e => e.EpisodeNumber = 1) + .Build() + }; + + _episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" }; + + Mocker.GetMock() + .Setup(v => v.Get(Moq.It.IsAny())) + .Returns(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); + + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new List()); + } + + private void GivenProper() + { + _episodeFile.Quality.Revision.Version = 2; + } + + [Test] + public void should_truncate_from_beginning() + { + _series.Title = "The Fantastic Life of Mr. Sisko"; + + _episodeFile.Quality.Quality = Quality.Bluray1080p; + _episodeFile.ReleaseGroup = "IWishIWasALittleBitTallerIWishIWasABallerIWishIHadAGirlWhoLookedGoodIWouldCallHerIWishIHadARabbitInAHatWithABatAndASixFourImpala"; + _episodes = _episodes.Take(1).ToList(); + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}-{ReleaseGroup:12}"; + + var result = Subject.BuildFileName(_episodes, _series, _episodeFile, ".mkv"); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("The Fantastic Life of Mr. Sisko - S01E01 - Episode Title 1 Bluray-1080p-IWishIWas....mkv"); + } + + [Test] + public void should_truncate_from_from_end() + { + _series.Title = "The Fantastic Life of Mr. Sisko"; + + _episodeFile.Quality.Quality = Quality.Bluray1080p; + _episodeFile.ReleaseGroup = "IWishIWasALittleBitTallerIWishIWasABallerIWishIHadAGirlWhoLookedGoodIWouldCallHerIWishIHadARabbitInAHatWithABatAndASixFourImpala"; + _episodes = _episodes.Take(1).ToList(); + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}-{ReleaseGroup:-17}"; + + var result = Subject.BuildFileName(_episodes, _series, _episodeFile, ".mkv"); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("The Fantastic Life of Mr. Sisko - S01E01 - Episode Title 1 Bluray-1080p-...ASixFourImpala.mkv"); + } + } +} diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedSeriesTitleFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedSeriesTitleFixture.cs new file mode 100644 index 000000000..993af1ce1 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedSeriesTitleFixture.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests +{ + [TestFixture] + + public class TruncatedSeriesTitleFixture : CoreTest + { + private Series _series; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _series = Builder + .CreateNew() + .With(s => s.Title = "Series Title") + .Build(); + + _namingConfig = NamingConfig.Default; + _namingConfig.MultiEpisodeStyle = 0; + _namingConfig.RenameEpisodes = true; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(_namingConfig); + + Mocker.GetMock() + .Setup(v => v.Get(Moq.It.IsAny())) + .Returns(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); + + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new List()); + } + + [TestCase("{Series Title:16}", "The Fantastic...")] + [TestCase("{Series TitleThe:17}", "Fantastic Life...")] + [TestCase("{Series CleanTitle:-13}", "...Mr. Sisko")] + public void should_truncate_series_title(string format, string expected) + { + _series.Title = "The Fantastic Life of Mr. Sisko"; + _namingConfig.SeriesFolderFormat = format; + + var result = Subject.GetSeriesFolder(_series, _namingConfig); + result.Should().Be(expected); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs index 6b9b24847..5fe44ddfd 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 791469fd6..57ebfc5ca 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -315,6 +315,7 @@ namespace NzbDrone.Core.Organizer folderName = CleanFolderName(folderName); folderName = ReplaceReservedDeviceNames(folderName); + folderName = folderName.Replace("{ellipsis}", "..."); return folderName; } @@ -337,6 +338,7 @@ namespace NzbDrone.Core.Organizer folderName = CleanFolderName(folderName); folderName = ReplaceReservedDeviceNames(folderName); + folderName = folderName.Replace("{ellipsis}", "..."); return folderName; } @@ -492,19 +494,19 @@ namespace NzbDrone.Core.Organizer private void AddSeriesTokens(Dictionary> tokenHandlers, Series series) { - tokenHandlers["{Series Title}"] = m => series.Title; - tokenHandlers["{Series CleanTitle}"] = m => CleanTitle(series.Title); - tokenHandlers["{Series TitleYear}"] = m => TitleYear(series.Title, series.Year); - tokenHandlers["{Series CleanTitleYear}"] = m => CleanTitle(TitleYear(series.Title, series.Year)); - tokenHandlers["{Series TitleWithoutYear}"] = m => TitleWithoutYear(series.Title); - tokenHandlers["{Series CleanTitleWithoutYear}"] = m => CleanTitle(TitleWithoutYear(series.Title)); - tokenHandlers["{Series TitleThe}"] = m => TitleThe(series.Title); - tokenHandlers["{Series CleanTitleThe}"] = m => CleanTitleThe(series.Title); - tokenHandlers["{Series TitleTheYear}"] = m => TitleYear(TitleThe(series.Title), series.Year); - tokenHandlers["{Series CleanTitleTheYear}"] = m => CleanTitleTheYear(series.Title, series.Year); - tokenHandlers["{Series TitleTheWithoutYear}"] = m => TitleWithoutYear(TitleThe(series.Title)); - tokenHandlers["{Series CleanTitleTheWithoutYear}"] = m => CleanTitleThe(TitleWithoutYear(series.Title)); - tokenHandlers["{Series TitleFirstCharacter}"] = m => TitleFirstCharacter(TitleThe(series.Title)); + tokenHandlers["{Series Title}"] = m => Truncate(series.Title, m.CustomFormat); + tokenHandlers["{Series CleanTitle}"] = m => Truncate(CleanTitle(series.Title), m.CustomFormat); + tokenHandlers["{Series TitleYear}"] = m => Truncate(TitleYear(series.Title, series.Year), m.CustomFormat); + tokenHandlers["{Series CleanTitleYear}"] = m => Truncate(CleanTitle(TitleYear(series.Title, series.Year)), m.CustomFormat); + tokenHandlers["{Series TitleWithoutYear}"] = m => Truncate(TitleWithoutYear(series.Title), m.CustomFormat); + tokenHandlers["{Series CleanTitleWithoutYear}"] = m => Truncate(CleanTitle(TitleWithoutYear(series.Title)), m.CustomFormat); + tokenHandlers["{Series TitleThe}"] = m => Truncate(TitleThe(series.Title), m.CustomFormat); + tokenHandlers["{Series CleanTitleThe}"] = m => Truncate(CleanTitleThe(series.Title), m.CustomFormat); + tokenHandlers["{Series TitleTheYear}"] = m => Truncate(TitleYear(TitleThe(series.Title), series.Year), m.CustomFormat); + tokenHandlers["{Series CleanTitleTheYear}"] = m => Truncate(CleanTitleTheYear(series.Title, series.Year), m.CustomFormat); + tokenHandlers["{Series TitleTheWithoutYear}"] = m => Truncate(TitleWithoutYear(TitleThe(series.Title)), m.CustomFormat); + tokenHandlers["{Series CleanTitleTheWithoutYear}"] = m => Truncate(CleanTitleThe(TitleWithoutYear(series.Title)), m.CustomFormat); + tokenHandlers["{Series TitleFirstCharacter}"] = m => Truncate(TitleFirstCharacter(TitleThe(series.Title)), m.CustomFormat); tokenHandlers["{Series Year}"] = m => series.Year.ToString(); } @@ -659,15 +661,15 @@ namespace NzbDrone.Core.Organizer private void AddEpisodeTitleTokens(Dictionary> tokenHandlers, List episodes, int maxLength) { - tokenHandlers["{Episode Title}"] = m => GetEpisodeTitle(GetEpisodeTitles(episodes), "+", maxLength); - tokenHandlers["{Episode CleanTitle}"] = m => GetEpisodeTitle(GetEpisodeTitles(episodes).Select(CleanTitle).ToList(), "and", maxLength); + tokenHandlers["{Episode Title}"] = m => GetEpisodeTitle(GetEpisodeTitles(episodes), "+", maxLength, m.CustomFormat); + tokenHandlers["{Episode CleanTitle}"] = m => GetEpisodeTitle(GetEpisodeTitles(episodes).Select(CleanTitle).ToList(), "and", maxLength, m.CustomFormat); } private void AddEpisodeFileTokens(Dictionary> tokenHandlers, EpisodeFile episodeFile, bool useCurrentFilenameAsFallback) { tokenHandlers["{Original Title}"] = m => GetOriginalTitle(episodeFile, useCurrentFilenameAsFallback); tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(episodeFile, useCurrentFilenameAsFallback); - tokenHandlers["{Release Group}"] = m => episodeFile.ReleaseGroup ?? m.DefaultValue("Sonarr"); + tokenHandlers["{Release Group}"] = m => Truncate(episodeFile.ReleaseGroup, m.CustomFormat) ?? m.DefaultValue("Sonarr"); } private void AddQualityTokens(Dictionary> tokenHandlers, Series series, EpisodeFile episodeFile) @@ -1046,8 +1048,15 @@ namespace NzbDrone.Core.Organizer return titles; } - private string GetEpisodeTitle(List titles, string separator, int maxLength) + private string GetEpisodeTitle(List titles, string separator, int maxLength, string formatter) { + var maxFormatterLength = GetMaxLengthFromFormatter(formatter); + + if (maxFormatterLength > 0) + { + maxLength = Math.Min(maxLength, maxFormatterLength); + } + separator = $" {separator.Trim()} "; var joined = string.Join(separator, titles); @@ -1144,6 +1153,7 @@ namespace NzbDrone.Core.Organizer var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); tokenHandlers["{Episode Title}"] = m => string.Empty; tokenHandlers["{Episode CleanTitle}"] = m => string.Empty; + tokenHandlers["{ellipsis}"] = m => "..."; var result = ReplaceTokens(pattern, tokenHandlers, namingConfig); @@ -1202,6 +1212,30 @@ namespace NzbDrone.Core.Organizer return result.TrimStart(' ', '.').TrimEnd(' '); } + + private string Truncate(string input, string formatter) + { + var maxLength = GetMaxLengthFromFormatter(formatter); + + if (maxLength == 0 || input.Length <= Math.Abs(maxLength)) + { + return input; + } + + if (maxLength < 0) + { + return $"{{ellipsis}}{input.Reverse().Truncate(Math.Abs(maxLength) - 3).TrimEnd(' ', '.').Reverse()}"; + } + + return $"{input.Truncate(maxLength - 3).TrimEnd(' ', '.')}{{ellipsis}}"; + } + + private int GetMaxLengthFromFormatter(string formatter) + { + int.TryParse(formatter, out var maxCustomLength); + + return maxCustomLength; + } } internal sealed class TokenMatch