Optimize anime identity

This commit is contained in:
cxfksword 2023-02-21 18:44:34 +08:00
parent b46ab340f1
commit 702fac4596
27 changed files with 4059 additions and 254 deletions

View File

@ -13,6 +13,7 @@ using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Model.Serialization;
using Microsoft.AspNetCore.Http;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.MetaShark.Test
{
@ -29,9 +30,38 @@ namespace Jellyfin.Plugin.MetaShark.Test
}));
[TestMethod]
public void TestGetMetadata()
{
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
Task.Run(async () =>
{
var info = new EpisodeInfo()
{
Name = "Spice and Wolf",
Path = "/test/Spice and Wolf/Spice and Wolf II/[VCB-Studio] Spice and Wolf II [01][Ma10p_1080p][x265_flac].mp4",
MetadataLanguage = "zh",
SeriesProviderIds = new Dictionary<string, string>() { { MetadataProvider.Tmdb.ToString(), "26707" } },
IsAutomated = false,
};
var provider = new EpisodeProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi);
var result = await provider.GetMetadata(info, CancellationToken.None);
Assert.IsNotNull(result);
var str = result.ToJson();
Console.WriteLine(result.ToJson());
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGuessEpisodeNumber()
public void TestFixParseInfo()
{
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
@ -42,18 +72,18 @@ namespace Jellyfin.Plugin.MetaShark.Test
var omdbApi = new OmdbApi(loggerFactory);
var provider = new EpisodeProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi);
var guessInfo = provider.GuessEpisodeNumber("[POPGO][Stand_Alone_Complex][05][1080P][BluRay][x264_FLACx2_AC3x1][chs_jpn][D87C36B6].mkv");
Assert.AreEqual(guessInfo.episodeNumber, 5);
var parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/[POPGO][Stand_Alone_Complex][05][1080P][BluRay][x264_FLACx2_AC3x1][chs_jpn][D87C36B6].mkv" });
Assert.AreEqual(parseResult.IndexNumber, 5);
guessInfo = provider.GuessEpisodeNumber("Fullmetal Alchemist Brotherhood.E05.1920X1080");
Assert.AreEqual(guessInfo.episodeNumber, 5);
parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/Fullmetal Alchemist Brotherhood.E05.1920X1080" });
Assert.AreEqual(parseResult.IndexNumber, 5);
guessInfo = provider.GuessEpisodeNumber("[SAIO-Raws] Neon Genesis Evangelion 05 [BD 1440x1080 HEVC-10bit OPUSx2 ASSx2].mkv");
Assert.AreEqual(guessInfo.episodeNumber, 5);
parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/[SAIO-Raws] Neon Genesis Evangelion 05 [BD 1440x1080 HEVC-10bit OPUSx2 ASSx2].mkv" });
Assert.AreEqual(parseResult.IndexNumber, 5);
guessInfo = provider.GuessEpisodeNumber("[Moozzi2] Samurai Champloo [SP03] Battlecry (Opening) PV (BD 1920x1080 x.264 AC3).mkv");
Assert.AreEqual(guessInfo.episodeNumber, 3);
Assert.AreEqual(guessInfo.seasonNumber, 0);
parseResult = provider.FixParseInfo(new EpisodeInfo() { Path = "/test/[Moozzi2] Samurai Champloo [SP03] Battlecry (Opening) PV (BD 1920x1080 x.264 AC3).mkv" });
Assert.AreEqual(parseResult.IndexNumber, 3);
Assert.AreEqual(parseResult.ParentIndexNumber, 0);
}
}

View File

@ -31,95 +31,126 @@ namespace Jellyfin.Plugin.MetaShark.Test
// 混合中英文
var fileName = "新世界.New.World.2013.BluRay.1080p.x265.10bit.MNHD-FRDS";
var parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, "新世界");
Assert.AreEqual(parseResult.Name, "New World");
Assert.AreEqual(parseResult.Year, 2013);
fileName = "V字仇杀队.V.for.Vendetta.2006";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, "V字仇杀队");
Assert.AreEqual(parseResult.Name, "V for Vendetta");
Assert.AreEqual(parseResult.Year, 2006);
fileName = "罗马假日.Roman.Holiday.1953.WEB-DL.1080p.x265.AAC.2Audios.GREENOTEA";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, "罗马假日");
Assert.AreEqual(parseResult.Name, "Roman Holiday");
Assert.AreEqual(parseResult.Year, 1953);
// 只英文
fileName = "A.Chinese.Odyssey.Part.1.1995.BluRay.1080p.x265.10bit.2Audio-MiniHD";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "A Chinese Odyssey Part 1");
Assert.AreEqual(parseResult.Year, 1995);
fileName = "New.World.2013.BluRay.1080p.x265.10bit.MNHD-FRDS";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "New World");
Assert.AreEqual(parseResult.Year, 2013);
fileName = "Who.Am.I.1998.1080p.BluRay.x264.DTS-FGT";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "Who Am I");
Assert.AreEqual(parseResult.Year, 1998);
// 只中文
fileName = "机动战士高达 逆袭的夏亚";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "机动战士高达 逆袭的夏亚");
Assert.AreEqual(parseResult.Year, null);
fileName = "秒速5厘米";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "秒速5厘米");
Assert.AreEqual(parseResult.Year, null);
// 标题加年份
fileName = "V字仇杀队 (2006)";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, null);
Assert.AreEqual(parseResult.Name, "V字仇杀队");
Assert.AreEqual(parseResult.Year, 2006);
// anime
fileName = "[SAIO-Raws] もののけ姫 Mononoke Hime [BD 1920x1036 HEVC-10bit OPUSx2 AC3].mp4";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, "もののけ姫");
Assert.AreEqual(parseResult.Name, "Mononoke Hime");
Assert.AreEqual(parseResult.Year, null);
}
[TestMethod]
public void TestTVSeriesParse()
{
// 混合中英文
var fileName = "新世界.New.World.2013.BluRay.1080p.x265.10bit.MNHD-FRDS";
var fileName = "航海王:狂热行动.One.Piece.Stampede.2019.BD720P.X264.AAC.Japanese&Mandarin.CHS.Mp4Ba";
var parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, "航海王:狂热行动");
Assert.AreEqual(parseResult.Name, "One Piece Stampede");
Assert.AreEqual(parseResult.Year, 2019);
// 混合中英文带副标题
fileName = "航海王:狂热行动.One.Piece.Stampede.2019.BD720P.X264.AAC.Japanese&Mandarin.CHS.Mp4Ba";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
// 只英文
fileName = "She-Hulk.Attorney.at.Law.S01.1080p.WEBRip.x265-RARBG";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "She-Hulk Attorney at Law");
Assert.AreEqual(parseResult.ParentIndexNumber, 1);
Assert.AreEqual(parseResult.Year, null);
fileName = "Bright.Future.S01.2022.2160p.HDR.WEB-DL.H265.AAC-BlackTV[BTBTT]";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "Bright Future");
Assert.AreEqual(parseResult.ParentIndexNumber, 1);
Assert.AreEqual(parseResult.Year, 2022);
fileName = "Back.to.the.Future.Part.II.1989.BluRay.1080p.x265.10bit.2Audio-MiniHD[BTBTT]";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "Back to the Future Part II");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.Year, 1989);
// anime混合中日文
fileName = "[异域-11番小队][罗马浴场 THERMAE_ROMAE][1-6+SP][BDRIP][720P][X264-10bit_AAC]";
var anitomyResult = AnitomySharp.AnitomySharp.Parse(fileName);
Console.WriteLine(anitomyResult.ToJson());
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, "罗马浴场");
Assert.AreEqual(parseResult.Name, "THERMAE ROMAE");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.Year, null);
// anime
fileName = "[Nekomoe kissaten][Shin Ikkitousen][01-03][720p][CHT]";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "Shin Ikkitousen");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.Year, null);
fileName = "[SAIO-Raws] Fullmetal Alchemist Brotherhood [BD 1920x1080 HEVC-10bit OPUS][2009]";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "Fullmetal Alchemist Brotherhood");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.Year, 2009);
}
[TestMethod]
@ -128,53 +159,68 @@ namespace Jellyfin.Plugin.MetaShark.Test
// 混合中英文
var fileName = "新世界.New.World.2013.BluRay.1080p.x265.10bit.MNHD-FRDS";
var parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.ChineseName, "新世界");
Assert.AreEqual(parseResult.Name, "New World");
Assert.AreEqual(parseResult.Year, 2013);
// 只英文
fileName = "She-Hulk.Attorney.At.Law.S01E01.1080p.WEBRip.x265-RARBG";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "She-Hulk Attorney At Law");
Assert.AreEqual(parseResult.ParentIndexNumber, 1);
Assert.AreEqual(parseResult.IndexNumber, 1);
// anime
fileName = "[YYDM-11FANS][THERMAE_ROMAE][02][BDRIP][720P][X264-10bit_AAC][7FF2269F]";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "THERMAE ROMAE");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 2);
// anime带季数
fileName = "[WMSUB][Detective Conan - Zeros Tea Time ][S01][E06][BIG5][1080P].mp4";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "Detective Conan - Zeros Tea Time");
Assert.AreEqual(parseResult.ParentIndexNumber, 1);
Assert.AreEqual(parseResult.IndexNumber, 6);
fileName = "[KTXP][Machikado_Mazoku_S2][01][BIG5][1080p]";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "Machikado Mazoku");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, 1);
// anime特典
fileName = "[KissSub][Steins;Gate][SP][GB_BIG5_JP][BDrip][1080P][HEVC] 边界曲面的缺失之环";
parseResult = NameParser.Parse(fileName);
Console.WriteLine(parseResult.ToJson());
Assert.AreEqual(parseResult.Name, "边界曲面的缺失之环");
Assert.AreEqual(parseResult.ParentIndexNumber, null);
Assert.AreEqual(parseResult.IndexNumber, null);
}
[TestMethod]
public void TestCheckExtra()
{
var name = "[VCB-Studio] Spice and Wolf [CM02][Ma10p_1080p][x265_flac]";
var result = NameParser.IsExtra(name);
Console.WriteLine(result);
var fileName = "[VCB-Studio] Spice and Wolf [CM02][Ma10p_1080p][x265_flac]";
var parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
name = "[VCB-Studio] Spice and Wolf [Menu01_2][Ma10p_1080p][x265_flac]";
result = NameParser.IsExtra(name);
Console.WriteLine(result);
fileName = "[VCB-Studio] Spice and Wolf [Menu01_2][Ma10p_1080p][x265_flac]";
parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
fileName = "[VCB-Studio] Spice and Wolf [NCED][Ma10p_1080p][x265_flac]";
parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
fileName = "[VCB-Studio] Spice and Wolf [NCOP][Ma10p_1080p][x265_flac]";
parseResult = NameParser.Parse(fileName);
Assert.IsTrue(parseResult.IsExtra);
name = "[VCB-Studio] Spice and Wolf [NCED][Ma10p_1080p][x265_flac]";
result = NameParser.IsExtra(name);
Console.WriteLine(result);
name = "[VCB-Studio] Spice and Wolf [NCOP][Ma10p_1080p][x265_flac]";
result = NameParser.IsExtra(name);
Console.WriteLine(result);
}
}

View File

@ -51,5 +51,36 @@ namespace Jellyfin.Plugin.MetaShark.Test
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGuessSeasonNumberByFileName()
{
var info = new SeasonInfo() { };
var doubanApi = new DoubanApi(loggerFactory);
var tmdbApi = new TmdbApi(loggerFactory);
var omdbApi = new OmdbApi(loggerFactory);
var httpClientFactory = new DefaultHttpClientFactory();
var libraryManagerStub = new Mock<ILibraryManager>();
var httpContextAccessorStub = new Mock<IHttpContextAccessor>();
var provider = new SeasonProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi);
var result = provider.GuessSeasonNumberByFileName("/data/downloads/jellyfin/tv/向往的生活/第2季");
Assert.AreEqual(result, 2);
result = provider.GuessSeasonNumberByFileName("/data/downloads/jellyfin/tv/向往的生活 第2季");
Assert.AreEqual(result, 2);
result = provider.GuessSeasonNumberByFileName("/data/downloads/jellyfin/tv/向往的生活/第三季");
Assert.AreEqual(result, 3);
result = provider.GuessSeasonNumberByFileName("/data/downloads/jellyfin/tv/攻壳机动队Ghost_in_The_Shell_S.A.C._2nd_GIG");
Assert.AreEqual(result, 2);
result = provider.GuessSeasonNumberByFileName("/data/downloads/jellyfin/tv/Spice and Wolf/Spice and Wolf 2");
Assert.AreEqual(result, 2);
result = provider.GuessSeasonNumberByFileName("/data/downloads/jellyfin/tv/Spice and Wolf/Spice and Wolf 2 test");
Assert.AreEqual(result, null);
}
}
}

View File

@ -96,15 +96,14 @@ namespace Jellyfin.Plugin.MetaShark.Test
[TestMethod]
public void TestSearch()
{
var keyword = "重返少年时";
var keyword = "狼与香辛料";
var api = new TmdbApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await api.SearchSeriesAsync(keyword, "zh", CancellationToken.None)
.ConfigureAwait(false);
var result = await api.SearchSeriesAsync(keyword, "zh", CancellationToken.None).ConfigureAwait(false);
Assert.IsNotNull(result);
TestContext.WriteLine(result.ToJson());
}

View File

@ -63,7 +63,7 @@ namespace Jellyfin.Plugin.MetaShark.Api
Regex regDuration = new Regex(@"片长: (.+?)\n", RegexOptions.Compiled);
Regex regScreen = new Regex(@"(上映日期|首播): (.+?)\n", RegexOptions.Compiled);
Regex regSubname = new Regex(@"又名: (.+?)\n", RegexOptions.Compiled);
Regex regImdb = new Regex(@"IMDb: (tt\d+)", RegexOptions.Compiled);
Regex regImdb = new Regex(@"IMDb: (tt\d+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
Regex regSite = new Regex(@"官方网站: (.+?)\n", RegexOptions.Compiled);
Regex regNameMath = new Regex(@"(.+第\w季|[\w\uff1a\uff01\uff0c\u00b7]+)\s*(.*)", RegexOptions.Compiled);
Regex regRole = new Regex(@"\([饰|配] (.+?)\)", RegexOptions.Compiled);

View File

@ -16,12 +16,13 @@ namespace Jellyfin.Plugin.MetaShark.Core
private static readonly Regex unusedReg = new Regex(@"\[.+?\]|\(.+?\)|【.+?】", RegexOptions.Compiled);
private static readonly Regex extrasReg = new Regex(@"\[(OP|ED|PV|CM|Menu|NCED|NCOP|Drama|PreView)[0-9_]*?\]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex fixSeasonNumberReg = new Regex(@"(\[|\.)S(\d{1,2})(\]|\.)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static ParseNameResult Parse(string fileName, bool isTvSeries = false)
{
var parseResult = new ParseNameResult();
var anitomyResult = AnitomySharp.AnitomySharp.Parse(fileName);
var isAnime = IsAnime(fileName);
foreach (var item in anitomyResult)
{
switch (item.Category)
@ -49,6 +50,16 @@ namespace Jellyfin.Plugin.MetaShark.Core
parseResult.Name = CleanName(item.Value);
}
break;
case AnitomySharp.Element.ElementCategory.ElementEpisodeTitle:
parseResult.EpisodeName = item.Value;
break;
case AnitomySharp.Element.ElementCategory.ElementAnimeSeason:
var seasonNumber = item.Value.ToInt();
if (seasonNumber > 0)
{
parseResult.ParentIndexNumber = seasonNumber;
}
break;
case AnitomySharp.Element.ElementCategory.ElementEpisodeNumber:
var year = ParseYear(item.Value);
if (year > 0)
@ -57,18 +68,15 @@ namespace Jellyfin.Plugin.MetaShark.Core
}
else
{
var indexNumber = item.Value.ToInt();
if (indexNumber > 0)
var episodeNumber = item.Value.ToInt();
if (episodeNumber > 0)
{
parseResult.IndexNumber = item.Value.ToInt();
parseResult.IndexNumber = episodeNumber;
}
}
break;
case AnitomySharp.Element.ElementCategory.ElementAnimeType:
if (item.Value == "SP")
{
parseResult.IsSpecial = true;
}
parseResult.AnimeType = item.Value;
break;
case AnitomySharp.Element.ElementCategory.ElementAnimeYear:
parseResult.Year = item.Value.ToInt();
@ -78,8 +86,18 @@ namespace Jellyfin.Plugin.MetaShark.Core
}
}
// 修正动画季信息特殊情况,格式:[SXX]
if (!parseResult.ParentIndexNumber.HasValue && isAnime)
{
var match = fixSeasonNumberReg.Match(fileName);
if (match.Success && match.Groups.Count > 2)
{
parseResult.ParentIndexNumber = match.Groups[2].Value.ToInt();
}
}
// 假如Anitomy解析不到year尝试使用jellyfin默认parser看能不能解析成功
if (parseResult.Year == null && !IsAnime(fileName))
if (parseResult.Year == null && !isAnime)
{
var nativeParseResult = ParseMovie(fileName);
if (nativeParseResult.Year != null)
@ -139,7 +157,7 @@ namespace Jellyfin.Plugin.MetaShark.Core
return 0;
}
public static bool IsSpecial(string path)
public static bool IsSpecialDirectory(string path)
{
var fileName = Path.GetFileNameWithoutExtension(path) ?? string.Empty;
if (IsAnime(fileName))
@ -150,18 +168,12 @@ namespace Jellyfin.Plugin.MetaShark.Core
}
string folder = Path.GetFileName(Path.GetDirectoryName(path)) ?? string.Empty;
return folder == "SPs" && !extrasReg.IsMatch(fileName);
return folder == "SPs";
}
return false;
}
public static bool IsExtra(string name)
{
return IsAnime(name) && extrasReg.IsMatch(name);
}
// 判断是否为动漫
// https://github.com/jxxghp/nas-tools/blob/f549c924558fd49e183333285bc6a804af1a2cb7/app/media/meta/metainfo.py#L51

View File

@ -15,5 +15,26 @@ namespace Jellyfin.Plugin.MetaShark.Core
dateTime = dateTime.AddSeconds(unixTimeStamp).ToLocalTime();
return dateTime;
}
/// <summary>
/// 转换数字
/// </summary>
public static int? ChineseNumberToInt(string str)
{
switch (str)
{
case "一": return 1;
case "二": return 2;
case "三": return 3;
case "四": return 4;
case "五": return 5;
case "六": return 6;
case "七": return 7;
case "八": return 8;
case "九": return 9;
case "零": return 0;
default: return null;
}
}
}
}

View File

@ -14,10 +14,10 @@
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="0.17.1" />
<PackageReference Include="AnitomySharp" Version="0.2.0" />
<PackageReference Include="AngleSharp" Version="1.0.1" />
<PackageReference Include="Jellyfin.Controller" Version="10.8.0" />
<PackageReference Include="Jellyfin.Model" Version="10.8.0" />
<PackageReference Include="Newtonsoft.Json" Version="9.0.1" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />

