feat: add danmu api scraper. close #102

This commit is contained in:
cxfksword
2025-12-10 21:45:18 +08:00
parent ecb819a4f0
commit 209d6ff4d4
17 changed files with 1499 additions and 15 deletions

View File

@@ -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);
// 清理代码
// 例如,释放资源或重置状态
}

View 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();
}
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('/');
}
}

View File

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

View File

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