diff --git a/Jellyfin.Plugin.Danmu/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Danmu/Configuration/PluginConfiguration.cs index bf18a28..d78a991 100644 --- a/Jellyfin.Plugin.Danmu/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.Danmu/Configuration/PluginConfiguration.cs @@ -158,4 +158,9 @@ public class DandanOption /// 中文简繁转换。0-不转换,1-转换为简体,2-转换为繁体 /// public int ChConvert { get; set; } = 0; -} \ No newline at end of file + + /// + /// 使用文件哈希值进行匹配. + /// + public bool MatchByFileHash { get; set; } = true; +} diff --git a/Jellyfin.Plugin.Danmu/Configuration/configPage.html b/Jellyfin.Plugin.Danmu/Configuration/configPage.html index 8566aa6..f95de7f 100644 --- a/Jellyfin.Plugin.Danmu/Configuration/configPage.html +++ b/Jellyfin.Plugin.Danmu/Configuration/configPage.html @@ -68,6 +68,14 @@ +
+ +
勾选后,搜索和匹配的成功率和精确度会更高,但会消耗额外的计算资源,当视频文件存储在远端时还会占用网络带宽。
+
@@ -139,6 +147,7 @@ document.querySelector('#WithRelatedDanmu').checked = config.Dandan.WithRelatedDanmu; document.querySelector('#ChConvert').value = config.Dandan.ChConvert; + document.querySelector('#MatchByFileHash').checked = config.Dandan.MatchByFileHash; var html = ''; @@ -189,6 +198,7 @@ var dandan = new Object(); dandan.WithRelatedDanmu = document.querySelector('#WithRelatedDanmu').checked; dandan.ChConvert = document.querySelector('#ChConvert').value; + dandan.MatchByFileHash = document.querySelector('#MatchByFileHash').checked; config.Dandan = dandan; ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) { diff --git a/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs b/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs index 7d36bcb..e58d474 100644 --- a/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs +++ b/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs @@ -279,11 +279,17 @@ public class LibraryManagerEventsHelper : IDisposable // 读取最新数据,要不然取不到年份信息 var currentItem = _libraryManager.GetItemById(item.Id) ?? item; - var mediaId = await scraper.SearchMediaId(currentItem); + var mediaId = await scraper.SearchMediaId(currentItem).ConfigureAwait(false); if (string.IsNullOrEmpty(mediaId)) { - _logger.LogInformation("[{0}]匹配失败:{1} ({2})", scraper.Name, item.Name, item.ProductionYear); - continue; + _logger.LogInformation("[{0}]元数据匹配失败:{1} ({2}),尝试文件匹配", scraper.Name, item.Name, item.ProductionYear); + + mediaId = await scraper.SearchMediaIdByFile((Movie)currentItem).ConfigureAwait(false); + if (string.IsNullOrEmpty(mediaId)) + { + _logger.LogInformation("[{0}]文件匹配失败:{1}", scraper.Name, currentItem.Path); + continue; + } } var media = await scraper.GetMedia(item, mediaId); @@ -642,16 +648,43 @@ public class LibraryManagerEventsHelper : IDisposable var queueUpdateMeta = new List(); foreach (var item in items) { - // 如果 Episode 没有弹幕元数据,但 Season 有弹幕元数据,表示该集是刮削完成后再新增的,需要重新匹配获取 + // 如果 Episode 没有弹幕元数据,表示该集是刮削完成后再新增的,需要重新匹配获取 var scrapers = this._scraperManager.All(); var season = item.Season; var allDanmuProviderIds = scrapers.Select(x => x.ProviderId).ToList(); var episodeFirstProviderId = allDanmuProviderIds.FirstOrDefault(x => !string.IsNullOrEmpty(item.GetProviderId(x))); var seasonFirstProviderId = allDanmuProviderIds.FirstOrDefault(x => !string.IsNullOrEmpty(season.GetProviderId(x))); - if (string.IsNullOrEmpty(episodeFirstProviderId) && !string.IsNullOrEmpty(seasonFirstProviderId) && item.IndexNumber.HasValue) + if (string.IsNullOrEmpty(episodeFirstProviderId) && item.IndexNumber.HasValue) { - var scraper = scrapers.First(x => x.ProviderId == seasonFirstProviderId); - var providerVal = season.GetProviderId(seasonFirstProviderId); + AbstractScraper? scraper = null; + string? providerVal = null; + + // 如果 Season 没有弹幕元数据,说明 Add 时使用元数据搜索失败了,此时使用文件信息再次匹配 + if (string.IsNullOrEmpty(seasonFirstProviderId)) + { + foreach (var s in scrapers) + { + var mediaId = await s.SearchMediaIdByFile(item).ConfigureAwait(false); + if (!string.IsNullOrEmpty(mediaId)) + { + scraper = s; + providerVal = mediaId; + break; + } + } + } + else + { + scraper = scrapers.First(x => x.ProviderId == seasonFirstProviderId); + providerVal = season.GetProviderId(seasonFirstProviderId); + } + + if (scraper == null) + { + _logger.LogInformation("使用文件匹配失败:{0}", item.Path); + continue; + } + var media = await scraper.GetMedia(season, providerVal); if (media != null) { diff --git a/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs b/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs index 3c2ea00..7975167 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs @@ -47,6 +47,16 @@ public abstract class AbstractScraper /// 影片id public abstract Task SearchMediaId(BaseItem item); + /// + /// 根据文件搜索匹配的影片id + /// + /// 影片对象 + /// 影片id + public virtual Task SearchMediaIdByFile(Video video) + { + return Task.FromResult(null); + } + /// /// 获取影片信息 /// diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs index 6cb176e..a7f326d 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Jellyfin.Plugin.Danmu.Scrapers.Entity; using System.Collections.Generic; using Jellyfin.Plugin.Danmu.Core.Extensions; +using System.Linq; namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan; @@ -38,6 +39,21 @@ public class Dandan : AbstractScraper var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; var searchName = this.NormalizeSearchName(item.Name); var animes = await this._api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false); + var matches = await this._api.MatchAsync(item, CancellationToken.None).ConfigureAwait(false); + + foreach (var match in matches) + { + var anime = await this._api.GetAnimeAsync(match.AnimeId, CancellationToken.None).ConfigureAwait(false); + if (anime == null) + { + continue; + } + + animes.Add(anime); + } + + animes = animes.DistinctBy(x => x.AnimeId).ToList(); + foreach (var anime in animes) { var animeId = anime.AnimeId; @@ -110,6 +126,28 @@ public class Dandan : AbstractScraper return null; } + public override async Task SearchMediaIdByFile(Video video) + { + var isMovieItemType = video is MediaBrowser.Controller.Entities.Movies.Movie; + var matches = await _api.MatchAsync(video, CancellationToken.None).ConfigureAwait(false); + foreach (var match in matches) + { + if (isMovieItemType && match.Type != "movie") + { + continue; + } + + if (!isMovieItemType && match.Type == "movie") + { + continue; + } + + log.LogInformation("通过文件特征匹配到动画: {Title}, AnimeId: {AnimeId}", match.AnimeTitle, match.AnimeId); + return $"{match.AnimeId}"; + } + + return null; + } public override async Task GetMedia(BaseItem item, string id) { @@ -235,7 +273,7 @@ public class Dandan : AbstractScraper { return list; } - + var anime = await this._api.GetAnimeAsync(animeId, CancellationToken.None).ConfigureAwait(false); if (anime == null) { diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs index 293025d..4c105d6 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs @@ -13,6 +13,8 @@ using Microsoft.Extensions.Caching.Memory; using Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity; using Jellyfin.Plugin.Danmu.Configuration; using Jellyfin.Plugin.Danmu.Core.Extensions; +using MediaBrowser.Controller.Entities; +using System.IO; namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan; @@ -31,7 +33,8 @@ public class DandanApi : AbstractApi } } - protected string ApiID { + protected string ApiID + { get { var apiId = Environment.GetEnvironmentVariable("DANDAN_API_ID"); @@ -44,7 +47,8 @@ public class DandanApi : AbstractApi } } - protected string ApiSecret { + protected string ApiSecret + { get { var apiSecret = Environment.GetEnvironmentVariable("DANDAN_API_SECRET"); @@ -98,6 +102,78 @@ public class DandanApi : AbstractApi return new List(); } + public async Task> MatchAsync(BaseItem item, CancellationToken cancellationToken) + { + if (item == null) + { + return new List(); + } + + var cacheKey = $"match_{item.Id}"; + var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }; + if (_memoryCache.TryGetValue>(cacheKey, out var matches)) + { + return matches; + } + + var matchRequest = new Dictionary + { + ["fileName"] = Path.GetFileNameWithoutExtension(item.Path), + ["fileHash"] = "00000000000000000000000000000000", + ["fileSize"] = item.Size ?? 0, + ["videoDuration"] = (item.RunTimeTicks ?? 0) / 10000000, + ["matchMode"] = "fileNameOnly", + }; + if (this.Config.MatchByFileHash) + { + matchRequest["fileHash"] = await this.ComputeFileHashAsync(item.Path).ConfigureAwait(false); + matchRequest["matchMode"] = "hashAndFileName"; + } + + var url = "https://api.dandanplay.net/api/v2/match"; + var response = await this.Request(url, HttpMethod.Post, matchRequest, cancellationToken).ConfigureAwait(false); + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null && result.Success && result.Matches != null) + { + _memoryCache.Set>(cacheKey, result.Matches, expiredOption); + return result.Matches; + } + + _memoryCache.Set>(cacheKey, new List(), expiredOption); + return new List(); + } + + private async Task ComputeFileHashAsync(string filePath) + { + try + { + using (var stream = File.OpenRead(filePath)) + { + // 读取前16MB + var buffer = new byte[16 * 1024 * 1024]; + var bytesRead = await stream.ReadAsync(buffer).ConfigureAwait(false); + + if (bytesRead > 0) + { + // 如果文件小于16MB,调整buffer大小 + if (bytesRead < buffer.Length) + { + Array.Resize(ref buffer, bytesRead); + } + + var hash = MD5.HashData(buffer); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "计算文件哈希值时出错: {Path}", filePath); + } + + return string.Empty; + } + public async Task GetAnimeAsync(long animeId, CancellationToken cancellationToken) { if (animeId <= 0) @@ -118,11 +194,15 @@ public class DandanApi : AbstractApi var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); if (result != null && result.Success && result.Bangumi != null) { - // 过滤掉特典剧集,episodeNumber为S1/S2.。。 anime = result.Bangumi; if (anime.Episodes != null) { + // 过滤掉特典剧集,episodeNumber为S1/S2.。。 anime.Episodes = anime.Episodes.Where(x => x.EpisodeNumber.ToInt() > 0).ToList(); + + // 本接口与 search 返回不完全一致,补全缺失字段 + anime.EpisodeCount = anime.Episodes.Count; + anime.StartDate = anime.Episodes.FirstOrDefault()?.AirDate; } _memoryCache.Set(cacheKey, anime, expiredOption); return anime; @@ -170,20 +250,28 @@ public class DandanApi : AbstractApi } protected async Task Request(string url, CancellationToken cancellationToken) + { + return await Request(url, HttpMethod.Get, null, cancellationToken).ConfigureAwait(false); + } + + protected async Task Request(string url, HttpMethod method, object? content = null, CancellationToken cancellationToken = default) { var timestamp = DateTimeOffset.Now.ToUnixTimeSeconds(); var signature = GenerateSignature(url, timestamp); HttpResponseMessage response; - using (var request = new HttpRequestMessage(HttpMethod.Get, url)) { + using (var request = new HttpRequestMessage(method, url)) { request.Headers.Add("X-AppId", ApiID); request.Headers.Add("X-Signature", signature); request.Headers.Add("X-Timestamp", timestamp.ToString()); + if (method == HttpMethod.Post && content != null) + { + request.Content = JsonContent.Create(content, null, _jsonOptions); + } response = await this.httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); } - response.EnsureSuccessStatusCode(); return response; } diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Episode.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Episode.cs index fc93c90..d18fa8d 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Episode.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Episode.cs @@ -12,5 +12,8 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity [JsonPropertyName("episodeNumber")] public string EpisodeNumber { get; set; } + + [JsonPropertyName("airDate")] + public string? AirDate { get; set; } } } \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/MatchResponseV2.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/MatchResponseV2.cs new file mode 100644 index 0000000..62315e7 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/MatchResponseV2.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity +{ + public class MatchResponseV2 + { + [JsonPropertyName("errorCode")] + public int ErrorCode { get; set; } + + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("errorMessage")] + public string ErrorMessage { get; set; } + + [JsonPropertyName("isMatched")] + public bool IsMatched { get; set; } + + [JsonPropertyName("matches")] + public List Matches { get; set; } + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/MatchResultV2.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/MatchResultV2.cs new file mode 100644 index 0000000..3f55af2 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/MatchResultV2.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity +{ + public class MatchResultV2 + { + [JsonPropertyName("episodeId")] + public long EpisodeId { get; set; } + + [JsonPropertyName("animeId")] + public long AnimeId { get; set; } + + [JsonPropertyName("animeTitle")] + public string AnimeTitle { get; set; } + + [JsonPropertyName("episodeTitle")] + public string EpisodeTitle { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("typeDescription")] + public string TypeDescription { get; set; } + + [JsonPropertyName("shift")] + public int Shift { get; set; } + } +}