View File

@ -10,8 +10,68 @@ namespace Jellyfin.Plugin.MetaShark.Model
{
public class ParseNameResult : ItemLookupInfo
{
public string ChineseName { get; set; }
public string? ChineseName { get; set; } = null;
public bool IsSpecial { get; set; }
/// <summary>
/// 可能会解析不对最好只在动画SP中才使用
/// </summary>
public string? EpisodeName { get; set; } = null;
private string _animeType = string.Empty;
public string AnimeType
{
get
{
return _animeType.ToUpper();
}
set
{
_animeType = value;
}
}
public bool IsSpecial
{
get
{
return !string.IsNullOrEmpty(AnimeType) && AnimeType.ToUpper() == "SP";
}
}
public bool IsExtra
{
get
{
return !string.IsNullOrEmpty(AnimeType) && AnimeType.ToUpper() != "SP";
}
}
public string? PaddingZeroIndexNumber
{
get
{
if (!IndexNumber.HasValue)
{
return null;
}
return $"{IndexNumber:00}";
}
}
public string ExtraName
{
get
{
if (IndexNumber.HasValue)
{
return $"{Name} {AnimeType} {PaddingZeroIndexNumber}";
}
else
{
return $"{Name} {AnimeType}";
}
}
}
}
}

View File

@ -106,9 +106,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
protected async Task<string?> GuessByDoubanAsync(ItemLookupInfo info, CancellationToken cancellationToken)
{
// ParseName is required here.
// Caller provides the filename with extension stripped and NOT the parsed filename
var fileName = GetNotParsedName(info);
var fileName = GetOriginalFileName(info);
var parseResult = NameParser.Parse(fileName);
var searchName = !string.IsNullOrEmpty(parseResult.ChineseName) ? parseResult.ChineseName : parseResult.Name;
info.Year = parseResult.Year; // 默认parser对anime年份会解析出错以anitomy为准
@ -154,7 +152,15 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
}
// 不存在年份时,返回第一个
//// 不存在年份计算相似度返回相似度大于0.8的第一个(可能出现冷门资源名称更相同的情况。。。)
// var jw = new JaroWinkler();
// item = result.Where(x => x.Category == cat && x.Rating > 5).OrderByDescending(x => Math.Max(jw.Similarity(searchName, x.Name), jw.Similarity(searchName, x.OriginalName))).FirstOrDefault();
// if (item != null && Math.Max(jw.Similarity(searchName, item.Name), jw.Similarity(searchName, item.OriginalName)) > 0.8)
// {
// return item.Sid;
// }
// 不存在年份时,返回豆瓣结果第一个
item = result.Where(x => x.Category == cat).FirstOrDefault();
if (item != null)
{
@ -209,7 +215,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
protected async Task<string?> GuestByTmdbAsync(string name, int? year, ItemLookupInfo info, CancellationToken cancellationToken)
{
var fileName = GetNotParsedName(info);
var fileName = GetOriginalFileName(info);
this.Log($"GuestByTmdb of [name]: {name} [year]: {year}");
switch (info)
@ -417,15 +423,16 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return language;
}
protected string GetNotParsedName(ItemLookupInfo info)
protected string GetOriginalFileName(ItemLookupInfo info)
{
var directoryName = Path.GetFileName(Path.GetDirectoryName(info.Path));
if (directoryName != null && directoryName.StartsWith(info.Name))
switch (info)
{
return directoryName;
case SeriesInfo:
case SeasonInfo:
return Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
default:
return Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
}
return Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
}
protected string RemoveSeasonSubfix(string name)

View File

@ -72,48 +72,20 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return specialResult;
}
// 使用AnitomySharp进行重新解析解决anime识别错误
info = this.FixParseInfo(info);
// 剧集信息只有tmdb有
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out var seriesTmdbId);
var seasonNumber = info.ParentIndexNumber;
var episodeNumber = info.IndexNumber;
var indexNumberEnd = info.IndexNumberEnd;
// 修正anime命名格式导致的seasonNumber错误从season元数据读取)
var episodeItem = _libraryManager.FindByPath(info.Path, false);
var season = episodeItem != null ? ((Episode)episodeItem).Season : null;
if (season != null && seasonNumber != season.IndexNumber)
result.HasMetadata = true;
result.Item = new Episode
{
this.Log("FixSeasionNumber: old: {0} new: {1}", seasonNumber, season.IndexNumber);
seasonNumber = season.IndexNumber;
}
// 没有season级目录或目录不命名不规范时会为null
if (seasonNumber is null)
{
this.Log("FixSeasionNumber: season number is null, set to default 1");
seasonNumber = 1;
}
// 修正anime命名格式导致的episodeNumber错误
var fileName = Path.GetFileNameWithoutExtension(info.Path) ?? string.Empty;
var guessInfo = this.GuessEpisodeNumber(fileName);
this.Log("GuessEpisodeNumber: fileName: {0} seasonNumber: {1} episodeNumber: {2} name: {3}", fileName, guessInfo.seasonNumber, guessInfo.episodeNumber, guessInfo.Name);
if (guessInfo.seasonNumber.HasValue && guessInfo.seasonNumber != seasonNumber)
{
seasonNumber = guessInfo.seasonNumber.Value;
}
if (guessInfo.episodeNumber.HasValue)
{
episodeNumber = guessInfo.episodeNumber;
result.HasMetadata = true;
result.Item = new Episode
{
ParentIndexNumber = seasonNumber,
IndexNumber = episodeNumber
};
if (!string.IsNullOrEmpty(guessInfo.Name))
{
result.Item.Name = guessInfo.Name;
}
}
ParentIndexNumber = seasonNumber,
IndexNumber = episodeNumber,
Name = info.Name,
};
if (episodeNumber is null or 0 || seasonNumber is null or 0 || string.IsNullOrEmpty(seriesTmdbId))
{
@ -131,7 +103,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return result;
}
// 自动搜索匹配时判断tmdb剧集信息数目和视频是否一致不一致不处理
// TODO自动搜索匹配或识别判断tmdb剧集信息数目和视频是否一致不一致不处理现在通过IsAutomated判断不太准确
if (info.IsAutomated)
{
var videoFilesCount = this.GetVideoFileCount(Path.GetDirectoryName(info.Path));
@ -157,7 +129,6 @@ namespace Jellyfin.Plugin.MetaShark.Providers
{
IndexNumber = episodeNumber,
ParentIndexNumber = seasonNumber,
IndexNumberEnd = info.IndexNumberEnd
};
@ -172,86 +143,99 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
public EpisodeInfo FixParseInfo(EpisodeInfo info)
{
this.Log("GetImageResponse url: {0}", url);
return _httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken);
}
// 使用AnitomySharp进行重新解析解决anime识别错误
var fileName = Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
var parseResult = NameParser.Parse(fileName);
info.Name = parseResult.Name;
info.Year = parseResult.Year;
public GuessInfo GuessEpisodeNumber(string fileName, double max = double.PositiveInfinity)
{
var guessInfo = new GuessInfo();
var parseResult = AnitomySharp.AnitomySharp.Parse(fileName);
var animeSpecialType = parseResult.FirstOrDefault(x => x.Category == AnitomySharp.Element.ElementCategory.ElementAnimeType && x.Value == "SP");
if (animeSpecialType != null)
// 没有season级目录或文件命名不规范时ParentIndexNumber会为null
if (info.ParentIndexNumber is null)
{
guessInfo.seasonNumber = 0;
}
var animeEpisode = parseResult.FirstOrDefault(x => x.Category == AnitomySharp.Element.ElementCategory.ElementEpisodeNumber);
if (animeEpisode != null)
{
guessInfo.episodeNumber = animeEpisode.Value.ToInt();
info.ParentIndexNumber = parseResult.ParentIndexNumber;
}
if (!guessInfo.episodeNumber.HasValue)
// 修正anime命名格式导致的seasonNumber错误从season元数据读取)
if (info.ParentIndexNumber is null)
{
foreach (var regex in EpisodeFileNameRegex)
var episodeItem = _libraryManager.FindByPath(info.Path, false);
var season = episodeItem != null ? ((Episode)episodeItem).Season : null;
if (season != null && info.ParentIndexNumber != season.IndexNumber)
{
if (!regex.IsMatch(fileName))
continue;
if (!int.TryParse(regex.Match(fileName).Groups[1].Value.Trim('.'), out var index))
continue;
guessInfo.episodeNumber = index;
break;
this.Log("FixSeasonNumber: old: {0} new: {1}", info.ParentIndexNumber, season.IndexNumber);
info.ParentIndexNumber = season.IndexNumber;
}
// 当没有season级目录时默认为1即当成只有一季
if (info.ParentIndexNumber is null && season != null && season.LocationType == LocationType.Virtual)
{
this.Log("FixSeasonNumber: season is virtual, set to default 1");
info.ParentIndexNumber = 1;
}
}
if (guessInfo.episodeNumber > 1000)
if (NameParser.IsAnime(fileName))
{
// 可能解析了分辨率,忽略返回
guessInfo.episodeNumber = null;
// 特典
if (parseResult.IsSpecial)
{
info.ParentIndexNumber = 0;
}
// 动画的OP/ED/MENU等
if (parseResult.IsExtra)
{
info.ParentIndexNumber = null;
}
}
var animeName = parseResult.FirstOrDefault(x => x.Category == AnitomySharp.Element.ElementCategory.ElementAnimeTitle);
if (animeName != null && NameParser.IsAnime(fileName))
// 大于1000可能错误解析了分辨率
if (parseResult.IndexNumber.HasValue && parseResult.IndexNumber < 1000)
{
guessInfo.Name = animeName.Value;
info.IndexNumber = parseResult.IndexNumber;
}
return guessInfo;
this.Log("FixParseInfo: fileName: {0} seasonNumber: {1} episodeNumber: {2} name: {3}", fileName, info.ParentIndexNumber, info.IndexNumber, info.Name);
return info;
}
private MetadataResult<Episode>? HandleAnimeSpecialAndExtras(EpisodeInfo info)
{
var fileName = Path.GetFileNameWithoutExtension(info.Path) ?? string.Empty;
if (NameParser.IsExtra(fileName))
// 特典或extra视频可能和正片剧集放在同一目录
var fileName = Path.GetFileNameWithoutExtension(info.Path) ?? info.Name;
var parseResult = NameParser.Parse(fileName);
if (parseResult.IsExtra)
{
this.Log($"Found anime extra of [name]: {fileName}");
var result = new MetadataResult<Episode>();
result.HasMetadata = true;
// 假如已有ParentIndexNumber设为特典覆盖掉
// 假如已有ParentIndexNumber设为特典覆盖掉设为null不会替换旧值
if (info.ParentIndexNumber.HasValue)
{
result.Item = new Episode
{
ParentIndexNumber = 0,
IndexNumber = null,
Name = fileName
Name = parseResult.ExtraName
};
return result;
}
// 没ParentIndexNumber时使用文件名
// 没ParentIndexNumber时只修改名称
result.Item = new Episode
{
Name = fileName
Name = parseResult.ExtraName
};
return result;
}
if (NameParser.IsSpecial(info.Path))
if (parseResult.IsSpecial || NameParser.IsSpecialDirectory(info.Path))
{
this.Log($"Found anime sp of [name]: {fileName}");
var result = new MetadataResult<Episode>();
@ -259,8 +243,8 @@ namespace Jellyfin.Plugin.MetaShark.Providers
result.Item = new Episode
{
ParentIndexNumber = 0,
IndexNumber = null,
Name = fileName
IndexNumber = parseResult.IndexNumber,
Name = parseResult.EpisodeName ?? parseResult.Name,
};
return result;
@ -285,6 +269,10 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
var dirInfo = new DirectoryInfo(dir);
if (dirInfo == null)
{
return 0;
}
var files = dirInfo.GetFiles();
var nameOptions = new Emby.Naming.Common.NamingOptions();
@ -301,6 +289,14 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return videoFilesCount;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
this.Log("GetImageResponse url: {0}", url);
return _httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken);
}
public void Dispose()
{
Dispose(true);

View File

@ -103,10 +103,6 @@ namespace Jellyfin.Plugin.MetaShark.Providers
{
// 自动扫描搜索匹配元数据
sid = await this.GuessByDoubanAsync(info, cancellationToken).ConfigureAwait(false);
// if (string.IsNullOrEmpty(sid))
// {
// tmdbId = await this.GuestByTmdbAsync(info, cancellationToken).ConfigureAwait(false);
// }
}
if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid))

View File

@ -45,7 +45,6 @@ namespace Jellyfin.Plugin.MetaShark.Providers
/// <inheritdoc />
public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Season>();
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out var seriesTmdbId);
@ -57,35 +56,18 @@ namespace Jellyfin.Plugin.MetaShark.Providers
if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid))
{
// 从sereis获取正确名称info.Name当是标准格式如S01等时会变成第x季非标准名称没法识别时默认文件名
var series = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false);
if (series == null)
// 季文件夹名称不规范没法拿到seasonNumber尝试从文件名猜出
if (seasonNumber is null)
{
return result;
seasonNumber = this.GuessSeasonNumberByFileName(info.Path);
}
var seriesName = RemoveSeasonSubfix(series.Name);
// TODO:季文件夹名称不规范没法拿到seasonNumber尝试从文件名猜出
// 没有季id但存在tmdbid尝试从tmdb获取对应季的年份信息用于从豆瓣搜索对应季数据
// 搜索豆瓣季id
if (string.IsNullOrEmpty(seasonSid))
{
var seasonYear = 0;
if (!string.IsNullOrEmpty(seriesTmdbId) && (seasonNumber.HasValue && seasonNumber > 0))
{
var season = await this._tmdbApi
.GetSeasonAsync(seriesTmdbId.ToInt(), seasonNumber.Value, info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
seasonYear = season?.AirDate?.Year ?? 0;
}
if (!string.IsNullOrEmpty(seriesName) && seasonYear > 0)
{
seasonSid = await this.GuestDoubanSeasonByYearAsync(seriesName, seasonYear, cancellationToken).ConfigureAwait(false);
}
seasonSid = await this.GuessDoubanSeasonId(sid, seriesTmdbId, seasonNumber, info, cancellationToken).ConfigureAwait(false);
}
// 获取季豆瓣数据
if (!string.IsNullOrEmpty(seasonSid))
{
@ -104,7 +86,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
ProductionYear = subject.Year,
Genres = subject.Genres,
PremiereDate = subject.ScreenTime, // 发行日期
IndexNumber = info.IndexNumber,
IndexNumber = seasonNumber,
};
result.Item = movie;
@ -127,7 +109,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
// tmdb有数据豆瓣找不到尝试获取tmdb的季数据
// 豆瓣找不到季数据尝试获取tmdb的季数据
if (string.IsNullOrEmpty(seasonSid) && !string.IsNullOrWhiteSpace(seriesTmdbId) && (seasonNumber.HasValue && seasonNumber > 0))
{
var tmdbResult = await this.GetMetadataByTmdb(info, seriesTmdbId, seasonNumber.Value, cancellationToken).ConfigureAwait(false);
@ -145,31 +127,129 @@ namespace Jellyfin.Plugin.MetaShark.Providers
// series使用TMDB元数据来源
// tmdb季级没有对应id只通过indexNumber区分
if (!string.IsNullOrWhiteSpace(seriesTmdbId) && (seasonNumber.HasValue && seasonNumber > 0))
return await this.GetMetadataByTmdb(info, seriesTmdbId, seasonNumber, cancellationToken).ConfigureAwait(false);
}
public int? GuessSeasonNumberByFileName(string path)
{
// 当没有season级目录时path为空直接返回
if (string.IsNullOrEmpty(path))
{
var tmdbResult = await this.GetMetadataByTmdb(info, seriesTmdbId, seasonNumber.Value, cancellationToken).ConfigureAwait(false);
if (tmdbResult != null)
return null;
}
// TODO: 有时series name中会带有季信息
var fileName = Path.GetFileName(path);
if (string.IsNullOrEmpty(fileName))
{
return null;
}
var regSeason = new Regex(@"第(.)(季|部)", RegexOptions.Compiled);
var match = regSeason.Match(fileName);
if (match.Success && match.Groups.Count > 1)
{
var seasonNumber = match.Groups[1].Value.ToInt();
if (seasonNumber <= 0)
{
return tmdbResult;
seasonNumber = Utils.ChineseNumberToInt(match.Groups[1].Value) ?? 0;
}
if (seasonNumber > 0)
{
this.Log($"Found season number of filename: {fileName} seasonNumber: {seasonNumber}");
return seasonNumber;
}
}
return result;
var seasonNameMap = new Dictionary<string, int>() {
{@"[ ._](I|1st)[ ._]", 1},
{@"[ ._](II|2nd)[ ._]", 2},
{@"[ ._](III|3rd)[ ._]", 3},
{@"[ ._](IIII|4th)[ ._]", 3},
};
foreach (var entry in seasonNameMap)
{
if (Regex.IsMatch(fileName, entry.Key))
{
this.Log($"Found season number of filename: {fileName} seasonNumber: {entry.Value}");
return entry.Value;
}
}
// 带数字末尾的
match = Regex.Match(fileName, @"[ ._](\d{1,2})$");
if (match.Success && match.Groups.Count > 1)
{
var seasonNumber = match.Groups[1].Value.ToInt();
if (seasonNumber > 0)
{
this.Log($"Found season number of filename: {fileName} seasonNumber: {seasonNumber}");
return seasonNumber;
}
}
return null;
}
public async Task<MetadataResult<Season>?> GetMetadataByTmdb(SeasonInfo info, string seriesTmdbId, int seasonNumber, CancellationToken cancellationToken)
public async Task<string?> GuessDoubanSeasonId(string? sid, string? seriesTmdbId, int? seasonNumber, ItemLookupInfo info, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(sid))
{
return null;
}
// 从sereis获取正确名称info.Name当是标准格式如S01等时会变成第x季非标准名称默认文件名
var series = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false);
if (series == null)
{
return null;
}
var seriesName = RemoveSeasonSubfix(series.Name);
// 没有季id但存在tmdbid尝试从tmdb获取对应季的年份信息用于从豆瓣搜索对应季数据
var seasonYear = 0;
if (!string.IsNullOrEmpty(seriesTmdbId) && (seasonNumber.HasValue && seasonNumber > 0))
{
var season = await this._tmdbApi
.GetSeasonAsync(seriesTmdbId.ToInt(), seasonNumber.Value, info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
seasonYear = season?.AirDate?.Year ?? 0;
}
if (!string.IsNullOrEmpty(seriesName) && seasonYear > 0)
{
return await this.GuestDoubanSeasonByYearAsync(seriesName, seasonYear, cancellationToken).ConfigureAwait(false);
}
return null;
}
public async Task<MetadataResult<Season>> GetMetadataByTmdb(SeasonInfo info, string? seriesTmdbId, int? seasonNumber, CancellationToken cancellationToken)
{
var result = new MetadataResult<Season>();
if (string.IsNullOrEmpty(seriesTmdbId))
{
return result;
}
if (seasonNumber is null or 0)
{
return result;
}
var seasonResult = await this._tmdbApi
.GetSeasonAsync(seriesTmdbId.ToInt(), seasonNumber, info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.GetSeasonAsync(seriesTmdbId.ToInt(), seasonNumber ?? 0, info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
if (seasonResult == null)
{
this.Log($"Not found season from TMDB. {info.Name} seriesTmdbId: {seriesTmdbId} seasonNumber: {seasonNumber}");
return null;
return result;
}
var result = new MetadataResult<Season>();
result.HasMetadata = true;
result.Item = new Season
{

View File

@ -84,7 +84,6 @@ namespace Jellyfin.Plugin.MetaShark.Providers
this.Log($"GetSeriesMetadata of [name]: {info.Name} [providerIds]: {info.ProviderIds.ToJson()} IsAutomated: {info.IsAutomated}");
var result = new MetadataResult<Series>();
// 使用刷新元数据时providerIds会保留旧有值只有识别/新增才会没值
var sid = info.GetProviderId(DoubanProviderId);
var tmdbId = info.GetProviderId(MetadataProvider.Tmdb);
var metaSource = info.GetProviderId(Plugin.ProviderId);
@ -99,7 +98,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers
if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid))
{
this.Log($"GetSeriesMetadata of douban [sid]: \"{sid}\"");
this.Log($"GetSeriesMetadata of douban [sid]: {sid}");
var subject = await this._doubanApi.GetMovieAsync(sid, cancellationToken).ConfigureAwait(false);
if (subject == null)
{
@ -123,28 +122,18 @@ namespace Jellyfin.Plugin.MetaShark.Providers
Tagline = string.Empty,
};
// 设置imdb元数据
if (!string.IsNullOrEmpty(subject.Imdb))
{
item.SetProviderId(MetadataProvider.Imdb, subject.Imdb);
// 通过imdb获取TMDB id
var newTmdbId = await this.GetTmdbIdByImdbAsync(subject.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(newTmdbId))
{
tmdbId = newTmdbId;
item.SetProviderId(MetadataProvider.Tmdb, tmdbId);
}
}
// 尝试通过搜索匹配获取tmdbId
if (string.IsNullOrEmpty(tmdbId))
// 搜索匹配tmdbId
var newTmdbId = await this.FindTmdbId(seriesName, subject.Imdb, subject.Year, info, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(newTmdbId))
{
var newTmdbId = await this.GuestByTmdbAsync(seriesName, subject.Year, info, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(newTmdbId))
{
tmdbId = newTmdbId;
item.SetProviderId(MetadataProvider.Tmdb, tmdbId);
}
tmdbId = newTmdbId;
item.SetProviderId(MetadataProvider.Tmdb, tmdbId);
}
@ -164,37 +153,68 @@ namespace Jellyfin.Plugin.MetaShark.Providers
}
if (!string.IsNullOrEmpty(tmdbId))
return await this.GetMetadataByTmdb(tmdbId, info, cancellationToken).ConfigureAwait(false);
}
private async Task<MetadataResult<Series>> GetMetadataByTmdb(string? tmdbId, ItemLookupInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Series>();
if (string.IsNullOrEmpty(tmdbId))
{
this.Log($"GetSeriesMetadata of tmdb [id]: \"{tmdbId}\"");
var tvShow = await _tmdbApi
.GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
if (tvShow == null)
{
return result;
}
result = new MetadataResult<Series>
{
Item = MapTvShowToSeries(tvShow, info.MetadataCountryCode),
ResultLanguage = info.MetadataLanguage ?? tvShow.OriginalLanguage
};
foreach (var person in GetPersons(tvShow))
{
result.AddPerson(person);
}
result.QueriedById = true;
result.HasMetadata = true;
return result;
}
this.Log($"GetSeriesMetadata of tmdb [id]: \"{tmdbId}\"");
var tvShow = await _tmdbApi
.GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, info.MetadataLanguage, cancellationToken)
.ConfigureAwait(false);
if (tvShow == null)
{
return result;
}
result = new MetadataResult<Series>
{
Item = MapTvShowToSeries(tvShow, info.MetadataCountryCode),
ResultLanguage = info.MetadataLanguage ?? tvShow.OriginalLanguage
};
foreach (var person in GetPersons(tvShow))
{
result.AddPerson(person);
}
result.QueriedById = true;
result.HasMetadata = true;
return result;
}
private async Task<string?> FindTmdbId(string name, string imdb, int? year, ItemLookupInfo info, CancellationToken cancellationToken)
{
// 通过imdb获取TMDB id
if (!string.IsNullOrEmpty(imdb))
{
var tmdbId = await this.GetTmdbIdByImdbAsync(imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(tmdbId))
{
return tmdbId;
}
}
// 尝试通过搜索匹配获取tmdbId
if (!string.IsNullOrEmpty(name) && year != null && year > 0)
{
var tmdbId = await this.GuestByTmdbAsync(name, year, info, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(tmdbId))
{
return tmdbId;
}
}
return null;
}
private Series MapTvShowToSeries(TvShow seriesResult, string preferredCountryCode)
{
var series = new Series
@ -365,9 +385,5 @@ namespace Jellyfin.Plugin.MetaShark.Providers
return this._httpClientFactory.CreateClient().GetAsync(new Uri(url), cancellationToken);
}
private void Log(string? message, params object?[] args)
{
this._logger.LogInformation($"[MetaShark] {message}", args);
}
}
}

