New: Limit filenames to a maximum of 255 characters

(cherry picked from commit 34d81356a3b3b378ce669ea65c5802b64efaad6e)
pull/3864/head
Mark McDowall 5 years ago committed by Bogdan
parent a0385ca53f
commit f4292be588

@ -0,0 +1,144 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
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 TruncatedTrackTitlesFixture : CoreTest<FileNameBuilder>
{
private Artist _artist;
private Album _album;
private AlbumRelease _release;
private List<Track> _tracks;
private TrackFile _trackFile;
private NamingConfig _namingConfig;
[SetUp]
public void Setup()
{
_artist = Builder<Artist>
.CreateNew()
.With(s => s.Name = "Avenged Sevenfold")
.Build();
_album = Builder<Album>
.CreateNew()
.With(s => s.Title = "Hail to the King")
.Build();
_release = Builder<AlbumRelease>
.CreateNew()
.With(s => s.Media = new List<Medium> { new () { Number = 14 } })
.Build();
_namingConfig = NamingConfig.Default;
_namingConfig.RenameTracks = true;
Mocker.GetMock<INamingConfigService>()
.Setup(c => c.GetConfig()).Returns(_namingConfig);
_tracks = new List<Track>
{
Builder<Track>.CreateNew()
.With(e => e.Title = "First Track Title 1")
.With(e => e.MediumNumber = 1)
.With(e => e.AbsoluteTrackNumber = 1)
.With(e => e.AlbumRelease = _release)
.Build(),
Builder<Track>.CreateNew()
.With(e => e.Title = "Another Track Title")
.With(e => e.MediumNumber = 1)
.With(e => e.AbsoluteTrackNumber = 2)
.With(e => e.AlbumRelease = _release)
.Build(),
Builder<Track>.CreateNew()
.With(e => e.Title = "Yet Another Track Title")
.With(e => e.MediumNumber = 1)
.With(e => e.AbsoluteTrackNumber = 3)
.With(e => e.AlbumRelease = _release)
.Build(),
Builder<Track>.CreateNew()
.With(e => e.Title = "Yet Another Track Title Take 2")
.With(e => e.MediumNumber = 1)
.With(e => e.AbsoluteTrackNumber = 4)
.With(e => e.AlbumRelease = _release)
.Build(),
Builder<Track>.CreateNew()
.With(e => e.Title = "Yet Another Track Title Take 3")
.With(e => e.MediumNumber = 1)
.With(e => e.AbsoluteTrackNumber = 5)
.With(e => e.AlbumRelease = _release)
.Build(),
Builder<Track>.CreateNew()
.With(e => e.Title = "Yet Another Track Title Take 4")
.With(e => e.MediumNumber = 1)
.With(e => e.AbsoluteTrackNumber = 6)
.With(e => e.AlbumRelease = _release)
.Build(),
Builder<Track>.CreateNew()
.With(e => e.Title = "A Really Really Really Really Long Track Title")
.With(e => e.MediumNumber = 1)
.With(e => e.AbsoluteTrackNumber = 7)
.With(e => e.AlbumRelease = _release)
.Build()
};
_trackFile = new TrackFile { Quality = new QualityModel(Quality.MP3_320), ReleaseGroup = "LidarrTest" };
Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
}
private void GivenProper()
{
_trackFile.Quality.Revision.Version = 2;
}
[Test]
public void should_truncate_with_ellipsis_between_first_and_last_episode_titles()
{
_namingConfig.StandardTrackFormat = "{Artist Name} - {Album Title} - {track:00} - {Track Title} [{Quality Title}]";
var result = Subject.BuildTrackFileName(_tracks, _artist, _album, _trackFile);
result.Length.Should().BeLessOrEqualTo(255);
result.Should().Be("Avenged Sevenfold - Hail to the King - 01 - First Track Title 1...A Really Really Really Really Long Track Title [MP3-320]");
}
[Test]
public void should_truncate_with_ellipsis_if_only_first_episode_title_fits()
{
_artist.Name = "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.StandardTrackFormat = "{Artist Name} - {Album Title} - {track:00} - {Track Title} [{Quality Title}]";
var result = Subject.BuildTrackFileName(_tracks, _artist, _album, _trackFile);
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 - Hail to the King - 01 - First Track Title 1... [MP3-320]");
result.Length.Should().BeLessOrEqualTo(255);
}
[Test]
public void should_truncate_first_episode_title_with_ellipsis_if_only_partially_fits()
{
_artist.Name = "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";
_namingConfig.StandardTrackFormat = "{Artist Name} - {Album Title} - {track:00} - {Track Title} [{Quality Title}]";
var result = Subject.BuildTrackFileName(new List<Track> { _tracks.First() }, _artist, _album, _trackFile);
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 - Hail to the King - 01 - First... [MP3-320]");
result.Length.Should().BeLessOrEqualTo(255);
}
}
}

