feat: add support dandan api specification

This commit is contained in:
cxfksword
2025-11-16 21:59:19 +08:00
parent 10caedf695
commit 2b64aa2ae4
23 changed files with 1157 additions and 144 deletions

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

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

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

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

View File

@@ -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>
/// 是否已废弃

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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