View File

@ -0,0 +1,135 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
namespace AnitomySharp
{
/// <summary>
/// A library capable of parsing Anime filenames.
///
/// 用于解析动漫文件名的库。
///
/// This code is a C++ to C# port of <see href="https://github.com/erengy/anitomy">Anitomy</see>,
/// using the already existing Java port <see href="https://github.com/Vorror/anitomyJ">AnitomyJ</see> as a reference.
/// </summary>
public class AnitomySharp
{
/// <summary>
///
/// </summary>
private AnitomySharp() { }
/// <summary>
/// Parses an anime <paramref name="filename"/> into its consituent elements.
///
/// 将动画文件名拆分为其组成元素。
/// </summary>
/// <param name="filename">the anime file name 动画文件名</param>
/// <returns>the list of parsed elements 分解后的元素列表</returns>
public static IEnumerable<Element> Parse(string filename)
{
return Parse(filename, new Options());
}
/// <summary>
/// Parses an anime <paramref name="filename"/> into its constituent elements.
///
/// 将动画文件名拆分为其组成元素。
/// </summary>
/// <param name="filename">the anime file name 动画文件名</param>
/// <param name="options">the options to parse with, use <see cref="Parse(string)"/> to use default options</param>
/// <returns>the list of parsed elements 分解后的元素列表</returns>
/// <remarks>**逻辑:**
/// 1. 提取文件扩展名;
/// 2.
/// 3. #TODO
/// </remarks>
public static IEnumerable<Element> Parse(string filename, Options options)
{
var elements = new List<Element>(32);
var tokens = new List<Token>();
/** remove/parse extension */
var fname = filename;
if (options.ParseFileExtension)
{
var extension = "";
if (RemoveExtensionFromFilename(ref fname, ref extension))
{
/** 将文件扩展名元素加入元素列表 */
elements.Add(new Element(Element.ElementCategory.ElementFileExtension, extension));
}
}
/** set filename */
if (string.IsNullOrEmpty(filename))
{
return elements;
}
/** 将去除扩展名后的文件名加入元素列表 */
elements.Add(new Element(Element.ElementCategory.ElementFileName, fname));
/** tokenize
1.
2.
*/
var isTokenized = new Tokenizer(fname, elements, options, tokens).Tokenize();
if (!isTokenized)
{
return elements;
}
new Parser(elements, options, tokens).Parse();
// elements.ForEach(x => Console.WriteLine("\"" + x.Category + "\"" + ": " + "\"" + x.Value + "\""));
return elements;
}
/// <summary>
/// Removes the extension from the <paramref name="filename"/>
///
/// 确认扩展名有效,即在指定的<see cref="Element.ElementCategory.ElementFileExtension">文件扩展名元素类别</see>中,然后去除文件扩展名
/// </summary>
/// <param name="filename">the ref that will be updated with the new filename</param>
/// <param name="extension">the ref that will be updated with the file extension</param>
/// <returns>if the extension was successfully separated from the filename</returns>
private static bool RemoveExtensionFromFilename(ref string filename, ref string extension)
{
int position;
if (string.IsNullOrEmpty(filename) || (position = filename.LastIndexOf('.')) == -1)
{
return false;
}
/** remove file extension */
extension = filename.Substring(position + 1);
if (extension.Length > 4 || !extension.All(char.IsLetterOrDigit))
{
return false;
}
/** check if valid anime extension */
var keyword = KeywordManager.Normalize(extension);
if (!KeywordManager.Contains(Element.ElementCategory.ElementFileExtension, keyword))
{
return false;
}
filename = filename.Substring(0, position);
return true;
}
}
}

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>AnitomySharp.NET6</PackageId>
<Authors>tabratton;senritsu</Authors>
<Description>AnitomySharp is a C# port of Anitomy by erengy, a library for parsing anime video filenames. All credit to erengy for the actual library and logic.
</Description>
<RepositoryUrl>https://github.com/chu-shen/AnitomySharp.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<AssemblyVersion>0.3.0</AssemblyVersion>
<FileVersion>0.3.0</FileVersion>
<Version>0.3.0</Version>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<DocumentationFile>AnitomySharp.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Include="..\LICENSE" Pack="true" Visible="false" PackagePath="" />
<PackageReference Include="Microsoft.DocAsCode.App" Version="2.60.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,197 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp
{
/// <summary>
/// An <see cref="Element"/> represents an identified Anime <see cref="Token"/>.
/// A single filename may contain multiple of the same
/// token(e.g <see cref="ElementCategory.ElementEpisodeNumber"/>).
///
/// 一个元素即是一个已标识的标记(token)
///
/// 单个文件名可能包含多个相同的标记,比如:`ElementEpisodeNumber`元素类别的标记
/// </summary>
public class Element
{
/// <summary>
/// Element Categories
///
/// 元素类别
/// </summary>
public enum ElementCategory
{
/// <summary>
/// 元素类别:动画季度,不带<see cref="ElementAnimeSeasonPrefix"/>前缀
/// </summary>
ElementAnimeSeason,
/// <summary>
/// 元素类别:季度前缀,用于标识<see cref="ElementAnimeSeason">季度</see>的元素类别
/// </summary>
ElementAnimeSeasonPrefix,
/// <summary>
/// 元素类别:动画名
/// </summary>
ElementAnimeTitle,
/// <summary>
/// 元素类别:动画类型
/// </summary>
ElementAnimeType,
/// <summary>
/// 元素类别:动画年份,唯一
/// </summary>
ElementAnimeYear,
/// <summary>
/// 元素类别:音频术语
/// </summary>
ElementAudioTerm,
/// <summary>
/// 元素类别:设备,用于标识设备类型
/// </summary>
ElementDeviceCompatibility,
/// <summary>
/// 元素类别:剧集数
/// </summary>
ElementEpisodeNumber,
/// <summary>
/// 元素类别:等效剧集数,常见于多季度番剧
/// </summary>
ElementEpisodeNumberAlt,
/// <summary>
/// 元素类别剧集前缀比如“E”
/// </summary>
ElementEpisodePrefix,
/// <summary>
/// 元素类别:剧集名
/// </summary>
ElementEpisodeTitle,
/// <summary>
/// 元素类别:文件校验码,唯一
/// </summary>
ElementFileChecksum,
/// <summary>
/// 元素类别:文件扩展名,唯一
/// </summary>
ElementFileExtension,
/// <summary>
/// 文件名,唯一
/// </summary>
ElementFileName,
/// <summary>
/// 元素类别:语言
/// </summary>
ElementLanguage,
/// <summary>
/// 元素类别:其他,暂时无法分类的元素
/// </summary>
ElementOther,
/// <summary>
/// 元素类别:发布组,唯一
/// </summary>
ElementReleaseGroup,
/// <summary>
/// 元素类别:发布信息
/// </summary>
ElementReleaseInformation,
/// <summary>
/// 元素类别:发布版本
/// </summary>
ElementReleaseVersion,
/// <summary>
/// 元素类别:来源
/// </summary>
ElementSource,
/// <summary>
/// 元素类别:字幕
/// </summary>
ElementSubtitles,
/// <summary>
/// 元素类别:视频分辨率
/// </summary>
ElementVideoResolution,
/// <summary>
/// 元素类别:视频术语
/// </summary>
ElementVideoTerm,
/// <summary>
/// 元素类别:卷数
/// </summary>
ElementVolumeNumber,
/// <summary>
/// 元素类别:卷前缀
/// </summary>
ElementVolumePrefix,
/// <summary>
/// 元素类别:未知元素类型
/// </summary>
ElementUnknown
}
/// <summary>
///
/// </summary>
public ElementCategory Category { get; set; }
/// <summary>
///
/// </summary>
public string Value { get; }
/// <summary>
/// Constructs a new Element
///
/// 构造一个元素
/// </summary>
/// <param name="category">the category of the element</param>
/// <param name="value">the element's value</param>
public Element(ElementCategory category, string value)
{
Category = category;
Value = value;
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public override int GetHashCode()
{
return -1926371015 + Value.GetHashCode();
}
/// <summary>
///
/// </summary>
/// <param name="obj"></param>
/// <returns></returns>
public override bool Equals(object obj)
{
if (this == obj)
{
return true;
}
if (obj == null || GetType() != obj.GetType())
{
return false;
}
var other = (Element)obj;
return Category.Equals(other.Category);
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public override string ToString()
{
return $"Element{{category={Category}, value='{Value}'}}";
}
}
}

View File

@ -0,0 +1,451 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
namespace AnitomySharp
{
/// <summary>
/// A class to manager the list of known anime keywords. This class is analogous to <c>keyword.cpp</c> of Anitomy, and <c>KeywordManager.java</c> of AnitomyJ
///
/// 本类用于管理已知动漫关键词列表
///
/// </summary>
public static class KeywordManager
{
/// <summary>
/// 包含所有关键词(大写)的内部关键词元素词典
/// </summary>
private static readonly Dictionary<string, Keyword> Keys = new Dictionary<string, Keyword>();
/// <summary>
/// 文件扩展名,无值
/// </summary>
private static readonly Dictionary<string, Keyword> Extensions = new Dictionary<string, Keyword>();
/// <summary>
/// ~~一眼真~~
///
/// 在主逻辑前预处理用的关键词集合,优先处理后被视为一个标识(token),不会被后续操作拆散。
///
/// 如果关键词中部分字符包含在<see cref="Options.AllowedDelimiters"/>,强烈建议添加到此列表(注意:仅添加无歧义关键词)
///
/// 如果没有添加,后续处理的时候会被<see cref="Options.AllowedDelimiters"/>拆分。不过程序带了验证方法<see cref="Tokenizer.ValidateDelimiterTokens"/>,可以一定程度上重新还原关键词
/// </summary>
private static readonly List<Tuple<Element.ElementCategory, List<string>>> PeekEntries;
/// <summary>
/// 添加元素类别的关键词至<see cref="Keys"/>
/// </summary>
static KeywordManager()
{
var optionsDefault = new KeywordOptions();
var optionsInvalid = new KeywordOptions(true, true, false);
var optionsUnidentifiable = new KeywordOptions(false, true, true);
var optionsUnidentifiableInvalid = new KeywordOptions(false, true, false);
var optionsUnidentifiableUnsearchable = new KeywordOptions(false, false, true);
Add(Element.ElementCategory.ElementAnimeSeasonPrefix,
optionsUnidentifiable,
new List<string> { "SAISON", "SEASON" });
Add(Element.ElementCategory.ElementAnimeType,
optionsUnidentifiable,
new List<string> {
"GEKIJOUBAN", "MOVIE",
"OAD", "OAV", "ONA", "OVA",
"TV",
"番外編", "總集編","映像特典","特典","特典アニメ",
// 特典 Special 剩下的各种类型可以全部命名成 SP对于较特殊意义的特典也可以自定义命名
"SPECIAL", "SPECIALS", "SP",
// 真人特典 Interview/Talk/Stage... 目前我们对于节目、采访、舞台活动、制作等三次元画面的长视频,一概怼成 IV。
"IV",
// 音乐视频 Music Video
"MV"});
// add "SP" to ElementAnimeType with optionsUnidentifiable
// Add(Element.ElementCategory.ElementAnimeType,
// optionsUnidentifiableUnsearchable,
// new List<string> {"SP"}); // e.g. "Yumeiro Patissiere SP Professional"
Add(Element.ElementCategory.ElementAnimeType,
optionsUnidentifiableInvalid,
new List<string> {
// https://github.com/vcb-s/VCB-S_Collation/blob/master/specification.md
// 无字 OP/ED Non-Credit Opening/Ending
"ED", "ENDING", "NCED", "NCOP", "OP", "OPENING",
// 预告 Preview 预告下一话内容 注意编号表示其预告的是第几话的内容而不是跟在哪一话后面
"PREVIEW",
// 菜单 Menu BD/DVD 播放选择菜单
"MENU",
// 广告 Commercial Message 电视放送广告,时长一般在 7s/15s/30s/45s/... 左右
"CM","SPOT",
// 宣传片/预告片 Promotion Video / Trailer 一般时长在 1~2min 命名参考原盘和 jsum
"PV", "Teaser","TRAILER",
// 真人特典 Interview/Talk/Stage... 目前我们对于节目、采访、舞台活动、制作等三次元画面的长视频,一概怼成 IV。
"INTERVIEW",
"EVENT", "TOKUTEN", "LOGO"});
Add(Element.ElementCategory.ElementAudioTerm,
optionsDefault,
new List<string> {
// Audio channels
"5.1","7.1","2CH", "6CH",
"DTS", "DTS-ES", "DTS-MA", "DTS-HD","DTS-HDMA",
"TRUE-HD", "TRUEHD", "THD",
// Audio codec
"AAC", "AACX2", "AACX3", "AACX4", "2XAAC", "3XAAC", "2AAC", "3AAC",
"AC3", "AC3X2","AC3X3", "EAC3", "E-AC-3",
"FLAC", "FLACX2", "FLACX3", "FLACX4", "2XFLAC", "3XFLAC", "2FLAC", "3FLAC", "4FLAC",
"LOSSLESS", "MP3", "OGG", "VORBIS",
"ATMOS",
// Audio language
"DUAL","DUALAUDIO"
});
Add(Element.ElementCategory.ElementDeviceCompatibility,
optionsDefault,
new List<string> { "IPAD3", "IPHONE5", "IPOD", "PS3", "PS3アプコン", "XBOX", "XBOX360", "PSP" });
Add(Element.ElementCategory.ElementDeviceCompatibility,
optionsUnidentifiable,
new List<string> { "ANDROID" });
Add(Element.ElementCategory.ElementEpisodePrefix,
optionsDefault,
new List<string> { "EP", "EP.", "EPS", "EPS.", "EPISODE", "EPISODE.", "EPISODES", "CAPITULO", "EPISODIO", "EPIS\u00F3DIO", "FOLGE" });
Add(Element.ElementCategory.ElementEpisodePrefix,
optionsInvalid,
new List<string> { "E", "\\x7B2C" }); // single-letter episode keywords are not valid tokens
Add(Element.ElementCategory.ElementFileExtension,
optionsDefault,
new List<string> { "3GP", "AVI", "DIVX", "FLV", "M2TS", "MKV", "MOV", "MP4", "MPG",
"OGM", "RM", "RMVB", "TS", "WEBM", "WMV" });
Add(Element.ElementCategory.ElementFileExtension,
optionsInvalid,
new List<string> { "AAC", "AIFF", "FLAC", "M4A", "MP3", "MKA", "OGG", "WAV", "WMA", "7Z", "RAR", "ZIP", "ASS", "SRT" });
Add(Element.ElementCategory.ElementLanguage,
optionsDefault,
new List<string> { "ENG", "ENGLISH", "ESPANO", "JAP", "PT-BR", "SPANISH", "VOSTFR",
"ZH-HANS", "ZH-HANT", "CHS", "CHT", "CHN", "JPN", "JPSC", "JPTC" });
Add(Element.ElementCategory.ElementLanguage,
optionsUnidentifiable,
new List<string> { "ESP", "ITA", "SC", "TC" }); // e.g. "Tokyo ESP:, "Bokura ga Ita"
Add(Element.ElementCategory.ElementOther,
optionsDefault,
new List<string> { "REMASTER", "REMASTERED", "UNCUT", "TS", "VFR", "WIDESCREEN", "WS", "SPURSENGINE" });
Add(Element.ElementCategory.ElementReleaseGroup,
optionsDefault,
new List<string> {
// rip group
"AI-RAWS","AIROTA","ANK-RAWS","ANK","ANE","AKATOMBA-RAWS","ATTKC","BEANSUB","BEATRICE-RAWS",
"CASO","COOLCOMIC","COMMIE","DANNI","DMG","DYMY","EUPHO","EMTP-RAWS","ENKANREC","EXILED-DESTINY","FLSNOW",
"FREEWIND","FZSD","GTX-RAWS","GST","HAKUGETSU","HQR","HKG","JYFANSUB","JSUM","KAGURA","KAMETSU",
"KAMIGAMI-RAWS","KAMIGAMI","诸神字幕组","KNA-SUBS","KOEISUB","KTXP","LOWPOWER-RAWS","LKSUB",
"LIUYUN","LOLIHOUSE","LITTLEBAKAS!","MABORS","MAWEN1250","MGRT","MMZY-SUB","MH","MOOZZI2",
"PUSSUB","POPGO","PHILOSOPHY-RAWS","PPP-RAW","QTS","RARBG","RATH","REINFORCE","RUELL-NEXT","RUELL-RAWS",
"R1RAW","SNOW-RAWS","SFEO-RAWS","SHINSEN-SUBS","SHIROKOI","SWEETSUB","SUMISORA","SOFCJ-RAWS","TSDM",
"THORA","TUCAPTIONS","TXXZ","UCCUSS","UHA-WINGS","U2-RIP","VCB-STUDIO","VCB-S","XYX98","XKSUB","XRIP",
"异域-11番小队","YYDM","YUSYABU","YLBUDSUB","ZAGZAD","AHU-SUB",
"HYSUB", "SAKURATO", "SKYMOON-RAWS", "COMICAT&KISSSUB","FUSSOIR",
// bangumi
"ANI", "NC-RAWS", "LILITH-RAWS", "NAN-RAWS","MINGY","NANDESUKA","KISSSUB",
// other
"PTER",
// echi
"脸肿字幕组","魔穗字幕组","桜都字幕组","MAHOXOKAZU","極彩花夢",
// Unidentifiable
"YUUKI"
});
Add(Element.ElementCategory.ElementReleaseInformation,
optionsDefault,
new List<string> {
"BATCH", "COMPLETE", "PATCH", "REMUX", "REV", "REPACK", "FIN",
"生肉", "熟肉",
// source
"BILIBILI","B-GLOBAL", "BAHA", "GYAO!", "U-NEXT","SENTAI"});
Add(Element.ElementCategory.ElementReleaseInformation,
optionsDefault,
new List<string> {
// echi
"18禁", "18禁アニメ", "15禁", "無修正", "无修正", "无码", "無碼","有码", "NOWATERMARK","CENSORED","UNCENSORED","DECENSORED","有修正","无删减","未删减","有删减",
// echi Studios
"AIC","ANIMAC","ANIMAN","APPLE","BOOTLEG","CELEB","CHERRYLIPS","CHIPPAI","COLLABORATIONWORKS","COSMOS","DREAMディースリー","DISCOVERY","DODER","EDGEエッジ","EDGE","EROZUKI","HILLS","HONNYBIT","JAM","JHV","JVD","MILKY蜜","MILKY","MOONROCK","MS-PICTURES","NUR","OFFICEAO","PASHMINAA","PASHMINA","PETIT","PINKPINEAPPLE","PIXY","PORO","QUEENBEE","SCHOOLZONE","SHS","SPERMATION","STARLINK","TDKコア","UNCEN","UTAMARO","VIB","ZIZ","アニアン","ひまじん","エイベックス・トラックス","オブテイン・フューチャー","クランベリー","ジャパンホームビデオ","ジュエル","バニラ","ピンクパイナップル","ファイブウェイズ","ミューズ","プリンセス・プロダクション","れもんは~と","れもんは〜と","アムモ","じゅうしぃまんご~","メリージェーン","メリー・ジェーン","せるふぃっしゅ","アームス","アスミック","フェアリーダスト","メリー_ジェーン","ばにぃうぉ~か~","ショーテン","あんてきぬすっ","エンゼルフィッシュ","オゼ","ガールズトーク","クリムゾン","サークルトリビュート","こっとんど~る","カナメプロダクション","オレンジビデオハウス","ウエスト・ケープ・コーポレーション","メリー・ジェーン","アートミック","シネマパラダイス","あかとんぼ","ディースリー","ディスカバリー","ナック映画","メディアバンク","ボイジャーエンターテイメント","ミントハウス","フレンズ","ミルクセーキ","ハニーディップ","パック・イン・ビデオ","サン出版","わるきゅ~れ++","虫プロダクション","バンダイビジュアル","ハピネット・ピクチャーズXビクターエンタテインメント","ちちのや","センテスタジオ","メディアセブン","セブン","スタジオ・ファンタジア","ショコラ","カレス・コミュニケーションズ","ソフト・オン・デマンド","ソフト・オン・デマンド","セントリリア","2匹目のどぜう","37℃","北栄商事","創美企画","創美","大映映像","大映","晋遊舎","晋遊社","鈴木みら乃","虎の穴","蜜MITSU","魔人","妄想実現めでぃあ","遊人","真空間","宇宙企画"
});
Add(Element.ElementCategory.ElementReleaseInformation,
optionsUnidentifiable,
new List<string> { "END", "FINAL" }); // e.g. "The End of Evangelion", 'Final Approach"
Add(Element.ElementCategory.ElementReleaseVersion,
optionsDefault,
new List<string> { "V0", "V1", "V2", "V3", "V4" });
Add(Element.ElementCategory.ElementSource,
optionsDefault,
new List<string> {"BD", "BDRIP", "BD-BOX", "BDBOX", "UHD", "UHDRIP", "BLURAY", "BLU-RAY",
"DVD", "DVD5", "DVD9", "DVD-R2J", "DVDRIP", "DVD-RIP",
"R2DVD", "R2J", "R2JDVD", "R2JDVDRIP",
"HDTV", "HDTVRIP", "TVRIP", "TV-RIP",
"WEBCAST", "WEBRIP", "WEB-DL", "WEB",
"DLRIP"});
Add(Element.ElementCategory.ElementSubtitles,
optionsDefault,
new List<string> { "ASS", "GB", "BIG5", "DUB", "DUBBED", "HARDSUB", "HARDSUBS", "RAW", "SOFTSUB",
"SOFTSUBS", "SUB", "SUBBED", "SUBTITLED" });
Add(Element.ElementCategory.ElementVideoTerm,
optionsDefault,
new List<string> {
// Frame rate
"24FPS", "30FPS", "48FPS", "60FPS", "120FPS","SVFI",
// Video codec
"8BIT", "8-BIT", "10BIT", "10BITS", "10-BIT", "10-BITS",
"HEVC-10BIT", "HEVC-YUV420P10","X264-10BIT", "X264-HI10P",
"HI10", "HI10P", "MA10P","MA444-10P", "HI444", "HI444P", "HI444PP",
"H264", "H265", "X264", "X265",
"AVC", "HEVC", "HEVC2", "DIVX", "DIVX5", "DIVX6", "XVID",
"YUV420", "YUV420P8", "YUV420P10", "YUV420P10LE", "YUV444", "YUV444P10", "YUV444P10LE","AV1",
"MAIN10", "MAIN10P", "MAIN12", "MAIN12P",
"HDR", "HDR10", "HMAX","DOVI","DOLBY VISION",
// Video format
"AVI", "RMVB", "WMV", "WMV3", "WMV9", "MKV", "MP4", "MPEG",
// Video quality
"HQ", "LQ",
// Video resolution
"UHD", "HD", "SD"});
Add(Element.ElementCategory.ElementVolumePrefix,
optionsDefault,
new List<string> { "VOL", "VOL.", "VOLUME" });
PeekEntries = new List<Tuple<Element.ElementCategory, List<string>>>
{
Tuple.Create(Element.ElementCategory.ElementAnimeType, new List<string> {
// 预告
"WEB PREVIEW" }),
Tuple.Create(Element.ElementCategory.ElementAudioTerm, new List<string> { "2.0CH", "5.1CH", "7.1CH", "DTS5.1", "MA.5.1", "MA.2.0", "MA.7.1", "TRUEHD5.1", "DDP5.1", "DD5.1", "DUAL AUDIO" }),
Tuple.Create(Element.ElementCategory.ElementVideoTerm, new List<string> { "H.264", "H264", "H.265", "X.264", "23.976FPS", "29.97FPS", "59.94FPS", "59.940FPS" }),// e.g. "H264-Flac"
Tuple.Create(Element.ElementCategory.ElementVideoResolution, new List<string> { "480P", "720P", "1080P", "2160P", "4K", "6K", "8K" }),
Tuple.Create(Element.ElementCategory.ElementReleaseGroup, new List<string> { "X_X", "A.I.R.NESSUB", "FUDAN_NRC", "T.H.X", "MAHO.SUB", "OKAZU.SUB", "THUNDER.SUB","ORION ORIGIN", "NEKOMOE KISSATEN" }),
Tuple.Create(Element.ElementCategory.ElementReleaseInformation, new List<string> {
// echi
"NO WATERMARK", "ALL PRODUCTS", "AN DERCEN", "BLUE EYES", "BOMB! CUTE! BOMB!", "COLLABORATION WORKS", "GREEN BUNNY", "GOLD BEAR", "HOODS ENTERTAINMENT", "HOT BEAR", "KING BEE", "PLATINUM MILKY", "MOON ROCK", "OBTAIN FUTURE", "QUEEN BEE", "SOFT DEMAND", "STUDIO ZEALOT", "SURVIVE MORE", "WHITE BEAR", "メリー ジェーン", "ビーム エンタテインメント", "蜜 -MITSU-","W.C.C.","J.A.V.N.","HSHARE.NET" })
};
}
/// <summary>
/// 字符串(<paramref name="word"/>)转换为大写
/// </summary>
/// <param name="word">待转换的字符串</param>
/// <returns>返回当前字符串的大写形式</returns>
public static string Normalize(string word)
{
return string.IsNullOrEmpty(word) ? word : word.ToUpperInvariant();
}
/// <summary>
/// 判断元素列表中是否包含给定的字符串(<paramref name="keyword"/>)
/// </summary>
/// <param name="category">元素类别</param>
/// <param name="keyword">待判断的字符串</param>
/// <returns>`true`表示包含</returns>
public static bool Contains(Element.ElementCategory category, string keyword)
{
var keys = GetKeywordContainer(category);
if (keys.TryGetValue(keyword, out var foundEntry))
{
return foundEntry.Category == category;
}
return false;
}
/// <summary>
/// Finds a particular <c>keyword</c>. If found sets <c>category</c> and <c>options</c> to the found search result.
///
/// 查找给定的关键词,并更新其元素分类和关键词配置
///
/// 如果在<see cref="Keys"/>中找到,则将<see cref="Keys"/>中此关键词对应的元素分类和关键词配置赋给给定的关键词
/// </summary>
/// <param name="keyword">the keyword to search for</param>
/// <param name="category">the reference that will be set/changed to the found keyword category</param>
/// <param name="options">the reference that will be set/changed to the found keyword options</param>
/// <returns>true if the keyword was found</returns>
public static bool FindAndSet(string keyword, ref Element.ElementCategory category, ref KeywordOptions options)
{
var keys = GetKeywordContainer(category);
if (!keys.TryGetValue(keyword, out var foundEntry))
{
return false;
}
if (category == Element.ElementCategory.ElementUnknown)
{
category = foundEntry.Category;
}
else if (foundEntry.Category != category)
{
return false;
}
options = foundEntry.Options;
return true;
}
/// <summary>
/// Given a particular <c>filename</c> and <c>range</c> attempt to preidentify the token before we attempt the main parsing logic
///
/// 在使用主处理逻辑前,尝试对给定的文件名和范围预先确定标记(token),关键词来自<see cref="PeekEntries"/>
/// </summary>
/// <param name="filename">the filename</param>
/// <param name="range">the search range</param>
/// <param name="elements">elements array that any pre-identified elements will be added to</param>
/// <param name="preidentifiedTokens">elements array that any pre-identified token ranges will be added to</param>
public static void PeekAndAdd(string filename, TokenRange range, List<Element> elements, List<TokenRange> preidentifiedTokens)
{
var endR = range.Offset + range.Size;
/** 获得本次操作的字符串 */
var search = filename.Substring(range.Offset, endR > filename.Length ? filename.Length - range.Offset : endR - range.Offset);
foreach (var entry in PeekEntries)
{
foreach (var keyword in entry.Item2)
{
var foundIdx = search.IndexOf(keyword, StringComparison.CurrentCultureIgnoreCase);
if (foundIdx == -1) continue;
foundIdx += range.Offset;
/** 将一眼真的关键字加入元素列表 */
elements.Add(new Element(entry.Item1, filename.Substring(foundIdx, keyword.Length)));
// elements.Add(new Element(entry.Item1, keyword));
/** 将匹配到的关键词字符串范围添加到preidentifiedTokens */
preidentifiedTokens.Add(new TokenRange(foundIdx, keyword.Length));
}
}
}
// Private API
/// <summary>
/// Returns the appropriate keyword container.
///
/// 返回合适的内部关键词元素词典<see cref="Keys"/>
///
/// 如果元素类型为文件扩展名,则返回空值的<see cref="Extensions"/>,否则返回<see cref="Keys"/>
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
private static Dictionary<string, Keyword> GetKeywordContainer(Element.ElementCategory category)
{
return category == Element.ElementCategory.ElementFileExtension ? Extensions : Keys;
}
/// <summary>
/// Adds a <c>category</c>, <c>options</c>, and <c>keywords</c> to the internal keywords list.
///
/// 将元素分类、关键词配置添加给指定的关键词列表。最终形成一个内部关键词元素词典<see cref="Keys"/>
/// </summary>
/// <param name="category"></param>
/// <param name="options"></param>
/// <param name="keywords"></param>
private static void Add(Element.ElementCategory category, KeywordOptions options, IEnumerable<string> keywords)
{
var keys = GetKeywordContainer(category);
foreach (var key in keywords.Where(k => !string.IsNullOrEmpty(k) && !keys.ContainsKey(k)))
{
keys[key] = new Keyword(category, options);
}
}
}
/// <summary>
/// Keyword options for a particular keyword.
///
/// 关键词配置
/// </summary>
public class KeywordOptions
{
/// <summary>
/// 是否可分辨,是否会产生歧义,是否会出现在动画标题中
/// </summary>
public bool Identifiable { get; }
/// <summary>
/// 是否可检索 #TODO
///
/// <see cref="ParserHelper.IsElementCategorySearchable"/>
/// </summary>
public bool Searchable { get; }
/// <summary>
/// 是否有效 #TODO
/// </summary>
public bool Valid { get; }
/// <summary>
/// 默认关键词配置:可识别,可检索,有效
/// </summary>
public KeywordOptions() : this(true, true, true) { }
/// <summary>
/// Constructs a new keyword options
///
/// 构造一个关键词配置
/// </summary>
/// <param name="identifiable">if the token is identifiable</param>
/// <param name="searchable">if the token is searchable</param>
/// <param name="valid">if the token is valid</param>
public KeywordOptions(bool identifiable, bool searchable, bool valid)
{
Identifiable = identifiable;
Searchable = searchable;
Valid = valid;
}
}
/// <summary>
/// A Keyword
///
/// 关键词结构体
/// </summary>
public struct Keyword
{
/// <summary>
/// 元素类别 <see cref="Element.ElementCategory"/>
/// </summary>
public readonly Element.ElementCategory Category;
/// <summary>
/// 关键词配置 <see cref="KeywordOptions"/>
/// </summary>
public readonly KeywordOptions Options;
/// <summary>
/// Constructs a new Keyword
///
/// 构造一个新的关键词
/// </summary>
/// <param name="category">the category of the keyword</param>
/// <param name="options">the keyword's options</param>
public Keyword(Element.ElementCategory category, KeywordOptions options)
{
Category = category;
Options = options;
}
}
}

View File

@ -0,0 +1,58 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp
{
/// <summary>
/// AnitomySharp search configuration options
///
/// 提取元素时的默认配置项
/// </summary>
public class Options
{
/// <summary>
/// 提取元素时使用的分隔符
/// </summary>
public string AllowedDelimiters { get; }
/// <summary>
/// 是否尝试提取集数。`true`表示提取
/// </summary>
public bool ParseEpisodeNumber { get; }
/// <summary>
/// 是否尝试提取本集标题。`true`表示提取
/// </summary>
public bool ParseEpisodeTitle { get; }
/// <summary>
/// 是否提取文件扩展名。`true`表示提取
/// </summary>
public bool ParseFileExtension { get; }
/// <summary>
/// 是否提取发布组。`true`表示提取
/// </summary>
public bool ParseReleaseGroup { get; }
/// <summary>
/// 提取元素时的配置项
/// </summary>
/// <param name="delimiters">默认值:" _.+,|"</param>
/// <param name="episode">默认值true</param>
/// <param name="title">默认值true</param>
/// <param name="extension">默认值true</param>
/// <param name="group">默认值true</param>
public Options(string delimiters = " _.+,| ", bool episode = true, bool title = true, bool extension = true, bool group = true)
{
AllowedDelimiters = delimiters;
ParseEpisodeNumber = episode;
ParseEpisodeTitle = title;
ParseFileExtension = extension;
ParseReleaseGroup = group;
}
}
}

View File

@ -0,0 +1,535 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace AnitomySharp
{
/// <summary>
/// Class to classify <see cref="Token"/>s
///
/// 用于标记(token)分类的类
/// </summary>
public class Parser
{
/// <summary>
/// 用于确认<see cref="Element.ElementCategory.ElementEpisodeNumber"/>元素是否已存在
/// </summary>
public bool IsEpisodeKeywordsFound { get; private set; }
/// <summary>
/// <see cref="ParserHelper"/>
/// </summary>
public ParserHelper ParseHelper { get; }
/// <summary>
/// <see cref="ParserNumber"/>
/// </summary>
public ParserNumber ParseNumber { get; }
/// <summary>
/// 元素列表 <see cref="Elements"/>
/// </summary>
public List<Element> Elements { get; }
/// <summary>
/// 标记列表 <see cref="Tokens"/>
/// </summary>
public List<Token> Tokens { get; }
/// <summary>
/// 提取元素时的<see cref="Options">配置项</see>
/// </summary>
private Options Options { get; }
/// <summary>
/// Constructs a new token parser
///
/// 构造一个标记(token)解析
///
/// 并创建ParserHelper和ParserNumber各一个实例
/// </summary>
/// <param name="elements">the list where parsed elements will be added</param>
/// <param name="options">the parser options</param>
/// <param name="tokens">the list of tokens</param>
public Parser(List<Element> elements, Options options, List<Token> tokens)
{
Elements = elements;
Options = options;
Tokens = tokens;
ParseHelper = new ParserHelper(this);
ParseNumber = new ParserNumber(this);
}
/// <summary>
/// Begins the parsing process
///
/// 开始处理
/// </summary>
/// <returns></returns>
public bool Parse()
{
SearchForKeywords();
SearchForIsolatedNumbers();
if (Options.ParseEpisodeNumber)
{
SearchForEpisodeNumber();
}
SearchForAnimeTitle();
if (Options.ParseReleaseGroup && Empty(Element.ElementCategory.ElementReleaseGroup))
{
SearchForReleaseGroup();
}
if (Options.ParseEpisodeTitle && !Empty(Element.ElementCategory.ElementEpisodeNumber))
{
SearchForEpisodeTitle();
}
ValidateElements();
return Empty(Element.ElementCategory.ElementAnimeTitle);
}
/// <summary>
/// Search for anime keywords.
///
/// 主要是根据关键词列表匹配标记(token),并将匹配到的关键字添加到元素列表
/// </summary>
private void SearchForKeywords()
{
for (var i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
/** 过滤已知标记类型的标记 */
if (token.Category != Token.TokenCategory.Unknown) continue;
var word = token.Content;
word = word.Trim(" -".ToCharArray());
if (string.IsNullOrEmpty(word)) continue;
// Don't bother if the word is a number that cannot be CRC
if (word.Length != 8 && StringHelper.IsNumericString(word)) continue;
var keyword = KeywordManager.Normalize(word);
var category = Element.ElementCategory.ElementUnknown;
var keywordOptions = new KeywordOptions();
/** 首先在关键词列表中匹配关键词如无则执行else */
if (KeywordManager.FindAndSet(keyword, ref category, ref keywordOptions))
{
/** 根据配置跳过发布组元素 */
if (!Options.ParseReleaseGroup && category == Element.ElementCategory.ElementReleaseGroup) continue;
/** 跳过配置为不能搜索的元素 */
if (!ParseHelper.IsElementCategorySearchable(category) || !keywordOptions.Searchable) continue;
/** 跳过已经包含的Singular元素类别 */
if (ParseHelper.IsElementCategorySingular(category) && !Empty(category)) continue;
switch (category)
{
case Element.ElementCategory.ElementAnimeSeasonPrefix:
ParseHelper.CheckAndSetAnimeSeasonKeyword(token, i);
continue;
case Element.ElementCategory.ElementEpisodePrefix when keywordOptions.Valid:
ParseHelper.CheckExtentKeyword(Element.ElementCategory.ElementEpisodeNumber, i, token);
continue;
case Element.ElementCategory.ElementReleaseVersion:
word = word.Substring(1);
break;
case Element.ElementCategory.ElementVolumePrefix:
ParseHelper.CheckExtentKeyword(Element.ElementCategory.ElementVolumeNumber, i, token);
continue;
}
}
else
{
/** 如果还不存在ElementFileChecksum元素类型且该标记满足Crc32规则 */
if (Empty(Element.ElementCategory.ElementFileChecksum) && ParserHelper.IsCrc32(word))
{
category = Element.ElementCategory.ElementFileChecksum;
}
/** 如果还不存在ElementVideoResolution元素类型且该标记满足分辨率规则 */
else if (Empty(Element.ElementCategory.ElementVideoResolution) && ParserHelper.IsResolution(word))
{
category = Element.ElementCategory.ElementVideoResolution;
}
}
/** 如果此标记的元素分类仍为ElementUnknown则跳过此标记的处理*/
if (category == Element.ElementCategory.ElementUnknown) continue;
Elements.Add(new Element(category, word));
if (keywordOptions.Identifiable)
{
token.Category = Token.TokenCategory.Identifier;
}
}
}
/// <summary>
/// Search for episode number.
///
/// 匹配标记列表中的集数
/// </summary>
private void SearchForEpisodeNumber()
{
var tokens = new List<int>();
for (var i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
// List all unknown tokens that contain a number
if (token.Category == Token.TokenCategory.Unknown &&
ParserHelper.IndexOfFirstDigit(token.Content) != -1)
{
tokens.Add(i);
}
}
if (tokens.Count == 0)
{
// search Japanese Pattern without number
if (Empty(Element.ElementCategory.ElementEpisodeNumber))
{
for (var i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
if (token.Category == Token.TokenCategory.Unknown &&
ParserHelper.IndexOfFirstDigit(token.Content) == -1)
{
ParseNumber.MatchJapaneseCounterPattern(token.Content, token);
}
}
}
return;
}
IsEpisodeKeywordsFound = !Empty(Element.ElementCategory.ElementEpisodeNumber);
// If a token matches a known episode pattern, it has to be the episode number
if (ParseNumber.SearchForEpisodePatterns(tokens)) return;
// We have previously found an episode number via keywords
if (!Empty(Element.ElementCategory.ElementEpisodeNumber)) return;
// From now on, we're only interested in numeric tokens
tokens.RemoveAll(r => !StringHelper.IsNumericString(Tokens[r].Content));
// e.g. "01 (176)", "29 (04)"
if (ParseNumber.SearchForEquivalentNumbers(tokens)) return;
// e.g. " - 08"
if (ParseNumber.SearchForSeparatedNumbers(tokens)) return;
// "e.g. "[12]", "(2006)"
if (ParseNumber.SearchForIsolatedNumbers(tokens)) return;
// Consider using the last number as a last resort
ParseNumber.SearchForLastNumber(tokens);
}
/// <summary>
/// Search for anime title
///
/// 搜索动画名
/// </summary>
private void SearchForAnimeTitle()
{
var enclosedTitle = false;
var tokenBegin = Token.FindToken(Tokens, 0, Tokens.Count, Token.TokenFlag.FlagNotEnclosed, Token.TokenFlag.FlagUnknown);
// without ReleaseGroup, only anime title e.g. "[2005][Paniponi Dash!][BDRIP][1080P][1-26Fin+OVA+SP]"
var tokenBeginWithNoReleaseGroup = Tokens.Count;
// If that doesn't work, find the first unknown token in the second enclosed
// group, assuming that the first one is the release group
if (!Token.InListRange(tokenBegin, Tokens))
{
tokenBegin = 0;
enclosedTitle = true;
var skippedPreviousGroup = false;
do
{
tokenBegin = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagUnknown);
tokenBeginWithNoReleaseGroup = tokenBegin;
if (!Token.InListRange(tokenBegin, Tokens)) break;
// Ignore groups that are composed of non-Latin characters or non-Chinese characters
// 对于同时有中英文名称,并且两者分割开来,如:“[異域字幕組][漆黑的子彈][Black Bullet][11][1280x720][繁体].mp4”则只会返回第一个匹配到的
if ((StringHelper.IsMostlyLatinString(Tokens[tokenBegin].Content) || StringHelper.IsMostlyChineseString(Tokens[tokenBegin].Content)) && skippedPreviousGroup)
{
break;
}
// if ReleaseGroup is empty
if (Options.ParseReleaseGroup && Empty(Element.ElementCategory.ElementReleaseGroup))
{
// Get the first unknown token of the next group
tokenBegin = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagBracket);
tokenBegin = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagUnknown);
}
// make sure the new token don't in Element.ElementCategory
// if in or outListRange
// return pretoken
// TODO match other ElementCategory
if ((Token.InListRange(tokenBegin, Tokens) && KeywordManager.Contains(Element.ElementCategory.ElementAnimeType, Tokens[tokenBegin].Content.ToUpper()))
|| tokenBegin == Tokens.Count)
{
tokenBegin = tokenBeginWithNoReleaseGroup;
}
skippedPreviousGroup = true;
} while (Token.InListRange(tokenBegin, Tokens));
}
if (!Token.InListRange(tokenBegin, Tokens)) return;
// Continue until an identifier (or a bracket, if the title is enclosed) is found
var tokenEnd = Token.FindToken(
Tokens,
tokenBegin,
Tokens.Count,
Token.TokenFlag.FlagIdentifier,
enclosedTitle ? Token.TokenFlag.FlagBracket : Token.TokenFlag.FlagNone);
// If within the interval there's an open bracket without its matching pair,
// move the upper endpoint back to the bracket
if (!enclosedTitle)
{
var lastBracket = tokenEnd;
var bracketOpen = false;
for (var i = tokenBegin; i < tokenEnd; i++)
{
if (Tokens[i].Category != Token.TokenCategory.Bracket) continue;
lastBracket = i;
bracketOpen = !bracketOpen;
}
if (bracketOpen) tokenEnd = lastBracket;
}
// If the interval ends with an enclosed group (e.g. "Anime Title [Fansub]"),
// move the upper endpoint back to the beginning of the group. We ignore
// parentheses in order to keep certain groups (e.g. "(TV)") intact.
if (!enclosedTitle)
{
var token = Token.FindPrevToken(Tokens, tokenEnd, Token.TokenFlag.FlagNotDelimiter);
while (ParseHelper.IsTokenCategory(token, Token.TokenCategory.Bracket) && Tokens[token].Content[0] != ')')
{
token = Token.FindPrevToken(Tokens, token, Token.TokenFlag.FlagBracket);
if (!Token.InListRange(token, Tokens)) continue;
tokenEnd = token;
token = Token.FindPrevToken(Tokens, tokenEnd, Token.TokenFlag.FlagNotDelimiter);
}
}
ParseHelper.BuildElement(Element.ElementCategory.ElementAnimeTitle, false, Tokens.GetRange(tokenBegin, tokenEnd - tokenBegin));
}
/// <summary>
/// Search for release group
///
/// 搜索发布组
/// </summary>
private void SearchForReleaseGroup()
{
for (int tokenBegin = 0, tokenEnd = tokenBegin; tokenBegin < Tokens.Count;)
{
// Find the first enclosed unknown token
tokenBegin = Token.FindToken(Tokens, tokenEnd, Tokens.Count, Token.TokenFlag.FlagEnclosed, Token.TokenFlag.FlagUnknown);
if (!Token.InListRange(tokenBegin, Tokens)) return;
// Continue until a bracket or identifier is found
tokenEnd = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagBracket, Token.TokenFlag.FlagIdentifier);
// 去除纯数字发布组
if (Regex.Match(Tokens[tokenBegin].Content, ParserNumber.RegexMatchOnlyStart + @"^[0-9]+$" + ParserNumber.RegexMatchOnlyEnd).Success) continue;
if (!Token.InListRange(tokenEnd, Tokens) || Tokens[tokenEnd].Category != Token.TokenCategory.Bracket) continue;
// Ignore if it's not the first non-delimiter token in group
var prevToken = Token.FindPrevToken(Tokens, tokenBegin, Token.TokenFlag.FlagNotDelimiter);
if (Token.InListRange(prevToken, Tokens) && Tokens[prevToken].Category != Token.TokenCategory.Bracket) continue;
ParseHelper.BuildElement(Element.ElementCategory.ElementReleaseGroup, true, Tokens.GetRange(tokenBegin, tokenEnd - tokenBegin));
return;
}
}
/// <summary>
/// Search for episode title
///
/// 搜索剧集标题
/// </summary>
private void SearchForEpisodeTitle()
{
int tokenBegin;
var tokenEnd = 0;
do
{
// Find the first non-enclosed unknown token
tokenBegin = Token.FindToken(Tokens, tokenEnd, Tokens.Count, Token.TokenFlag.FlagNotEnclosed, Token.TokenFlag.FlagUnknown);
if (!Token.InListRange(tokenBegin, Tokens)) return;
// Continue until a bracket or identifier is found
tokenEnd = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagBracket, Token.TokenFlag.FlagIdentifier);
// Ignore if it's only a dash
if (tokenEnd - tokenBegin <= 2 && ParserHelper.IsDashCharacter(Tokens[tokenBegin].Content[0])) continue;
//if (tokenBegin.Pos == null || tokenEnd.Pos == null) continue;
ParseHelper.BuildElement(Element.ElementCategory.ElementEpisodeTitle, false, Tokens.GetRange(tokenBegin, tokenEnd - tokenBegin));
return;
} while (Token.InListRange(tokenBegin, Tokens));
}
/// <summary>
/// Search for isolated numbers
///
/// 搜索孤立数字的处理逻辑
/// </summary>
private void SearchForIsolatedNumbers()
{
for (var i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
/** 跳过括号标记类型的标记 */
if (token.Category == Token.TokenCategory.Bracket) continue;
var tokenContent = token.Content;
// e.g. "2016-17"
const string regexPattern = ParserNumber.RegexMatchOnlyStart + @"(\d{1,4})([-~&+])(\d{2,4})" + ParserNumber.RegexMatchOnlyEnd;
var match = Regex.Match(token.Content, regexPattern);
if (match.Success)
{
tokenContent = tokenContent.Split(match.Groups[2].Value)[0];
}
// add newtype e.g. "2021 OVA"
if (token.Category != Token.TokenCategory.Unknown || !StringHelper.IsNumericString(tokenContent) ||
!(ParseHelper.IsTokenContainAnimeType(i) ^ ParseHelper.IsTokenIsolated(i)))
{
continue;
}
var number = StringHelper.StringToInt(tokenContent);
// Anime year
if (number >= ParserNumber.AnimeYearMin && number <= ParserNumber.AnimeYearMax)
{
if (Empty(Element.ElementCategory.ElementAnimeYear))
{
Elements.Add(new Element(Element.ElementCategory.ElementAnimeYear, token.Content));
token.Category = Token.TokenCategory.Identifier;
continue;
}
}
// Video resolution
if (number != 480 && number != 720 && number != 1080 && number != 2160) continue;
// If these numbers are isolated, it's more likely for them to be the
// video resolution rather than the episode number. Some fansub groups use these without the "p" suffix.
// if (!Empty(Element.ElementCategory.ElementVideoResolution)) continue;
Elements.Add(new Element(Element.ElementCategory.ElementVideoResolution, token.Content));
token.Category = Token.TokenCategory.Identifier;
}
}
/// <summary>
/// Validate Elements
///
/// 验证元素有效性
/// </summary>
private void ValidateElements()
{
if (!Empty(Element.ElementCategory.ElementAnimeType) && !Empty(Element.ElementCategory.ElementEpisodeTitle))
{
var episodeTitle = Get(Element.ElementCategory.ElementEpisodeTitle);
for (var i = 0; i < Elements.Count;)
{
var el = Elements[i];
if (el.Category == Element.ElementCategory.ElementAnimeType)
{
if (episodeTitle.Contains(el.Value))
{
if (episodeTitle.Length == el.Value.Length)
{
Elements.RemoveAll(element =>
element.Category == Element.ElementCategory.ElementEpisodeTitle); // invalid episode title
}
else
{
var keyword = KeywordManager.Normalize(el.Value);
if (KeywordManager.Contains(Element.ElementCategory.ElementAnimeType, keyword))
{
i = Erase(el); // invalid anime type
continue;
}
}
}
}
++i;
}
}
}
/// <summary>
/// Returns whether or not the parser contains this category
///
/// 判断当前的元素列表<see cref="Elements"/>是否包含传入的元素类别
/// </summary>
/// <param name="category"></param>
/// <returns>不包含则返回`true`,否则`false`</returns>
private bool Empty(Element.ElementCategory category)
{
return Elements.All(element => element.Category != category);
}
/// <summary>
/// Returns the value of a particular category
///
/// 返回传入元素类别的值
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
private string Get(Element.ElementCategory category)
{
var foundElement = Elements.Find(element => element.Category == category);
if (foundElement != null) return foundElement.Value;
Element e = new Element(category, "");
Elements.Add(e);
foundElement = e;
return foundElement.Value;
}
/// <summary>
/// Deletes the first element with the same <c>element.Category</c> and returns the deleted element's position.
///
/// 删除第一个具有相同<see cref="Element.Category"/>的元素
/// </summary>
/// <param name="element"></param>
/// <returns>返回被删除元素的位置</returns>
private int Erase(Element element)
{
var removedIdx = -1;
for (var i = 0; i < Elements.Count; i++)
{
var currentElement = Elements[i];
if (element.Category != currentElement.Category) continue;
removedIdx = i;
Elements.RemoveAt(i);
break;
}
return removedIdx;
}
}
}

