diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 1489a3054..acbfb0aa7 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -343,6 +343,7 @@ + diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index 42d56cc51..7c070315d 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -11,6 +11,7 @@ using NzbDrone.Core.Organizer; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests { @@ -438,7 +439,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests _namingConfig.AnimeEpisodeFormat = "{Series.Title}.{season}x{episode:00}.{absolute:000}\\{Series.Title}.S{season:00}E{episode:00}.{absolute:00}.{Episode.Title}"; Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.15x06.100\\South.Park.S15E06.100.City.Sushi"); + .Should().Be("South.Park.15x06.100\\South.Park.S15E06.100.City.Sushi".AsOsAgnostic()); } [Test] @@ -448,7 +449,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests _namingConfig.AnimeEpisodeFormat = "{Series Title} Season {season:0000} Episode {episode:0000}\\{Series.Title}.S{season:00}E{episode:00}.{absolute:00}.{Episode.Title}"; Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park Season 0015 Episode 0006\\South.Park.S15E06.100.City.Sushi"); + .Should().Be("South Park Season 0015 Episode 0006\\South.Park.S15E06.100.City.Sushi".AsOsAgnostic()); } [Test] diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedEpisodeTitlesFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedEpisodeTitlesFixture.cs new file mode 100644 index 000000000..5fb88e7c8 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedEpisodeTitlesFixture.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +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 TruncatedEpisodeTitlesFixture : 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.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(), + + Builder.CreateNew() + .With(e => e.Title = "Another Episode Title") + .With(e => e.SeasonNumber = 1) + .With(e => e.EpisodeNumber = 2) + .Build(), + + Builder.CreateNew() + .With(e => e.Title = "Yet Another Episode Title") + .With(e => e.SeasonNumber = 1) + .With(e => e.EpisodeNumber = 3) + .Build(), + + Builder.CreateNew() + .With(e => e.Title = "Yet Another Episode Title Take 2") + .With(e => e.SeasonNumber = 1) + .With(e => e.EpisodeNumber = 4) + .Build(), + + Builder.CreateNew() + .With(e => e.Title = "Yet Another Episode Title Take 3") + .With(e => e.SeasonNumber = 1) + .With(e => e.EpisodeNumber = 5) + .Build(), + + Builder.CreateNew() + .With(e => e.Title = "Yet Another Episode Title Take 4") + .With(e => e.SeasonNumber = 1) + .With(e => e.EpisodeNumber = 6) + .Build(), + + Builder.CreateNew() + .With(e => e.Title = "A Really Really Really Really Long Episode Title") + .With(e => e.SeasonNumber = 1) + .With(e => e.EpisodeNumber = 7) + .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)); + } + + private void GivenProper() + { + _episodeFile.Quality.Revision.Version = 2; + } + + [Test] + public void should_truncate_with_ellipsis_between_first_and_last_episode_titles() + { + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"; + + var result = Subject.BuildFileName(_episodes, _series, _episodeFile); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("Series Title - S01E01-02-03-04-05-06-07 - Episode Title 1...A Really Really Really Really Long Episode Title HDTV-720p"); + } + + [Test] + public void should_truncate_with_ellipsis_if_only_first_episode_title_fits() + { + _series.Title = "Lorem ipsum dolor sit amet, consectetur adipiscing elit Maecenas et magna sem Morbi vitae volutpat quam, id porta arcu Orci varius natoque penatibus et magnis dis parturient montes"; + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"; + + var result = Subject.BuildFileName(_episodes, _series, _episodeFile); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("Lorem ipsum dolor sit amet, consectetur adipiscing elit Maecenas et magna sem Morbi vitae volutpat quam, id porta arcu Orci varius natoque penatibus et magnis dis parturient montes - S01E01-02-03-04-05-06-07 - Episode Title 1... HDTV-720p"); + } + + [Test] + public void should_truncate_first_episode_title_with_ellipsis_if_only_partially_fits() + { + _series.Title = "Lorem ipsum dolor sit amet, consectetur adipiscing elit Maecenas et magna sem Morbi vitae volutpat quam, id porta arcu Orci varius natoque penatibus et magnis dis parturient montes nascetur ridiculus musu Cras vestibulum"; + _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"; + + var result = Subject.BuildFileName(new List{_episodes.First()}, _series, _episodeFile); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("Lorem ipsum dolor sit amet, consectetur adipiscing elit Maecenas et magna sem Morbi vitae volutpat quam, id porta arcu Orci varius natoque penatibus et magnis dis parturient montes nascetur ridiculus musu Cras vestibulum - S01E01 - Episode Ti... HDTV-720p"); + } + } +} diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index d5d77284e..3b549af92 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -124,7 +124,6 @@ namespace NzbDrone.Core.Organizer } var pattern = namingConfig.StandardEpisodeFormat; - var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); episodes = episodes.OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber).ToList(); @@ -138,25 +137,41 @@ namespace NzbDrone.Core.Organizer pattern = namingConfig.AnimeEpisodeFormat; } - pattern = AddSeasonEpisodeNumberingTokens(pattern, tokenHandlers, episodes, namingConfig); - pattern = AddAbsoluteNumberingTokens(pattern, tokenHandlers, series, episodes, namingConfig); + var splitPatterns = pattern.Split(new char[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + var components = new List(); - UpdateMediaInfoIfNeeded(pattern, episodeFile, series); + foreach (var s in splitPatterns) + { + var splitPattern = s; + var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); + splitPattern = AddSeasonEpisodeNumberingTokens(splitPattern, tokenHandlers, episodes, namingConfig); + splitPattern = AddAbsoluteNumberingTokens(splitPattern, tokenHandlers, series, episodes, namingConfig); - AddSeriesTokens(tokenHandlers, series); - AddIdTokens(tokenHandlers, series); - AddEpisodeTokens(tokenHandlers, episodes); - AddEpisodeFileTokens(tokenHandlers, episodeFile); - AddQualityTokens(tokenHandlers, series, episodeFile); - AddMediaInfoTokens(tokenHandlers, episodeFile); - AddPreferredWords(tokenHandlers, series, episodeFile, preferredWords); + UpdateMediaInfoIfNeeded(splitPattern, episodeFile, series); + AddSeriesTokens(tokenHandlers, series); + AddIdTokens(tokenHandlers, series); + AddEpisodeTokens(tokenHandlers, episodes); + AddEpisodeTitlePlaceholderTokens(tokenHandlers); + AddEpisodeFileTokens(tokenHandlers, episodeFile); + AddQualityTokens(tokenHandlers, series, episodeFile); + AddMediaInfoTokens(tokenHandlers, episodeFile); + AddPreferredWords(tokenHandlers, series, episodeFile, preferredWords); - var fileName = ReplaceTokens(pattern, tokenHandlers, namingConfig).Trim(); - fileName = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString()); - fileName = TrimSeparatorsRegex.Replace(fileName, string.Empty); + var component = ReplaceTokens(splitPattern, tokenHandlers, namingConfig).Trim(); + var maxEpisodeTitleLength = 255 - GetLengthWithoutEpisodeTitle(component, namingConfig); - return fileName; + AddEpisodeTitleTokens(tokenHandlers, episodes, maxEpisodeTitleLength); + component = ReplaceTokens(component, tokenHandlers, namingConfig).Trim(); + + component = FileNameCleanupRegex.Replace(component, match => match.Captures[0].Value[0].ToString()); + component = TrimSeparatorsRegex.Replace(component, string.Empty); + component = component.Replace("{ellipsis}", "..."); + + components.Add(component); + } + + return string.Join(Path.DirectorySeparatorChar.ToString(), components); } public string BuildFilePath(Series series, int seasonNumber, string fileName, string extension) @@ -529,9 +544,18 @@ namespace NzbDrone.Core.Organizer { tokenHandlers["{Air Date}"] = m => "Unknown"; } + } - tokenHandlers["{Episode Title}"] = m => GetEpisodeTitle(episodes, "+"); - tokenHandlers["{Episode CleanTitle}"] = m => CleanTitle(GetEpisodeTitle(episodes, "and")); + private void AddEpisodeTitlePlaceholderTokens(Dictionary> tokenHandlers) + { + tokenHandlers["{Episode Title}"] = m => m.RegexMatch.Value; + tokenHandlers["{Episode CleanTitle}"] = m => m.RegexMatch.Value; + } + + 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); } private void AddEpisodeFileTokens(Dictionary> tokenHandlers, EpisodeFile episodeFile) @@ -807,13 +831,14 @@ namespace NzbDrone.Core.Organizer }).ToArray()); } - private string GetEpisodeTitle(List episodes, string separator) + private List GetEpisodeTitles(List episodes) { - separator = string.Format(" {0} ", separator.Trim()); - if (episodes.Count == 1) { - return episodes.First().Title.TrimEnd(EpisodeTitleTrimCharacters); + return new List + { + episodes.First().Title.TrimEnd(EpisodeTitleTrimCharacters) + }; } var titles = episodes.Select(c => c.Title.TrimEnd(EpisodeTitleTrimCharacters)) @@ -828,7 +853,42 @@ namespace NzbDrone.Core.Organizer .ToList(); } - return string.Join(separator, titles); + return titles; + } + + private string GetEpisodeTitle(List titles, string separator, int maxLength) + { + separator = $" {separator.Trim()} "; + + var joined = string.Join(separator, titles); + + if (joined.Length <= maxLength) + { + return joined; + } + + var firstTitle = titles.First(); + + if (titles.Count >= 2) + { + var lastTitle = titles.Last(); + if (firstTitle.Length + lastTitle.Length + 3 <= maxLength) + { + return $"{firstTitle.Trim(' ', '.')}{{ellipsis}}{lastTitle}"; + } + } + + if (titles.Count > 1 && firstTitle.Length + 3 <= maxLength) + { + return $"{firstTitle.Trim(' ', '.')}{{ellipsis}}"; + } + + if (titles.Count == 1 && firstTitle.Length <= maxLength) + { + return firstTitle; + } + + return $"{firstTitle.Substring(0, maxLength - 3).Trim(' ', '.')}{{ellipsis}}"; } private string CleanupEpisodeTitle(string title) @@ -881,6 +941,17 @@ namespace NzbDrone.Core.Organizer return Path.GetFileNameWithoutExtension(episodeFile.RelativePath); } + + private int GetLengthWithoutEpisodeTitle(string pattern, NamingConfig namingConfig) + { + var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); + tokenHandlers["{Episode Title}"] = m => string.Empty; + tokenHandlers["{Episode CleanTitle}"] = m => string.Empty; + + var result = ReplaceTokens(pattern, tokenHandlers, namingConfig); + + return result.Length; + } } internal sealed class TokenMatch