mirror of
https://github.com/cxfksword/jellyfin-plugin-danmu.git
synced 2026-02-02 17:59:58 +08:00
Merge pull request #72 from moetayuko/main
support file-based media matching
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user