From d86aeb7472f7f514c6793172e3858af299472bf8 Mon Sep 17 00:00:00 2001 From: Alan Collins Date: Sat, 9 Mar 2024 22:54:06 -0600 Subject: [PATCH] New: Release Hash renaming token Closes #6570 --- .../MediaManagement/Naming/NamingModal.js | 22 +++++ .../AggregateReleaseHashFixture.cs | 83 +++++++++++++++++++ .../FileNameBuilderFixture.cs | 22 +++++ .../ParserTests/AnimeMetadataParserFixture.cs | 32 ++++++- .../Migration/204_add_release_hash.cs | 76 +++++++++++++++++ src/NzbDrone.Core/MediaFiles/EpisodeFile.cs | 1 + .../Aggregators/AggregateReleaseHash.cs | 41 +++++++++ .../EpisodeImport/ImportApprovedEpisodes.cs | 1 + .../Organizer/FileNameBuilder.cs | 1 + .../Parser/Model/LocalEpisode.cs | 1 + src/NzbDrone.Core/Parser/Parser.cs | 48 +++++------ 11 files changed, 303 insertions(+), 25 deletions(-) create mode 100644 src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseHashFixture.cs create mode 100644 src/NzbDrone.Core/Datastore/Migration/204_add_release_hash.cs create mode 100644 src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseHash.cs diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js index 9af6a1160..f873ec1d9 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -154,6 +154,10 @@ const otherTokens = [ { token: '{Custom Format:FormatName}', example: 'AMZN' } ]; +const otherAnimeTokens = [ + { token: '{Release Hash}', example: 'ABCDEFGH' } +]; + const originalTokens = [ { token: '{Original Title}', example: 'The.Series.Title\'s!.S01E01.WEBDL.1080p.x264-EVOLVE' }, { token: '{Original Filename}', example: 'the.series.title\'s!.s01e01.webdl.1080p.x264-EVOLVE' } @@ -535,6 +539,24 @@ class NamingModal extends Component { } ) } + + { + anime && otherAnimeTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseHashFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseHashFixture.cs new file mode 100644 index 000000000..e3e8b848c --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseHashFixture.cs @@ -0,0 +1,83 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators +{ + [TestFixture] + public class AggregateReleaseHashFixture : CoreTest + { + private Series _series; + + [SetUp] + public void Setup() + { + _series = Builder.CreateNew().Build(); + } + + [Test] + public void should_prefer_file() + { + var fileEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC) [ABCDEFGH]"); + var folderEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 [12345678]"); + var downloadClientEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC) [ABCD1234]"); + var localEpisode = new LocalEpisode + { + FileEpisodeInfo = fileEpisodeInfo, + FolderEpisodeInfo = folderEpisodeInfo, + DownloadClientEpisodeInfo = downloadClientEpisodeInfo, + Path = @"C:\Test\Unsorted TV\Series.Title.S01\Series.Title.S01E01.mkv".AsOsAgnostic(), + Series = _series + }; + + Subject.Aggregate(localEpisode, null); + + localEpisode.ReleaseHash.Should().Be("ABCDEFGH"); + } + + [Test] + public void should_fallback_to_downloadclient() + { + var fileEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC)"); + var downloadClientEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC) [ABCD1234]"); + var folderEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 [12345678]"); + var localEpisode = new LocalEpisode + { + FileEpisodeInfo = fileEpisodeInfo, + FolderEpisodeInfo = folderEpisodeInfo, + DownloadClientEpisodeInfo = downloadClientEpisodeInfo, + Path = @"C:\Test\Unsorted TV\Series.Title.S01\Series.Title.S01E01.WEB-DL.mkv".AsOsAgnostic(), + Series = _series + }; + + Subject.Aggregate(localEpisode, null); + + localEpisode.ReleaseHash.Should().Be("ABCD1234"); + } + + [Test] + public void should_fallback_to_folder() + { + var fileEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC)"); + var downloadClientEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC)"); + var folderEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 [12345678]"); + var localEpisode = new LocalEpisode + { + FileEpisodeInfo = fileEpisodeInfo, + FolderEpisodeInfo = folderEpisodeInfo, + DownloadClientEpisodeInfo = downloadClientEpisodeInfo, + Path = @"C:\Test\Unsorted TV\Series.Title.S01\Series.Title.S01E01.WEB-DL.mkv".AsOsAgnostic(), + Series = _series + }; + + Subject.Aggregate(localEpisode, null); + + localEpisode.ReleaseHash.Should().Be("12345678"); + } + } +} diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index 521d2d8d9..3b0cdb0af 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -991,6 +991,28 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests result.Should().EndWith("HDR"); } + [Test] + public void should_replace_release_hash_with_stored_hash() + { + _namingConfig.StandardEpisodeFormat = "{Release Hash}"; + + _episodeFile.ReleaseHash = "ABCDEFGH"; + + Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + .Should().Be("ABCDEFGH"); + } + + [Test] + public void should_replace_null_release_hash_with_empty_string() + { + _namingConfig.StandardEpisodeFormat = "{Release Hash}"; + + _episodeFile.ReleaseHash = null; + + Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + .Should().Be(string.Empty); + } + private void GivenMediaInfoModel(string videoCodec = "h264", string audioCodec = "dts", int audioChannels = 6, diff --git a/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs index 5a1f8bef4..52794f643 100644 --- a/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs @@ -22,12 +22,42 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Series Title - 031 - The Resolution to Kill [Lunar].avi", "Lunar", "")] [TestCase("[ACX]Series Title 01 Episode Name [Kosaka] [9C57891E].mkv", "ACX", "9C57891E")] [TestCase("[S-T-D] Series Title! - 06 (1280x720 10bit AAC) [59B3F2EA].mkv", "S-T-D", "59B3F2EA")] - public void should_parse_absolute_numbers(string postTitle, string subGroup, string hash) + + // These tests are dupes of the above, except with parenthesized hashes instead of square bracket + [TestCase("[SubDESU]_Show_Title_DxD_07_(1280x720_x264-AAC)_(6B7FD717)", "SubDESU", "6B7FD717")] + [TestCase("[Chihiro]_Show_Title!!_-_06_[848x480_H.264_AAC](859EEAFA)", "Chihiro", "859EEAFA")] + [TestCase("[Underwater]_Show_Title_-_12_(720p)_(5C7BC4F9)", "Underwater", "5C7BC4F9")] + [TestCase("[HorribleSubs]_Show_Title_-_33_[720p]", "HorribleSubs", "")] + [TestCase("[HorribleSubs] Show-Title - 13 [1080p].mkv", "HorribleSubs", "")] + [TestCase("[Doremi].Show.Title.5.Go.Go!.31.[1280x720].(C65D4B1F).mkv", "Doremi", "C65D4B1F")] + [TestCase("[Doremi].Show.Title.5.Go.Go!.31[1280x720].(C65D4B1F)", "Doremi", "C65D4B1F")] + [TestCase("[Doremi].Show.Title.5.Go.Go!.31.[1280x720].mkv", "Doremi", "")] + [TestCase("[K-F] Series Title 214", "K-F", "")] + [TestCase("[K-F] Series Title S10E14 214", "K-F", "")] + [TestCase("[K-F] Series Title 10x14 214", "K-F", "")] + [TestCase("[K-F] Series Title 214 10x14", "K-F", "")] + [TestCase("Series Title - 031 - The Resolution to Kill [Lunar].avi", "Lunar", "")] + [TestCase("[ACX]Series Title 01 Episode Name [Kosaka] (9C57891E).mkv", "ACX", "9C57891E")] + [TestCase("[S-T-D] Series Title! - 06 (1280x720 10bit AAC) (59B3F2EA).mkv", "S-T-D", "59B3F2EA")] + public void should_parse_releasegroup_and_hash(string postTitle, string subGroup, string hash) { var result = Parser.Parser.ParseTitle(postTitle); result.Should().NotBeNull(); result.ReleaseGroup.Should().Be(subGroup); result.ReleaseHash.Should().Be(hash); } + + [TestCase("[DHD] Series Title! - 08 (1280x720 10bit AAC) [8B00F2EA].mkv", "8B00F2EA")] + [TestCase("[DHD] Series Title! - 10 (1280x720 10bit AAC) [10BBF2EA].mkv", "10BBF2EA")] + [TestCase("[DHD] Series Title! - 08 (1280x720 10bit AAC) [008BF28B].mkv", "008BF28B")] + [TestCase("[DHD] Series Title! - 10 (1280x720 10bit AAC) [000BF10B].mkv", "000BF10B")] + [TestCase("[DHD] Series Title! - 08 (1280x720 8bit AAC) [8B8BF2EA].mkv", "8B8BF2EA")] + [TestCase("[DHD] Series Title! - 10 (1280x720 8bit AAC) [10B10BEA].mkv", "10B10BEA")] + public void should_parse_release_hashes_with_10b_or_8b(string postTitle, string hash) + { + var result = Parser.Parser.ParseTitle(postTitle); + result.Should().NotBeNull(); + result.ReleaseHash.Should().Be(hash); + } } } diff --git a/src/NzbDrone.Core/Datastore/Migration/204_add_release_hash.cs b/src/NzbDrone.Core/Datastore/Migration/204_add_release_hash.cs new file mode 100644 index 000000000..887d35cda --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/204_add_release_hash.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Data; +using System.IO; +using Dapper; +using FluentMigrator; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore.Migration.Framework; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(204)] + public class add_add_release_hash : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("EpisodeFiles").AddColumn("ReleaseHash").AsString().Nullable(); + + Execute.WithConnection(UpdateEpisodeFiles); + } + + private void UpdateEpisodeFiles(IDbConnection conn, IDbTransaction tran) + { + var updates = new List(); + + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT \"Id\", \"SceneName\", \"RelativePath\", \"OriginalFilePath\" FROM \"EpisodeFiles\""; + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var id = reader.GetInt32(0); + var sceneName = reader[1] as string; + var relativePath = reader[2] as string; + var originalFilePath = reader[3] as string; + + ParsedEpisodeInfo parsedEpisodeInfo = null; + + var originalTitle = sceneName; + + if (originalTitle.IsNullOrWhiteSpace() && originalFilePath.IsNotNullOrWhiteSpace()) + { + originalTitle = Path.GetFileNameWithoutExtension(originalFilePath); + } + + if (originalTitle.IsNotNullOrWhiteSpace()) + { + parsedEpisodeInfo = Parser.Parser.ParseTitle(originalTitle); + } + + if (parsedEpisodeInfo == null || parsedEpisodeInfo.ReleaseHash.IsNullOrWhiteSpace()) + { + parsedEpisodeInfo = Parser.Parser.ParseTitle(Path.GetFileNameWithoutExtension(relativePath)); + } + + if (parsedEpisodeInfo != null && parsedEpisodeInfo.ReleaseHash.IsNotNullOrWhiteSpace()) + { + updates.Add(new + { + Id = id, + ReleaseHash = parsedEpisodeInfo.ReleaseHash + }); + } + } + } + + if (updates.Count > 0) + { + var updateEpisodeFilesSql = "UPDATE \"EpisodeFiles\" SET \"ReleaseHash\" = @ReleaseHash WHERE \"Id\" = @Id"; + conn.Execute(updateEpisodeFilesSql, updates, transaction: tran); + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs index 8dee12c2b..cd810a457 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs @@ -21,6 +21,7 @@ namespace NzbDrone.Core.MediaFiles public string OriginalFilePath { get; set; } public string SceneName { get; set; } public string ReleaseGroup { get; set; } + public string ReleaseHash { get; set; } public QualityModel Quality { get; set; } public IndexerFlags IndexerFlags { get; set; } public MediaInfoModel MediaInfo { get; set; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseHash.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseHash.cs new file mode 100644 index 000000000..a2012de14 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Aggregation/Aggregators/AggregateReleaseHash.cs @@ -0,0 +1,41 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators +{ + public class AggregateReleaseHash : IAggregateLocalEpisode + { + public int Order => 1; + + public LocalEpisode Aggregate(LocalEpisode localEpisode, DownloadClientItem downloadClientItem) + { + var releaseHash = GetReleaseHash(localEpisode.FileEpisodeInfo); + + if (releaseHash.IsNullOrWhiteSpace()) + { + releaseHash = GetReleaseHash(localEpisode.DownloadClientEpisodeInfo); + } + + if (releaseHash.IsNullOrWhiteSpace()) + { + releaseHash = GetReleaseHash(localEpisode.FolderEpisodeInfo); + } + + localEpisode.ReleaseHash = releaseHash; + + return localEpisode; + } + + private string GetReleaseHash(ParsedEpisodeInfo episodeInfo) + { + // ReleaseHash doesn't make sense for a FullSeason, since hashes should be specific to a file + if (episodeInfo == null || episodeInfo.FullSeason) + { + return null; + } + + return episodeInfo.ReleaseHash; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs index 74e2a71e6..d591a068d 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -95,6 +95,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport episodeFile.SeasonNumber = localEpisode.SeasonNumber; episodeFile.Episodes = localEpisode.Episodes; episodeFile.ReleaseGroup = localEpisode.ReleaseGroup; + episodeFile.ReleaseHash = localEpisode.ReleaseHash; episodeFile.Languages = localEpisode.Languages; // Prefer the release type from the download client, folder and finally the file so we have the most accurate information. diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 947d5f555..c09978bfa 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -623,6 +623,7 @@ namespace NzbDrone.Core.Organizer tokenHandlers["{Original Title}"] = m => GetOriginalTitle(episodeFile, useCurrentFilenameAsFallback); tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(episodeFile, useCurrentFilenameAsFallback); tokenHandlers["{Release Group}"] = m => episodeFile.ReleaseGroup.IsNullOrWhiteSpace() ? m.DefaultValue("Sonarr") : Truncate(episodeFile.ReleaseGroup, m.CustomFormat); + tokenHandlers["{Release Hash}"] = m => episodeFile.ReleaseHash ?? string.Empty; } private void AddQualityTokens(Dictionary> tokenHandlers, Series series, EpisodeFile episodeFile) diff --git a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs b/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs index af7c7347c..65f6e84f8 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs @@ -37,6 +37,7 @@ namespace NzbDrone.Core.Parser.Model public bool ExistingFile { get; set; } public bool SceneSource { get; set; } public string ReleaseGroup { get; set; } + public string ReleaseHash { get; set; } public string SceneName { get; set; } public bool OtherVideoFiles { get; set; } public List CustomFormats { get; set; } diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 693eaa36d..aa2a121b9 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -83,11 +83,11 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title Season+Episode - new Regex(@"^(?:\[(?.+?)\](?:_|-|\s|\.)?)(?.+?)(?:[-_\W](?<![()\[!]))+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)(?:[_. ](?!\d+)).*?(?<hash>\[\w{8}\])?(?:$|\.)", + new Regex(@"^(?:\[(?<subgroup>.+?)\](?:_|-|\s|\.)?)(?<title>.+?)(?:[-_\W](?<![()\[!]))+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)(?:[_. ](?!\d+)).*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title Episode Absolute Episode Number ([SubGroup] Series Title Episode 01) - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)(?<title>.+?)[-_. ](?:Episode)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:_|-|\s|\.)*?(?<hash>\[.{8}\])?(?:$|\.)?", + new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)(?<title>.+?)[-_. ](?:Episode)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+)))+.*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title Absolute Episode Number + Season+Episode @@ -95,39 +95,39 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title Season+Episode + Absolute Episode Number - new Regex(@"^(?:\[(?<subgroup>.+?)\](?:_|-|\s|\.)?)(?<title>.+?)(?:[-_\W](?<![()\[!]))+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)(?:(?:_|-|\s|\.)+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+|\-[a-z])))+.*?(?<hash>\[\w{8}\])?(?:$|\.)", + new Regex(@"^(?:\[(?<subgroup>.+?)\](?:_|-|\s|\.)?)(?<title>.+?)(?:[-_\W](?<![()\[!]))+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)(?:(?:_|-|\s|\.)+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+|\-[a-z])))+.*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title with trailing number Absolute Episode Number - Batch separated with tilde - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?[^-]+?)(?:(?<![-_. ]|\b[0]\d+) - )[-_. ]?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+))\s?~\s?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+))(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?[^-]+?)(?:(?<![-_. ]|\b[0]\d+) - )[-_. ]?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+))\s?~\s?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+))(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title with season number in brackets Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^-]+?)[_. ]+?\(Season[_. ](?<season>\d+)\)[-_. ]+?(?:[-_. ]?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^-]+?)[_. ]+?\(Season[_. ](?<season>\d+)\)[-_. ]+?(?:[-_. ]?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title with trailing number Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^-]+?)(?:(?<![-_. ]|\b[0]\d+) - )(?:[-_. ]?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^-]+?)(?:(?<![-_. ]|\b[0]\d+) - )(?:[-_. ]?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title with trailing 3-digit number and sub title - Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^]]+?)(?:[-_. ]{3}?(?<absoluteepisode>\d{2}(\.\d{1,2})?(?!-?\d+|-[a-z]+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^]]+?)(?:[-_. ]{3}?(?<absoluteepisode>\d{2}(\.\d{1,2})?(?!-?\d+|-[a-z]+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title with trailing number Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^-]+?)(?:(?<![-_. ]|\b[0]\d+)[_ ]+)(?:[-_. ]?(?<absoluteepisode>\d{3}(\.\d{1,2})?(?!\d+|-[a-z]+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^-]+?)(?:(?<![-_. ]|\b[0]\d+)[_ ]+)(?:[-_. ]?(?<absoluteepisode>\d{3}(\.\d{1,2})?(?!\d+|-[a-z]+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title - Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)(?:(?<!\b[0]\d+))(?:[. ]-[. ](?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+|[-])))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)(?:(?<!\b[0]\d+))(?:[. ]-[. ](?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+|[-])))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title Absolute Episode Number - Absolute Episode Number (batches without full separator between title and absolute episode numbers) - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)(?:(?<!\b[0]\d+))(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+|[-]))[. ]-[. ](?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+|[-]))(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)(?:(?<!\b[0]\d+))(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+|[-]))[. ]-[. ](?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+|[-]))(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - [SubGroup] Title Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)[-_. ]+\(?(?:[-_. ]?#?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+|-[a-z]+)))+\)?(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", + new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)[-_. ]+\(?(?:[-_. ]?#?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+|-[a-z]+)))+\)?(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Multi-episode Repeated (S01E05 - S01E06) @@ -155,11 +155,11 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title Absolute Episode Number [SubGroup] [Hash]? (Series Title Episode 99-100 [RlsGroup] [ABCD1234]) - new Regex(@"^(?<title>.+?)[-_. ]Episode(?:[-_. ]+(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:.+?)\[(?<subgroup>.+?)\].*?(?<hash>\[\w{8}\])?(?:$|\.)", + new Regex(@"^(?<title>.+?)[-_. ]Episode(?:[-_. ]+(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:.+?)\[(?<subgroup>.+?)\].*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title Absolute Episode Number [SubGroup] [Hash] - new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{3}(\.\d{1,2})(?!\d+)))+(?:.+?)\[(?<subgroup>.+?)\].*?(?<hash>\[\w{8}\])?(?:$|\.)", + new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{3}(\.\d{1,2})(?!\d+)))+(?:.+?)\[(?<subgroup>.+?)\].*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title Absolute Episode Number (Year) [SubGroup] @@ -167,11 +167,11 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title with trailing number, Absolute Episode Number and hash - new Regex(@"^(?<title>[^-]+?)(?:(?<![-_. ]|\b[0]\d+) - )(?:[-_. ]?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])(?:$|\.mkv)", + new Regex(@"^(?<title>[^-]+?)(?:(?<![-_. ]|\b[0]\d+) - )(?:[-_. ]?(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>[(\[]\w{8}[)\]])(?:$|\.mkv)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title Absolute Episode Number [Hash] - new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?[-_. ]+.*?(?<hash>\[\w{8}\])(?:$|\.)", + new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?[-_. ]+.*?(?<hash>[(\[]\w{8}[)\]])$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Episodes with airdate AND season/episode number, capture season/episode only @@ -358,7 +358,7 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title Absolute Episode Number (E195 or E1206) - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:_|-|\s|\.)+(?:e|ep)(?<absoluteepisode>(\d{3}|\d{4})(\.\d{1,2})?))+[-_. ].*?(?<hash>\[\w{8}\])?(?:$|\.)", + new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:_|-|\s|\.)+(?:e|ep)(?<absoluteepisode>(\d{3}|\d{4})(\.\d{1,2})?))+[-_. ].*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Supports 1103/1113 naming @@ -386,27 +386,27 @@ namespace NzbDrone.Core.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime Range - Title Absolute Episode Number (ep01-12) - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:_|\s|\.)+(?:e|ep)(?<absoluteepisode>\d{2,3}(\.\d{1,2})?)-(?<absoluteepisode>(?<!\d+)\d{1,2}(\.\d{1,2})?(?!\d+|-)).*?(?<hash>\[\w{8}\])?(?:$|\.)", + new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:_|\s|\.)+(?:e|ep)(?<absoluteepisode>\d{2,3}(\.\d{1,2})?)-(?<absoluteepisode>(?<!\d+)\d{1,2}(\.\d{1,2})?(?!\d+|-)).*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title Absolute Episode Number (e66) - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:_|-|\s|\.)+(?:e|ep)(?<absoluteepisode>\d{2,4}(\.\d{1,2})?))+[-_. ].*?(?<hash>\[\w{8}\])?(?:$|\.)", + new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:_|-|\s|\.)+(?:e|ep)(?<absoluteepisode>\d{2,4}(\.\d{1,2})?))+[-_. ].*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title Episode Absolute Episode Number (Series Title Episode 01) - new Regex(@"^(?<title>.+?)[-_. ](?:Episode)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+)))+(?:_|-|\s|\.)*?(?<hash>\[.{8}\])?(?:$|\.)?", + new Regex(@"^(?<title>.+?)[-_. ](?:Episode)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+)))+.*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime Range - Title Absolute Episode Number (1 or 2 digit absolute episode numbers in a range, 1-10) - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)[_. ]+(?<absoluteepisode>(?<!\d+)\d{1,2}(\.\d{1,2})?(?!\d+))-(?<absoluteepisode>(?<!\d+)\d{1,2}(\.\d{1,2})?(?!\d+|-))(?:_|\s|\.)*?(?<hash>\[.{8}\])?(?:$|\.)?", + new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)[_. ]+(?<absoluteepisode>(?<!\d+)\d{1,2}(\.\d{1,2})?(?!\d+))-(?<absoluteepisode>(?<!\d+)\d{1,2}(\.\d{1,2})?(?!\d+|-)).*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title Absolute Episode Number - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,4}(\.\d{1,2})?(?!\d+|[ip])))+(?:_|-|\s|\.)*?(?<hash>\[.{8}\])?(?:$|\.)?", + new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,4}(\.\d{1,2})?(?!\d+|[ip])))+.*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Anime - Title {Absolute Episode Number} - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+|[ip])))+(?:_|-|\s|\.)*?(?<hash>\[.{8}\])?(?:$|\.)?", + new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+(?<absoluteepisode>(?<!\d+)\d{2,3}(\.\d{1,2})?(?!\d+|[ip])))+.*?(?<hash>[(\[]\w{8}[)\]])?$", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Extant, terrible multi-episode naming (extant.10708.hdtv-lol.mp4) @@ -492,7 +492,7 @@ namespace NzbDrone.Core.Parser private static readonly Regex FileExtensionRegex = new Regex(@"\.[a-z0-9]{2,4}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly RegexReplace SimpleTitleRegex = new RegexReplace(@"(?:(480|540|576|720|1080|2160)[ip]|[xh][\W_]?26[45]|DD\W?5\W1|[<>?*]|848x480|1280x720|1920x1080|3840x2160|4096x2160|(8|10)b(it)?|10-bit)\s*?", + private static readonly RegexReplace SimpleTitleRegex = new RegexReplace(@"(?:(480|540|576|720|1080|2160)[ip]|[xh][\W_]?26[45]|DD\W?5\W1|[<>?*]|848x480|1280x720|1920x1080|3840x2160|4096x2160|(?<![a-f0-9])(8|10)(b(?![a-z0-9])|bit)|10-bit)\s*?", string.Empty, RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -1197,7 +1197,7 @@ namespace NzbDrone.Core.Parser if (hash.Success) { - var hashValue = hash.Value.Trim('[', ']'); + var hashValue = hash.Value.Trim('[', ']', '(', ')'); if (hashValue.Equals("1280x720")) {