@ -97,6 +97,11 @@ namespace NzbDrone.Core
return intList.Max();
}
public static int GetByteCount(this string input)
{
return Encoding.UTF8.GetByteCount(input);
}
public static string Truncate(this string s, int maxLength)
{
if (Encoding.UTF8.GetByteCount(s) <= maxLength)

@ -6,6 +6,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
@ -82,7 +83,7 @@ namespace NzbDrone.Core.Organizer
_logger = logger;
}
public string BuildTrackFileName(List<Track> tracks, Artist artist, Album album, TrackFile trackFile, NamingConfig namingConfig = null, List<CustomFormat> customFormats = null)
private string BuildTrackFileName(List<Track> tracks, Artist artist, Album album, TrackFile trackFile, int maxPath, NamingConfig namingConfig = null, List<CustomFormat> customFormats = null)
{
if (namingConfig == null)
{
@ -106,33 +107,39 @@ namespace NzbDrone.Core.Organizer
pattern = namingConfig.MultiDiscTrackFormat;
}
var tokenHandlers = new Dictionary<string, Func<TokenMatch, string>>(FileNameBuilderTokenEqualityComparer.Instance);
tracks = tracks.OrderBy(e => e.AlbumReleaseId).ThenBy(e => e.TrackNumber).ToList();
AddArtistTokens(tokenHandlers, artist);
AddAlbumTokens(tokenHandlers, album);
AddMediumTokens(tokenHandlers, tracks.First().AlbumRelease.Value.Media.SingleOrDefault(m => m.Number == tracks.First().MediumNumber));
AddTrackTokens(tokenHandlers, tracks, artist);
AddTrackFileTokens(tokenHandlers, trackFile);
AddQualityTokens(tokenHandlers, artist, trackFile);
AddMediaInfoTokens(tokenHandlers, trackFile);
AddCustomFormats(tokenHandlers, artist, trackFile, customFormats);
var splitPatterns = pattern.Split(new char[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries);
var components = new List<string>();
foreach (var s in splitPatterns)
{
var splitPattern = s;
var tokenHandlers = new Dictionary<string, Func<TokenMatch, string>>(FileNameBuilderTokenEqualityComparer.Instance);
splitPattern = FormatTrackNumberTokens(splitPattern, "", tracks);
splitPattern = FormatMediumNumberTokens(splitPattern, "", tracks);
var component = ReplaceTokens(splitPattern, tokenHandlers, namingConfig).Trim();
AddArtistTokens(tokenHandlers, artist);
AddAlbumTokens(tokenHandlers, album);
AddMediumTokens(tokenHandlers, tracks.First().AlbumRelease.Value.Media.SingleOrDefault(m => m.Number == tracks.First().MediumNumber));
AddTrackTokens(tokenHandlers, tracks, artist);
AddTrackTitlePlaceholderTokens(tokenHandlers);
AddTrackFileTokens(tokenHandlers, trackFile);
AddQualityTokens(tokenHandlers, artist, trackFile);
AddMediaInfoTokens(tokenHandlers, trackFile);
AddCustomFormats(tokenHandlers, artist, trackFile, customFormats);
var component = ReplaceTokens(splitPattern, tokenHandlers, namingConfig, true).Trim();
var maxPathSegmentLength = Math.Min(LongPathSupport.MaxFileNameLength, maxPath);
var maxTrackTitleLength = maxPathSegmentLength - GetLengthWithoutTrackTitle(component, namingConfig);
AddTrackTitleTokens(tokenHandlers, tracks, maxTrackTitleLength);
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}", "...");
if (component.IsNotNullOrWhiteSpace())
{
@ -143,6 +150,11 @@ namespace NzbDrone.Core.Organizer
return Path.Combine(components.ToArray());
}
public string BuildTrackFileName(List<Track> tracks, Artist artist, Album album, TrackFile trackFile, NamingConfig namingConfig = null, List<CustomFormat> customFormats = null)
{
return BuildTrackFileName(tracks, artist, album, trackFile, LongPathSupport.MaxFilePathLength, namingConfig, customFormats);
}
public string BuildTrackFilePath(Artist artist, string fileName, string extension)
{
Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace();
@ -300,9 +312,6 @@ namespace NzbDrone.Core.Organizer
private void AddTrackTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, List<Track> tracks, Artist artist)
{
tokenHandlers["{Track Title}"] = m => GetTrackTitle(tracks, "+");
tokenHandlers["{Track CleanTitle}"] = m => CleanTitle(GetTrackTitle(tracks, "and"));
// Use the track's ArtistMetadata by default, as it will handle the "Various Artists" case
// (where the album artist is "Various Artists" but each track has its own artist). Fall back
// to the album artist if we don't have any track ArtistMetadata for whatever reason.
@ -316,6 +325,18 @@ namespace NzbDrone.Core.Organizer
}
}
private void AddTrackTitlePlaceholderTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers)
{
tokenHandlers["{Track Title}"] = m => null;
tokenHandlers["{Track CleanTitle}"] = m => null;
}
private void AddTrackTitleTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, List<Track> tracks, int maxLength)
{
tokenHandlers["{Track Title}"] = m => GetTrackTitle(GetTrackTitles(tracks), "+", maxLength);
tokenHandlers["{Track CleanTitle}"] = m => GetTrackTitle(GetTrackTitles(tracks).Select(CleanTitle).ToList(), "and", maxLength);
}
private void AddTrackFileTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, TrackFile trackFile)
{
tokenHandlers["{Original Title}"] = m => GetOriginalTitle(trackFile);
@ -369,13 +390,29 @@ namespace NzbDrone.Core.Organizer
tokenHandlers["{Custom Formats}"] = m => string.Join(" ", customFormats.Where(x => x.IncludeCustomFormatWhenRenaming));
}
private string ReplaceTokens(string pattern, Dictionary<string, Func<TokenMatch, string>> tokenHandlers, NamingConfig namingConfig)
private string ReplaceTokens(string pattern, Dictionary<string, Func<TokenMatch, string>> tokenHandlers, NamingConfig namingConfig, bool escape = false)
{
return TitleRegex.Replace(pattern, match => ReplaceToken(match, tokenHandlers, namingConfig));
return TitleRegex.Replace(pattern, match => ReplaceToken(match, tokenHandlers, namingConfig, escape));
}
private string ReplaceToken(Match match, Dictionary<string, Func<TokenMatch, string>> tokenHandlers, NamingConfig namingConfig)
private string ReplaceToken(Match match, Dictionary<string, Func<TokenMatch, string>> tokenHandlers, NamingConfig namingConfig, bool escape)
{
if (match.Groups["escaped"].Success)
{
if (escape)
{
return match.Value;
}
else if (match.Value == "{{")
{
return "{";
}
else if (match.Value == "}}")
{
return "}";
}
}
var tokenMatch = new TokenMatch
{
RegexMatch = match,
@ -393,7 +430,15 @@ namespace NzbDrone.Core.Organizer
var tokenHandler = tokenHandlers.GetValueOrDefault(tokenMatch.Token, m => string.Empty);
var replacementText = tokenHandler(tokenMatch).Trim();
var replacementText = tokenHandler(tokenMatch);
if (replacementText == null)
{
// Preserve original token if handler returned null
return match.Value;
}
replacementText = replacementText.Trim();
if (tokenMatch.Token.All(t => !char.IsLetter(t) || char.IsLower(t)))
{
@ -416,6 +461,11 @@ namespace NzbDrone.Core.Organizer
replacementText = tokenMatch.Prefix + replacementText + tokenMatch.Suffix;
}
if (escape)
{
replacementText = replacementText.Replace("{", "{{").Replace("}", "}}");
}
return replacementText;
}
@ -469,13 +519,14 @@ namespace NzbDrone.Core.Organizer
}).ToArray());
}
private string GetTrackTitle(List<Track> tracks, string separator)
private List<string> GetTrackTitles(List<Track> tracks)
{
separator = string.Format(" {0} ", separator.Trim());
if (tracks.Count == 1)
{
return tracks.First().Title.TrimEnd(TrackTitleTrimCharacters);
return new List<string>
{
tracks.First().Title.TrimEnd(TrackTitleTrimCharacters)
};
}
var titles = tracks.Select(c => c.Title.TrimEnd(TrackTitleTrimCharacters))
@ -490,7 +541,44 @@ namespace NzbDrone.Core.Organizer
.ToList();
}
return string.Join(separator, titles);
return titles;
}
private string GetTrackTitle(List<string> titles, string separator, int maxLength)
{
separator = $" {separator.Trim()} ";
var joined = string.Join(separator, titles);
if (joined.GetByteCount() <= maxLength)
{
return joined;
}
var firstTitle = titles.First();
var firstTitleLength = firstTitle.GetByteCount();
if (titles.Count >= 2)
{
var lastTitle = titles.Last();
var lastTitleLength = lastTitle.GetByteCount();
if (firstTitleLength + lastTitleLength + 3 <= maxLength)
{
return $"{firstTitle.TrimEnd(' ', '.')}{{ellipsis}}{lastTitle}";
}
}
if (titles.Count > 1 && firstTitleLength + 3 <= maxLength)
{
return $"{firstTitle.TrimEnd(' ', '.')}{{ellipsis}}";
}
if (titles.Count == 1 && firstTitleLength <= maxLength)
{
return firstTitle;
}
return $"{firstTitle.Truncate(maxLength - 3).TrimEnd(' ', '.')}{{ellipsis}}";
}
private string CleanupTrackTitle(string title)
@ -538,6 +626,17 @@ namespace NzbDrone.Core.Organizer
return Path.GetFileNameWithoutExtension(trackFile.Path);
}
private int GetLengthWithoutTrackTitle(string pattern, NamingConfig namingConfig)
{
var tokenHandlers = new Dictionary<string, Func<TokenMatch, string>>(FileNameBuilderTokenEqualityComparer.Instance);
tokenHandlers["{Track Title}"] = m => string.Empty;
tokenHandlers["{Track CleanTitle}"] = m => string.Empty;
var result = ReplaceTokens(pattern, tokenHandlers, namingConfig);
return result.GetByteCount();
}
private static string CleanFileName(string name, NamingConfig namingConfig)
{
var result = name;

Loading…
Cancel
Save