mirror of
https://github.com/cxfksword/jellyfin-plugin-danmu.git
synced 2026-02-03 02:04:47 +08:00
feat: add danmu api scraper. close #102
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Configuration;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Test
|
||||
{
|
||||
@@ -19,15 +26,60 @@ namespace Jellyfin.Plugin.Danmu.Test
|
||||
options.TimestampFormat = "hh:mm:ss ";
|
||||
}));
|
||||
|
||||
protected Plugin? mockPlugin;
|
||||
protected PluginConfiguration mockConfiguration = new PluginConfiguration();
|
||||
|
||||
[TestInitialize]
|
||||
public void SetUp()
|
||||
{
|
||||
DotNetEnv.Env.TraversePath().Load();
|
||||
|
||||
// Mock Plugin.Instance 及其依赖项
|
||||
var mockApplicationPaths = new Mock<IApplicationPaths>();
|
||||
mockApplicationPaths.Setup(p => p.PluginConfigurationsPath).Returns(Path.GetTempPath());
|
||||
mockApplicationPaths.Setup(p => p.PluginsPath).Returns(Path.GetTempPath());
|
||||
|
||||
var mockApplicationHost = new Mock<IApplicationHost>();
|
||||
mockApplicationHost.Setup(h => h.GetExports<Scrapers.AbstractScraper>(false))
|
||||
.Returns(new List<Scrapers.AbstractScraper>());
|
||||
|
||||
var mockXmlSerializer = new Mock<IXmlSerializer>();
|
||||
|
||||
var mockScraperManager = new Mock<Scrapers.ScraperManager>(
|
||||
Mock.Of<ILoggerFactory>());
|
||||
|
||||
try
|
||||
{
|
||||
// 创建 Plugin 实例
|
||||
mockPlugin = new Plugin(
|
||||
mockApplicationPaths.Object,
|
||||
mockApplicationHost.Object,
|
||||
mockXmlSerializer.Object,
|
||||
mockScraperManager.Object
|
||||
);
|
||||
|
||||
// 使用反射设置 Configuration
|
||||
var configField = typeof(Plugin).BaseType?
|
||||
.GetField("_configuration", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (configField != null)
|
||||
{
|
||||
configField.SetValue(mockPlugin, mockConfiguration);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果无法创建 Plugin 实例,使用反射直接设置静态实例
|
||||
// 这是一个后备方案
|
||||
}
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void TearDown()
|
||||
{
|
||||
// 清理 Plugin.Instance
|
||||
var instanceProperty = typeof(Plugin).GetProperty("Instance", BindingFlags.Public | BindingFlags.Static);
|
||||
instanceProperty?.SetValue(null, null);
|
||||
|
||||
// 清理代码
|
||||
// 例如,释放资源或重置状态
|
||||
}
|
||||
|
||||
94
Jellyfin.Plugin.Danmu.Test/DanmuApiApiTest.cs
Normal file
94
Jellyfin.Plugin.Danmu.Test/DanmuApiApiTest.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.DanmuApi;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Test
|
||||
{
|
||||
[TestClass]
|
||||
public class DanmuApiApiTest : BaseTest
|
||||
{
|
||||
[TestMethod]
|
||||
public void TestSearchAsync()
|
||||
{
|
||||
var keyword = "火影忍者";
|
||||
var api = new DanmuApiApi(loggerFactory);
|
||||
|
||||
// 注意:此测试需要实际的服务器 URL,否则会返回空结果
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await api.SearchAsync(keyword, CancellationToken.None);
|
||||
Console.WriteLine($"Search results count: {result.Count}");
|
||||
foreach (var anime in result)
|
||||
{
|
||||
Console.WriteLine($" - {anime.AnimeTitle} (ID: {anime.BangumiId})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestGetBangumiAsync()
|
||||
{
|
||||
var bangumiId = "mzc00200nc1cbum";
|
||||
var api = new DanmuApiApi(loggerFactory);
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await api.GetBangumiAsync(bangumiId, CancellationToken.None);
|
||||
if (result != null)
|
||||
{
|
||||
Console.WriteLine($"Bangumi: {result.AnimeTitle}");
|
||||
Console.WriteLine($"Episodes count: {result.Episodes.Count}");
|
||||
foreach (var episode in result.Episodes.Take(5))
|
||||
{
|
||||
Console.WriteLine($" - {episode.EpisodeId}: {episode.EpisodeTitle}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Bangumi not found or server not configured");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestGetCommentsAsync()
|
||||
{
|
||||
var commentId = "14435";
|
||||
var api = new DanmuApiApi(loggerFactory);
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await api.GetCommentsAsync(commentId, CancellationToken.None);
|
||||
Console.WriteLine($"Comments count: {result.Count}");
|
||||
foreach (var comment in result.Take(5))
|
||||
{
|
||||
Console.WriteLine($" - {comment.M} (CID: {comment.Cid})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
265
Jellyfin.Plugin.Danmu.Test/DanmuApiTest.cs
Normal file
265
Jellyfin.Plugin.Danmu.Test/DanmuApiTest.cs
Normal file
@@ -0,0 +1,265 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Configuration;
|
||||
using Jellyfin.Plugin.Danmu.Model;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.DanmuApi;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Moq;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Test
|
||||
{
|
||||
[TestClass]
|
||||
public class DanmuApiTest : BaseTest
|
||||
{
|
||||
[TestMethod]
|
||||
public void TestSearchMovie()
|
||||
{
|
||||
var scraper = new DanmuApi(loggerFactory);
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "火影忍者"
|
||||
};
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await scraper.Search(item);
|
||||
Console.WriteLine($"Search results count: {result.Count}");
|
||||
foreach (var searchInfo in result)
|
||||
{
|
||||
Console.WriteLine($" - {searchInfo.Name} (ID: {searchInfo.Id}, Episodes: {searchInfo.EpisodeSize})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestSearchMediaId()
|
||||
{
|
||||
var scraper = new DanmuApi(loggerFactory);
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "阿丽塔:战斗天使(2019)【外语电影】from youku",
|
||||
ProductionYear = 2019
|
||||
};
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var mediaId = await scraper.SearchMediaId(item);
|
||||
if (mediaId != null)
|
||||
{
|
||||
Console.WriteLine($"Found media ID: {mediaId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Media ID not found or server not configured");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestGetMedia()
|
||||
{
|
||||
var scraper = new DanmuApi(loggerFactory);
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "测试电影"
|
||||
};
|
||||
var testBangumiId = "test-bangumi-id";
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var media = await scraper.GetMedia(item, testBangumiId);
|
||||
if (media != null)
|
||||
{
|
||||
Console.WriteLine($"Media ID: {media.Id}");
|
||||
Console.WriteLine($"Comment ID: {media.CommentId}");
|
||||
Console.WriteLine($"Episodes count: {media.Episodes.Count}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Media not found or server not configured");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestGetDanmuContent()
|
||||
{
|
||||
var scraper = new DanmuApi(loggerFactory);
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "测试电影"
|
||||
};
|
||||
var commentId = "test-comment-id";
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var danmaku = await scraper.GetDanmuContent(item, commentId);
|
||||
if (danmaku != null)
|
||||
{
|
||||
Console.WriteLine($"Chat Server: {danmaku.ChatServer}");
|
||||
Console.WriteLine($"Danmaku items count: {danmaku.Items.Count}");
|
||||
foreach (var item in danmaku.Items.Take(5))
|
||||
{
|
||||
Console.WriteLine($" - [{item.Progress}ms] {item.Content}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Danmaku not found or server not configured");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestFilterByAllowedSources()
|
||||
{
|
||||
// 模拟配置只允许特定采集源
|
||||
var originalConfig = Plugin.Instance?.Configuration?.DanmuApi?.AllowedSources;
|
||||
|
||||
try
|
||||
{
|
||||
if (Plugin.Instance?.Configuration?.DanmuApi != null)
|
||||
{
|
||||
Plugin.Instance.Configuration.DanmuApi.AllowedSources = "bilibili";
|
||||
}
|
||||
|
||||
var scraper = new DanmuApi(loggerFactory);
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "火影忍者"
|
||||
};
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await scraper.Search(item);
|
||||
Console.WriteLine($"Source filter test - Results count: {result.Count}");
|
||||
|
||||
// 验证所有结果都来自允许的采集源
|
||||
foreach (var searchInfo in result)
|
||||
{
|
||||
Console.WriteLine($" - {searchInfo.Name} (ID: {searchInfo.Id})");
|
||||
}
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
Console.WriteLine("✓ Source filter is working - only allowed sources returned");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("⚠ No results found with source filter");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 恢复原始配置
|
||||
if (Plugin.Instance?.Configuration?.DanmuApi != null)
|
||||
{
|
||||
Plugin.Instance.Configuration.DanmuApi.AllowedSources = originalConfig ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestFilterByAllowedPlatforms()
|
||||
{
|
||||
// 模拟配置只允许特定平台
|
||||
var originalConfig = Plugin.Instance?.Configuration?.DanmuApi?.AllowedPlatforms;
|
||||
|
||||
try
|
||||
{
|
||||
if (Plugin.Instance?.Configuration?.DanmuApi != null)
|
||||
{
|
||||
Plugin.Instance.Configuration.DanmuApi.AllowedPlatforms = "qq";
|
||||
}
|
||||
|
||||
var scraper = new DanmuApi(loggerFactory);
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "又见逍遥"
|
||||
};
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await scraper.GetMedia(item, "5510");
|
||||
Console.WriteLine($"Platform filter test - Results count: {result.Episodes.Count}");
|
||||
|
||||
// 验证所有结果都来自允许的平台
|
||||
foreach (var episode in result.Episodes)
|
||||
{
|
||||
Console.WriteLine($" - {episode.Title} (ID: {episode.Id})");
|
||||
}
|
||||
|
||||
if (result.Episodes.Count > 0)
|
||||
{
|
||||
Console.WriteLine("✓ Platform filter is working - only allowed platforms returned");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("⚠ No results found with platform filter");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 恢复原始配置
|
||||
if (Plugin.Instance?.Configuration?.DanmuApi != null)
|
||||
{
|
||||
Plugin.Instance.Configuration.DanmuApi.AllowedPlatforms = originalConfig ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
|
||||
public DandanOption Dandan { get; set; } = new DandanOption();
|
||||
|
||||
public DanmuApiOption DanmuApi { get; set; } = new DanmuApiOption();
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 弹幕源.
|
||||
@@ -168,3 +170,24 @@ public class DandanOption
|
||||
/// </summary>
|
||||
public bool MatchByFileHash { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 弹幕 API 配置
|
||||
/// </summary>
|
||||
public class DanmuApiOption
|
||||
{
|
||||
/// <summary>
|
||||
/// 弹幕 API 服务器地址(带 http/https 的 BaseURL)
|
||||
/// </summary>
|
||||
public string ServerUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 允许的平台列表(多个平台用逗号分隔,如:bilibili,tencent,youku)。为空则不限制
|
||||
/// </summary>
|
||||
public string AllowedPlatforms { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 允许的采集源列表(多个采集源用逗号分隔,如:dandan,mikan,dmhy)。为空则不限制
|
||||
/// </summary>
|
||||
public string AllowedSources { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,35 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="danmuApiSection" class="verticalSection verticalSection-extrabottompadding">
|
||||
<legend>
|
||||
<h3>弹幕API配置</h3>
|
||||
</legend>
|
||||
|
||||
<div class="fieldDescription" style="color: #ff9800; margin-bottom: 1em;">
|
||||
⚠️ 弹幕下载速度受API服务器限流配置影响
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="DanmuApiServerUrl">API服务器地址</label>
|
||||
<input id="DanmuApiServerUrl" name="DanmuApiServerUrl" type="text" is="emby-input" placeholder="http://example.com/{token}" />
|
||||
<div class="fieldDescription">填写API服务器的完整地址(包含 http:// 或 https://),部署推荐: <a href="https://github.com/huangxd-/danmu_api" target="_blank">danmu_api</a></div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="DanmuApiAllowedSources">允许的采集源</label>
|
||||
<input id="DanmuApiAllowedSources" name="DanmuApiAllowedSources" type="text" is="emby-input" placeholder="如:renren,hanjutv,bahamut" />
|
||||
<div class="fieldDescription">限制只从指定采集源获取弹幕,多个采集源用逗号分隔,留空则不限制采集源。<a href="https://github.com/huangxd-/danmu_api/?tab=readme-ov-file#%E9%87%87%E9%9B%86%E6%BA%90%E5%8F%8A%E5%AF%B9%E5%BA%94%E5%B9%B3%E5%8F%B0%E5%88%97%E8%A1%A8" target="_blank">采集源列表</a></a></div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer" style="display: none;">
|
||||
<label class="inputLabel inputLabelUnfocused" for="DanmuApiAllowedPlatforms">允许的平台</label>
|
||||
<input id="DanmuApiAllowedPlatforms" name="DanmuApiAllowedPlatforms" type="text" is="emby-input" placeholder="如:qq,bilibili1,bahamut" />
|
||||
<div class="fieldDescription">限制只从指定平台获取弹幕,多个平台用逗号分隔,留空则不限制平台</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="verticalSection verticalSection-extrabottompadding">
|
||||
<legend>
|
||||
<h3>生成ASS配置</h3>
|
||||
@@ -160,6 +189,10 @@
|
||||
document.querySelector('#ChConvert').value = config.Dandan.ChConvert;
|
||||
document.querySelector('#MatchByFileHash').checked = config.Dandan.MatchByFileHash;
|
||||
|
||||
document.querySelector('#DanmuApiServerUrl').value = config.DanmuApi.ServerUrl || '';
|
||||
document.querySelector('#DanmuApiAllowedPlatforms').value = config.DanmuApi.AllowedPlatforms || '';
|
||||
document.querySelector('#DanmuApiAllowedSources').value = config.DanmuApi.AllowedSources || '';
|
||||
|
||||
|
||||
var html = '';
|
||||
config.Scrapers.forEach(function (e) {
|
||||
@@ -214,6 +247,12 @@
|
||||
dandan.MatchByFileHash = document.querySelector('#MatchByFileHash').checked;
|
||||
config.Dandan = dandan;
|
||||
|
||||
var danmuApi = new Object();
|
||||
danmuApi.ServerUrl = document.querySelector('#DanmuApiServerUrl').value;
|
||||
danmuApi.AllowedPlatforms = document.querySelector('#DanmuApiAllowedPlatforms').value;
|
||||
danmuApi.AllowedSources = document.querySelector('#DanmuApiAllowedSources').value;
|
||||
config.DanmuApi = danmuApi;
|
||||
|
||||
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
private readonly List<LibraryEvent> _queuedEvents;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly MemoryCacheEntryOptions _pendingAddExpiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
|
||||
private readonly MemoryCacheEntryOptions _danmuUpdatedExpiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(24*60) };
|
||||
private readonly MemoryCacheEntryOptions _danmuUpdatedExpiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) };
|
||||
private readonly IItemRepository _itemRepository;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<LibraryManagerEventsHelper> _logger;
|
||||
@@ -77,6 +77,15 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
throw new ArgumentNullException(nameof(item));
|
||||
}
|
||||
|
||||
var libraryEvent = new LibraryEvent { Item = item, EventType = eventType };
|
||||
|
||||
// 检查队列中是否已存在相同的事件
|
||||
if (_queuedEvents.Contains(libraryEvent))
|
||||
{
|
||||
_logger.LogDebug("事件已在队列中,忽略重复添加: {ItemName} ({EventType})", item.Name, eventType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_queueTimer == null)
|
||||
{
|
||||
_queueTimer = new Timer(
|
||||
@@ -90,7 +99,7 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
_queueTimer.Change(TimeSpan.FromMilliseconds(10000), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
_queuedEvents.Add(new LibraryEvent { Item = item, EventType = eventType });
|
||||
_queuedEvents.Add(libraryEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,13 +513,13 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
var mediaId = await scraper.SearchMediaId(searchSeason);
|
||||
if (string.IsNullOrEmpty(mediaId))
|
||||
{
|
||||
_logger.LogInformation("[{0}]匹配失败:{1} ({2})", scraper.Name, season.Name, season.ProductionYear);
|
||||
_logger.LogInformation("[{0}]匹配失败:{1}-{2} ({3})", scraper.Name, series.Name, season.Name, season.ProductionYear);
|
||||
continue;
|
||||
}
|
||||
var media = await scraper.GetMedia(searchSeason, mediaId);
|
||||
if (media == null)
|
||||
{
|
||||
_logger.LogInformation("[{0}]匹配成功,但获取不到视频信息. id: {1}", scraper.Name, mediaId);
|
||||
_logger.LogInformation("[{0}]匹配成功,但获取不到视频信息. {1}-{2} id: {3}", scraper.Name, series.Name, season.Name, mediaId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -519,7 +528,7 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
season.SetProviderId(scraper.ProviderId, mediaId);
|
||||
queueUpdateMeta.Add(season);
|
||||
|
||||
_logger.LogInformation("[{0}]匹配成功:name={1} season_number={2} ProviderId: {3}", scraper.Name, season.Name, season.IndexNumber, mediaId);
|
||||
_logger.LogInformation("[{0}]匹配成功:{1}-{2} season_number:{3} ProviderId: {4}", scraper.Name, series.Name, season.Name, season.IndexNumber, mediaId);
|
||||
break;
|
||||
}
|
||||
catch (FrequentlyRequestException ex)
|
||||
@@ -796,9 +805,15 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
|
||||
// 没对应剧集号的,忽略处理
|
||||
var indexNumber = episode.IndexNumber ?? 0;
|
||||
if (indexNumber < 1 || indexNumber > media.Episodes.Count)
|
||||
if (indexNumber < 1)
|
||||
{
|
||||
_logger.LogInformation("[{0}]缺少集号或集号超过弹幕数,忽略处理. [{1}]{2}", scraper.Name, season.Name, fileName);
|
||||
_logger.LogInformation("[{0}]缺少集号,忽略处理. [{1}]{2}", scraper.Name, season.Name, fileName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indexNumber > media.Episodes.Count)
|
||||
{
|
||||
_logger.LogInformation("[{0}]集号超过弹幕数,忽略处理. [{1}]{2} 集号: {3} 弹幕数:{4}", scraper.Name, season.Name, fileName, indexNumber, media.Episodes.Count);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -877,10 +892,10 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
var checkDownloadedKey = $"{item.Id}_{commentId}";
|
||||
try
|
||||
{
|
||||
// 弹幕24小时内更新过,忽略处理(有时Update事件会重复执行)
|
||||
// 弹幕5分钟内更新过,忽略处理(有时Update事件会重复执行)
|
||||
if (!ignoreCheck && _memoryCache.TryGetValue(checkDownloadedKey, out var latestDownloaded))
|
||||
{
|
||||
_logger.LogInformation("[{0}]最近24小时已更新过弹幕xml,忽略处理:{1}.{2}", scraper.Name, item.IndexNumber, item.Name);
|
||||
_logger.LogInformation("[{0}]最近5分钟已更新过弹幕xml,忽略处理:{1}.{2}", scraper.Name, item.IndexNumber, item.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -888,10 +903,17 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
var danmaku = await scraper.GetDanmuContent(item, commentId);
|
||||
if (danmaku != null)
|
||||
{
|
||||
var bytes = danmaku.ToXml();
|
||||
if (bytes.Length < 1024)
|
||||
if (danmaku.Items.Count <= 0)
|
||||
{
|
||||
_logger.LogInformation("[{0}]弹幕内容少于1KB,忽略处理:{1}.{2}", scraper.Name, item.IndexNumber, item.Name);
|
||||
_logger.LogInformation("[{0}]弹幕内容为空,忽略处理:{1}.{2}", scraper.Name, item.IndexNumber, item.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
// 为了避免bilibili下架视频后,返回的失效弹幕内容把旧弹幕覆盖掉,这里做个内容判断
|
||||
var bytes = danmaku.ToXml();
|
||||
if (bytes.Length < 1024 && scraper.ProviderName == Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili.ScraperProviderName)
|
||||
{
|
||||
_logger.LogInformation("[{0}]弹幕内容少于1KB,可能是已失效弹幕,忽略处理:{1}.{2}", scraper.Name, item.IndexNumber, item.Name);
|
||||
return;
|
||||
}
|
||||
await this.SaveDanmu(item, bytes);
|
||||
|
||||
@@ -7,9 +7,26 @@ using MediaBrowser.Controller.Entities;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Model;
|
||||
|
||||
public class LibraryEvent
|
||||
public class LibraryEvent : IEquatable<LibraryEvent>
|
||||
{
|
||||
public BaseItem Item { get; set; }
|
||||
|
||||
public EventType EventType { get; set; }
|
||||
|
||||
public bool Equals(LibraryEvent? other)
|
||||
{
|
||||
if (other is null) return false;
|
||||
if (ReferenceEquals(this, other)) return true;
|
||||
return Item?.Id == other.Item?.Id && EventType == other.EventType;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is LibraryEvent other && Equals(other);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(Item?.Id, EventType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +130,9 @@ public abstract class AbstractScraper
|
||||
}
|
||||
|
||||
|
||||
protected string NormalizeSearchName(string name)
|
||||
protected virtual string NormalizeSearchName(string name)
|
||||
{
|
||||
// 去掉可能存在的季名称
|
||||
return Regex.Replace(name, @"\s*第.季", "");
|
||||
return Regex.Replace(name, @"\s*第.季", "").Trim();
|
||||
}
|
||||
}
|
||||
|
||||
377
Jellyfin.Plugin.Danmu/Scrapers/DanmuApi/DanmuApi.cs
Normal file
377
Jellyfin.Plugin.Danmu/Scrapers/DanmuApi/DanmuApi.cs
Normal file
@@ -0,0 +1,377 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Entity;
|
||||
using Jellyfin.Plugin.Danmu.Core.Extensions;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.DanmuApi;
|
||||
|
||||
public class DanmuApi : AbstractScraper
|
||||
{
|
||||
public const string ScraperProviderName = "弹幕API";
|
||||
public const string ScraperProviderId = "DanmuApiID";
|
||||
|
||||
private readonly DanmuApiApi _api;
|
||||
|
||||
public DanmuApi(ILoggerFactory logManager)
|
||||
: base(logManager.CreateLogger<DanmuApi>())
|
||||
{
|
||||
_api = new DanmuApiApi(logManager);
|
||||
}
|
||||
|
||||
public override int DefaultOrder => 10;
|
||||
|
||||
public override bool DefaultEnable => false;
|
||||
|
||||
public override string Name => "弹幕API";
|
||||
|
||||
public override string ProviderName => ScraperProviderName;
|
||||
|
||||
public override string ProviderId => ScraperProviderId;
|
||||
|
||||
public override uint HashPrefix => 16;
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
|
||||
{
|
||||
var list = new List<ScraperSearchInfo>();
|
||||
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);
|
||||
|
||||
// 过滤采集源
|
||||
animes = FilterByAllowedSources(animes);
|
||||
|
||||
foreach (var anime in animes)
|
||||
{
|
||||
list.Add(new ScraperSearchInfo()
|
||||
{
|
||||
Id = anime.BangumiId,
|
||||
Name = this.NormalizeAnimeTitle(anime.AnimeTitle),
|
||||
Category = anime.TypeDescription,
|
||||
Year = anime.Year,
|
||||
EpisodeSize = anime.EpisodeCount,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public override async Task<string?> SearchMediaId(BaseItem item)
|
||||
{
|
||||
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);
|
||||
|
||||
// 过滤采集源
|
||||
animes = FilterByAllowedSources(animes);
|
||||
|
||||
foreach (var anime in animes)
|
||||
{
|
||||
var title = this.NormalizeSearchName(anime.AnimeTitle);
|
||||
|
||||
// 检测标题是否相似(越大越相似)
|
||||
var score = searchName.Distance(title);
|
||||
if (score < 0.7)
|
||||
{
|
||||
log.LogDebug("[{0}] 标题差异太大,忽略处理. 搜索词:{1}, score: {2}", title, searchName, score);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检测年份是否一致
|
||||
var itemPubYear = item.ProductionYear ?? 0;
|
||||
if (itemPubYear > 0 && anime.Year > 0 && itemPubYear != anime.Year)
|
||||
{
|
||||
log.LogDebug("[{0}] 发行年份不一致,忽略处理. 年份:{1} jellyfin: {2}", title, anime.Year, itemPubYear);
|
||||
continue;
|
||||
}
|
||||
|
||||
return anime.BangumiId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override async Task<ScraperMedia?> GetMedia(BaseItem item, string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bangumi = await _api.GetBangumiAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
if (bangumi == null)
|
||||
{
|
||||
log.LogInformation("[{0}]获取不到视频信息:id={1}", this.Name, id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 过滤平台源
|
||||
var filteredEpisodes = FilterByAllowedPlatforms(bangumi);
|
||||
|
||||
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
|
||||
var media = new ScraperMedia();
|
||||
|
||||
media.Id = id;
|
||||
if (isMovieItemType && filteredEpisodes.Count > 0)
|
||||
{
|
||||
media.CommentId = filteredEpisodes[0].EpisodeId;
|
||||
}
|
||||
|
||||
if (filteredEpisodes.Count > 0)
|
||||
{
|
||||
foreach (var ep in filteredEpisodes)
|
||||
{
|
||||
media.Episodes.Add(new ScraperEpisode()
|
||||
{
|
||||
Id = ep.EpisodeId,
|
||||
CommentId = ep.EpisodeId,
|
||||
Title = ep.EpisodeTitle
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
public override async Task<ScraperEpisode?> GetMediaEpisode(BaseItem item, string id)
|
||||
{
|
||||
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
|
||||
if (isMovieItemType)
|
||||
{
|
||||
// id 是 bangumiId
|
||||
var bangumi = await _api.GetBangumiAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
if (bangumi == null || bangumi.Episodes == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 过滤平台源
|
||||
var filteredEpisodes = FilterByAllowedPlatforms(bangumi);
|
||||
if (filteredEpisodes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScraperEpisode()
|
||||
{
|
||||
Id = id,
|
||||
CommentId = filteredEpisodes[0].EpisodeId,
|
||||
Title = filteredEpisodes[0].EpisodeTitle
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// id 是 episodeId
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ScraperEpisode() { Id = id, CommentId = id };
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<ScraperDanmaku?> GetDanmuContent(BaseItem item, string commentId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(commentId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var comments = await _api.GetCommentsAsync(commentId, CancellationToken.None).ConfigureAwait(false);
|
||||
var danmaku = new ScraperDanmaku();
|
||||
danmaku.ChatId = 0;
|
||||
danmaku.ChatServer = "danmu-api";
|
||||
|
||||
foreach (var comment in comments)
|
||||
{
|
||||
var danmakuText = new ScraperDanmakuText();
|
||||
var arr = comment.P.Split(",");
|
||||
|
||||
if (arr.Length >= 4)
|
||||
{
|
||||
danmakuText.Progress = (int)(Convert.ToDouble(arr[0]) * 1000);
|
||||
danmakuText.Mode = Convert.ToInt32(arr[1]);
|
||||
danmakuText.Color = Convert.ToUInt32(arr[2]);
|
||||
danmakuText.MidHash = arr[3];
|
||||
danmakuText.Id = comment.Cid;
|
||||
danmakuText.Content = comment.M;
|
||||
|
||||
danmaku.Items.Add(danmakuText);
|
||||
}
|
||||
}
|
||||
|
||||
return danmaku;
|
||||
}
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> SearchForApi(string keyword)
|
||||
{
|
||||
var list = new List<ScraperSearchInfo>();
|
||||
var animes = await this._api.SearchAsync(keyword, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// 过滤采集源
|
||||
animes = FilterByAllowedSources(animes);
|
||||
|
||||
foreach (var anime in animes)
|
||||
{
|
||||
list.Add(new ScraperSearchInfo()
|
||||
{
|
||||
Id = anime.BangumiId,
|
||||
Name = anime.AnimeTitle,
|
||||
Category = anime.TypeDescription,
|
||||
Year = anime.Year,
|
||||
EpisodeSize = anime.EpisodeCount,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public override async Task<List<ScraperEpisode>> GetEpisodesForApi(string id)
|
||||
{
|
||||
var list = new List<ScraperEpisode>();
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
var bangumi = await this._api.GetBangumiAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
if (bangumi == null)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
// 过滤平台源
|
||||
var filteredEpisodes = FilterByAllowedPlatforms(bangumi);
|
||||
|
||||
if (filteredEpisodes.Count > 0)
|
||||
{
|
||||
foreach (var ep in filteredEpisodes)
|
||||
{
|
||||
list.Add(new ScraperEpisode()
|
||||
{
|
||||
Id = ep.EpisodeId,
|
||||
CommentId = ep.EpisodeId,
|
||||
Title = ep.EpisodeTitle
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public override async Task<ScraperDanmaku?> DownloadDanmuForApi(string commentId)
|
||||
{
|
||||
return await this.GetDanmuContent(null, commentId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据配置的允许采集源列表过滤结果
|
||||
/// </summary>
|
||||
private List<Entity.Anime> FilterByAllowedSources(List<Entity.Anime> animes)
|
||||
{
|
||||
var allowedSources = Plugin.Instance?.Configuration?.DanmuApi?.AllowedSources;
|
||||
|
||||
// 如果未配置采集源限制,返回所有结果
|
||||
if (string.IsNullOrWhiteSpace(allowedSources))
|
||||
{
|
||||
return animes;
|
||||
}
|
||||
|
||||
// 解析允许的采集源列表(按逗号分隔,转小写)
|
||||
var sourceSet = allowedSources
|
||||
.Split(',')
|
||||
.Select(s => s.Trim().ToLowerInvariant())
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToHashSet();
|
||||
|
||||
// 如果采集源列表为空,返回所有结果
|
||||
if (sourceSet.Count == 0)
|
||||
{
|
||||
return animes;
|
||||
}
|
||||
|
||||
// 过滤:只保留 Source 属性在允许列表中的结果
|
||||
var filtered = animes.Where(anime =>
|
||||
{
|
||||
var source = anime.Source.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
// 没有 source 标记不保留
|
||||
return false;
|
||||
}
|
||||
return sourceSet.Contains(source);
|
||||
}).ToList();
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 根据配置的允许平台列表过滤结果
|
||||
/// </summary>
|
||||
private List<Entity.Episode> FilterByAllowedPlatforms(Entity.Bangumi bangumi)
|
||||
{
|
||||
var allowedPlatforms = Plugin.Instance?.Configuration?.DanmuApi?.AllowedPlatforms;
|
||||
|
||||
// 如果未配置平台限制,返回第一组剧集
|
||||
if (string.IsNullOrWhiteSpace(allowedPlatforms))
|
||||
{
|
||||
return bangumi.EpisodeGroups.FirstOrDefault()?.Episodes ?? new List<Entity.Episode>();
|
||||
}
|
||||
|
||||
// 解析允许的平台列表(按逗号分隔,转小写)
|
||||
var platformSet = allowedPlatforms
|
||||
.Split(',')
|
||||
.Select(p => p.Trim().ToLowerInvariant())
|
||||
.Where(p => !string.IsNullOrEmpty(p))
|
||||
.ToHashSet();
|
||||
|
||||
// 如果平台列表为空,返回第一组剧集
|
||||
if (platformSet.Count == 0)
|
||||
{
|
||||
return bangumi.EpisodeGroups.FirstOrDefault()?.Episodes ?? new List<Entity.Episode>();
|
||||
}
|
||||
|
||||
// 从分组中找到第一个匹配的平台组
|
||||
var groups = bangumi.EpisodeGroups;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var platform = group.Platform.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
// 如果平台为空(未知平台)且只有一个组,返回该组
|
||||
if (string.IsNullOrWhiteSpace(platform) && groups.Count == 1)
|
||||
{
|
||||
return group.Episodes;
|
||||
}
|
||||
|
||||
// 如果平台在允许列表中,返回该组
|
||||
if (platformSet.Contains(platform))
|
||||
{
|
||||
return group.Episodes;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配的平台组,返回空列表
|
||||
return new List<Entity.Episode>();
|
||||
}
|
||||
|
||||
protected override string NormalizeSearchName(string name)
|
||||
{
|
||||
// 去掉可能存在的季名称
|
||||
name = Regex.Replace(name, @"\s*第.季", "");
|
||||
// 去掉年份后的所有部分,如"阿丽塔:战斗天使(2019)【外语电影】from youku"
|
||||
name = Regex.Replace(name, @"\(\d{4}\)|【.*】|from .*", "");
|
||||
return name.Trim();
|
||||
}
|
||||
|
||||
protected string NormalizeAnimeTitle(string name)
|
||||
{
|
||||
return Regex.Replace(name, @"\(\d{4}\)|【.*】", " ").Trim();
|
||||
}
|
||||
}
|
||||
204
Jellyfin.Plugin.Danmu/Scrapers/DanmuApi/DanmuApiApi.cs
Normal file
204
Jellyfin.Plugin.Danmu/Scrapers/DanmuApi/DanmuApiApi.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.DanmuApi.Entity;
|
||||
using Jellyfin.Plugin.Danmu.Configuration;
|
||||
using RateLimiter;
|
||||
using ComposableAsync;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.DanmuApi;
|
||||
|
||||
public class DanmuApiApi : AbstractApi
|
||||
{
|
||||
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(12, TimeSpan.FromMinutes(1));
|
||||
|
||||
public DanmuApiOption Config
|
||||
{
|
||||
get
|
||||
{
|
||||
return Plugin.Instance?.Configuration.DanmuApi ?? new DanmuApiOption();
|
||||
}
|
||||
}
|
||||
|
||||
public string ServerUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var serverUrl = Config.ServerUrl?.Trim();
|
||||
if (string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
// 尝试从环境变量获取
|
||||
serverUrl = Environment.GetEnvironmentVariable("DANMU_API_SERVER_URL");
|
||||
if (string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// 移除末尾的 /
|
||||
return serverUrl.TrimEnd('/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DanmuApiApi"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
|
||||
public DanmuApiApi(ILoggerFactory loggerFactory)
|
||||
: base(loggerFactory.CreateLogger<DanmuApiApi>())
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 搜索动漫
|
||||
/// GET /api/v2/search/anime?keyword={keyword}
|
||||
/// </summary>
|
||||
public async Task<List<Anime>> SearchAsync(string keyword, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyword) || string.IsNullOrEmpty(ServerUrl))
|
||||
{
|
||||
return new List<Anime>();
|
||||
}
|
||||
|
||||
var cacheKey = $"danmuapi_search_{keyword}";
|
||||
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
|
||||
if (_memoryCache.TryGetValue<List<Anime>>(cacheKey, out var searchResult))
|
||||
{
|
||||
return searchResult;
|
||||
}
|
||||
|
||||
keyword = HttpUtility.UrlEncode(keyword);
|
||||
var url = $"{ServerUrl}/api/v2/search/anime?keyword={keyword}";
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SearchResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null && result.Success && result.Animes != null)
|
||||
{
|
||||
_memoryCache.Set(cacheKey, result.Animes, expiredOption);
|
||||
return result.Animes;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DanmuApi 搜索失败: {Keyword}", keyword);
|
||||
}
|
||||
|
||||
var emptyList = new List<Anime>();
|
||||
_memoryCache.Set(cacheKey, emptyList, expiredOption);
|
||||
return emptyList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取番剧详情和剧集列表
|
||||
/// GET /api/v2/bangumi/{id}
|
||||
/// </summary>
|
||||
public async Task<Bangumi?> GetBangumiAsync(string bangumiId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(bangumiId) || string.IsNullOrEmpty(ServerUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cacheKey = $"danmuapi_bangumi_{bangumiId}";
|
||||
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
|
||||
if (_memoryCache.TryGetValue<Bangumi?>(cacheKey, out var bangumi))
|
||||
{
|
||||
return bangumi;
|
||||
}
|
||||
|
||||
var url = $"{ServerUrl}/api/v2/bangumi/{bangumiId}";
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BangumiResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null && result.Success && result.Bangumi != null)
|
||||
{
|
||||
_memoryCache.Set(cacheKey, result.Bangumi, expiredOption);
|
||||
return result.Bangumi;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DanmuApi 获取番剧详情失败: {BangumiId}", bangumiId);
|
||||
}
|
||||
|
||||
_memoryCache.Set<Bangumi?>(cacheKey, null, expiredOption);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取弹幕内容
|
||||
/// GET /api/v2/comment/{id}
|
||||
/// </summary>
|
||||
public async Task<List<Comment>> GetCommentsAsync(string commentId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(commentId) || string.IsNullOrEmpty(ServerUrl))
|
||||
{
|
||||
return new List<Comment>();
|
||||
}
|
||||
|
||||
await this.LimitRequestFrequently();
|
||||
|
||||
var url = $"{ServerUrl}/api/v2/comment/{commentId}";
|
||||
|
||||
const int maxRetries = 3;
|
||||
for (int attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
if (attempt < maxRetries - 1)
|
||||
{
|
||||
_logger.LogWarning("DanmuApi 获取弹幕遇到429限流,等待31秒后重试 (尝试 {Attempt}/{MaxRetries}): {CommentId}", attempt + 1, maxRetries, commentId);
|
||||
await Task.Delay(TimeSpan.FromSeconds(31), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("DanmuApi 获取弹幕遇到429限流,已达到最大重试次数: {CommentId}", commentId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CommentResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null && result.Comments != null)
|
||||
{
|
||||
return result.Comments;
|
||||
}
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DanmuApi 获取弹幕失败: {CommentId}", commentId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new List<Comment>();
|
||||
}
|
||||
|
||||
protected async Task LimitRequestFrequently()
|
||||
{
|
||||
await this._timeConstraint;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.DanmuApi.Entity
|
||||
{
|
||||
public class BangumiResponse
|
||||
{
|
||||
[JsonPropertyName("errorCode")]
|
||||
public int ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
[JsonPropertyName("bangumi")]
|
||||
public Bangumi? Bangumi { get; set; }
|
||||
}
|
||||
|
||||
public class Bangumi
|
||||
{
|
||||
[JsonPropertyName("animeId")]
|
||||
public long AnimeId { get; set; }
|
||||
|
||||
[JsonPropertyName("bangumiId")]
|
||||
public string BangumiId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("animeTitle")]
|
||||
public string AnimeTitle { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("episodes")]
|
||||
public List<Episode> Episodes { get; set; } = new List<Episode>();
|
||||
|
||||
/// <summary>
|
||||
/// 按平台分组的剧集列表,保持原始顺序
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public List<EpisodeGroup> EpisodeGroups
|
||||
{
|
||||
get
|
||||
{
|
||||
var groups = new List<EpisodeGroup>();
|
||||
var currentPlatform = string.Empty;
|
||||
var currentEpisodes = new List<Episode>();
|
||||
|
||||
foreach (var episode in Episodes)
|
||||
{
|
||||
var platform = episode.Platform ?? string.Empty;
|
||||
|
||||
if (platform != currentPlatform)
|
||||
{
|
||||
if (currentEpisodes.Count > 0)
|
||||
{
|
||||
groups.Add(new EpisodeGroup
|
||||
{
|
||||
Platform = currentPlatform,
|
||||
Episodes = currentEpisodes
|
||||
});
|
||||
}
|
||||
|
||||
currentPlatform = platform;
|
||||
currentEpisodes = new List<Episode>();
|
||||
}
|
||||
|
||||
currentEpisodes.Add(episode);
|
||||
}
|
||||
|
||||
// 添加最后一组
|
||||
if (currentEpisodes.Count > 0)
|
||||
{
|
||||
groups.Add(new EpisodeGroup
|
||||
{
|
||||
Platform = currentPlatform,
|
||||
Episodes = currentEpisodes
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class EpisodeGroup
|
||||
{
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
public List<Episode> Episodes { get; set; } = new List<Episode>();
|
||||
}
|
||||
|
||||
public class Episode
|
||||
{
|
||||
private static readonly Regex PlatformRegex = new Regex(@"【(.+?)】", RegexOptions.Compiled);
|
||||
|
||||
[JsonPropertyName("seasonId")]
|
||||
public string SeasonId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("episodeId")]
|
||||
public string EpisodeId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("episodeTitle")]
|
||||
public string EpisodeTitle { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("episodeNumber")]
|
||||
public string EpisodeNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 从 EpisodeTitle 中解析平台标识,格式如:【qq】 第1集
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? Platform
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(EpisodeTitle))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = PlatformRegex.Match(EpisodeTitle);
|
||||
if (match.Success && match.Groups.Count > 1)
|
||||
{
|
||||
return match.Groups[1].Value.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.DanmuApi.Entity
|
||||
{
|
||||
public class CommentResponse
|
||||
{
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
[JsonPropertyName("comments")]
|
||||
public List<Comment> Comments { get; set; } = new List<Comment>();
|
||||
}
|
||||
|
||||
public class Comment
|
||||
{
|
||||
[JsonPropertyName("cid")]
|
||||
public long Cid { get; set; }
|
||||
|
||||
[JsonPropertyName("p")]
|
||||
public string P { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("m")]
|
||||
public string M { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.DanmuApi.Entity
|
||||
{
|
||||
public class SearchResponse
|
||||
{
|
||||
[JsonPropertyName("errorCode")]
|
||||
public int ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
[JsonPropertyName("animes")]
|
||||
public List<Anime> Animes { get; set; } = new List<Anime>();
|
||||
}
|
||||
|
||||
public class Anime
|
||||
{
|
||||
private static readonly Regex YearRegex = new Regex(@"\((\d{4})\)", RegexOptions.Compiled);
|
||||
private static readonly Regex FromRegex = new Regex(@"from\s+(\w+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
[JsonPropertyName("animeId")]
|
||||
public long AnimeId { get; set; }
|
||||
|
||||
[JsonPropertyName("bangumiId")]
|
||||
public string BangumiId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("animeTitle")]
|
||||
public string AnimeTitle { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("typeDescription")]
|
||||
public string TypeDescription { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageUrl")]
|
||||
public string ImageUrl { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("episodeCount")]
|
||||
public int EpisodeCount { get; set; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 从 AnimeTitle 中解析年份,格式如:火影忍者疾风传剧场版:羁绊(2008)【电影】from tencent
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int? Year
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(AnimeTitle))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = YearRegex.Match(AnimeTitle);
|
||||
if (match.Success && match.Groups.Count > 1)
|
||||
{
|
||||
if (int.TryParse(match.Groups[1].Value, out var year))
|
||||
{
|
||||
// 验证年份在合理范围内(1900-2100)
|
||||
if (year >= 1900 && year <= 2100)
|
||||
{
|
||||
return year;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.DanmuApi.ExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public class EpisodeExternalId : IExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => DanmuApi.ScraperProviderName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => DanmuApi.ScraperProviderId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ExternalIdMediaType? Type => ExternalIdMediaType.Episode;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Supports(IHasProviderIds item) => item is Episode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.DanmuApi.ExternalId;
|
||||
|
||||
/// <summary>
|
||||
/// External URLs for DanmuApi.
|
||||
/// </summary>
|
||||
public class ExternalUrlProvider : IExternalUrlProvider
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string Name => DanmuApi.ScraperProviderName;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<string> GetExternalUrls(BaseItem item)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case Season season:
|
||||
if (item.TryGetProviderId(DanmuApi.ScraperProviderId, out var externalId))
|
||||
{
|
||||
var serverUrl = GetServerUrl();
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
yield return $"{serverUrl}/api/v2/bangumi/{externalId}";
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return "#";
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case Episode episode:
|
||||
if (item.TryGetProviderId(DanmuApi.ScraperProviderId, out externalId))
|
||||
{
|
||||
var serverUrl = GetServerUrl();
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
yield return $"{serverUrl}/api/v2/comment/{externalId}";
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return "#";
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case Movie:
|
||||
if (item.TryGetProviderId(DanmuApi.ScraperProviderId, out externalId))
|
||||
{
|
||||
var serverUrl = GetServerUrl();
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
yield return $"{serverUrl}/api/v2/bangumi/{externalId}";
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetServerUrl()
|
||||
{
|
||||
var serverUrl = Plugin.Instance?.Configuration?.DanmuApi?.ServerUrl?.Trim();
|
||||
if (string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
// 尝试从环境变量获取
|
||||
serverUrl = Environment.GetEnvironmentVariable("DANMU_API_SERVER_URL");
|
||||
if (string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// 移除末尾的 /
|
||||
return serverUrl.TrimEnd('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.DanmuApi.ExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public class MovieExternalId : IExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => DanmuApi.ScraperProviderName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => DanmuApi.ScraperProviderId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ExternalIdMediaType? Type => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Supports(IHasProviderIds item) => item is Movie;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.DanmuApi.ExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public class SeasonExternalId : IExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => DanmuApi.ScraperProviderName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => DanmuApi.ScraperProviderId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ExternalIdMediaType? Type => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Supports(IHasProviderIds item) => item is Season;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user