From 422db874f079516ede8637c893d29657eb0fd7fa Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 25 Jan 2024 17:07:41 -0800 Subject: [PATCH] New: Accept ':##' on renaming tokens to allow specifying a maximum length for movie titles and release group (cherry picked from commit 19db75b36beaa5e549d903b136dbda300f1f8562) Closes #9713 --- .../StringExtensionTests/ReverseFixture.cs | 17 ++++ .../Extensions/StringExtensions.cs | 9 ++ .../TruncatedMovieTitleFixture.cs | 56 +++++++++++++ .../TruncatedReleaseGroupFixture.cs | 82 +++++++++++++++++++ .../Organizer/FileNameBuilder.cs | 43 ++++++++-- 5 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/ReverseFixture.cs create mode 100644 src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedMovieTitleFixture.cs create mode 100644 src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedReleaseGroupFixture.cs 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 fbb1d9fff..8e327cd88 100644 --- a/src/NzbDrone.Common/Extensions/StringExtensions.cs +++ b/src/NzbDrone.Common/Extensions/StringExtensions.cs @@ -219,5 +219,14 @@ public static string ToUrlHost(this string input) { 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/TruncatedMovieTitleFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedMovieTitleFixture.cs new file mode 100644 index 000000000..95ddae88d --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedMovieTitleFixture.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.Movies; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests +{ + [TestFixture] + + public class TruncatedMovieTitleFixture : CoreTest + { + private Movie _movie; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _movie = Builder + .CreateNew() + .With(s => s.Title = "Movie Title") + .Build(); + + _namingConfig = NamingConfig.Default; + _namingConfig.RenameMovies = 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("{Movie Title:16}", "The Fantastic...")] + [TestCase("{Movie TitleThe:17}", "Fantastic Life...")] + [TestCase("{Movie CleanTitle:-13}", "...Mr. Sisko")] + public void should_truncate_series_title(string format, string expected) + { + _movie.Title = "The Fantastic Life of Mr. Sisko"; + _namingConfig.MovieFolderFormat = format; + + var result = Subject.GetMovieFolder(_movie, _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..c8447aeeb --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TruncatedReleaseGroupFixture.cs @@ -0,0 +1,82 @@ +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.Movies; +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 Movie _movie; + private MovieFile _movieFile; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _movie = Builder + .CreateNew() + .With(s => s.Title = "Movie Title") + .With(s => s.Year = 2024) + .Build(); + + _namingConfig = NamingConfig.Default; + _namingConfig.RenameMovies = true; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(_namingConfig); + + _movieFile = new MovieFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "RadarrTest" }; + + 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() + { + _movieFile.Quality.Revision.Version = 2; + } + + [Test] + public void should_truncate_from_beginning() + { + _movie.Title = "The Fantastic Life of Mr. Sisko"; + + _movieFile.Quality.Quality = Quality.Bluray1080p; + _movieFile.ReleaseGroup = "IWishIWasALittleBitTallerIWishIWasABallerIWishIHadAGirlWhoLookedGoodIWouldCallHerIWishIHadARabbitInAHatWithABatAndASixFourImpala"; + _namingConfig.StandardMovieFormat = "{Movie Title} ({Release Year}) {Quality Full}-{ReleaseGroup:12}"; + + var result = Subject.BuildFileName(_movie, _movieFile); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("The Fantastic Life of Mr. Sisko (2024) Bluray-1080p-IWishIWas..."); + } + + [Test] + public void should_truncate_from_from_end() + { + _movie.Title = "The Fantastic Life of Mr. Sisko"; + + _movieFile.Quality.Quality = Quality.Bluray1080p; + _movieFile.ReleaseGroup = "IWishIWasALittleBitTallerIWishIWasABallerIWishIHadAGirlWhoLookedGoodIWouldCallHerIWishIHadARabbitInAHatWithABatAndASixFourImpala"; + _namingConfig.StandardMovieFormat = "{Movie Title} ({Release Year}) {Quality Full}-{ReleaseGroup:-17}"; + + var result = Subject.BuildFileName(_movie, _movieFile); + result.Length.Should().BeLessOrEqualTo(255); + result.Should().Be("The Fantastic Life of Mr. Sisko (2024) Bluray-1080p-...ASixFourImpala"); + } + } +} diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index f0447045f..0520189bd 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -134,6 +134,7 @@ public string BuildFileName(Movie movie, MovieFile movieFile, NamingConfig namin component = FileNameCleanupRegex.Replace(component, match => match.Captures[0].Value[0].ToString()); component = TrimSeparatorsRegex.Replace(component, string.Empty); + component = component.Replace("{ellipsis}", "..."); component = ReplaceReservedDeviceNames(component); if (component.IsNotNullOrWhiteSpace()) @@ -197,6 +198,7 @@ public string GetMovieFolder(Movie movie, NamingConfig namingConfig = null) var component = ReplaceTokens(splitPattern, tokenHandlers, namingConfig); component = CleanFolderName(component); + component = component.Replace("{ellipsis}", "..."); component = ReplaceReservedDeviceNames(component); if (component.IsNotNullOrWhiteSpace()) @@ -264,15 +266,15 @@ public static string CleanFolderName(string name) private void AddMovieTokens(Dictionary> tokenHandlers, Movie movie) { - tokenHandlers["{Movie Title}"] = m => GetLanguageTitle(movie, m.CustomFormat); - tokenHandlers["{Movie CleanTitle}"] = m => CleanTitle(GetLanguageTitle(movie, m.CustomFormat)); - tokenHandlers["{Movie TitleThe}"] = m => TitleThe(movie.Title); + tokenHandlers["{Movie Title}"] = m => Truncate(GetLanguageTitle(movie, m.CustomFormat), m.CustomFormat); + tokenHandlers["{Movie CleanTitle}"] = m => Truncate(CleanTitle(GetLanguageTitle(movie, m.CustomFormat)), m.CustomFormat); + tokenHandlers["{Movie TitleThe}"] = m => Truncate(TitleThe(movie.Title), m.CustomFormat); tokenHandlers["{Movie TitleFirstCharacter}"] = m => TitleFirstCharacter(TitleThe(GetLanguageTitle(movie, m.CustomFormat))); - tokenHandlers["{Movie OriginalTitle}"] = m => movie.MovieMetadata.Value.OriginalTitle ?? string.Empty; - tokenHandlers["{Movie CleanOriginalTitle}"] = m => CleanTitle(movie.MovieMetadata.Value.OriginalTitle ?? string.Empty); + tokenHandlers["{Movie OriginalTitle}"] = m => Truncate(movie.MovieMetadata.Value.OriginalTitle, m.CustomFormat) ?? string.Empty; + tokenHandlers["{Movie CleanOriginalTitle}"] = m => Truncate(CleanTitle(movie.MovieMetadata.Value.OriginalTitle ?? string.Empty), m.CustomFormat); tokenHandlers["{Movie Certification}"] = m => movie.MovieMetadata.Value.Certification ?? string.Empty; - tokenHandlers["{Movie Collection}"] = m => movie.MovieMetadata.Value.CollectionTitle ?? string.Empty; + tokenHandlers["{Movie Collection}"] = m => Truncate(movie.MovieMetadata.Value.CollectionTitle, m.CustomFormat) ?? string.Empty; } private string GetLanguageTitle(Movie movie, string isoCodes) @@ -306,7 +308,7 @@ private void AddEditionTagsTokens(Dictionary> t { if (movieFile.Edition.IsNotNullOrWhiteSpace()) { - tokenHandlers["{Edition Tags}"] = m => CultureInfo.CurrentCulture.TextInfo.ToTitleCase(movieFile.Edition.ToLower()); + tokenHandlers["{Edition Tags}"] = m => Truncate(CultureInfo.CurrentCulture.TextInfo.ToTitleCase(movieFile.Edition.ToLower()), m.CustomFormat); } } @@ -331,8 +333,7 @@ private void AddMovieFileTokens(Dictionary> tok { tokenHandlers["{Original Title}"] = m => GetOriginalTitle(movieFile, multipleTokens); tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(movieFile, multipleTokens); - - tokenHandlers["{Release Group}"] = m => movieFile.ReleaseGroup ?? m.DefaultValue("Radarr"); + tokenHandlers["{Release Group}"] = m => Truncate(movieFile.ReleaseGroup, m.CustomFormat) ?? m.DefaultValue("Radarr"); } private void AddQualityTokens(Dictionary> tokenHandlers, Movie movie, MovieFile movieFile) @@ -623,6 +624,30 @@ private string ReplaceReservedDeviceNames(string input) // Replace reserved windows device names with an alternative return ReservedDeviceNamesRegex.Replace(input, match => match.Value.Replace(".", "_")); } + + 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