using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Tv; namespace NzbDrone.Core.Organizer { public interface IBuildFileNames { string BuildFileName(List episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig = null); string BuildFilePath(Series series, Int32 seasonNumber, String fileName, String extension); BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); string GetSeriesFolder(Series series, NamingConfig namingConfig = null); string GetSeasonFolder(Series series, Int32 seasonNumber, NamingConfig namingConfig = null); } public class FileNameBuilder : IBuildFileNames { private readonly INamingConfigService _namingConfigService; private readonly IQualityDefinitionService _qualityDefinitionService; private readonly ICached _episodeFormatCache; private readonly ICached _absoluteEpisodeFormatCache; private readonly Logger _logger; private static readonly Regex TitleRegex = new Regex(@"\{(?[- ._\[(]*)(?(?:[a-z0-9]+)(?:(?[- ._]+)(?:[a-z0-9]+))?)(?::(?[a-z0-9]+))?(?[- ._)\]]*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex EpisodeRegex = new Regex(@"(?\{episode(?:\:0+)?})", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex SeasonRegex = new Regex(@"(?\{season(?:\:0+)?})", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex AbsoluteEpisodeRegex = new Regex(@"(?\{absolute(?:\:0+)?})", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?(?<=})[- ._]+?)?(?s?{season(?:\:0+)?}(?[- ._]?[ex])(?{episode(?:\:0+)?}))(?[- ._]+?(?={))?", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static readonly Regex AbsoluteEpisodePatternRegex = new Regex(@"(?(?<=})[- ._]+?)?(?{absolute(?:\:0+)?})(?[- ._]+?(?={))?", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static readonly Regex AirDateRegex = new Regex(@"\{Air(\s|\W|_)Date\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static readonly Regex SeriesTitleRegex = new Regex(@"(?\{(?:Series)(?[- ._])(Clean)?Title\})", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex FileNameCleanupRegex = new Regex(@"([- ._])(\1)+", RegexOptions.Compiled); private static readonly Regex TrimSeparatorsRegex = new Regex(@"[- ._]$", RegexOptions.Compiled); private static readonly char[] EpisodeTitleTrimCharacters = new[] { ' ', '.', '?' }; public FileNameBuilder(INamingConfigService namingConfigService, IQualityDefinitionService qualityDefinitionService, ICacheManager cacheManager, Logger logger) { _namingConfigService = namingConfigService; _qualityDefinitionService = qualityDefinitionService; _episodeFormatCache = cacheManager.GetCache(GetType(), "episodeFormat"); _absoluteEpisodeFormatCache = cacheManager.GetCache(GetType(), "absoluteEpisodeFormat"); _logger = logger; } public string BuildFileName(List episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig = null) { if (namingConfig == null) { namingConfig = _namingConfigService.GetConfig(); } if (!namingConfig.RenameEpisodes) { return GetOriginalTitle(episodeFile); } if (namingConfig.StandardEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Standard) { throw new NamingFormatException("Standard episode format cannot be empty"); } if (namingConfig.DailyEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Daily) { throw new NamingFormatException("Daily episode format cannot be empty"); } if (namingConfig.AnimeEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Anime) { throw new NamingFormatException("Anime episode format cannot be empty"); } var pattern = namingConfig.StandardEpisodeFormat; var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); episodes = episodes.OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber).ToList(); if (series.SeriesType == SeriesTypes.Daily) { pattern = namingConfig.DailyEpisodeFormat; } if (series.SeriesType == SeriesTypes.Anime && episodes.All(e => e.AbsoluteEpisodeNumber.HasValue)) { pattern = namingConfig.AnimeEpisodeFormat; } pattern = AddSeasonEpisodeNumberingTokens(pattern, tokenHandlers, episodes, namingConfig); pattern = AddAbsoluteNumberingTokens(pattern, tokenHandlers, series, episodes, namingConfig); AddSeriesTokens(tokenHandlers, series); AddEpisodeTokens(tokenHandlers, episodes); AddEpisodeFileTokens(tokenHandlers, series, episodeFile); AddQualityTokens(tokenHandlers, series, episodeFile); AddMediaInfoTokens(tokenHandlers, episodeFile); var fileName = ReplaceTokens(pattern, tokenHandlers).Trim(); fileName = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString()); fileName = TrimSeparatorsRegex.Replace(fileName, String.Empty); return fileName; } public string BuildFilePath(Series series, int seasonNumber, string fileName, string extension) { Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace(); string path = series.Path; if (series.SeasonFolder) { string seasonFolder; if (seasonNumber == 0) { seasonFolder = "Specials"; } else { var nameSpec = _namingConfigService.GetConfig(); seasonFolder = GetSeasonFolder(series, seasonNumber, nameSpec); } seasonFolder = CleanFileName(seasonFolder); path = Path.Combine(path, seasonFolder); } return Path.Combine(path, fileName + extension); } public BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec) { var episodeFormat = GetEpisodeFormat(nameSpec.StandardEpisodeFormat).LastOrDefault(); if (episodeFormat == null) { return new BasicNamingConfig(); } var basicNamingConfig = new BasicNamingConfig { Separator = episodeFormat.Separator, NumberStyle = episodeFormat.SeasonEpisodePattern }; var titleTokens = TitleRegex.Matches(nameSpec.StandardEpisodeFormat); foreach (Match match in titleTokens) { var separator = match.Groups["separator"].Value; var token = match.Groups["token"].Value; if (!separator.Equals(" ")) { basicNamingConfig.ReplaceSpaces = true; } if (token.StartsWith("{Series", StringComparison.InvariantCultureIgnoreCase)) { basicNamingConfig.IncludeSeriesTitle = true; } if (token.StartsWith("{Episode", StringComparison.InvariantCultureIgnoreCase)) { basicNamingConfig.IncludeEpisodeTitle = true; } if (token.StartsWith("{Quality", StringComparison.InvariantCultureIgnoreCase)) { basicNamingConfig.IncludeQuality = true; } } return basicNamingConfig; } public string GetSeriesFolder(Series series, NamingConfig namingConfig = null) { if (namingConfig == null) { namingConfig = _namingConfigService.GetConfig(); } var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); AddSeriesTokens(tokenHandlers, series); return CleanFolderName(ReplaceTokens(namingConfig.SeriesFolderFormat, tokenHandlers)); } public string GetSeasonFolder(Series series, Int32 seasonNumber, NamingConfig namingConfig = null) { if (namingConfig == null) { namingConfig = _namingConfigService.GetConfig(); } var tokenHandlers = new Dictionary>(FileNameBuilderTokenEqualityComparer.Instance); AddSeriesTokens(tokenHandlers, series); AddSeasonTokens(tokenHandlers, seasonNumber); return CleanFolderName(ReplaceTokens(namingConfig.SeasonFolderFormat, tokenHandlers)); } public static string CleanTitle(string name) { string[] dropCharacters = { ":", ".", "(", ")" }; string result = name; for (int i = 0; i < dropCharacters.Length; i++) { result = result.Replace(dropCharacters[i], ""); } return result; } public static string CleanFileName(string name) { string result = name; string[] badCharacters = { "\\", "/", "<", ">", "?", "*", ":", "|", "\"" }; string[] goodCharacters = { "+", "+", "", "", "!", "-", "-", "", "" }; for (int i = 0; i < badCharacters.Length; i++) { result = result.Replace(badCharacters[i], goodCharacters[i]); } return result.Trim(); } public static string CleanFolderName(string name) { name = FileNameCleanupRegex.Replace(name, match => match.Captures[0].Value[0].ToString()); return name.Trim(' ', '.'); } private void AddSeriesTokens(Dictionary> tokenHandlers, Series series) { tokenHandlers["{Series Title}"] = m => series.Title; tokenHandlers["{Series CleanTitle}"] = m => CleanTitle(series.Title); } private String AddSeasonEpisodeNumberingTokens(String pattern, Dictionary> tokenHandlers, List episodes, NamingConfig namingConfig) { var episodeFormats = GetEpisodeFormat(pattern).DistinctBy(v => v.SeasonEpisodePattern).ToList(); int index = 1; foreach (var episodeFormat in episodeFormats) { var seasonEpisodePattern = episodeFormat.SeasonEpisodePattern; string formatPattern; switch ((MultiEpisodeStyle)namingConfig.MultiEpisodeStyle) { case MultiEpisodeStyle.Duplicate: formatPattern = episodeFormat.Separator + episodeFormat.SeasonEpisodePattern; seasonEpisodePattern = FormatNumberTokens(seasonEpisodePattern, formatPattern, episodes); break; case MultiEpisodeStyle.Repeat: formatPattern = episodeFormat.EpisodeSeparator + episodeFormat.EpisodePattern; seasonEpisodePattern = FormatNumberTokens(seasonEpisodePattern, formatPattern, episodes); break; case MultiEpisodeStyle.Scene: formatPattern = "-" + episodeFormat.EpisodeSeparator + episodeFormat.EpisodePattern; seasonEpisodePattern = FormatNumberTokens(seasonEpisodePattern, formatPattern, episodes); break; case MultiEpisodeStyle.Range: formatPattern = "-" + episodeFormat.EpisodePattern; var eps = new List { episodes.First() }; if (episodes.Count > 1) eps.Add(episodes.Last()); seasonEpisodePattern = FormatNumberTokens(seasonEpisodePattern, formatPattern, eps); break; //MultiEpisodeStyle.Extend default: formatPattern = "-" + episodeFormat.EpisodePattern; seasonEpisodePattern = FormatNumberTokens(seasonEpisodePattern, formatPattern, episodes); break; } var token = String.Format("{{Season Episode{0}}}", index++); pattern = pattern.Replace(episodeFormat.SeasonEpisodePattern, token); tokenHandlers[token] = m => seasonEpisodePattern; } AddSeasonTokens(tokenHandlers, episodes.First().SeasonNumber); if (episodes.Count > 1) { tokenHandlers["{Episode}"] = m => episodes.First().EpisodeNumber.ToString(m.CustomFormat) + "-" + episodes.Last().EpisodeNumber.ToString(m.CustomFormat); } else { tokenHandlers["{Episode}"] = m => episodes.First().EpisodeNumber.ToString(m.CustomFormat); } return pattern; } private String AddAbsoluteNumberingTokens(String pattern, Dictionary> tokenHandlers, Series series, List episodes, NamingConfig namingConfig) { var absoluteEpisodeFormats = GetAbsoluteFormat(pattern).DistinctBy(v => v.AbsoluteEpisodePattern).ToList(); int index = 1; foreach (var absoluteEpisodeFormat in absoluteEpisodeFormats) { if (series.SeriesType != SeriesTypes.Anime) { pattern = pattern.Replace(absoluteEpisodeFormat.AbsoluteEpisodePattern, ""); continue; } var absoluteEpisodePattern = absoluteEpisodeFormat.AbsoluteEpisodePattern; string formatPattern; switch ((MultiEpisodeStyle) namingConfig.MultiEpisodeStyle) { case MultiEpisodeStyle.Duplicate: formatPattern = absoluteEpisodeFormat.Separator + absoluteEpisodeFormat.AbsoluteEpisodePattern; absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, episodes); break; case MultiEpisodeStyle.Repeat: var repeatSeparator = absoluteEpisodeFormat.Separator.Trim().IsNullOrWhiteSpace() ? " " : absoluteEpisodeFormat.Separator.Trim(); formatPattern = repeatSeparator + absoluteEpisodeFormat.AbsoluteEpisodePattern; absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, episodes); break; case MultiEpisodeStyle.Scene: formatPattern = "-" + absoluteEpisodeFormat.AbsoluteEpisodePattern; absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, episodes); break; case MultiEpisodeStyle.Range: formatPattern = "-" + absoluteEpisodeFormat.AbsoluteEpisodePattern; var eps = new List {episodes.First()}; if (episodes.Count > 1) eps.Add(episodes.Last()); absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, eps); break; //MultiEpisodeStyle.Extend default: formatPattern = "-" + absoluteEpisodeFormat.AbsoluteEpisodePattern; absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, episodes); break; } var token = String.Format("{{Absolute Pattern{0}}}", index++); pattern = pattern.Replace(absoluteEpisodeFormat.AbsoluteEpisodePattern, token); tokenHandlers[token] = m => absoluteEpisodePattern; } return pattern; } private void AddSeasonTokens(Dictionary> tokenHandlers, Int32 seasonNumber) { tokenHandlers["{Season}"] = m => seasonNumber.ToString(m.CustomFormat); } private void AddEpisodeTokens(Dictionary> tokenHandlers, List episodes) { if (!episodes.First().AirDate.IsNullOrWhiteSpace()) { tokenHandlers["{Air Date}"] = m => episodes.First().AirDate.Replace('-', ' '); } else { tokenHandlers["{Air Date}"] = m => "Unknown"; } tokenHandlers["{Episode Title}"] = m => GetEpisodeTitle(episodes); } private void AddEpisodeFileTokens(Dictionary> tokenHandlers, Series series, EpisodeFile episodeFile) { tokenHandlers["{Original Title}"] = m => GetOriginalTitle(episodeFile); tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(episodeFile); tokenHandlers["{Release Group}"] = m => episodeFile.ReleaseGroup ?? "Sonarr"; } private void AddQualityTokens(Dictionary> tokenHandlers, Series series, EpisodeFile episodeFile) { var qualityTitle = _qualityDefinitionService.Get(episodeFile.Quality.Quality).Title; var qualityProper = GetQualityProper(series, episodeFile.Quality); tokenHandlers["{Quality Full}"] = m => String.Format("{0} {1}", qualityTitle, qualityProper); tokenHandlers["{Quality Title}"] = m => qualityTitle; tokenHandlers["{Quality Proper}"] = m => qualityProper; } private void AddMediaInfoTokens(Dictionary> tokenHandlers, EpisodeFile episodeFile) { if (episodeFile.MediaInfo == null) return; String mediaInfoVideo; switch (episodeFile.MediaInfo.VideoCodec) { case "AVC": // TODO: What to do if the original SceneName is hashed? if (!episodeFile.SceneName.IsNullOrWhiteSpace() && Path.GetFileNameWithoutExtension(episodeFile.SceneName).Contains("h264")) { mediaInfoVideo = "h264"; } else { mediaInfoVideo = "x264"; } break; default: mediaInfoVideo = episodeFile.MediaInfo.VideoCodec; break; } String mediaInfoAudio; switch (episodeFile.MediaInfo.AudioFormat) { case "AC-3": mediaInfoAudio = "AC3"; break; case "MPEG Audio": if (episodeFile.MediaInfo.AudioProfile == "Layer 3") { mediaInfoAudio = "MP3"; } else { mediaInfoAudio = episodeFile.MediaInfo.AudioFormat; } break; case "DTS": mediaInfoAudio = episodeFile.MediaInfo.AudioFormat; break; default: mediaInfoAudio = episodeFile.MediaInfo.AudioFormat; break; } var mediaInfoAudioLanguages = GetLanguagesToken(episodeFile.MediaInfo.AudioLanguages); if (!mediaInfoAudioLanguages.IsNullOrWhiteSpace()) { mediaInfoAudioLanguages = String.Format("[{0}]", mediaInfoAudioLanguages); } if (mediaInfoAudioLanguages == "[EN]") { mediaInfoAudioLanguages = String.Empty; } var mediaInfoSubtitleLanguages = GetLanguagesToken(episodeFile.MediaInfo.Subtitles); if (!mediaInfoSubtitleLanguages.IsNullOrWhiteSpace()) { mediaInfoSubtitleLanguages = String.Format("[{0}]", mediaInfoSubtitleLanguages); } tokenHandlers["{MediaInfo Video}"] = m => mediaInfoVideo; tokenHandlers["{MediaInfo Audio}"] = m => mediaInfoAudio; tokenHandlers["{MediaInfo Simple}"] = m => String.Format("{0} {1}", mediaInfoVideo, mediaInfoAudio); tokenHandlers["{MediaInfo Full}"] = m => String.Format("{0} {1}{2} {3}", mediaInfoVideo, mediaInfoAudio, mediaInfoAudioLanguages, mediaInfoSubtitleLanguages); } private string GetLanguagesToken(String mediaInfoLanguages) { List tokens = new List(); foreach (var item in mediaInfoLanguages.Split('/')) { if (!string.IsNullOrWhiteSpace(item)) tokens.Add(item.Trim()); } var cultures = System.Globalization.CultureInfo.GetCultures(System.Globalization.CultureTypes.NeutralCultures); for (int i = 0; i < tokens.Count; i++) { try { var cultureInfo = cultures.FirstOrDefault(p => p.EnglishName == tokens[i]); if (cultureInfo != null) tokens[i] = cultureInfo.TwoLetterISOLanguageName.ToUpper(); } catch { } } return string.Join("+", tokens.Distinct()); } private string ReplaceTokens(String pattern, Dictionary> tokenHandlers) { return TitleRegex.Replace(pattern, match => ReplaceToken(match, tokenHandlers)); } private string ReplaceToken(Match match, Dictionary> tokenHandlers) { var tokenMatch = new TokenMatch { RegexMatch = match, Prefix = match.Groups["prefix"].Value, Separator = match.Groups["separator"].Value, Suffix = match.Groups["suffix"].Value, Token = match.Groups["token"].Value, CustomFormat = match.Groups["customFormat"].Value }; if (tokenMatch.CustomFormat.IsNullOrWhiteSpace()) { tokenMatch.CustomFormat = null; } var tokenHandler = tokenHandlers.GetValueOrDefault(tokenMatch.Token, m => String.Empty); var replacementText = tokenHandler(tokenMatch).Trim(); if (tokenMatch.Token.All(t => !Char.IsLetter(t) || Char.IsLower(t))) { replacementText = replacementText.ToLower(); } else if (tokenMatch.Token.All(t => !Char.IsLetter(t) || Char.IsUpper(t))) { replacementText = replacementText.ToUpper(); } if (!tokenMatch.Separator.IsNullOrWhiteSpace()) { replacementText = replacementText.Replace(" ", tokenMatch.Separator); } replacementText = CleanFileName(replacementText); if (!replacementText.IsNullOrWhiteSpace()) { replacementText = tokenMatch.Prefix + replacementText + tokenMatch.Suffix; } return replacementText; } private string FormatNumberTokens(string basePattern, string formatPattern, List episodes) { var pattern = String.Empty; for (int i = 0; i < episodes.Count; i++) { var patternToReplace = i == 0 ? basePattern : formatPattern; pattern += EpisodeRegex.Replace(patternToReplace, match => ReplaceNumberToken(match.Groups["episode"].Value, episodes[i].EpisodeNumber)); } return ReplaceSeasonTokens(pattern, episodes.First().SeasonNumber); } private string FormatAbsoluteNumberTokens(string basePattern, string formatPattern, List episodes) { var pattern = String.Empty; for (int i = 0; i < episodes.Count; i++) { var patternToReplace = i == 0 ? basePattern : formatPattern; pattern += AbsoluteEpisodeRegex.Replace(patternToReplace, match => ReplaceNumberToken(match.Groups["absolute"].Value, episodes[i].AbsoluteEpisodeNumber.Value)); } return ReplaceSeasonTokens(pattern, episodes.First().SeasonNumber); } private string ReplaceSeasonTokens(string pattern, int seasonNumber) { return SeasonRegex.Replace(pattern, match => ReplaceNumberToken(match.Groups["season"].Value, seasonNumber)); } private string ReplaceNumberToken(string token, int value) { var split = token.Trim('{', '}').Split(':'); if (split.Length == 1) return value.ToString("0"); return value.ToString(split[1]); } private EpisodeFormat[] GetEpisodeFormat(string pattern) { return _episodeFormatCache.Get(pattern, () => SeasonEpisodePatternRegex.Matches(pattern).OfType() .Select(match => new EpisodeFormat { EpisodeSeparator = match.Groups["episodeSeparator"].Value, Separator = match.Groups["separator"].Value, EpisodePattern = match.Groups["episode"].Value, SeasonEpisodePattern = match.Groups["seasonEpisode"].Value, }).ToArray()); } private AbsoluteEpisodeFormat[] GetAbsoluteFormat(string pattern) { return _absoluteEpisodeFormatCache.Get(pattern, () => AbsoluteEpisodePatternRegex.Matches(pattern).OfType() .Select(match => new AbsoluteEpisodeFormat { Separator = match.Groups["separator"].Value.IsNotNullOrWhiteSpace() ? match.Groups["separator"].Value : "-", AbsoluteEpisodePattern = match.Groups["absolute"].Value }).ToArray()); } private string GetEpisodeTitle(List episodes) { if (episodes.Count == 1) { return episodes.First().Title.TrimEnd(EpisodeTitleTrimCharacters); } var titles = episodes .Select(c => c.Title.TrimEnd(EpisodeTitleTrimCharacters)) .Select(Parser.Parser.CleanupEpisodeTitle) .Distinct(); return String.Join(" + ", titles); } private string GetQualityProper(Series series, QualityModel quality) { if (quality.Revision.Version > 1) { if (series.SeriesType == SeriesTypes.Anime) { return "v" + quality.Revision.Version; } return "Proper"; } return String.Empty; } private string GetOriginalTitle(EpisodeFile episodeFile) { if (episodeFile.SceneName.IsNullOrWhiteSpace()) { return GetOriginalFileName(episodeFile); } return episodeFile.SceneName; } private string GetOriginalFileName(EpisodeFile episodeFile) { if (episodeFile.RelativePath.IsNullOrWhiteSpace()) { return Path.GetFileNameWithoutExtension(episodeFile.Path); } return Path.GetFileNameWithoutExtension(episodeFile.RelativePath); } } internal sealed class TokenMatch { public Match RegexMatch { get; set; } public String Prefix { get; set; } public String Separator { get; set; } public String Suffix { get; set; } public String Token { get; set; } public String CustomFormat { get; set; } } public enum MultiEpisodeStyle { Extend = 0, Duplicate = 1, Repeat = 2, Scene = 3, Range = 4 } }