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 1bf501c2c..ae5e43bbf 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -268,5 +268,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/TruncatedArtistNameFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedArtistNameFixture.cs new file mode 100644 index 000000000..97bcc2743 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedArtistNameFixture.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Music; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests +{ + [TestFixture] + + public class TruncatedArtistNameFixture : CoreTest + { + private Artist _artist; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _artist = Builder + .CreateNew() + .With(s => s.Name = "Artist Name") + .Build(); + + _namingConfig = NamingConfig.Default; + _namingConfig.RenameTracks = 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("{Artist Name:16}", "The Fantastic...")] + [TestCase("{Artist NameThe:17}", "Fantastic Life...")] + [TestCase("{Artist CleanName:-13}", "...Mr. Sisko")] + public void should_truncate_artist_name(string format, string expected) + { + _artist.Name = "The Fantastic Life of Mr. Sisko"; + _namingConfig.ArtistFolderFormat = format; + + var result = Subject.GetArtistFolder(_artist, _namingConfig); + result.Should().Be(expected); + } + } +} 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..aa0723b54 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedReleaseGroupFixture.cs @@ -0,0 +1,106 @@ +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.Music; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests +{ + [TestFixture] + + public class TruncatedReleaseGroupFixture : CoreTest + { + private Artist _artist; + private Album _album; + private AlbumRelease _release; + private List _tracks; + private TrackFile _trackFile; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _artist = Builder + .CreateNew() + .With(s => s.Name = "Artist Name") + .Build(); + + _album = Builder + .CreateNew() + .With(s => s.Title = "Album Title") + .Build(); + + _release = Builder + .CreateNew() + .With(s => s.Media = new List { new () { Number = 14 } }) + .Build(); + + _namingConfig = NamingConfig.Default; + _namingConfig.RenameTracks = true; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(_namingConfig); + + _tracks = new List + { + Builder.CreateNew() + .With(e => e.Title = "Track Title 1") + .With(e => e.MediumNumber = 1) + .With(e => e.AbsoluteTrackNumber = 1) + .With(e => e.AlbumRelease = _release) + .Build(), + }; + + _trackFile = new TrackFile { Quality = new QualityModel(Quality.MP3_320), ReleaseGroup = "LidarrTest" }; + + 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() + { + _trackFile.Quality.Revision.Version = 2; + } + + [Test] + public void should_truncate_from_beginning() + { + _artist.Name = "The Fantastic Life of Mr. Sisko"; + + _trackFile.Quality.Quality = Quality.FLAC; + _trackFile.ReleaseGroup = "IWishIWasALittleBitTallerIWishIWasABallerIWishIHadAGirlWhoLookedGoodIWouldCallHerIWishIHadARabbitInAHatWithABatAndASixFourImpala"; + _tracks = _tracks.Take(1).ToList(); + _namingConfig.StandardTrackFormat = "{Artist Name} - {Album Title} - {track:00} - {Track Title} [{Quality Title}]-{ReleaseGroup:12}"; + + var result = Subject.BuildTrackFileName(_tracks, _artist, _album, _trackFile, ".flac"); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("The Fantastic Life of Mr. Sisko - Album Title - 01 - Track Title 1 [FLAC]-IWishIWas....flac"); + } + + [Test] + public void should_truncate_from_from_end() + { + _artist.Name = "The Fantastic Life of Mr. Sisko"; + + _trackFile.Quality.Quality = Quality.FLAC; + _trackFile.ReleaseGroup = "IWishIWasALittleBitTallerIWishIWasABallerIWishIHadAGirlWhoLookedGoodIWouldCallHerIWishIHadARabbitInAHatWithABatAndASixFourImpala"; + _tracks = _tracks.Take(1).ToList(); + _namingConfig.StandardTrackFormat = "{Artist Name} - {Album Title} - {track:00} - {Track Title} [{Quality Title}]-{ReleaseGroup:-17}"; + + var result = Subject.BuildTrackFileName(_tracks, _artist, _album, _trackFile, ".flac"); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("The Fantastic Life of Mr. Sisko - Album Title - 01 - Track Title 1 [FLAC]-...ASixFourImpala.flac"); + } + } +} 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 b9f3a318b..c08caa6ab 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -34,7 +34,7 @@ namespace NzbDrone.Core.Organizer private readonly ICached _absoluteTrackFormatCache; private readonly Logger _logger; - private static readonly Regex TitleRegex = new Regex(@"(?\{\{|\}\})|\{(?[- ._\[(]*)(?(?:[a-z0-9]+)(?:(?[- ._]+)(?:[a-z0-9]+))?)(?::(?[a-z0-9]+))?(?[- ._)\]]*)\}", + private static readonly Regex TitleRegex = new Regex(@"(?\{\{|\}\})|\{(?[- ._\[(]*)(?(?:[a-z0-9]+)(?:(?[- ._]+)(?:[a-z0-9]+))?)(?::(?[a-z0-9+-]+(?[- ._)\]]*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); public static readonly Regex TrackRegex = new Regex(@"(?\{track(?:\:0+)?})", @@ -241,6 +241,7 @@ namespace NzbDrone.Core.Organizer var component = ReplaceTokens(splitPattern, tokenHandlers, namingConfig); component = CleanFolderName(component); component = ReplaceReservedDeviceNames(component); + component = component.Replace("{ellipsis}", "..."); if (component.IsNotNullOrWhiteSpace()) { @@ -296,9 +297,9 @@ namespace NzbDrone.Core.Organizer private void AddArtistTokens(Dictionary> tokenHandlers, Artist artist) { - tokenHandlers["{Artist Name}"] = m => artist.Name; - tokenHandlers["{Artist CleanName}"] = m => CleanTitle(artist.Name); - tokenHandlers["{Artist NameThe}"] = m => TitleThe(artist.Name); + tokenHandlers["{Artist Name}"] = m => Truncate(artist.Name, m.CustomFormat); + tokenHandlers["{Artist CleanName}"] = m => Truncate(CleanTitle(artist.Name), m.CustomFormat); + tokenHandlers["{Artist NameThe}"] = m => Truncate(TitleThe(artist.Name), m.CustomFormat); tokenHandlers["{Artist Genre}"] = m => artist.Metadata.Value.Genres?.FirstOrDefault() ?? string.Empty; tokenHandlers["{Artist NameFirstCharacter}"] = m => TitleFirstCharacter(TitleThe(artist.Name)); tokenHandlers["{Artist MbId}"] = m => artist.ForeignArtistId ?? string.Empty; @@ -311,9 +312,9 @@ namespace NzbDrone.Core.Organizer private void AddAlbumTokens(Dictionary> tokenHandlers, Album album) { - tokenHandlers["{Album Title}"] = m => album.Title; - tokenHandlers["{Album CleanTitle}"] = m => CleanTitle(album.Title); - tokenHandlers["{Album TitleThe}"] = m => TitleThe(album.Title); + tokenHandlers["{Album Title}"] = m => Truncate(album.Title, m.CustomFormat); + tokenHandlers["{Album CleanTitle}"] = m => Truncate(CleanTitle(album.Title), m.CustomFormat); + tokenHandlers["{Album TitleThe}"] = m => Truncate(TitleThe(album.Title), m.CustomFormat); tokenHandlers["{Album Type}"] = m => album.AlbumType; tokenHandlers["{Album Genre}"] = m => album.Genres.FirstOrDefault() ?? string.Empty; tokenHandlers["{Album MbId}"] = m => album.ForeignAlbumId ?? string.Empty; @@ -323,14 +324,9 @@ namespace NzbDrone.Core.Organizer tokenHandlers["{Album Disambiguation}"] = m => album.Disambiguation; } - if (album.ReleaseDate.HasValue) - { - tokenHandlers["{Release Year}"] = m => album.ReleaseDate.Value.Year.ToString(); - } - else - { - tokenHandlers["{Release Year}"] = m => "Unknown"; - } + tokenHandlers["{Release Year}"] = album.ReleaseDate.HasValue + ? m => album.ReleaseDate.Value.Year.ToString() + : m => "Unknown"; } private void AddMediumTokens(Dictionary> tokenHandlers, Medium medium) @@ -347,9 +343,9 @@ namespace NzbDrone.Core.Organizer var firstArtist = tracks.Select(t => t.ArtistMetadata?.Value).FirstOrDefault() ?? artist.Metadata; if (firstArtist != null) { - tokenHandlers["{Track ArtistName}"] = m => firstArtist.Name; - tokenHandlers["{Track ArtistCleanName}"] = m => CleanTitle(firstArtist.Name); - tokenHandlers["{Track ArtistNameThe}"] = m => TitleThe(firstArtist.Name); + tokenHandlers["{Track ArtistName}"] = m => Truncate(firstArtist.Name, m.CustomFormat); + tokenHandlers["{Track ArtistCleanName}"] = m => Truncate(CleanTitle(firstArtist.Name), m.CustomFormat); + tokenHandlers["{Track ArtistNameThe}"] = m => Truncate(TitleThe(firstArtist.Name), m.CustomFormat); tokenHandlers["{Track ArtistMbId}"] = m => firstArtist.ForeignArtistId ?? string.Empty; } } @@ -362,15 +358,15 @@ namespace NzbDrone.Core.Organizer private void AddTrackTitleTokens(Dictionary> tokenHandlers, List tracks, int maxLength) { - tokenHandlers["{Track Title}"] = m => GetTrackTitle(GetTrackTitles(tracks), "+", maxLength); - tokenHandlers["{Track CleanTitle}"] = m => GetTrackTitle(GetTrackTitles(tracks).Select(CleanTitle).ToList(), "and", maxLength); + tokenHandlers["{Track Title}"] = m => GetTrackTitle(GetTrackTitles(tracks), "+", maxLength, m.CustomFormat); + tokenHandlers["{Track CleanTitle}"] = m => GetTrackTitle(GetTrackTitles(tracks).Select(CleanTitle).ToList(), "and", maxLength, m.CustomFormat); } private void AddTrackFileTokens(Dictionary> tokenHandlers, TrackFile trackFile) { tokenHandlers["{Original Title}"] = m => GetOriginalTitle(trackFile); tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(trackFile); - tokenHandlers["{Release Group}"] = m => trackFile.ReleaseGroup ?? m.DefaultValue("Lidarr"); + tokenHandlers["{Release Group}"] = m => Truncate(trackFile.ReleaseGroup, m.CustomFormat) ?? m.DefaultValue("Lidarr"); } private void AddQualityTokens(Dictionary> tokenHandlers, Artist artist, TrackFile trackFile) @@ -573,8 +569,15 @@ namespace NzbDrone.Core.Organizer return titles; } - private string GetTrackTitle(List titles, string separator, int maxLength) + private string GetTrackTitle(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); @@ -660,6 +663,7 @@ namespace NzbDrone.Core.Organizer var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); tokenHandlers["{Track Title}"] = m => string.Empty; tokenHandlers["{Track CleanTitle}"] = m => string.Empty; + tokenHandlers["{ellipsis}"] = m => "..."; var result = ReplaceTokens(pattern, tokenHandlers, namingConfig); @@ -718,6 +722,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