View File

@ -0,0 +1,397 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AnitomySharp
{
/// <summary>
/// Utility class to assist in the parsing.
///
/// 辅助解析的工具类
/// </summary>
public class ParserHelper
{
/// <summary>
/// 破折号
/// </summary>
private const string Dashes = "-\u2010\u2011\u2012\u2013\u2014\u2015";
/// <summary>
/// 带空格的破折号
/// </summary>
private const string DashesWithSpace = " -\u2010\u2011\u2012\u2013\u2014\u2015";
/// <summary>
/// 英文与数字匹配词典
/// </summary>
private static readonly Dictionary<string, string> Ordinals = new Dictionary<string, string>
{
{"1st", "1"}, {"First", "1"},
{"2nd", "2"}, {"Second", "2"},
{"3rd", "3"}, {"Third", "3"},
{"4th", "4"}, {"Fourth", "4"},
{"5th", "5"}, {"Fifth", "5"},
{"6th", "6"}, {"Sixth", "6"},
{"7th", "7"}, {"Seventh", "7"},
{"8th", "8"}, {"Eighth", "8"},
{"9th", "9"}, {"Ninth", "9"},
{"一", "1"}, {"壱", "1"},
{"二", "2"}, {"弐", "2"},
{"三", "3"}, {"参", "3"},
{"四", "4"}, {"上", "1"},
{"五", "5"}, {"下", "2"},
{"六", "6"}, {"前", "1"},
{"七", "7"}, {"後", "2"},
{"八", "8"}, {"中", "2"}, //most only 2 episodes
{"九", "9"}, {"", "1"},
{"十", "10"},{"Ⅱ", "2"},
{"Ⅲ", "3"}
};
/// <summary>
///
/// </summary>
private readonly Parser _parser;
/// <summary>
///
/// </summary>
/// <param name="parser"></param>
public ParserHelper(Parser parser)
{
_parser = parser;
}
/// <summary>
/// Returns whether or not the <c>result</c> matches the <c>category</c>.
///
/// 判断传入的标记(token)的类型是否与传入的类别一致
/// </summary>
/// <param name="result"></param>
/// <param name="category"></param>
/// <returns></returns>
public bool IsTokenCategory(int result, Token.TokenCategory category)
{
return Token.InListRange(result, _parser.Tokens) && _parser.Tokens[result].Category == category;
}
/// <summary>
/// Returns whether or not the <c>str</c> is a CRC string.
///
/// 如果给定字符串为CRC则返回`true`
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsCrc32(string str)
{
return str != null && str.Length == 8 && StringHelper.IsHexadecimalString(str);
}
/// <summary>
/// Returns whether or not the <c>character</c> is a dash character
///
/// 判断给定字符是否为破折号
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
public static bool IsDashCharacter(char c)
{
return Dashes.Contains(c.ToString());
}
/// <summary>
/// Returns a number from an original (e.g. 2nd)
///
/// 转换原始值中的英文数字
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static string GetNumberFromOrdinal(string str)
{
if (string.IsNullOrEmpty(str)) return "";
return Ordinals.TryGetValue(str, out var foundString) ? foundString : "";
}
/// <summary>
/// Returns the index of the first digit in the <c>str</c>; -1 otherwise.
///
/// 返回<c>str</c>中第一个数字的索引位置
/// </summary>
/// <param name="str"></param>
/// <returns>如果无数字,则返回-1</returns>
public static int IndexOfFirstDigit(string str)
{
if (string.IsNullOrEmpty(str)) return -1;
for (var i = 0; i < str.Length; i++)
{
if (char.IsDigit(str, i))
{
return i;
}
}
return -1;
}
/// <summary>
/// Returns whether or not the <c>str</c> is a resolution.
///
/// 如果给定字符串为分辨率,则返回`true`
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsResolution(string str)
{
if (string.IsNullOrEmpty(str)) return false;
const int minWidthSize = 3;
const int minHeightSize = 3;
if (str.Length >= minWidthSize + 1 + minHeightSize)
{
var pos = str.IndexOfAny("xX\u00D7".ToCharArray());
if (pos == -1 || pos < minWidthSize || pos > str.Length - (minHeightSize + 1)) return false;
return !str.Where((t, i) => i != pos && !char.IsDigit(t)).Any();
}
if (str.Length < minHeightSize + 1) return false;
{
if (char.ToLower(str[str.Length - 1]) != 'p') return false;
for (var i = 0; i < str.Length - 1; i++)
{
if (!char.IsDigit(str[i])) return false;
}
return true;
}
}
/// <summary>
/// Returns whether or not the <c>category</c> is searchable.
///
///
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
public bool IsElementCategorySearchable(Element.ElementCategory category)
{
switch (category)
{
case Element.ElementCategory.ElementAnimeSeasonPrefix:
case Element.ElementCategory.ElementAnimeType:
case Element.ElementCategory.ElementAudioTerm:
case Element.ElementCategory.ElementDeviceCompatibility:
case Element.ElementCategory.ElementEpisodePrefix:
case Element.ElementCategory.ElementFileChecksum:
case Element.ElementCategory.ElementLanguage:
case Element.ElementCategory.ElementOther:
case Element.ElementCategory.ElementReleaseGroup:
case Element.ElementCategory.ElementReleaseInformation:
case Element.ElementCategory.ElementReleaseVersion:
case Element.ElementCategory.ElementSource:
case Element.ElementCategory.ElementSubtitles:
case Element.ElementCategory.ElementVideoResolution:
case Element.ElementCategory.ElementVideoTerm:
case Element.ElementCategory.ElementVolumePrefix:
return true;
default:
return false;
}
}
/// <summary>
/// Returns whether the <c>category</c> is singular.
///
///
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
public bool IsElementCategorySingular(Element.ElementCategory category)
{
switch (category)
{
case Element.ElementCategory.ElementAnimeSeason:
case Element.ElementCategory.ElementAnimeType:
case Element.ElementCategory.ElementAudioTerm:
case Element.ElementCategory.ElementDeviceCompatibility:
case Element.ElementCategory.ElementEpisodeNumber:
case Element.ElementCategory.ElementLanguage:
case Element.ElementCategory.ElementOther:
case Element.ElementCategory.ElementReleaseInformation:
case Element.ElementCategory.ElementSource:
case Element.ElementCategory.ElementVideoTerm:
return false;
default:
return false;
}
}
/// <summary>
/// Returns whether or not a token at the current <c>pos</c> is isolated(surrounded by braces).
///
/// 判断当前位置标记(token)是否孤立,即是否被括号包裹
/// </summary>
/// <param name="pos"></param>
/// <returns></returns>
public bool IsTokenIsolated(int pos)
{
var prevToken = Token.FindPrevToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
if (!IsTokenCategory(prevToken, Token.TokenCategory.Bracket)) return false;
var nextToken = Token.FindNextToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
return IsTokenCategory(nextToken, Token.TokenCategory.Bracket);
}
/// <summary>
/// Returns whether or not a token at the current <c>pos+1</c> is ElementAnimeType.
///
/// 判断当前标记(token)的下一个标记的类型是否为ElementAnimeType。如果是则返回`true`
/// </summary>
/// <param name="pos"></param>
/// <returns></returns>
public bool IsTokenContainAnimeType(int pos)
{
var prevToken = Token.FindPrevToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
if (!IsTokenCategory(prevToken, Token.TokenCategory.Bracket)) return false;
var nextToken = Token.FindNextToken(_parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
return KeywordManager.Contains(Element.ElementCategory.ElementAnimeType, _parser.Tokens[nextToken].Content);
}
/// <summary>
/// Finds and sets the anime season keyword.
///
/// 查找动画季度关键词并添加对应元素
/// </summary>
/// <param name="token"></param>
/// <param name="currentTokenPos"></param>
/// <returns></returns>
public bool CheckAndSetAnimeSeasonKeyword(Token token, int currentTokenPos)
{
void SetAnimeSeason(Token first, Token second, string content)
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementAnimeSeason, content));
first.Category = Token.TokenCategory.Identifier;
second.Category = Token.TokenCategory.Identifier;
}
var previousToken = Token.FindPrevToken(_parser.Tokens, currentTokenPos, Token.TokenFlag.FlagNotDelimiter);
if (Token.InListRange(previousToken, _parser.Tokens))
{
var number = GetNumberFromOrdinal(_parser.Tokens[previousToken].Content);
if (!string.IsNullOrEmpty(number))
{
SetAnimeSeason(_parser.Tokens[previousToken], token, number);
return true;
}
}
var nextToken = Token.FindNextToken(_parser.Tokens, currentTokenPos, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(nextToken, _parser.Tokens) ||
!StringHelper.IsNumericString(_parser.Tokens[nextToken].Content)) return false;
SetAnimeSeason(token, _parser.Tokens[nextToken], _parser.Tokens[nextToken].Content);
return true;
}
/// <summary>
/// A Method to find the correct volume/episode number when prefixed (i.e. Vol.4).
///
/// 用于查找带前缀的正确的卷数/集数值
/// </summary>
/// <param name="category">the category we're searching for</param>
/// <param name="currentTokenPos">the current token position</param>
/// <param name="token">the token</param>
/// <returns>true if we found the volume/episode number</returns>
public bool CheckExtentKeyword(Element.ElementCategory category, int currentTokenPos, Token token)
{
var nToken = Token.FindNextToken(_parser.Tokens, currentTokenPos, Token.TokenFlag.FlagNotDelimiter);
if (!IsTokenCategory(nToken, Token.TokenCategory.Unknown)) return false;
if (IndexOfFirstDigit(_parser.Tokens[nToken].Content) != 0) return false;
switch (category)
{
case Element.ElementCategory.ElementEpisodeNumber:
if (!_parser.ParseNumber.MatchEpisodePatterns(_parser.Tokens[nToken].Content, _parser.Tokens[nToken]))
{
_parser.ParseNumber.SetEpisodeNumber(_parser.Tokens[nToken].Content, _parser.Tokens[nToken], false);
}
break;
case Element.ElementCategory.ElementVolumeNumber:
if (!_parser.ParseNumber.MatchVolumePatterns(_parser.Tokens[nToken].Content, _parser.Tokens[nToken]))
{
_parser.ParseNumber.SetVolumeNumber(_parser.Tokens[nToken].Content, _parser.Tokens[nToken], false);
}
break;
}
token.Category = Token.TokenCategory.Identifier;
return true;
}
/// <summary>
///
/// </summary>
/// <param name="category"></param>
/// <param name="keepDelimiters"></param>
/// <param name="tokens"></param>
public void BuildElement(Element.ElementCategory category, bool keepDelimiters, List<Token> tokens)
{
var element = new StringBuilder();
for (var i = 0; i < tokens.Count; i++)
{
var token = tokens[i];
switch (token.Category)
{
case Token.TokenCategory.Unknown:
element.Append(token.Content);
token.Category = Token.TokenCategory.Identifier;
break;
case Token.TokenCategory.Bracket:
element.Append(token.Content);
break;
case Token.TokenCategory.Delimiter:
var delimiter = "";
if (!string.IsNullOrEmpty(token.Content))
{
delimiter = token.Content[0].ToString();
}
if (keepDelimiters)
{
element.Append(delimiter);
}
else if (Token.InListRange(i, tokens))
{
switch (delimiter)
{
case ",":
case "&":
element.Append(delimiter);
break;
default:
element.Append(' ');
break;
}
}
break;
}
}
if (!keepDelimiters)
{
element = new StringBuilder(element.ToString().Trim(DashesWithSpace.ToCharArray()));
}
if (!string.IsNullOrEmpty(element.ToString()))
{
_parser.Elements.Add(new Element(category, element.ToString()));
}
}
}
}

