Merge pull request #72 from moetayuko/main

support file-based media matching
This commit is contained in:
cxfksword
2025-03-23 14:32:15 +08:00
committed by GitHub
9 changed files with 252 additions and 14 deletions

View File

@@ -158,4 +158,9 @@ public class DandanOption
/// 中文简繁转换。0-不转换1-转换为简体2-转换为繁体
/// </summary>
public int ChConvert { get; set; } = 0;
}
/// <summary>
/// 使用文件哈希值进行匹配.
/// </summary>
public bool MatchByFileHash { get; set; } = true;
}

View File

@@ -68,6 +68,14 @@
<option value="2">转换为繁体</option>
</select>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="MatchByFileHash" name="MatchByFileHash" type="checkbox"
is="emby-checkbox" />
<span>使用文件哈希值进行匹配</span>
</label>
<div class="fieldDescription">勾选后,搜索和匹配的成功率和精确度会更高,但会消耗额外的计算资源,当视频文件存储在远端时还会占用网络带宽。</div>
</div>
</fieldset>
<fieldset class="verticalSection verticalSection-extrabottompadding">
@@ -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) {

View File

@@ -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<BaseItem>();
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)
{

View File

@@ -47,6 +47,16 @@ public abstract class AbstractScraper
/// <returns>影片id</returns>
public abstract Task<string?> SearchMediaId(BaseItem item);
/// <summary>
/// 根据文件搜索匹配的影片id
/// </summary>
/// <param name="video">影片对象</param>
/// <returns>影片id</returns>
public virtual Task<string?> SearchMediaIdByFile(Video video)
{
return Task.FromResult<string?>(null);
}
/// <summary>
/// 获取影片信息
/// </summary>

View File

@@ -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<string?> 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<ScraperMedia?> 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)
{

View File

@@ -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<Anime>();
}
public async Task<List<MatchResultV2>> MatchAsync(BaseItem item, CancellationToken cancellationToken)
{
if (item == null)
{
return new List<MatchResultV2>();
}
var cacheKey = $"match_{item.Id}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) };
if (_memoryCache.TryGetValue<List<MatchResultV2>>(cacheKey, out var matches))
{
return matches;
}
var matchRequest = new Dictionary<string, object>
{
["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<MatchResponseV2>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Success && result.Matches != null)
{
_memoryCache.Set<List<MatchResultV2>>(cacheKey, result.Matches, expiredOption);
return result.Matches;
}
_memoryCache.Set<List<MatchResultV2>>(cacheKey, new List<MatchResultV2>(), expiredOption);
return new List<MatchResultV2>();
}
private async Task<string> 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<Anime?> GetAnimeAsync(long animeId, CancellationToken cancellationToken)
{
if (animeId <= 0)
@@ -118,11 +194,15 @@ public class DandanApi : AbstractApi
var result = await response.Content.ReadFromJsonAsync<AnimeResult>(_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<Anime?>(cacheKey, anime, expiredOption);
return anime;
@@ -170,20 +250,28 @@ public class DandanApi : AbstractApi
}
protected async Task<HttpResponseMessage> Request(string url, CancellationToken cancellationToken)
{
return await Request(url, HttpMethod.Get, null, cancellationToken).ConfigureAwait(false);
}
protected async Task<HttpResponseMessage> 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;
}

View File

@@ -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; }
}
}

View File

@@ -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<MatchResultV2> Matches { get; set; }
}
}

View File

@@ -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; }
}
}