mirror of
https://github.com/cxfksword/jellyfin-plugin-danmu.git
synced 2026-02-02 17:59:58 +08:00
feat: add support dandan api specification
This commit is contained in:
378
Jellyfin.Plugin.Danmu.Test/ApiControllerTest.cs
Normal file
378
Jellyfin.Plugin.Danmu.Test/ApiControllerTest.cs
Normal file
@@ -0,0 +1,378 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Controllers;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Bilibili;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Dandan;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Iqiyi;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Tencent;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Mgtv;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Youku;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Test
|
||||
{
|
||||
[TestClass]
|
||||
public class ApiControllerTest : BaseTest
|
||||
{
|
||||
private ApiController _apiController = null!;
|
||||
private ScraperManager _scraperManager = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public new void SetUp()
|
||||
{
|
||||
base.SetUp();
|
||||
|
||||
// 初始化 ScraperManager 并注册所有弹幕源
|
||||
_scraperManager = new ScraperManager(loggerFactory);
|
||||
_scraperManager.Register(new Bilibili(loggerFactory));
|
||||
// _scraperManager.Register(new Dandan(loggerFactory));
|
||||
// _scraperManager.Register(new Youku(loggerFactory));
|
||||
// _scraperManager.Register(new Iqiyi(loggerFactory));
|
||||
// _scraperManager.Register(new Tencent(loggerFactory));
|
||||
// _scraperManager.Register(new Mgtv(loggerFactory));
|
||||
|
||||
// Mock 依赖
|
||||
var fileSystemMock = new Mock<IFileSystem>();
|
||||
var libraryManagerMock = new Mock<ILibraryManager>();
|
||||
var itemRepositoryMock = new Mock<MediaBrowser.Controller.Persistence.IItemRepository>();
|
||||
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
|
||||
|
||||
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(
|
||||
itemRepositoryMock.Object,
|
||||
libraryManagerMock.Object,
|
||||
loggerFactory,
|
||||
fileSystemStub.Object,
|
||||
_scraperManager);
|
||||
|
||||
// 创建 ApiController 实例
|
||||
_apiController = new ApiController(
|
||||
fileSystemMock.Object,
|
||||
loggerFactory,
|
||||
libraryManagerEventsHelper,
|
||||
libraryManagerMock.Object,
|
||||
_scraperManager);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestSearchAnime()
|
||||
{
|
||||
Console.WriteLine("========== 测试搜索动画 ==========");
|
||||
|
||||
var keyword = "葬送的芙莉莲";
|
||||
Console.WriteLine($"搜索关键词: {keyword}");
|
||||
Console.WriteLine();
|
||||
|
||||
var result = await _apiController.SearchAnime(keyword);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsTrue(result.Success);
|
||||
Assert.IsNotNull(result.Animes);
|
||||
|
||||
// 打印 JSON 结果
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
var json = JsonSerializer.Serialize(result, jsonOptions);
|
||||
Console.WriteLine("搜索结果 JSON:");
|
||||
Console.WriteLine(json);
|
||||
Console.WriteLine();
|
||||
|
||||
// 验证结果
|
||||
var animeList = result.Animes.ToList();
|
||||
if (animeList.Any())
|
||||
{
|
||||
Console.WriteLine($"找到 {animeList.Count} 个结果");
|
||||
foreach (var anime in animeList.Take(5))
|
||||
{
|
||||
Console.WriteLine($" - AnimeId: {anime.AnimeId}, BangumiId: {anime.BangumiId}, Title: {anime.AnimeTitle}, Type: {anime.TypeDescription}");
|
||||
|
||||
// 验证 AnimeId 格式(11位数字,前2位是 HashPrefix)
|
||||
var animeIdStr = anime.AnimeId.ToString();
|
||||
Assert.IsTrue(animeIdStr.Length <= 11, $"AnimeId {anime.AnimeId} 应该不超过11位");
|
||||
|
||||
// 验证前2位是有效的 HashPrefix (10-15)
|
||||
if (animeIdStr.Length >= 2)
|
||||
{
|
||||
var prefix = int.Parse(animeIdStr.Substring(0, 2));
|
||||
Assert.IsTrue(prefix >= 10 && prefix <= 15, $"HashPrefix {prefix} 应该在 10-15 范围内");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("未找到搜索结果");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestSearchAnimeEpisodes()
|
||||
{
|
||||
Console.WriteLine("========== 测试搜索动画剧集 ==========");
|
||||
|
||||
var keyword = "孤独的美食家";
|
||||
Console.WriteLine($"搜索关键词: {keyword}");
|
||||
Console.WriteLine();
|
||||
|
||||
var result = await _apiController.SearchAnimeEpisodes(keyword);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsTrue(result.Success);
|
||||
Assert.IsNotNull(result.Animes);
|
||||
|
||||
// 打印 JSON 结果
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
var json = JsonSerializer.Serialize(result, jsonOptions);
|
||||
Console.WriteLine("搜索结果(含剧集)JSON:");
|
||||
Console.WriteLine(json);
|
||||
Console.WriteLine();
|
||||
|
||||
// 验证结果
|
||||
var animeEpisodesList = result.Animes.ToList();
|
||||
if (animeEpisodesList.Any())
|
||||
{
|
||||
Console.WriteLine($"找到 {animeEpisodesList.Count} 个结果");
|
||||
foreach (var anime in result.Animes.Take(3))
|
||||
{
|
||||
Console.WriteLine($" - AnimeId: {anime.AnimeId}, Title: {anime.AnimeTitle}, Episodes: {anime.Episodes?.Count ?? 0}");
|
||||
|
||||
if (anime.Episodes != null && anime.Episodes.Any())
|
||||
{
|
||||
Console.WriteLine($" 前3集: {string.Join(", ", anime.Episodes.Take(3).Select(e => $"E{e.EpisodeNumber}"))}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("未找到搜索结果");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestGetAnime()
|
||||
{
|
||||
Console.WriteLine("========== 测试获取动画详情 ==========");
|
||||
|
||||
// 步骤1: 先搜索动画以填充缓存
|
||||
var keyword = "葬送的芙莉莲";
|
||||
Console.WriteLine($"步骤1: 搜索动画 '{keyword}' 以填充缓存");
|
||||
var searchResult = await _apiController.SearchAnime(keyword);
|
||||
|
||||
Assert.IsNotNull(searchResult);
|
||||
Assert.IsTrue(searchResult.Success);
|
||||
Assert.IsNotNull(searchResult.Animes);
|
||||
Assert.IsTrue(searchResult.Animes.Any(), "搜索应该返回至少一个结果");
|
||||
|
||||
// 步骤2: 获取第一个搜索结果的 AnimeId
|
||||
var firstAnime = searchResult.Animes.First();
|
||||
var animeId = firstAnime.AnimeId.ToString();
|
||||
Console.WriteLine($"步骤2: 使用 AnimeId '{animeId}' 获取详情");
|
||||
Console.WriteLine($" BangumiId: {firstAnime.BangumiId}");
|
||||
Console.WriteLine($" Title: {firstAnime.AnimeTitle}");
|
||||
Console.WriteLine();
|
||||
|
||||
// 步骤3: 通过 AnimeId 获取动画详情
|
||||
var result = await _apiController.GetAnime(animeId);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsTrue(result.Success, $"GetAnime 应该成功: {result.ErrorMessage}");
|
||||
Assert.IsNotNull(result.Bangumi, "Bangumi 不应该为 null");
|
||||
|
||||
// 打印 JSON 结果
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
var json = JsonSerializer.Serialize(result, jsonOptions);
|
||||
Console.WriteLine("动画详情 JSON:");
|
||||
Console.WriteLine(json);
|
||||
Console.WriteLine();
|
||||
|
||||
// 验证结果
|
||||
var bangumi = result.Bangumi;
|
||||
Console.WriteLine($"AnimeId: {bangumi.AnimeId}");
|
||||
Console.WriteLine($"BangumiId: {bangumi.BangumiId}");
|
||||
Console.WriteLine($"TypeDescription: {bangumi.TypeDescription}");
|
||||
|
||||
if (bangumi.Episodes != null && bangumi.Episodes.Any())
|
||||
{
|
||||
Console.WriteLine($"找到 {bangumi.Episodes.Count} 集");
|
||||
foreach (var ep in bangumi.Episodes.Take(5))
|
||||
{
|
||||
Console.WriteLine($" - 第{ep.EpisodeNumber}集, EpisodeId: {ep.EpisodeId}, Title: {ep.EpisodeTitle}");
|
||||
}
|
||||
|
||||
Assert.IsTrue(bangumi.Episodes.Count > 0, "应该至少有一集");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("未找到剧集信息");
|
||||
}
|
||||
|
||||
// 验证 AnimeId 格式
|
||||
var animeIdStr = bangumi.AnimeId.ToString();
|
||||
if (animeIdStr.Length >= 2)
|
||||
{
|
||||
var prefix = animeIdStr.Substring(0, 2);
|
||||
Console.WriteLine($"HashPrefix: {prefix}");
|
||||
Assert.IsTrue(int.Parse(prefix) >= 10 && int.Parse(prefix) <= 15,
|
||||
$"HashPrefix {prefix} 应该在 10-15 范围内");
|
||||
}
|
||||
|
||||
// 步骤4: 测试缓存未命中的情况
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("步骤4: 测试缓存未命中(使用不存在的 ID)");
|
||||
var notFoundResult = await _apiController.GetAnime("99999999999");
|
||||
Assert.IsNotNull(notFoundResult);
|
||||
Assert.IsFalse(notFoundResult.Success, "不存在的 ID 应该返回失败");
|
||||
Assert.AreEqual(404, notFoundResult.ErrorCode, "应该返回 404 错误码");
|
||||
Console.WriteLine($"预期的错误: {notFoundResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestConvertToAnimeId()
|
||||
{
|
||||
Console.WriteLine("========== 测试 ID 转换算法 ==========");
|
||||
Console.WriteLine();
|
||||
|
||||
var scraper = new Bilibili(loggerFactory);
|
||||
|
||||
// 测试用例
|
||||
var testCases = new[]
|
||||
{
|
||||
new { Id = "123456", Description = "纯数字ID (小于9位)" },
|
||||
new { Id = "999999999", Description = "纯数字ID (9位最大值)" },
|
||||
new { Id = "1234567890", Description = "纯数字ID (超过9位)" },
|
||||
new { Id = "XMzE2NTk4MjQw", Description = "字母数字混合ID (Youku)" },
|
||||
new { Id = "9bvckxy83zo", Description = "字母数字混合ID (Iqiyi)" },
|
||||
new { Id = "abc_xyz-123", Description = "包含特殊字符的ID" },
|
||||
new { Id = "", Description = "空字符串" },
|
||||
};
|
||||
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
// 使用反射调用私有方法
|
||||
var method = typeof(ApiController).GetMethod("ConvertToHashId",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
var animeId = (long)(method?.Invoke(_apiController, new object[] { scraper, testCase.Id }) ?? 0L);
|
||||
|
||||
var animeIdStr = animeId.ToString();
|
||||
var prefix = animeIdStr.Length >= 2 ? animeIdStr.Substring(0, 2) : "N/A";
|
||||
|
||||
Console.WriteLine($"ID: '{testCase.Id}'");
|
||||
Console.WriteLine($" 描述: {testCase.Description}");
|
||||
Console.WriteLine($" AnimeId: {animeId}");
|
||||
Console.WriteLine($" HashPrefix: {prefix}");
|
||||
Console.WriteLine($" 位数: {animeIdStr.Length}");
|
||||
Console.WriteLine();
|
||||
|
||||
// 验证非空ID应该有正确的前缀
|
||||
if (!string.IsNullOrEmpty(testCase.Id) && animeId > 0)
|
||||
{
|
||||
Assert.AreEqual("10", prefix, $"Bilibili的HashPrefix应该是10");
|
||||
Assert.IsTrue(animeIdStr.Length <= 11, $"AnimeId位数不应超过11位");
|
||||
}
|
||||
}
|
||||
|
||||
// 测试不同的 scraper
|
||||
Console.WriteLine("========== 测试不同弹幕源的 HashPrefix ==========");
|
||||
Console.WriteLine();
|
||||
|
||||
var scrapers = new AbstractScraper[]
|
||||
{
|
||||
new Bilibili(loggerFactory),
|
||||
new Dandan(loggerFactory),
|
||||
new Youku(loggerFactory),
|
||||
new Iqiyi(loggerFactory),
|
||||
new Tencent(loggerFactory),
|
||||
new Mgtv(loggerFactory)
|
||||
};
|
||||
|
||||
var testId = "123456";
|
||||
foreach (var testScraper in scrapers)
|
||||
{
|
||||
var method = typeof(ApiController).GetMethod("ConvertToHashId",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
var animeId = (long)(method?.Invoke(_apiController, new object[] { testScraper, testId }) ?? 0L);
|
||||
var animeIdStr = animeId.ToString();
|
||||
var prefix = animeIdStr.Length >= 2 ? animeIdStr.Substring(0, 2) : "N/A";
|
||||
|
||||
Console.WriteLine($"{testScraper.Name,-10} HashPrefix: {testScraper.HashPrefix} => AnimeId: {animeId} (前缀: {prefix})");
|
||||
|
||||
if (animeId > 0 && prefix != "N/A")
|
||||
{
|
||||
Assert.AreEqual(testScraper.HashPrefix.ToString(), prefix,
|
||||
$"{testScraper.Name} 的 HashPrefix 应该是 {testScraper.HashPrefix}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestSearchAnimeWithDifferentScrapers()
|
||||
{
|
||||
Console.WriteLine("========== 测试不同弹幕源的搜索结果 ==========");
|
||||
Console.WriteLine();
|
||||
|
||||
var keyword = "鬼灭之刃";
|
||||
Console.WriteLine($"搜索关键词: {keyword}");
|
||||
Console.WriteLine();
|
||||
|
||||
var result = await _apiController.SearchAnime(keyword);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.IsTrue(result.Success);
|
||||
Assert.IsNotNull(result.Animes);
|
||||
|
||||
// 按弹幕源分组显示结果
|
||||
var allAnimes = result.Animes.ToList();
|
||||
var groupedResults = allAnimes
|
||||
.GroupBy(a => a.TypeDescription.Split(']')[0] + "]")
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"共找到 {allAnimes.Count} 个结果,来自 {groupedResults.Count} 个弹幕源");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var group in groupedResults)
|
||||
{
|
||||
Console.WriteLine($"{group.Key} - {group.Count()} 个结果:");
|
||||
foreach (var anime in group.Take(2))
|
||||
{
|
||||
Console.WriteLine($" AnimeId: {anime.AnimeId}, BangumiId: {anime.BangumiId}, Title: {anime.AnimeTitle}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// 验证不同弹幕源有不同的 HashPrefix
|
||||
var prefixes = allAnimes
|
||||
.Where(a => a.AnimeId > 0)
|
||||
.Select(a => a.AnimeId.ToString().Substring(0, 2))
|
||||
.Distinct()
|
||||
.OrderBy(p => p)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"使用的 HashPrefix: {string.Join(", ", prefixes)}");
|
||||
|
||||
// 应该有多个不同的前缀
|
||||
if (allAnimes.Count > 0)
|
||||
{
|
||||
Assert.IsTrue(prefixes.Count > 0, "应该至少有一个HashPrefix");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ namespace Jellyfin.Plugin.Danmu.Test
|
||||
var vid = "a00149qxvfz";
|
||||
var api = new TencentApi(loggerFactory);
|
||||
var result = await api.GetDanmuContentAsync(vid, CancellationToken.None);
|
||||
Console.WriteLine(result);
|
||||
Console.WriteLine(result.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
437
Jellyfin.Plugin.Danmu/Controllers/ApiController.cs
Normal file
437
Jellyfin.Plugin.Danmu/Controllers/ApiController.cs
Normal file
@@ -0,0 +1,437 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Jellyfin.Plugin.Danmu.Core.Extensions;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity;
|
||||
using Jellyfin.Plugin.Danmu.Controllers.Entity;
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
public class ApiController : ControllerBase
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly LibraryManagerEventsHelper _libraryManagerEventsHelper;
|
||||
private readonly MediaBrowser.Model.IO.IFileSystem _fileSystem;
|
||||
private readonly ScraperManager _scraperManager;
|
||||
private static TimeSpan _cacheExpiration = TimeSpan.FromDays(31);
|
||||
private static readonly IMemoryCache _animeIdCache = new MemoryCache(new MemoryCacheOptions
|
||||
{
|
||||
// 设置缓存大小限制(可选)
|
||||
SizeLimit = 10000
|
||||
});
|
||||
private static readonly IMemoryCache _episodeIdCache = new MemoryCache(new MemoryCacheOptions
|
||||
{
|
||||
// 设置缓存大小限制(可选)
|
||||
SizeLimit = 10000
|
||||
});
|
||||
|
||||
private readonly ILogger<ApiController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ApiController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="fileSystem">Instance of the <see cref="MediaBrowser.Model.IO.IFileSystem"/> interface.</param>
|
||||
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
|
||||
/// <param name="libraryManagerEventsHelper">Instance of the <see cref="LibraryManagerEventsHelper"/> class.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="scraperManager">Instance of the <see cref="ScraperManager"/> class.</param>
|
||||
public ApiController(
|
||||
MediaBrowser.Model.IO.IFileSystem fileSystem,
|
||||
ILoggerFactory loggerFactory,
|
||||
LibraryManagerEventsHelper libraryManagerEventsHelper,
|
||||
ILibraryManager libraryManager,
|
||||
ScraperManager scraperManager)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
_logger = loggerFactory.CreateLogger<ApiController>();
|
||||
_libraryManager = libraryManager;
|
||||
_libraryManagerEventsHelper = libraryManagerEventsHelper;
|
||||
_scraperManager = scraperManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 scraper ID 转换为带有 HashPrefix 的 11 位 long 类型 AnimeId
|
||||
/// 格式:前 2 位是 HashPrefix,后 9 位是 ID 的哈希值
|
||||
/// 例如:HashPrefix=10, Id="abc123" => 10xxxxxxxxx (后9位是哈希值)
|
||||
/// </summary>
|
||||
/// <param name="scraper">弹幕源</param>
|
||||
/// <param name="id">原始 ID 字符串(可以是任意字母数字组合)</param>
|
||||
/// <param name="animeData">完整的 Anime 数据(可选)</param>
|
||||
/// <returns>11 位 long 类型的 AnimeId,失败时返回 0</returns>
|
||||
private long ConvertToHashId(AbstractScraper scraper, string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long animeId;
|
||||
|
||||
// 如果是纯数字且不超过 9 位,直接使用
|
||||
if (long.TryParse(id, out var numericId) && numericId <= 999999999)
|
||||
{
|
||||
animeId = ((long)scraper.HashPrefix * 1000000000) + numericId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 对于非纯数字或超长的 ID,使用哈希算法转换为 9 位数字
|
||||
// 使用 GetHashCode 并取绝对值,然后模 999999999 确保在 9 位范围内
|
||||
var hashCode = id.GetHashCode();
|
||||
var hashValue = Math.Abs(hashCode) % 1000000000; // 确保在 0-999999999 范围内
|
||||
|
||||
animeId = ((long)scraper.HashPrefix * 1000000000) + hashValue;
|
||||
}
|
||||
|
||||
return animeId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找弹幕
|
||||
/// </summary>
|
||||
[Route("/api/v2/search/anime")]
|
||||
[Route("/{token}/api/v2/search/anime")]
|
||||
[HttpGet]
|
||||
public async Task<ApiResult<Anime>> SearchAnime(string keyword)
|
||||
{
|
||||
var list = new List<Anime>();
|
||||
|
||||
if (string.IsNullOrEmpty(keyword))
|
||||
{
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
ErrorCode = 400,
|
||||
Success = false,
|
||||
ErrorMessage = "Keyword cannot be empty",
|
||||
Animes = list
|
||||
};
|
||||
}
|
||||
|
||||
var scrapers = this._scraperManager.All();
|
||||
var searchTasks = scrapers.Select(async scraper =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await scraper.SearchForApi(keyword).ConfigureAwait(false);
|
||||
return result.Select(searchInfo =>
|
||||
{
|
||||
var animeId = ConvertToHashId(scraper, searchInfo.Id);
|
||||
var anime = new Anime()
|
||||
{
|
||||
AnimeId = animeId,
|
||||
BangumiId = $"{animeId}",
|
||||
AnimeTitle = $"{searchInfo.Name} from {scraper.Name}",
|
||||
ImageUrl = "https://dummyimage.com/300x450/fff/ccc&text=No+Image",
|
||||
Type = searchInfo.Category,
|
||||
TypeDescription = searchInfo.Category,
|
||||
StartDate = searchInfo.StartDate,
|
||||
EpisodeCount = searchInfo.EpisodeSize > 0 ? searchInfo.EpisodeSize : null,
|
||||
Episodes = null
|
||||
};
|
||||
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
Size = 1,
|
||||
AbsoluteExpirationRelativeToNow = _cacheExpiration,
|
||||
};
|
||||
_animeIdCache.Set(anime.AnimeId, new AnimeCacheItem(scraper.ProviderId, searchInfo.Id, anime), cacheOptions);
|
||||
return anime;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{0}]Exception handled processing search movie [{1}]", scraper.Name, keyword);
|
||||
return Enumerable.Empty<Anime>();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(searchTasks).ConfigureAwait(false);
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
list.AddRange(result);
|
||||
}
|
||||
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
Success = true,
|
||||
Animes = list
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据关键词搜索所有匹配的剧集信息
|
||||
/// </summary>
|
||||
[Route("/api/v2/search/episodes")]
|
||||
[Route("/{token}/api/v2/search/episodes")]
|
||||
[HttpGet]
|
||||
public async Task<ApiResult<Anime>> SearchAnimeEpisodes(string anime)
|
||||
{
|
||||
var list = new List<Anime>();
|
||||
|
||||
if (string.IsNullOrEmpty(anime))
|
||||
{
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
ErrorCode = 400,
|
||||
Success = false,
|
||||
ErrorMessage = "Anime cannot be empty",
|
||||
Animes = list
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
var scrapers = this._scraperManager.All();
|
||||
var searchTasks = scrapers.Select(async scraper =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await scraper.SearchForApi(anime).ConfigureAwait(false);
|
||||
return result.Select(searchInfo =>
|
||||
{
|
||||
var animeId = ConvertToHashId(scraper, searchInfo.Id);
|
||||
var animeObj = new Anime()
|
||||
{
|
||||
AnimeId = animeId,
|
||||
BangumiId = $"{animeId}",
|
||||
AnimeTitle = $"{searchInfo.Name} from {scraper.Name}",
|
||||
ImageUrl = "https://dummyimage.com/300x450/fff/ccc&text=No+Image",
|
||||
Type = searchInfo.Category,
|
||||
TypeDescription = searchInfo.Category,
|
||||
StartDate = searchInfo.StartDate,
|
||||
EpisodeCount = searchInfo.EpisodeSize > 0 ? searchInfo.EpisodeSize : null,
|
||||
Episodes = this.GetAnimeEpisodes(scraper, searchInfo.Id).GetAwaiter().GetResult(),
|
||||
};
|
||||
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
Size = 1,
|
||||
AbsoluteExpirationRelativeToNow = _cacheExpiration,
|
||||
};
|
||||
_animeIdCache.Set(animeObj.AnimeId, new AnimeCacheItem(scraper.ProviderId, searchInfo.Id, animeObj), cacheOptions);
|
||||
return animeObj;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{0}]Exception handled processing search movie [{1}]", scraper.Name, anime);
|
||||
return Enumerable.Empty<Anime>();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(searchTasks).ConfigureAwait(false);
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
list.AddRange(result);
|
||||
}
|
||||
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
Success = true,
|
||||
Animes = list
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取详细信息
|
||||
/// </summary>
|
||||
[Route("/api/v2/bangumi/{id}")]
|
||||
[Route("/{token}/api/v2/bangumi/{id}")]
|
||||
[HttpGet]
|
||||
public async Task<ApiResult<Anime>> GetAnime(string id)
|
||||
{
|
||||
var list = new List<EpisodeInfo>();
|
||||
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
ErrorCode = 400,
|
||||
Success = false,
|
||||
ErrorMessage = "ID cannot be empty",
|
||||
};
|
||||
}
|
||||
|
||||
if (!long.TryParse(id, out var animeId))
|
||||
{
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
ErrorCode = 400,
|
||||
Success = false,
|
||||
ErrorMessage = "ID format is invalid",
|
||||
};
|
||||
}
|
||||
|
||||
// 从缓存中获取Anime数据
|
||||
if (!_animeIdCache.TryGetValue(animeId, out AnimeCacheItem? animeCacheItem) || animeCacheItem == null)
|
||||
{
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
ErrorCode = 404,
|
||||
Success = false,
|
||||
ErrorMessage = "Anime not found",
|
||||
};
|
||||
}
|
||||
|
||||
var animeData = animeCacheItem.AnimeData;
|
||||
if (animeData == null)
|
||||
{
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
ErrorCode = 404,
|
||||
Success = false,
|
||||
ErrorMessage = "Anime not found",
|
||||
};
|
||||
}
|
||||
|
||||
var scraper = this._scraperManager.All().FirstOrDefault(s => s.ProviderId == animeCacheItem.ScraperProviderId);
|
||||
if (scraper == null)
|
||||
{
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
ErrorCode = 400,
|
||||
Success = false,
|
||||
ErrorMessage = "No scraper available",
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var anime = animeData;
|
||||
anime.Episodes = await this.GetAnimeEpisodes(scraper, animeCacheItem.Id).ConfigureAwait(false);
|
||||
anime.EpisodeCount = anime.Episodes?.Count;
|
||||
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
Success = true,
|
||||
Bangumi = anime
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{0}]Exception handled processing get episodes [{1}]", scraper.Name, id);
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
ErrorCode = 500,
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task<List<Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.Episode>> GetAnimeEpisodes(AbstractScraper scraper, string id)
|
||||
{
|
||||
var list = new List<Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.Episode>();
|
||||
try
|
||||
{
|
||||
var result = await scraper.GetEpisodesForApi(id).ConfigureAwait(false);
|
||||
foreach (var (ep, idx) in result.WithIndex())
|
||||
{
|
||||
var episode = new Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.Episode()
|
||||
{
|
||||
EpisodeId = ConvertToHashId(scraper, ep.Id),
|
||||
EpisodeNumber = $"{idx + 1}",
|
||||
EpisodeTitle = ep.Title,
|
||||
AirDate = "1970-01-01T00:00:00Z"
|
||||
};
|
||||
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
Size = 1,
|
||||
AbsoluteExpirationRelativeToNow = _cacheExpiration,
|
||||
};
|
||||
_episodeIdCache.Set(episode.EpisodeId, new CommentCacheItem(scraper.ProviderId, ep.CommentId), cacheOptions);
|
||||
|
||||
list.Add(episode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{0}]Exception handled processing get episodes [{1}]", scraper.Name, id);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 下载弹幕.
|
||||
/// </summary>
|
||||
[Route("/api/v2/comment/{cid}")]
|
||||
[Route("/{token}/api/v2/comment/{cid}")]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> DownloadByCommentID(string cid, int chConvert=0, bool withRelated=true, string format="json")
|
||||
{
|
||||
if (string.IsNullOrEmpty(cid))
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
// 将字符串 cid 转换为 long 类型
|
||||
if (!long.TryParse(cid, out var episodeId))
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
// 从缓存中获取episode数据
|
||||
if (!_episodeIdCache.TryGetValue(episodeId, out CommentCacheItem? commentCacheItem) || commentCacheItem == null)
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
var scraper = this._scraperManager.All().FirstOrDefault(s => s.ProviderId == commentCacheItem.ScraperProviderId);
|
||||
if (scraper == null)
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
var commentId = commentCacheItem.CommentId;
|
||||
var danmaku = await scraper.DownloadDanmuForApi(commentId).ConfigureAwait(false);
|
||||
if (danmaku != null)
|
||||
{
|
||||
if (format.ToLower() == "xml")
|
||||
{
|
||||
var bytes = danmaku.ToXml();
|
||||
return File(bytes, "text/xml");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 把 danmaku 转为 CommentResult
|
||||
var result = new Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.CommentResult
|
||||
{
|
||||
Comments = danmaku.Items.Select(item => new Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.Comment
|
||||
{
|
||||
Cid = item.Id,
|
||||
P = $"{item.Progress / 1000.0:F2},{item.Mode},{item.Color},{item.MidHash}",
|
||||
Text = item.Content,
|
||||
Time = (uint)(item.Progress / 1000),
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var jsonString = result.ToJson();
|
||||
return File(System.Text.Encoding.UTF8.GetBytes(jsonString), "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Jellyfin.Plugin.Danmu/Controllers/Entity/AnimeCacheItem.cs
Normal file
19
Jellyfin.Plugin.Danmu/Controllers/Entity/AnimeCacheItem.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Jellyfin.Plugin.Danmu.Controllers.Entity
|
||||
{
|
||||
public class AnimeCacheItem
|
||||
{
|
||||
|
||||
public string ScraperProviderId { get; set; }
|
||||
|
||||
public string Id { get; set; }
|
||||
|
||||
public Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.Anime? AnimeData { get; set; }
|
||||
|
||||
public AnimeCacheItem(string providerId, string id, Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.Anime anime)
|
||||
{
|
||||
ScraperProviderId = providerId;
|
||||
Id = id;
|
||||
AnimeData = anime;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Jellyfin.Plugin.Danmu/Controllers/Entity/ApiResult.cs
Normal file
26
Jellyfin.Plugin.Danmu/Controllers/Entity/ApiResult.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Controllers.Entity
|
||||
{
|
||||
public class ApiResult<T>
|
||||
{
|
||||
[JsonPropertyName("errorCode")]
|
||||
public int ErrorCode { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; } = true;
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
[JsonPropertyName("animes")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IEnumerable<T>? Animes { get; set; }
|
||||
[JsonPropertyName("bangumi")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public T? Bangumi { get; set; } = default(T);
|
||||
|
||||
public ApiResult()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Jellyfin.Plugin.Danmu/Controllers/Entity/CommentCacheItem.cs
Normal file
19
Jellyfin.Plugin.Danmu/Controllers/Entity/CommentCacheItem.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Controllers.Entity
|
||||
{
|
||||
public class CommentCacheItem
|
||||
{
|
||||
|
||||
public string ScraperProviderId { get; set; } = string.Empty;
|
||||
|
||||
public string CommentId { get; set; }
|
||||
|
||||
public CommentCacheItem(string providerId, string commentId)
|
||||
{
|
||||
ScraperProviderId = providerId;
|
||||
CommentId = commentId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ public abstract class AbstractScraper
|
||||
/// Gets the provider id.
|
||||
/// </summary>
|
||||
public abstract string ProviderId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 用于区分不同源的animeId前缀
|
||||
/// </summary>
|
||||
public abstract uint HashPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已废弃
|
||||
|
||||
@@ -36,6 +36,8 @@ public class Bilibili : AbstractScraper
|
||||
|
||||
public override string ProviderId => ScraperProviderId;
|
||||
|
||||
public override uint HashPrefix => 10;
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
|
||||
{
|
||||
var list = new List<ScraperSearchInfo>();
|
||||
|
||||
@@ -22,6 +22,7 @@ public class BilibiliApi : AbstractApi
|
||||
private static readonly object _lock = new object();
|
||||
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000));
|
||||
private TimeLimiter _delayExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(100));
|
||||
private TimeLimiter _delayShortExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(10));
|
||||
|
||||
private static readonly Regex regBiliplusVideoInfo = new Regex(@"view\((.+?)\);", RegexOptions.Compiled);
|
||||
|
||||
@@ -377,7 +378,7 @@ public class BilibiliApi : AbstractApi
|
||||
segmentIndex += 1;
|
||||
|
||||
// 等待一段时间避免api请求太快
|
||||
await this._delayExecuteConstraint;
|
||||
await this._delayShortExecuteConstraint;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -33,6 +33,8 @@ public class Dandan : AbstractScraper
|
||||
|
||||
public override string ProviderId => ScraperProviderId;
|
||||
|
||||
public override uint HashPrefix => 11;
|
||||
|
||||
public override bool IsDeprecated => string.IsNullOrEmpty(this._api.ApiID) || string.IsNullOrEmpty(this._api.ApiSecret);
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
|
||||
|
||||
@@ -13,6 +13,9 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
|
||||
[JsonPropertyName("animeId")]
|
||||
public long AnimeId { get; set; }
|
||||
|
||||
[JsonPropertyName("bangumiId")]
|
||||
public string BangumiId { get; set; }
|
||||
|
||||
[JsonPropertyName("animeTitle")]
|
||||
public string AnimeTitle { get; set; }
|
||||
|
||||
@@ -34,6 +37,13 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
|
||||
[JsonPropertyName("episodes")]
|
||||
public List<Episode>? Episodes { get; set; }
|
||||
|
||||
[JsonPropertyName("rating")]
|
||||
public int Rating { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("isFavorited")]
|
||||
public bool IsFavorited { get; set; } = false;
|
||||
|
||||
[JsonIgnore]
|
||||
public int? Year
|
||||
{
|
||||
get
|
||||
|
||||
@@ -18,5 +18,8 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
|
||||
|
||||
[JsonPropertyName("m")]
|
||||
public string Text { get; set; }
|
||||
|
||||
[JsonPropertyName("t")]
|
||||
public uint Time { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,15 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
|
||||
{
|
||||
public class CommentResult
|
||||
{
|
||||
[JsonPropertyName("count")]
|
||||
public long Count
|
||||
{
|
||||
get
|
||||
{
|
||||
return Comments?.Count ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
[JsonPropertyName("comments")]
|
||||
public List<Comment> Comments { get; set; }
|
||||
}
|
||||
|
||||
@@ -9,4 +9,18 @@ public class ScraperSearchInfo
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public int? Year { get; set; }
|
||||
public int EpisodeSize { get; set; }
|
||||
|
||||
public string StartDate {
|
||||
get
|
||||
{
|
||||
if (Year.HasValue)
|
||||
{
|
||||
return $"{Year}-01-01T00:00:00Z";
|
||||
}
|
||||
else
|
||||
{
|
||||
return "1970-01-01T00:00:00Z";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ public class Iqiyi : AbstractScraper
|
||||
|
||||
public override string ProviderId => ScraperProviderId;
|
||||
|
||||
public override uint HashPrefix => 13;
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
|
||||
{
|
||||
var list = new List<ScraperSearchInfo>();
|
||||
|
||||
@@ -29,6 +29,7 @@ public class IqiyiApi : AbstractApi
|
||||
|
||||
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000));
|
||||
private TimeLimiter _delayExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(100));
|
||||
private TimeLimiter _delayShortExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(10));
|
||||
|
||||
protected string _cna = string.Empty;
|
||||
protected string _token = string.Empty;
|
||||
@@ -278,7 +279,7 @@ public class IqiyiApi : AbstractApi
|
||||
mat++;
|
||||
|
||||
// 等待一段时间避免api请求太快
|
||||
await _delayExecuteConstraint;
|
||||
await _delayShortExecuteConstraint;
|
||||
} while (mat < 1000);
|
||||
|
||||
return danmuList;
|
||||
|
||||
@@ -32,6 +32,8 @@ public class Mgtv : AbstractScraper
|
||||
|
||||
public override string ProviderId => ScraperProviderId;
|
||||
|
||||
public override uint HashPrefix => 15;
|
||||
|
||||
|
||||
private static readonly Regex regTvEpisodeTitle = new Regex(@"^第.+?集$", RegexOptions.Compiled);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ public class MgtvApi : AbstractApi
|
||||
{
|
||||
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000));
|
||||
private TimeLimiter _delayExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(100));
|
||||
private TimeLimiter _delayShortExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(10));
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MgtvApi"/> class.
|
||||
@@ -171,7 +172,7 @@ public class MgtvApi : AbstractApi
|
||||
|
||||
time++;
|
||||
// 等待一段时间避免api请求太快
|
||||
await _delayExecuteConstraint;
|
||||
await _delayShortExecuteConstraint;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -218,7 +219,7 @@ public class MgtvApi : AbstractApi
|
||||
|
||||
time = segmentResult?.Data?.Next ?? 0;
|
||||
// 等待一段时间避免api请求太快
|
||||
await _delayExecuteConstraint;
|
||||
await _delayShortExecuteConstraint;
|
||||
}
|
||||
while (time > 0);
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ public class Tencent : AbstractScraper
|
||||
|
||||
public override string ProviderId => ScraperProviderId;
|
||||
|
||||
public override uint HashPrefix => 14;
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
|
||||
{
|
||||
var list = new List<ScraperSearchInfo>();
|
||||
@@ -173,13 +175,65 @@ public class Tencent : AbstractScraper
|
||||
}
|
||||
|
||||
public override async Task<ScraperDanmaku?> GetDanmuContent(BaseItem item, string commentId)
|
||||
{
|
||||
return await this.GetDanmuContentInternal(commentId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> SearchForApi(string keyword)
|
||||
{
|
||||
var list = new List<ScraperSearchInfo>();
|
||||
var videos = await this._api.SearchAsync(keyword, CancellationToken.None).ConfigureAwait(false);
|
||||
foreach (var video in videos)
|
||||
{
|
||||
var videoId = video.Id;
|
||||
var title = video.Title;
|
||||
var pubYear = video.Year;
|
||||
var episodeSize = video.SubjectDoc.VideoNum;
|
||||
list.Add(new ScraperSearchInfo()
|
||||
{
|
||||
Id = $"{videoId}",
|
||||
Name = title,
|
||||
Category = video.TypeName,
|
||||
Year = pubYear,
|
||||
EpisodeSize = episodeSize,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public override async Task<List<ScraperEpisode>> GetEpisodesForApi(string id)
|
||||
{
|
||||
var list = new List<ScraperEpisode>();
|
||||
var video = await this._api.GetVideoAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
if (video == null)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
if (video.EpisodeList != null && video.EpisodeList.Count > 0)
|
||||
{
|
||||
foreach (var ep in video.EpisodeList)
|
||||
{
|
||||
list.Add(new ScraperEpisode() { Id = $"{ep.Vid}", CommentId = $"{ep.Vid}", Title = ep.Title });
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public override async Task<ScraperDanmaku?> DownloadDanmuForApi(string commentId)
|
||||
{
|
||||
return await this.GetDanmuContentInternal(commentId, true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ScraperDanmaku?> GetDanmuContentInternal(string commentId, bool isParallel = false)
|
||||
{
|
||||
if (string.IsNullOrEmpty(commentId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var comments = await _api.GetDanmuContentAsync(commentId, CancellationToken.None).ConfigureAwait(false);
|
||||
var comments = await _api.GetDanmuContentAsync(commentId, CancellationToken.None, isParallel).ConfigureAwait(false);
|
||||
var danmaku = new ScraperDanmaku();
|
||||
danmaku.ChatId = 1000;
|
||||
danmaku.ChatServer = "dm.video.qq.com";
|
||||
@@ -228,51 +282,4 @@ public class Tencent : AbstractScraper
|
||||
return danmaku;
|
||||
}
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> SearchForApi(string keyword)
|
||||
{
|
||||
var list = new List<ScraperSearchInfo>();
|
||||
var videos = await this._api.SearchAsync(keyword, CancellationToken.None).ConfigureAwait(false);
|
||||
foreach (var video in videos)
|
||||
{
|
||||
var videoId = video.Id;
|
||||
var title = video.Title;
|
||||
var pubYear = video.Year;
|
||||
var episodeSize = video.SubjectDoc.VideoNum;
|
||||
list.Add(new ScraperSearchInfo()
|
||||
{
|
||||
Id = $"{videoId}",
|
||||
Name = title,
|
||||
Category = video.TypeName,
|
||||
Year = pubYear,
|
||||
EpisodeSize = episodeSize,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public override async Task<List<ScraperEpisode>> GetEpisodesForApi(string id)
|
||||
{
|
||||
var list = new List<ScraperEpisode>();
|
||||
var video = await this._api.GetVideoAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
if (video == null)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
if (video.EpisodeList != null && video.EpisodeList.Count > 0)
|
||||
{
|
||||
foreach (var ep in video.EpisodeList)
|
||||
{
|
||||
list.Add(new ScraperEpisode() { Id = $"{ep.Vid}", CommentId = $"{ep.Vid}", Title = ep.Title });
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public override async Task<ScraperDanmaku?> DownloadDanmuForApi(string commentId)
|
||||
{
|
||||
return await this.GetDanmuContent(null, commentId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ public class TencentApi : AbstractApi
|
||||
{
|
||||
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000));
|
||||
private TimeLimiter _delayExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(100));
|
||||
private TimeLimiter _delayShortExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(10));
|
||||
|
||||
// 并行请求配置
|
||||
private const int DefaultParallelCount = 3;
|
||||
|
||||
|
||||
/// <summary>
|
||||
@@ -152,10 +156,12 @@ public class TencentApi : AbstractApi
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<TencentComment>> GetDanmuContentAsync(string vid, CancellationToken cancellationToken, bool isParallel = false)
|
||||
{
|
||||
return await this.GetDanmuContentAsync(vid, isParallel, DefaultParallelCount, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task<List<TencentComment>> GetDanmuContentAsync(string vid, CancellationToken cancellationToken)
|
||||
public async Task<List<TencentComment>> GetDanmuContentAsync(string vid, bool isParallel, int parallelCount, CancellationToken cancellationToken)
|
||||
{
|
||||
var danmuList = new List<TencentComment>();
|
||||
if (string.IsNullOrEmpty(vid))
|
||||
@@ -163,6 +169,10 @@ public class TencentApi : AbstractApi
|
||||
return danmuList;
|
||||
}
|
||||
|
||||
if (parallelCount <= 0)
|
||||
{
|
||||
parallelCount = DefaultParallelCount;
|
||||
}
|
||||
|
||||
var url = $"https://dm.video.qq.com/barrage/base/{vid}";
|
||||
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
@@ -173,29 +183,79 @@ public class TencentApi : AbstractApi
|
||||
{
|
||||
var start = result.SegmentStart.ToLong();
|
||||
var size = result.SegmentSpan.ToLong();
|
||||
var segmentKeys = new List<long>();
|
||||
|
||||
// 收集所有需要下载的分段键
|
||||
for (long i = start; result.SegmentIndex.ContainsKey(i) && size > 0; i += size)
|
||||
{
|
||||
segmentKeys.Add(i);
|
||||
}
|
||||
|
||||
var segment = result.SegmentIndex[i];
|
||||
var segmentUrl = $"https://dm.video.qq.com/barrage/segment/{vid}/{segment.SegmentName}";
|
||||
var segmentResponse = await httpClient.GetAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
|
||||
segmentResponse.EnsureSuccessStatusCode();
|
||||
if (isParallel)
|
||||
{
|
||||
// 并行执行
|
||||
var tasks = new List<Task<List<TencentComment>>>();
|
||||
var semaphore = new SemaphoreSlim(parallelCount, parallelCount);
|
||||
|
||||
var segmentResult = await segmentResponse.Content.ReadFromJsonAsync<TencentCommentSegmentResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (segmentResult != null && segmentResult.BarrageList != null)
|
||||
foreach (var key in segmentKeys)
|
||||
{
|
||||
// 30秒每segment,为避免弹幕太大,从中间隔抽取最大60秒200条弹幕
|
||||
danmuList.AddRange(segmentResult.BarrageList.ExtractToNumber(100));
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _delayShortExecuteConstraint;
|
||||
return await this.GetSegmentDanmuAsync(vid, result.SegmentIndex[key].SegmentName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
tasks.Add(task);
|
||||
}
|
||||
|
||||
// 等待一段时间避免api请求太快
|
||||
await _delayExecuteConstraint;
|
||||
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
foreach (var comments in results)
|
||||
{
|
||||
danmuList.AddRange(comments);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 串行执行
|
||||
foreach (var key in segmentKeys)
|
||||
{
|
||||
var comments = await this.GetSegmentDanmuAsync(vid, result.SegmentIndex[key].SegmentName, cancellationToken).ConfigureAwait(false);
|
||||
danmuList.AddRange(comments);
|
||||
|
||||
// 等待一段时间避免api请求太快
|
||||
await _delayShortExecuteConstraint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return danmuList;
|
||||
}
|
||||
|
||||
private async Task<List<TencentComment>> GetSegmentDanmuAsync(string vid, string segmentName, CancellationToken cancellationToken)
|
||||
{
|
||||
var segmentUrl = $"https://dm.video.qq.com/barrage/segment/{vid}/{segmentName}";
|
||||
var segmentResponse = await httpClient.GetAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
|
||||
segmentResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var segmentResult = await segmentResponse.Content.ReadFromJsonAsync<TencentCommentSegmentResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (segmentResult != null && segmentResult.BarrageList != null)
|
||||
{
|
||||
// 30秒每segment,为避免弹幕太大,从中间隔抽取最大60秒200条弹幕
|
||||
return segmentResult.BarrageList.ExtractToNumber(100).ToList();
|
||||
}
|
||||
|
||||
return new List<TencentComment>();
|
||||
}
|
||||
|
||||
protected async Task LimitRequestFrequently()
|
||||
{
|
||||
await this._timeConstraint;
|
||||
|
||||
@@ -35,6 +35,8 @@ public class Youku : AbstractScraper
|
||||
|
||||
public override string ProviderId => ScraperProviderId;
|
||||
|
||||
public override uint HashPrefix => 12;
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
|
||||
{
|
||||
var list = new List<ScraperSearchInfo>();
|
||||
@@ -172,26 +174,41 @@ public class Youku : AbstractScraper
|
||||
}
|
||||
|
||||
public override async Task<ScraperDanmaku?> GetDanmuContent(BaseItem item, string commentId)
|
||||
{
|
||||
return await this.GetDanmuContentInternal(commentId, false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override async Task<ScraperDanmaku?> DownloadDanmuForApi(string commentId)
|
||||
{
|
||||
return await this.GetDanmuContentInternal(commentId, true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ScraperDanmaku?> GetDanmuContentInternal(string commentId, bool isParallel)
|
||||
{
|
||||
if (string.IsNullOrEmpty(commentId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var comments = await _api.GetDanmuContentAsync(commentId, CancellationToken.None).ConfigureAwait(false);
|
||||
var danmaku = new ScraperDanmaku();
|
||||
danmaku.ChatId = 1000;
|
||||
danmaku.ChatServer = "acs.youku.com";
|
||||
var comments = await _api.GetDanmuContentAsync(commentId, isParallel, 3, CancellationToken.None).ConfigureAwait(false);
|
||||
var danmaku = new ScraperDanmaku
|
||||
{
|
||||
ChatId = 1000,
|
||||
ChatServer = "acs.youku.com"
|
||||
};
|
||||
|
||||
foreach (var comment in comments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var danmakuText = new ScraperDanmakuText();
|
||||
danmakuText.Progress = (int)comment.Playat;
|
||||
danmakuText.Mode = 1;
|
||||
danmakuText.MidHash = $"[youku]{comment.Uid}";
|
||||
danmakuText.Id = comment.ID;
|
||||
danmakuText.Content = comment.Content;
|
||||
var danmakuText = new ScraperDanmakuText
|
||||
{
|
||||
Progress = (int)comment.Playat,
|
||||
Mode = 1,
|
||||
MidHash = $"[youku]{comment.Uid}",
|
||||
Id = comment.ID,
|
||||
Content = comment.Content
|
||||
};
|
||||
|
||||
var property = JsonSerializer.Deserialize<YoukuCommentProperty>(comment.Propertis);
|
||||
if (property != null)
|
||||
@@ -203,66 +220,11 @@ public class Youku : AbstractScraper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
log.LogWarning(ex, "Failed to parse comment: {CommentId}", comment.ID);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return danmaku;
|
||||
}
|
||||
|
||||
|
||||
public override async Task<List<ScraperSearchInfo>> SearchForApi(string keyword)
|
||||
{
|
||||
var list = new List<ScraperSearchInfo>();
|
||||
var videos = await this._api.SearchAsync(keyword, CancellationToken.None).ConfigureAwait(false);
|
||||
foreach (var video in videos)
|
||||
{
|
||||
var videoId = video.ID;
|
||||
var title = video.Title;
|
||||
var pubYear = video.Year;
|
||||
|
||||
var score = keyword.Distance(title);
|
||||
if (score <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(new ScraperSearchInfo()
|
||||
{
|
||||
Id = $"{videoId}",
|
||||
Name = title,
|
||||
Category = video.Type == "movie" ? "电影" : "电视剧",
|
||||
Year = pubYear,
|
||||
EpisodeSize = video.Total,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public override async Task<List<ScraperEpisode>> GetEpisodesForApi(string id)
|
||||
{
|
||||
var list = new List<ScraperEpisode>();
|
||||
var video = await this._api.GetVideoAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
if (video == null)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
if (video.Videos != null && video.Videos.Count > 0)
|
||||
{
|
||||
foreach (var ep in video.Videos)
|
||||
{
|
||||
list.Add(new ScraperEpisode() { Id = $"{ep.ID}", CommentId = $"{ep.ID}", Title = ep.Title });
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public override async Task<ScraperDanmaku?> DownloadDanmuForApi(string commentId)
|
||||
{
|
||||
return await this.GetDanmuContent(null, commentId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ public class YoukuApi : AbstractApi
|
||||
|
||||
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000));
|
||||
private TimeLimiter _delayExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(100));
|
||||
private TimeLimiter _delayShortExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(10));
|
||||
|
||||
// 并行请求配置
|
||||
private const int DefaultParallelCount = 3;
|
||||
|
||||
protected string _cna = string.Empty;
|
||||
protected string _token = string.Empty;
|
||||
@@ -226,7 +230,12 @@ public class YoukuApi : AbstractApi
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<YoukuComment>> GetDanmuContentAsync(string vid, CancellationToken cancellationToken)
|
||||
public async Task<List<YoukuComment>> GetDanmuContentAsync(string vid, CancellationToken cancellationToken, bool isParallel = false)
|
||||
{
|
||||
return await this.GetDanmuContentAsync(vid, isParallel, DefaultParallelCount, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<List<YoukuComment>> GetDanmuContentAsync(string vid, bool isParallel, int parallelCount, CancellationToken cancellationToken)
|
||||
{
|
||||
var danmuList = new List<YoukuComment>();
|
||||
if (string.IsNullOrEmpty(vid))
|
||||
@@ -234,8 +243,12 @@ public class YoukuApi : AbstractApi
|
||||
return danmuList;
|
||||
}
|
||||
|
||||
await this.EnsureTokenCookie(cancellationToken);
|
||||
if (parallelCount <= 0)
|
||||
{
|
||||
parallelCount = DefaultParallelCount;
|
||||
}
|
||||
|
||||
await this.EnsureTokenCookie(cancellationToken);
|
||||
|
||||
var episode = await this.GetEpisodeAsync(vid, cancellationToken);
|
||||
if (episode == null)
|
||||
@@ -244,13 +257,51 @@ public class YoukuApi : AbstractApi
|
||||
}
|
||||
|
||||
var totalMat = episode.TotalMat;
|
||||
for (int mat = 0; mat < totalMat; mat++)
|
||||
{
|
||||
var comments = await this.GetDanmuContentByMatAsync(vid, mat, cancellationToken);
|
||||
danmuList.AddRange(comments);
|
||||
|
||||
// 等待一段时间避免api请求太快
|
||||
await this._delayExecuteConstraint;
|
||||
if (isParallel)
|
||||
{
|
||||
// 并行执行
|
||||
var tasks = new List<Task<List<YoukuComment>>>();
|
||||
var semaphore = new SemaphoreSlim(parallelCount, parallelCount);
|
||||
|
||||
for (int mat = 0; mat < totalMat; mat++)
|
||||
{
|
||||
var currentMat = mat;
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await this._delayShortExecuteConstraint;
|
||||
return await this.GetDanmuContentByMatAsync(vid, currentMat, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
tasks.Add(task);
|
||||
}
|
||||
|
||||
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
foreach (var comments in results)
|
||||
{
|
||||
danmuList.AddRange(comments);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 串行执行
|
||||
for (int mat = 0; mat < totalMat; mat++)
|
||||
{
|
||||
var comments = await this.GetDanmuContentByMatAsync(vid, mat, cancellationToken);
|
||||
danmuList.AddRange(comments);
|
||||
|
||||
// 等待一段时间避免api请求太快
|
||||
await this._delayShortExecuteConstraint;
|
||||
}
|
||||
}
|
||||
|
||||
return danmuList;
|
||||
|
||||
10
README.md
10
README.md
@@ -11,6 +11,7 @@ jellyfin弹幕自动下载插件,已支持的弹幕来源:b站,~~弹弹pla
|
||||
* 自动下载xml格式弹幕
|
||||
* 生成ass格式弹幕
|
||||
* 支持api访问弹幕
|
||||
* 兼容弹弹play接口规范访问
|
||||
|
||||

|
||||
|
||||
@@ -44,16 +45,19 @@ jellyfin弹幕自动下载插件,已支持的弹幕来源:b站,~~弹弹pla
|
||||
|
||||
* `/api/danmu/{id}`: 获取影片或剧集的xml弹幕链接,不存在时,url为空
|
||||
* `/api/danmu/{id}/raw`: 获取影片或剧集的xml弹幕文件内容
|
||||
|
||||
* `/api/v2/search/anime?keyword=xxx`: 根据关键字搜索影视
|
||||
* `/api/v2/search/episodes?anime=xxx`: 根据关键字搜索的剧集信息
|
||||
* `/api/v2/bangumi/{id}`: 获取影视详细信息
|
||||
* `/api/v2/comment/{id}?format=xml`: 获取弹幕内容,默认json格式
|
||||
|
||||
## 如何播放
|
||||
|
||||
xml格式:
|
||||
|
||||
* [switchfin](https://github.com/dragonflylee/switchfin) (Windows/Mac/Linux) 🌟
|
||||
* [Senplayer](https://apps.apple.com/us/app/senplayer-video-media-player/id6443975850) (iOS/iPadOS/AppleTV) 🌟
|
||||
* [弹弹play](https://www.dandanplay.com/) (Windows/Mac/Android)
|
||||
* [KikoPlay](https://github.com/KikoPlayProject/KikoPlay) (Windows/Mac)
|
||||
* [Fileball](https://fileball.app/) (iOS/iPadOS/AppleTV)
|
||||
|
||||
|
||||
ass格式:
|
||||
@@ -63,8 +67,6 @@ ass格式:
|
||||
* Infuse (Mac/iOS/iPadOS/AppleTV)
|
||||
|
||||
|
||||
|
||||
|
||||
## How to build
|
||||
|
||||
1. Clone or download this repository
|
||||
|
||||
Reference in New Issue
Block a user