View File

@ -0,0 +1,792 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace AnitomySharp
{
/// <summary>
/// A utility class to assist in number parsing.
/// </summary>
public class ParserNumber
{
/// <summary>
/// 动画最小计算年份
/// </summary>
public const int AnimeYearMin = 1900;
/// <summary>
/// 动画最大计算年份
/// </summary>
public const int AnimeYearMax = 2100;
/// <summary>
///
/// </summary>
private const int EpisodeNumberMax = 9999;
/// <summary>
/// 最大卷数
/// </summary>
private const int VolumeNumberMax = 99;
/// <summary>
/// 正则开头
/// </summary>
public const string RegexMatchOnlyStart = @"\A(?:";
/// <summary>
/// 正则结尾
/// </summary>
public const string RegexMatchOnlyEnd = @")\z";
/// <summary>
///
/// </summary>
private readonly Parser _parser;
/// <summary>
///
/// </summary>
/// <param name="parser"></param>
public ParserNumber(Parser parser)
{
_parser = parser;
}
/// <summary>
/// Returns whether or not the <c>number</c> is a volume number
///
/// 返验证卷数字符串是否有效
/// </summary>
private static bool IsValidVolumeNumber(string number)
{
return StringHelper.StringToInt(number) <= VolumeNumberMax;
}
/// <summary>
/// Returns whether or not the <c>number</c> is a valid episode number.
///
/// 验证集数字符串是否有效
/// </summary>
private static bool IsValidEpisodeNumber(string number)
{
// Eliminate non numeric portion of number, then parse as double.
var temp = "";
for (var i = 0; i < number.Length && char.IsDigit(number[i]); i++)
{
temp += number[i];
}
return !string.IsNullOrEmpty(temp) && double.Parse(temp) <= EpisodeNumberMax;
}
/// <summary>
/// Sets the alternative episode number.
/// </summary>
private bool SetAlternativeEpisodeNumber(string number, Token token)
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementEpisodeNumberAlt, number));
token.Category = Token.TokenCategory.Identifier;
return true;
}
/// <summary>
/// Sets the volume number.
///
/// 添加卷数元素
/// </summary>
/// <param name="number">the number</param>
/// <param name="token">the token which contains the volume number</param>
/// <param name="validate">true if we should check if it's a valid number, false to disable verification</param>
/// <returns>true if the volume number was set</returns>
public bool SetVolumeNumber(string number, Token token, bool validate)
{
if (validate && !IsValidVolumeNumber(number)) return false;
_parser.Elements.Add(new Element(Element.ElementCategory.ElementVolumeNumber, number));
token.Category = Token.TokenCategory.Identifier;
return true;
}
/// <summary>
/// Sets the anime episode number.
///
/// 添加集数元素
/// </summary>
/// <param name="number">the episode number</param>
/// <param name="token">the token which contains the volume number</param>
/// <param name="validate">true if we should check if it's a valid episode number; false to disable validation</param>
/// <returns>true if the episode number was set</returns>
public bool SetEpisodeNumber(string number, Token token, bool validate)
{
if (validate && !IsValidEpisodeNumber(number)) return false;
token.Category = Token.TokenCategory.Identifier;
var category = Element.ElementCategory.ElementEpisodeNumber;
/** Handle equivalent numbers */
if (_parser.IsEpisodeKeywordsFound)
{
foreach (var element in _parser.Elements)
{
if (element.Category != Element.ElementCategory.ElementEpisodeNumber) continue;
/** The larger number gets to be the alternative one */
var comparison = StringHelper.StringToInt(number) - StringHelper.StringToInt(element.Value);
if (comparison > 0)
{
category = Element.ElementCategory.ElementEpisodeNumberAlt;
}
else if (comparison < 0)
{
element.Category = Element.ElementCategory.ElementEpisodeNumberAlt;
}
else
{
return false; /** No need to add the same number twice */
}
break;
}
}
_parser.Elements.Add(new Element(category, number));
return true;
}
/// <summary>
/// Checks if a number follows the specified <c>token</c>
///
/// 确认此标记中是否包含给定元素类型的关键词,如果包含且其能满足匹配模式,则添加此元素
/// </summary>
/// <param name="category">the category to set if a number follows the <c>token</c></param>
/// <param name="token">the token</param>
/// <returns>true if a number follows the token; false otherwise</returns>
private bool NumberComesAfterPrefix(Element.ElementCategory category, Token token)
{
var numberBegin = ParserHelper.IndexOfFirstDigit(token.Content);
var prefix = StringHelper.SubstringWithCheck(token.Content, 0, numberBegin).ToUpperInvariant();
if (!KeywordManager.Contains(category, prefix)) return false;
var number = StringHelper.SubstringWithCheck(token.Content, numberBegin, token.Content.Length - numberBegin);
switch (category)
{
case Element.ElementCategory.ElementEpisodePrefix:
if (!MatchEpisodePatterns(number, token))
{
SetEpisodeNumber(number, token, false);
}
return true;
case Element.ElementCategory.ElementVolumePrefix:
if (!MatchVolumePatterns(number, token))
{
SetVolumeNumber(number, token, false);
}
return true;
default:
return false;
}
}
/// <summary>
/// Checks whether the number precedes the word "of"
/// </summary>
/// <param name="token">the token</param>
/// <param name="currentTokenIdx">the index of the token</param>
/// <returns>true if the token precedes the word "of"</returns>
private bool NumberComesBeforeAnotherNumber(Token token, int currentTokenIdx)
{
var separatorToken = Token.FindNextToken(_parser.Tokens, currentTokenIdx, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(separatorToken, _parser.Tokens)) return false;
var separators = new List<Tuple<string, bool>>
{
Tuple.Create("&", true),
Tuple.Create("of", false)
};
foreach (var separator in separators)
{
if (_parser.Tokens[separatorToken].Content != separator.Item1) continue;
var otherToken = Token.FindNextToken(_parser.Tokens, separatorToken, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(otherToken, _parser.Tokens)
|| !StringHelper.IsNumericString(_parser.Tokens[otherToken].Content)) continue;
SetEpisodeNumber(token.Content, token, false);
if (separator.Item2)
{
SetEpisodeNumber(_parser.Tokens[otherToken].Content, _parser.Tokens[otherToken], false);
}
_parser.Tokens[separatorToken].Category = Token.TokenCategory.Identifier;
_parser.Tokens[otherToken].Category = Token.TokenCategory.Identifier;
return true;
}
return false;
}
// EPISODE MATCHERS
/// <summary>
/// Attempts to find an episode/season inside a <c>word</c>
///
/// 在传入的字符串中共尝试匹配季/集
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the word was matched to an episode/season number</returns>
public bool MatchEpisodePatterns(string word, Token token)
{
if (StringHelper.IsNumericString(word)) return false;
word = word.Trim(" -".ToCharArray());
// 根据前后是否为数字进行分流处理
var numericFront = char.IsDigit(word[0]);
var numericBack = char.IsDigit(word[word.Length - 1]);
if (numericFront && numericBack)
{
// e.g. "01v2"
if (MatchSingleEpisodePattern(word, token))
{
return true;
}
// e.g. "01-02", "03-05v2"
if (MatchMultiEpisodePattern(word, token))
{
return true;
}
// e.g. "07.5"
if (MatchFractionalEpisodePattern(word, token))
{
return true;
}
}
if (numericBack)
{
// e.g. "2x01", "S01E03", "S01-02xE001-150"
if (MatchSeasonAndEpisodePattern(word, token))
{
return true;
}
// e.g. "#01", "#02-03v2"
if (MatchNumberSignPattern(word, token))
{
return true;
}
}
// e.g. "ED1", "OP4a", "OVA2"
if (!numericFront && MatchTypeAndEpisodePattern(word, token))
{
return true;
}
// e.g. "4a", "111C"
if (numericFront && !numericBack && MatchPartialEpisodePattern(word, token))
{
return true;
}
// e.g. "01-24Fin"
if (word.IndexOf("fin", StringComparison.OrdinalIgnoreCase) >= 0)
{
if (MatchMultiEpisodePattern(word, token))
{
return true;
}
}
// U+8A71 is used as counter for stories, episodes of TV series, etc.
return MatchJapaneseCounterPattern(word, token);
}
/// <summary>
/// Match a single episode pattern. e.g. "01v2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchSingleEpisodePattern(string word, Token token)
{
const string regexPattern = RegexMatchOnlyStart + @"(\d{1,4})[vV](\d)" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
SetEpisodeNumber(match.Groups[1].Value, token, false);
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[2].Value));
return true;
}
/// <summary>
/// Match a multi episode pattern. e.g. "01-02", "03-05v2", "01-24Fin".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchMultiEpisodePattern(string word, Token token)
{
const string regexPattern = RegexMatchOnlyStart + @"(\d{1,4})(?:[vV](\d))?[-~&+](\d{1,4})(?:[vV](\d))?(FIN|Fin|fin)?" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
var lowerBound = match.Groups[1].Value;
var upperBound = match.Groups[3].Value;
/** Avoid matching expressions such as "009-1" or "5-2" */
if (StringHelper.StringToInt(lowerBound) >= StringHelper.StringToInt(upperBound)) return false;
if (!SetEpisodeNumber(lowerBound, token, true)) return false;
SetEpisodeNumber(upperBound, token, true);
if (!string.IsNullOrEmpty(match.Groups[2].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[2].Value));
}
if (!string.IsNullOrEmpty(match.Groups[4].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[4].Value));
}
if (!string.IsNullOrEmpty(match.Groups[5].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseInformation, match.Groups[5].Value));
}
return true;
}
/// <summary>
/// Match season and episode patterns. e.g. "2x01", "S01E03", "S01-02xE001-150", "S01E06v2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchSeasonAndEpisodePattern(string word, Token token)
{
const string regexPattern = RegexMatchOnlyStart + @"S?(\d{1,2})(?:-S?(\d{1,2}))?(?:x|[ ._-x]?E)(\d{1,4})(?:-E?(\d{1,4}))?(?:[vV](\d{1,2}))?" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
_parser.Elements.Add(new Element(Element.ElementCategory.ElementAnimeSeason, match.Groups[1].Value));
if (!string.IsNullOrEmpty(match.Groups[2].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementAnimeSeason, match.Groups[2].Value));
}
SetEpisodeNumber(match.Groups[3].Value, token, false);
if (!string.IsNullOrEmpty(match.Groups[4].Value))
{
SetEpisodeNumber(match.Groups[4].Value, token, false);
}
return true;
}
/// <summary>
/// Match type and episode. e.g. "ED1", "OP4a", "OVA2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchTypeAndEpisodePattern(string word, Token token)
{
var numberBegin = ParserHelper.IndexOfFirstDigit(word);
var prefix = StringHelper.SubstringWithCheck(word, 0, numberBegin);
var category = Element.ElementCategory.ElementAnimeType;
var options = new KeywordOptions();
if (!KeywordManager.FindAndSet(KeywordManager.Normalize(prefix), ref category, ref options)) return false;
_parser.Elements.Add(new Element(Element.ElementCategory.ElementAnimeType, prefix));
var number = word.Substring(numberBegin);
if (!MatchEpisodePatterns(number, token) && !SetEpisodeNumber(number, token, true)) return false;
var foundIdx = _parser.Tokens.IndexOf(token);
if (foundIdx == -1) return true;
token.Content = number;
_parser.Tokens.Insert(foundIdx,
new Token(options.Identifiable ? Token.TokenCategory.Identifier : Token.TokenCategory.Unknown, token.Enclosed, prefix));
return true;
}
/// <summary>
/// Match fractional episodes. e.g. "07.5"
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchFractionalEpisodePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word))
{
word = "";
}
const string regexPattern = RegexMatchOnlyStart + @"\d+\.5" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
return match.Success && SetEpisodeNumber(word, token, true);
}
/// <summary>
/// Match partial episodes. e.g. "4a", "111C".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchPartialEpisodePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) return false;
var foundIdx = Enumerable.Range(0, word.Length)
.DefaultIfEmpty(word.Length)
.FirstOrDefault(value => !char.IsDigit(word[value]));
var suffixLength = word.Length - foundIdx;
bool IsValidSuffix(int c) => c >= 'A' && c <= 'C' || c >= 'a' && c <= 'c';
return suffixLength == 1 && IsValidSuffix(word[foundIdx]) && SetEpisodeNumber(word, token, true);
}
/// <summary>
/// Match episodes with number signs. e.g. "#01", "#02-03v2"
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchNumberSignPattern(string word, Token token)
{
if (string.IsNullOrEmpty(word) || word[0] != '#') word = "";
const string regexPattern = RegexMatchOnlyStart + @"#(\d{1,4})(?:[-~&+](\d{1,4}))?(?:[vV](\d))?" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
if (!SetEpisodeNumber(match.Groups[1].Value, token, true)) return false;
if (!string.IsNullOrEmpty(match.Groups[2].Value))
{
SetEpisodeNumber(match.Groups[2].Value, token, false);
}
if (!string.IsNullOrEmpty(match.Groups[3].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[3].Value));
}
return true;
}
/// <summary>
/// Match Japanese patterns. e.g. U+8A71 is used as counter for stories, episodes of TV series, etc.
///
/// 匹配日文中常见顺序词
///
/// 符合这种匹配模式的,一般在集数后都紧跟本集标题 #TODO
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
public bool MatchJapaneseCounterPattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) return false;
// 1st|2nd|3rd| #TODO
string regexPattern = @"(|Ⅱ|Ⅲ)";
var match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = ParserHelper.GetNumberFromOrdinal(match.Groups[1].Value);
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
regexPattern = @"([上中下前後])([巻卷編编])";
match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = ParserHelper.GetNumberFromOrdinal(match.Groups[1].Value);
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
regexPattern = @"([第全]?)([0-9一二三四五六七八九十壱弐参]+)([期章話话巻卷幕夜期発縛])";
match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = match.Groups[2].Value;
if (!StringHelper.IsNumericString(episodeNumber))
{
episodeNumber = ParserHelper.GetNumberFromOrdinal(episodeNumber);
}
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
regexPattern = @"(vol|EPISODE|ACT|scene|ep|volume|screen|voice|case|menu|rail|round|game|page|collection|cage|office|doll|Princess)([ \.\-_])([0-9]+)";
match = Regex.Match(word, RegexMatchOnlyStart + regexPattern + RegexMatchOnlyEnd, RegexOptions.IgnoreCase);
if (match.Success)
{
var episodeNumber = match.Groups[3].Value;
SetEpisodeNumber(episodeNumber, token, false);
return true;
}
else
{
return false;
}
}
// VOLUME MATCHES
/// <summary>
/// Attempts to find an episode/season inside a <c>word</c>
///
/// 在传入的字符串中共尝试匹配季/集
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the word was matched to an episode/season number</returns>
public bool MatchVolumePatterns(string word, Token token)
{
// All patterns contain at least one non-numeric character
if (StringHelper.IsNumericString(word)) return false;
word = word.Trim(" -".ToCharArray());
var numericFront = char.IsDigit(word[0]);
var numericBack = char.IsDigit(word[word.Length - 1]);
if (numericFront && numericBack)
{
// e.g. "01v2" e.g. "01-02", "03-05v2"
return MatchSingleVolumePattern(word, token) || MatchMultiVolumePattern(word, token);
}
return false;
}
/// <summary>
/// Match single volume. e.g. "01v2"
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchSingleVolumePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) word = "";
const string regexPattern = RegexMatchOnlyStart + @"(\d{1,2})[vV](\d)" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
SetVolumeNumber(match.Groups[1].Value, token, false);
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[2].Value));
return true;
}
/// <summary>
/// Match multi-volume. e.g. "01-02", "03-05v2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchMultiVolumePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) word = "";
const string regexPattern = RegexMatchOnlyStart + @"(\d{1,2})[-~&+](\d{1,2})(?:[vV](\d))?" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
if (!match.Success) return false;
var lowerBound = match.Groups[1].Value;
var upperBound = match.Groups[2].Value;
if (StringHelper.StringToInt(lowerBound) >= StringHelper.StringToInt(upperBound)) return false;
if (!SetVolumeNumber(lowerBound, token, true)) return false;
SetVolumeNumber(upperBound, token, false);
if (string.IsNullOrEmpty(match.Groups[3].Value))
{
_parser.Elements.Add(new Element(Element.ElementCategory.ElementReleaseVersion, match.Groups[3].Value));
}
return true;
}
// SEARCH
/// <summary>
/// Searches for isolated numbers in a list of <c>tokens</c>.
///
/// 搜索孤立数字
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an isolated number was found</returns>
public bool SearchForIsolatedNumbers(IEnumerable<int> tokens)
{
return tokens
.Where(it => _parser.Tokens[it].Enclosed && _parser.ParseHelper.IsTokenIsolated(it))
.Any(it => SetEpisodeNumber(_parser.Tokens[it].Content, _parser.Tokens[it], true));
}
/// <summary>
/// Searches for separated numbers in a list of <c>tokens</c>.
///
/// 搜索带分隔符的数字
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true fi a separated number was found</returns>
public bool SearchForSeparatedNumbers(List<int> tokens)
{
foreach (var it in tokens)
{
var previousToken = Token.FindPrevToken(_parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
// See if the number has a preceding "-" separator
if (!_parser.ParseHelper.IsTokenCategory(previousToken, Token.TokenCategory.Unknown)
|| !ParserHelper.IsDashCharacter(_parser.Tokens[previousToken].Content[0])) continue;
if (!SetEpisodeNumber(_parser.Tokens[it].Content, _parser.Tokens[it], true)) continue;
_parser.Tokens[previousToken].Category = Token.TokenCategory.Identifier;
return true;
}
return false;
}
/// <summary>
/// Searches for episode patterns in a list of <c>tokens</c>.
///
/// 在标记列表中匹配集数模式
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an episode number was found</returns>
public bool SearchForEpisodePatterns(List<int> tokens)
{
foreach (var it in tokens)
{
var numericFront = _parser.Tokens[it].Content.Length > 0 && char.IsDigit(_parser.Tokens[it].Content[0]);
if (!numericFront)
{
// e.g. "EP.1", "Vol.1"
if (NumberComesAfterPrefix(Element.ElementCategory.ElementEpisodePrefix, _parser.Tokens[it]))
{
return true;
}
if (NumberComesAfterPrefix(Element.ElementCategory.ElementVolumePrefix, _parser.Tokens[it]))
{
continue;
}
}
else
{
// e.g. "8 & 10", "01 of 24"
if (NumberComesBeforeAnotherNumber(_parser.Tokens[it], it))
{
return true;
}
}
// Look for other patterns
if (MatchEpisodePatterns(_parser.Tokens[it].Content, _parser.Tokens[it]))
{
return true;
}
}
return false;
}
/// <summary>
/// Searches for equivalent number in a list of <c>tokens</c>. e.g. 08(114)
///
/// 匹配自带等效集数的数字,常见于分割放送
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an equivalent number was found</returns>
public bool SearchForEquivalentNumbers(List<int> tokens)
{
foreach (var it in tokens)
{
// Find number must be isolated.
if (_parser.ParseHelper.IsTokenIsolated(it) || !IsValidEpisodeNumber(_parser.Tokens[it].Content))
{
continue;
}
// Find the first enclosed, non-delimiter token
var nextToken = Token.FindNextToken(_parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
if (!_parser.ParseHelper.IsTokenCategory(nextToken, Token.TokenCategory.Bracket)) continue;
nextToken = Token.FindNextToken(_parser.Tokens, nextToken, Token.TokenFlag.FlagEnclosed,
Token.TokenFlag.FlagNotDelimiter);
if (!_parser.ParseHelper.IsTokenCategory(nextToken, Token.TokenCategory.Unknown)) continue;
// Check if it's an isolated number
if (!_parser.ParseHelper.IsTokenIsolated(nextToken)
|| !StringHelper.IsNumericString(_parser.Tokens[nextToken].Content)
|| !IsValidEpisodeNumber(_parser.Tokens[nextToken].Content))
{
continue;
}
var list = new List<Token>
{
_parser.Tokens[it], _parser.Tokens[nextToken]
};
list.Sort((o1, o2) => StringHelper.StringToInt(o1.Content) - StringHelper.StringToInt(o2.Content));
SetEpisodeNumber(list[0].Content, list[0], false);
SetAlternativeEpisodeNumber(list[1].Content, list[1]);
return true;
}
return false;
}
/// <summary>
/// Searches for the last number token in a list of <c>tokens</c>
///
/// 搜索最后一个数字
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if the last number token was found</returns>
public bool SearchForLastNumber(List<int> tokens)
{
for (var i = tokens.Count - 1; i >= 0; i--)
{
var it = tokens[i];
// Assuming that episode number always comes after the title,
// the first token cannot be what we're looking for
if (it == 0) continue;
if (_parser.Tokens[it].Enclosed) continue;
// Ignore if it's the first non-enclosed, non-delimiter token
if (_parser.Tokens.GetRange(0, it)
.All(r => r.Enclosed || r.Category == Token.TokenCategory.Delimiter))
{
continue;
}
var previousToken = Token.FindPrevToken(_parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
if (_parser.ParseHelper.IsTokenCategory(previousToken, Token.TokenCategory.Unknown))
{
if (_parser.Tokens[previousToken].Content.Equals("Movie", StringComparison.InvariantCultureIgnoreCase)
|| _parser.Tokens[previousToken].Content.Equals("Part", StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
}
// We'll use this number after all
if (SetEpisodeNumber(_parser.Tokens[it].Content, _parser.Tokens[it], true))
{
return true;
}
}
return false;
}
}
}

View File

@ -0,0 +1,155 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Linq;
namespace AnitomySharp
{
/// <summary>
/// A string helper class that is analogous to <c>string.cpp</c> of the original Anitomy, and <c>StringHelper.java</c> of AnitomyJ.
/// </summary>
public static class StringHelper
{
/// <summary>
/// Returns whether or not the character is alphanumeric
///
/// 如果给定字符为字母或数字,则返回`true`
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
public static bool IsAlphanumericChar(char c)
{
return c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z';
}
/// <summary>
/// Returns whether or not the character is a hex character.
///
/// 如果给定字符为十六进制字符,则返回`true`
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
private static bool IsHexadecimalChar(char c)
{
return c >= '0' && c <= '9' || c >= 'A' && c <= 'F' || c >= 'a' && c <= 'f';
}
/// <summary>
/// Returns whether or not the character is a latin character
///
/// 判断给定字符是否为拉丁字符
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
private static bool IsLatinChar(char c)
{
// We're just checking until the end of the Latin Extended-B block,
// rather than all the blocks that belong to the Latin script.
return c <= '\u024F';
}
/// <summary>
/// Returns whether or not the character is a Chinese character
///
/// 判断给定字符是否为中文字符
/// </summary>
/// <param name="c"></param>
/// <returns></returns>
private static bool IsChineseChar(char c)
{
// We're just checking until the end of the Latin Extended-B block,
// rather than all the blocks that belong to the Latin script.
return c <= '\u9FFF' && c >= '\u4E00';
}
/// <summary>
/// Returns whether or not the <c>str</c> is a hex string.
///
/// 如果给定字符串为十六进制字符串,则返回`true`
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsHexadecimalString(string str)
{
return !string.IsNullOrEmpty(str) && str.All(IsHexadecimalChar);
}
/// <summary>
/// Returns whether or not the <c>str</c> is mostly a latin string.
///
/// 判断给定字符串是否过半字符为拉丁
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsMostlyLatinString(string str)
{
var length = !string.IsNullOrEmpty(str) ? 1.0 : str.Length;
return str.Where(IsLatinChar).Count() / length >= 0.5;
}
/// <summary>
/// Returns whether or not the <c>str</c> is mostly a Chinese string.
///
/// 判断给定字符串是否过半字符为中文
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsMostlyChineseString(string str)
{
var length = !string.IsNullOrEmpty(str) ? 1.0 : str.Length;
return str.Where(IsChineseChar).Count() / length >= 0.5;
}
/// <summary>
/// Returns whether or not the <c>str</c> is a numeric string.
///
/// 判断字符串是否全数字
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static bool IsNumericString(string str)
{
return str.All(char.IsDigit);
}
/// <summary>
/// Returns the int value of the <c>str</c>; 0 otherwise.
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public static int StringToInt(string str)
{
try
{
return int.Parse(str);
}
catch (Exception ex)
{
Console.WriteLine(ex);
return 0;
}
}
/// <summary>
/// 提取给定范围的子字符串
/// </summary>
/// <param name="str"></param>
/// <param name="start"></param>
/// <param name="count"></param>
/// <returns></returns>
public static string SubstringWithCheck(string str, int start, int count)
{
if (start + count > str.Length) count = str.Length - start;
return str.Substring(start, count);
}
}
}

View File

@ -0,0 +1,331 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
namespace AnitomySharp
{
/// <summary>
/// An anime filename is tokenized into individual <see cref="Token"/>s. This class represents an individual token.
///
/// 动画文件名被标记化为单一的标记(token)
/// </summary>
public class Token
{
/// <summary>
/// The category of the token.
///
/// 标记(token)类型
/// </summary>
public enum TokenCategory
{
/// <summary>
/// 未知类型,
///
/// 包括:无括号/分隔符的字符串;分隔符分割后的字符串
/// </summary>
Unknown,
/// <summary>
/// 括号
/// </summary>
Bracket,
/// <summary>
/// 分隔符包括Options.AllowedDelimiters
/// </summary>
Delimiter,
/// <summary>
/// 标识符包括关键词一眼真keyword<see cref="KeywordManager.PeekEntries"/>被添加到token
/// </summary>
Identifier,
/// <summary>
/// 无效,错误的标记,不会出现在最后的标记(token)列表中。比如在<see cref="Tokenizer.ValidateDelimiterTokens">验证分隔符切分的标记</see>时,规则匹配到的无效标记(token)
/// </summary>
Invalid
}
/// <summary>
/// TokenFlag, used for searching specific token categories. This allows granular searching of TokenCategories.
///
/// 标记符,用于细粒度搜索特定的标记类型(<see cref="TokenCategory"/>)。
/// </summary>
public enum TokenFlag
{
/// <summary>
/// None 无
/// </summary>
FlagNone,
// Categories
/// <summary>
/// 括号符
/// </summary>
FlagBracket,
/// <summary>
///
/// </summary>
FlagNotBracket,
/// <summary>
/// 分隔符
/// </summary>
FlagDelimiter,
/// <summary>
///
/// </summary>
FlagNotDelimiter,
/// <summary>
/// 标识符
/// </summary>
FlagIdentifier,
/// <summary>
///
/// </summary>
FlagNotIdentifier,
/// <summary>
/// 未知
/// </summary>
FlagUnknown,
/// <summary>
///
/// </summary>
FlagNotUnknown,
/// <summary>
/// 有效
/// </summary>
FlagValid,
/// <summary>
///
/// </summary>
FlagNotValid,
// Enclosed (Meaning that it is enclosed in some bracket (e.g. [ ] ))
/// <summary>
/// 闭合符
/// </summary>
FlagEnclosed,
/// <summary>
/// 未闭合符
/// </summary>
FlagNotEnclosed
}
/// <summary>
/// Set of token category flags
///
/// 标识符分类列表
/// </summary>
private static readonly List<TokenFlag> FlagMaskCategories = new List<TokenFlag>
{
TokenFlag.FlagBracket, TokenFlag.FlagNotBracket,
TokenFlag.FlagDelimiter, TokenFlag.FlagNotDelimiter,
TokenFlag.FlagIdentifier, TokenFlag.FlagNotIdentifier,
TokenFlag.FlagUnknown, TokenFlag.FlagNotUnknown,
TokenFlag.FlagValid, TokenFlag.FlagNotValid
};
/// <summary>
/// Set of token enclosed flags
///
/// 闭合的标识符列表
/// </summary>
private static readonly List<TokenFlag> FlagMaskEnclosed = new List<TokenFlag>
{
TokenFlag.FlagEnclosed, TokenFlag.FlagNotEnclosed
};
/// <summary>
/// 标记的类型
/// </summary>
public TokenCategory Category { get; set; }
/// <summary>
/// 标记的内容
/// </summary>
public string Content { get; set; }
/// <summary>
/// 标记是否被括号包裹
/// </summary>
public bool Enclosed { get; }
/// <summary>
/// Constructs a new token
///
/// 构造一个新的标记(token)
/// </summary>
/// <param name="category">the token category</param>
/// <param name="enclosed">whether or not the token is enclosed in braces</param>
/// <param name="content">the token content</param>
public Token(TokenCategory category, bool enclosed, string content)
{
Category = category;
Enclosed = enclosed;
Content = content;
}
/// <summary>
/// Validates a token against the <c>flags</c>. The <c>flags</c> is used as a search parameter.
///
/// 验证传入的标记(token)是否满足标记符(flag)
/// </summary>
/// <param name="token">the token</param>
/// <param name="flags">the flags the token must conform against</param>
/// <returns>true if the token conforms to the set of <c>flags</c>; false otherwise</returns>
private static bool CheckTokenFlags(Token token, ICollection<TokenFlag> flags)
{
// Simple alias to check if flag is a part of the set
bool CheckFlag(TokenFlag flag)
{
return flags.Contains(flag);
}
// Make sure token is the correct closure
if (flags.Any(f => FlagMaskEnclosed.Contains(f)))
{
var success = CheckFlag(TokenFlag.FlagEnclosed) == token.Enclosed;
if (!success) return false; // Not enclosed correctly (e.g. enclosed when we're looking for non-enclosed).
}
// Make sure token is the correct category
if (!flags.Any(f => FlagMaskCategories.Contains(f))) return true;
var secondarySuccess = false;
void CheckCategory(TokenFlag fe, TokenFlag fn, TokenCategory c)
{
if (secondarySuccess) return;
var result = CheckFlag(fe) ? token.Category == c : CheckFlag(fn) && token.Category != c;
secondarySuccess = result;
}
CheckCategory(TokenFlag.FlagBracket, TokenFlag.FlagNotBracket, TokenCategory.Bracket);
CheckCategory(TokenFlag.FlagDelimiter, TokenFlag.FlagNotDelimiter, TokenCategory.Delimiter);
CheckCategory(TokenFlag.FlagIdentifier, TokenFlag.FlagNotIdentifier, TokenCategory.Identifier);
CheckCategory(TokenFlag.FlagUnknown, TokenFlag.FlagNotUnknown, TokenCategory.Unknown);
CheckCategory(TokenFlag.FlagNotValid, TokenFlag.FlagValid, TokenCategory.Invalid);
return secondarySuccess;
}
/// <summary>
/// Given a list of <c>tokens</c>, searches for any token token that matches the list of <c>flags</c>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <param name="begin">the search starting position.</param>
/// <param name="end">the search ending position.</param>
/// <param name="flags">the search flags</param>
/// <returns>the search result</returns>
public static int FindToken(List<Token> tokens, int begin, int end, params TokenFlag[] flags)
{
return FindTokenBase(tokens, begin, end, i => i < tokens.Count, i => i + 1, flags);
}
/// <summary>
/// Given a list of <c>tokens</c>, searches for the next token in <c>tokens</c> that matches the list of <c>flags</c>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <param name="first">the search starting position.</param>
/// <param name="flags">the search flags</param>
/// <returns>the search result</returns>
public static int FindNextToken(List<Token> tokens, int first, params TokenFlag[] flags)
{
return FindTokenBase(tokens, first + 1, tokens.Count, i => i < tokens.Count, i => i + 1, flags);
}
/// <summary>
/// Given a list of <c>tokens</c>, searches for the previous token in <c>tokens</c> that matches the list of <c>flags</c>.
///
/// 在给定的标记列表中搜索匹配输入的标记符前一个标记
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <param name="begin">the search starting position. Exclusive of position.Pos</param>
/// <param name="flags">the search flags</param>
/// <returns>the search result</returns>
public static int FindPrevToken(List<Token> tokens, int begin, params TokenFlag[] flags)
{
return FindTokenBase(tokens, begin - 1, -1, i => i >= 0, i => i - 1, flags);
}
/// <summary>
/// Given a list of tokens finds the first token that passes <see cref="CheckTokenFlags"/>.
///
/// 在给定的标记列表中找到第一个通过<see cref="CheckTokenFlags"/>的标记(token)
/// </summary>
/// <param name="tokens">the list of the tokens to search</param>
/// <param name="begin">the start index of the search.</param>
/// <param name="end">the end index of the search.</param>
/// <param name="shouldContinue">a function that returns whether or not we should continue searching</param>
/// <param name="next">a function that returns the next search index</param>
/// <param name="flags">the flags that each token should be validated against</param>
/// <returns>the found token</returns>
private static int FindTokenBase(
List<Token> tokens,
int begin,
int end,
Func<int, bool> shouldContinue,
Func<int, int> next,
params TokenFlag[] flags)
{
var find = new List<TokenFlag>();
find.AddRange(flags);
for (var i = begin; shouldContinue(i); i = next(i))
{
var token = tokens[i];
if (CheckTokenFlags(token, find))
{
return i;
}
}
return end;
}
/// <summary>
///
/// </summary>
/// <param name="pos"></param>
/// <param name="list"></param>
/// <returns></returns>
public static bool InListRange(int pos, List<Token> list)
{
return -1 < pos && pos < list.Count;
}
/// <summary>
///
/// </summary>
/// <param name="o"></param>
/// <returns></returns>
public override bool Equals(object o)
{
if (this == o) return true;
if (!(o is Token)) return false;
var token = (Token)o;
return Enclosed == token.Enclosed && Category == token.Category && Equals(Content, token.Content);
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public override int GetHashCode()
{
var hashCode = -1776802967;
hashCode = hashCode * -1521134295 + Category.GetHashCode();
hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Content);
hashCode = hashCode * -1521134295 + Enclosed.GetHashCode();
return hashCode;
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public override string ToString()
{
return $"Token{{category={Category}, content='{Content}', enclosed={Enclosed}}}";
}
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp
{
/// <summary>
/// 标记(token)的位置
/// </summary>
public struct TokenRange
{
/// <summary>
/// 偏移值
/// </summary>
public int Offset;
/// <summary>
/// Token长度
/// </summary>
public int Size;
/// <summary>
/// 构造<see cref="TokenRange"/>
/// </summary>
/// <param name="offset"></param>
/// <param name="size"></param>
public TokenRange(int offset, int size)
{
Offset = offset;
Size = size;
}
}
}

View File

@ -0,0 +1,391 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AnitomySharp
{
/// <summary>
/// A class that will tokenize an anime filename.
///
/// 用于动画文件名标记化的分词器
/// </summary>
public class Tokenizer
{
/// <summary>
/// 用于标记化的文件名
/// </summary>
private readonly string _filename;
/// <summary>
/// 用于添加预处理后标记(token)的元素列表
/// </summary>
private readonly List<Element> _elements;
/// <summary>
/// 用于解析的配置
/// </summary>
private readonly Options _options;
/// <summary>
/// 用于存储标记(token)的列表
/// </summary>
private readonly List<Token> _tokens;
/// <summary>
/// 括号列表
/// </summary>
private static readonly List<Tuple<string, string>> Brackets = new List<Tuple<string, string>>
{
new Tuple<string, string>("(", ")"), // U+0028-U+0029
new Tuple<string, string>("[", "]"), // U+005B-U+005D Square bracket
new Tuple<string, string>("{", "}"), // U+007B-U+007D Curly bracket
new Tuple<string, string>("\u300C", "\u300D"), // Corner bracket 「」
new Tuple<string, string>("\u300E", "\u300F"), // White corner bracket 『 』
new Tuple<string, string>("\u3010", "\u3011"), // Black lenticular bracket 【】
new Tuple<string, string>("\u3014", "\u3015"), // Black lenticular bracket
new Tuple<string, string>("\u3016", "\u3017"), // Black lenticular bracket 〖 〗
new Tuple<string, string>("\uFF08", "\uFF09"), // Fullwidth parenthesis
new Tuple<string, string>("\uFF3B", "\uFF3D"), // Fullwidth parenthesis
new Tuple<string, string>("\uFF5B", "\uFF5D") // Fullwidth parenthesis
};
/// <summary>
/// Tokenize a filename into <see cref="Element"/>s
///
/// 将传入的文件名标记化,拆分为单个元素
///
/// </summary>
/// <param name="filename">the filename</param>
/// <param name="elements">the list of elements where pre-identified tokens will be added</param>
/// <param name="options">the parser options</param>
/// <param name="tokens">the list of tokens where tokens will be added</param>
public Tokenizer(string filename, List<Element> elements, Options options, List<Token> tokens)
{
_filename = filename;
_elements = elements;
_options = options;
_tokens = tokens;
}
/// <summary>
/// Returns true if tokenization was successful; false otherwise.
///
/// 按照括号列表执行分词,根据<see cref="_tokens"/>大小判断是否标记化成功。成功返回true否则为false。
/// </summary>
/// <returns></returns>
public bool Tokenize()
{
TokenizeByBrackets();
return _tokens.Count > 0;
}
/// <summary>
/// Adds a token to the internal list of tokens
///
/// 添加标记(token)至<see cref="_tokens">_tokens列表</see>
/// </summary>
/// <param name="category">the token category</param>
/// <param name="enclosed">whether or not the token is enclosed in braces</param>
/// <param name="range">the token range</param>
private void AddToken(Token.TokenCategory category, bool enclosed, TokenRange range)
{
_tokens.Add(new Token(category, enclosed, StringHelper.SubstringWithCheck(_filename, range.Offset, range.Size)));
}
/// <summary>
/// 根据<see cref="Options.AllowedDelimiters">分隔符配置</see>,提取当前字符串范围内出现过的分隔符
/// </summary>
/// <param name="range"></param>
/// <returns></returns>
private string GetDelimiters(TokenRange range)
{
var delimiters = new StringBuilder();
bool IsDelimiter(char c)
{
/** alphanumeric不属于分隔符 */
if (StringHelper.IsAlphanumericChar(c)) return false;
return _options.AllowedDelimiters.Contains(c.ToString()) && !delimiters.ToString().Contains(c.ToString());
}
foreach (var i in Enumerable.Range(range.Offset, Math.Min(_filename.Length, range.Offset + range.Size) - range.Offset)
.Where(value => IsDelimiter(_filename[value])))
{
delimiters.Append(_filename[i]);
}
return delimiters.ToString();
}
/// <summary>
/// Tokenize by bracket.
///
/// 使用括号列表规则进行分词
/// </summary>
/// <remarks>括号总是成对出现。将括号作为停用符,将文件名划为多块</remarks>
private void TokenizeByBrackets()
{
/** 匹配到的(右)括号类型 */
string matchingBracket = null;
/** 返回范围内第一个(左)括号位置 */
int FindFirstBracket(int start, int end)
{
for (var i = start; i < end; i++)
{
foreach (var bracket in Brackets)
{
/** 和括号列表中每对的第一个括号进行比较 */
if (!_filename[i].Equals(char.Parse(bracket.Item1))) continue;
matchingBracket = bracket.Item2;
return i;
}
}
return -1;
}
/** 括号是否闭合 */
var isBracketOpen = false;
for (var i = 0; i < _filename.Length;)
{
/**
1. (isBracketOpen = false)使1()
2. (isBracketOpen = true)使2()(matchingBracket)
*/
var foundIdx = !isBracketOpen ? FindFirstBracket(i, _filename.Length) : _filename.IndexOf(matchingBracket, i, StringComparison.Ordinal);
/**
1.
2.
3. */
var range = new TokenRange(i, foundIdx == -1 ? _filename.Length : foundIdx - i);
if (range.Size > 0)
{
// Check if our range contains any known anime identifiers
TokenizeByPreidentified(isBracketOpen, range);
}
if (foundIdx != -1)
{
// mark as bracket 标记为括号并添加到_tokens列表
AddToken(Token.TokenCategory.Bracket, true, new TokenRange(range.Offset + range.Size, 1));
/** 括号是否闭合 取反 */
isBracketOpen = !isBracketOpen;
i = foundIdx + 1;
}
else
{
break;
}
}
}
/// <summary>
/// Tokenize by looking for known anime identifiers
///
/// 根据已知的动画关键词列表来分词
/// </summary>
/// <param name="enclosed">whether or not the current <c>range</c> is enclosed in braces. 当前范围是否位于闭合的括号中。</param>
/// <param name="range">the token range 标记的范围</param>
private void TokenizeByPreidentified(bool enclosed, TokenRange range)
{
var preidentifiedTokens = new List<TokenRange>();
// Find known anime identifiers
KeywordManager.PeekAndAdd(_filename, range, _elements, preidentifiedTokens);
var offset = range.Offset;
var subRange = new TokenRange(range.Offset, 0);
while (offset < range.Offset + range.Size)
{
foreach (var preidentifiedToken in preidentifiedTokens)
{
if (offset != preidentifiedToken.Offset) continue;
if (subRange.Size > 0)
{
TokenizeByDelimiters(enclosed, subRange);
}
AddToken(Token.TokenCategory.Identifier, enclosed, preidentifiedToken);
/** subRange偏移量移至此token后 */
subRange.Offset = preidentifiedToken.Offset + preidentifiedToken.Size;
offset = subRange.Offset - 1; // It's going to be incremented below
}
/**
1. (keyword)Size为0
2. Size大于0 */
subRange.Size = ++offset - subRange.Offset;
}
// Either there was no preidentified token range, or we're now about to process the tail of our current range
/**
1.
2.
*/
if (subRange.Size > 0)
{
TokenizeByDelimiters(enclosed, subRange);
}
}
/// <summary>
/// Tokenize by delimiters allowed in <see cref="Options"/>.AllowedDelimiters.
///
/// 使用提取元素时的分隔符配置进行分词
/// </summary>
/// <param name="enclosed">whether or not the current <code>range</code> is enclosed in braces</param>
/// <param name="range">the token range</param>
private void TokenizeByDelimiters(bool enclosed, TokenRange range)
{
var delimiters = GetDelimiters(range);
/** 如果这段字符串无分隔符则整个作为Unknown类型的标记(token) */
if (string.IsNullOrEmpty(delimiters))
{
AddToken(Token.TokenCategory.Unknown, enclosed, range);
return;
}
for (int i = range.Offset, end = range.Offset + range.Size; i < end;)
{
var found = Enumerable.Range(i, Math.Min(end, _filename.Length) - i)
.Where(c => delimiters.Contains(_filename[c].ToString()))
.DefaultIfEmpty(end)
.FirstOrDefault();
var subRange = new TokenRange(i, found - i);
if (subRange.Size > 0)
{
/** 分隔符分割后的字符串作为Unknown类型的标记(token) */
AddToken(Token.TokenCategory.Unknown, enclosed, subRange);
}
if (found != end)
{
/** 分隔符作为Delimiter类型的标记(token) */
AddToken(Token.TokenCategory.Delimiter, enclosed, new TokenRange(subRange.Offset + subRange.Size, 1));
i = found + 1;
}
else
{
break;
}
}
ValidateDelimiterTokens();
}
/// <summary>
/// Validates tokens (make sure certain words delimited by certain tokens aren't split)
///
/// 验证标记,确保由配置的分隔符提取标记(token)时<see cref="TokenizeByDelimiters"/>不会将有意义的单词拆分
/// </summary>
private void ValidateDelimiterTokens()
{
bool IsDelimiterToken(int it)
{
return Token.InListRange(it, _tokens) && _tokens[it].Category == Token.TokenCategory.Delimiter;
}
bool IsUnknownToken(int it)
{
return Token.InListRange(it, _tokens) && _tokens[it].Category == Token.TokenCategory.Unknown;
}
bool IsSingleCharacterToken(int it)
{
return IsUnknownToken(it) && _tokens[it].Content.Length == 1 && _tokens[it].Content[0] != '-';
}
void AppendTokenTo(Token src, Token dest)
{
dest.Content += src.Content;
src.Category = Token.TokenCategory.Invalid;
}
for (var i = 0; i < _tokens.Count; i++)
{
var token = _tokens[i];
if (token.Category != Token.TokenCategory.Delimiter) continue;
var delimiter = token.Content[0];
var prevToken = Token.FindPrevToken(_tokens, i, Token.TokenFlag.FlagValid);
var nextToken = Token.FindNextToken(_tokens, i, Token.TokenFlag.FlagValid);
// Check for single-character tokens to prevent splitting group names,
// keywords, episode numbers, etc.
if (delimiter != ' ' && delimiter != '_')
{
// Single character token
if (IsSingleCharacterToken(prevToken))
{
AppendTokenTo(token, _tokens[prevToken]);
while (IsUnknownToken(nextToken))
{
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]);
nextToken = Token.FindNextToken(_tokens, i, Token.TokenFlag.FlagValid);
if (!IsDelimiterToken(nextToken) || _tokens[nextToken].Content[0] != delimiter) continue;
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]);
nextToken = Token.FindNextToken(_tokens, nextToken, Token.TokenFlag.FlagValid);
}
continue;
}
if (IsSingleCharacterToken(nextToken))
{
AppendTokenTo(token, _tokens[prevToken]);
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]);
continue;
}
}
// Check for adjacent delimiters
if (IsUnknownToken(prevToken) && IsDelimiterToken(nextToken))
{
var nextDelimiter = _tokens[nextToken].Content[0];
if (delimiter != nextDelimiter && delimiter != ',')
{
if (nextDelimiter == ' ' || nextDelimiter == '_')
{
AppendTokenTo(token, _tokens[prevToken]);
}
}
}
else if (IsDelimiterToken(prevToken) && IsDelimiterToken(nextToken))
{
var prevDelimiter = _tokens[prevToken].Content[0];
var nextDelimiter = _tokens[nextToken].Content[0];
if (prevDelimiter == nextDelimiter && prevDelimiter != delimiter)
{
token.Category = Token.TokenCategory.Unknown; // e.g. "& in "_&_"
}
}
// Check for other special cases
if (delimiter != '&' && delimiter != '+') continue;
if (!IsUnknownToken(prevToken) || !IsUnknownToken(nextToken)) continue;
if (!StringHelper.IsNumericString(_tokens[prevToken].Content)
|| !StringHelper.IsNumericString(_tokens[nextToken].Content)) continue;
AppendTokenTo(token, _tokens[prevToken]);
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]); // e.g. 01+02
}
// Remove invalid tokens
_tokens.RemoveAll(token => token.Category == Token.TokenCategory.Invalid);
}
}
}

View File

@ -66,3 +66,8 @@ rm -rf MediaBrowser*.dll Microsoft*.dll Newtonsoft*.dll System*.dll Emby*.dll Je
1. Plugin run in error: `System.BadImageFormatException: Bad IL format.`
Remove all hidden file and `meta.json` in `metashark` plugin folder
## Thanks
[AnitomySharp](https://github.com/chu-shen/AnitomySharp)