Support tencent danmu

This commit is contained in:
cxfksword
2023-02-18 14:29:52 +08:00
parent fee43ededa
commit ca922ced04
30 changed files with 1173 additions and 45 deletions

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Danmu.Test
{
[TestClass]
public class BaseTest
{
protected ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Plugin.Danmu.Model;
using Jellyfin.Plugin.Danmu.Scrapers.Tencent;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Danmu.Test
{
[TestClass]
public class TencentApiTest : BaseTest
{
[TestMethod]
public void TestSearch()
{
Task.Run(async () =>
{
try
{
var keyword = "流浪地球";
var api = new TencentApi(loggerFactory);
var result = await api.SearchAsync(keyword, CancellationToken.None);
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetVideo()
{
Task.Run(async () =>
{
try
{
var vid = "mzc00200koowgko";
var api = new TencentApi(loggerFactory);
var result = await api.GetVideoAsync(vid, CancellationToken.None);
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetDanmu()
{
Task.Run(async () =>
{
try
{
var vid = "a00149qxvfz";
var api = new TencentApi(loggerFactory);
var result = await api.GetDanmuContentAsync(vid, CancellationToken.None);
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
}
}

View File

@@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Plugin.Danmu.Model;
using Jellyfin.Plugin.Danmu.Scrapers;
using Jellyfin.Plugin.Danmu.Scrapers.Tencent;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging;
using Moq;
namespace Jellyfin.Plugin.Danmu.Test
{
[TestClass]
public class TencentTest : BaseTest
{
[TestMethod]
public void TestAddMovie()
{
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Tencent.Tencent(loggerFactory));
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
var directoryServiceStub = new Mock<IDirectoryService>();
var libraryManagerStub = new Mock<ILibraryManager>();
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Movie
{
Name = "流浪地球"
};
var list = new List<LibraryEvent>();
list.Add(new LibraryEvent { Item = item, EventType = EventType.Add });
Task.Run(async () =>
{
try
{
await libraryManagerEventsHelper.ProcessQueuedMovieEvents(list, EventType.Add);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestUpdateMovie()
{
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Tencent.Tencent(loggerFactory));
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
var directoryServiceStub = new Mock<IDirectoryService>();
var libraryManagerStub = new Mock<ILibraryManager>();
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Movie
{
Name = "少年派的奇幻漂流",
ProviderIds = new Dictionary<string, string>() { { Tencent.ScraperProviderId, "19rrjv4kz0" } },
};
var list = new List<LibraryEvent>();
list.Add(new LibraryEvent { Item = item, EventType = EventType.Update });
Task.Run(async () =>
{
try
{
await libraryManagerEventsHelper.ProcessQueuedMovieEvents(list, EventType.Update);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestAddSeason()
{
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Tencent.Tencent(loggerFactory));
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
var directoryServiceStub = new Mock<IDirectoryService>();
var libraryManagerStub = new Mock<ILibraryManager>();
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Season
{
Name = "三体",
ProductionYear = 2023,
};
var list = new List<LibraryEvent>();
list.Add(new LibraryEvent { Item = item, EventType = EventType.Add });
Task.Run(async () =>
{
try
{
await libraryManagerEventsHelper.ProcessQueuedSeasonEvents(list, EventType.Add);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetMedia()
{
Task.Run(async () =>
{
try
{
var api = new Tencent(loggerFactory);
var media = await api.GetMedia(new Season(), "mzc002006n62s11");
Console.WriteLine(media);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
}
}

View File

@@ -24,7 +24,7 @@ namespace Jellyfin.Plugin.Danmu.Test
[TestMethod]
public void TestSearch()
{
var keyword = "一拳超人";
var keyword = "西虹市首富";
var api = new YoukuApi(loggerFactory);
Task.Run(async () =>

View File

@@ -0,0 +1,21 @@
using System;
namespace Jellyfin.Plugin.Danmu.Core;
class CanIgnoreException : Exception
{
public CanIgnoreException(string message) : base(message)
{
}
/// <summary>
/// Don't display call stack as it's irrelevant
/// </summary>
public override string StackTrace
{
get
{
return "";
}
}
}

View File

@@ -11,5 +11,28 @@ namespace Jellyfin.Plugin.Danmu.Core.Extensions
public static IEnumerable<(T item, int index)> WithIndex<T>(this IEnumerable<T> self)
=> self.Select((item, index) => (item, index));
/// <summary>
/// 从list抽取间隔指定大小数量的item
/// </summary>
public static IEnumerable<T> ExtractToNumber<T>(this IEnumerable<T> self, int limit)
{
var count = self.Count();
var step = (int)Math.Ceiling((double)count / limit);
var list = new List<T>();
var idx = 0;
for (var i = 0; i < limit; i++)
{
if (idx >= count)
{
break;
}
list.Add(self.ElementAt(idx));
idx += step;
}
return list;
}
}
}

View File

@@ -0,0 +1,18 @@
using System;
namespace Jellyfin.Plugin.Danmu.Core.Extensions
{
public static class UriExtension
{
public static string GetSecondLevelHost(this Uri uri)
{
var domain = uri.Host;
var arrHost = uri.Host.Split('.');
if (arrHost.Length >= 2)
{
domain = arrHost[arrHost.Length - 2] + "." + arrHost[arrHost.Length - 1];
}
return domain;
}
}
}

View File

@@ -13,10 +13,10 @@ namespace Jellyfin.Plugin.Danmu.Core.Http
{
public HttpClientHandlerEx()
{
// 忽略SSL证书问题
ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true;
ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true; // 忽略SSL证书问题
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
CookieContainer = new CookieContainer();
UseCookies = true; // 使用cookie
}
protected override Task<HttpResponseMessage> SendAsync(

View File

@@ -60,10 +60,10 @@ namespace Jellyfin.Plugin.Danmu.Core.Http
log.Append("Response: ").Append(response).Append('\n');
}
if (response?.Content != null)
{
log.Append("Content: ").Append(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)).Append('\n');
}
// if (response?.Content != null)
// {
// log.Append("Content: ").Append(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)).Append('\n');
// }
}
catch
{

View File

@@ -19,6 +19,7 @@ using System.Text.Json;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Dto;
using Jellyfin.Plugin.Danmu.Core;
namespace Jellyfin.Plugin.Danmu;
@@ -74,7 +75,7 @@ public class DanmuSubtitleProvider : ISubtitleProvider
_libraryManagerEventsHelper.QueueItem(item, EventType.Force);
}
throw new Exception($"弹幕下载已由{Plugin.Instance?.Name}插件接管,请忽略本异常.");
throw new CanIgnoreException($"弹幕下载已由{Plugin.Instance?.Name}插件接管.");
}
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)

View File

@@ -4,6 +4,7 @@ using System.Net.Http;
using System.Text.Json;
using System.Threading;
using Jellyfin.Extensions.Json;
using Jellyfin.Plugin.Danmu.Core.Extensions;
using Jellyfin.Plugin.Danmu.Core.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -12,7 +13,7 @@ namespace Jellyfin.Plugin.Danmu.Scrapers;
public abstract class AbstractApi : IDisposable
{
const string HTTP_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36 Edg/93.0.961.44";
public const string HTTP_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36 Edg/93.0.961.44";
protected ILogger _logger;
protected JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
protected HttpClient httpClient;
@@ -31,6 +32,45 @@ public abstract class AbstractApi : IDisposable
_memoryCache = new MemoryCache(new MemoryCacheOptions());
}
protected void AddCookies(string cookieVal, Uri uri)
{
// 清空旧的cookie
var cookies = _cookieContainer.GetCookies(uri);
foreach (Cookie co in cookies)
{
co.Expires = DateTime.Now.Subtract(TimeSpan.FromDays(1));
}
// 附加新的cookie
if (!string.IsNullOrEmpty(cookieVal))
{
var domain = uri.GetSecondLevelHost();
var arr = cookieVal.Split(';');
foreach (var str in arr)
{
var cookieArr = str.Split('=');
if (cookieArr.Length != 2)
{
continue;
}
var key = cookieArr[0].Trim();
var value = cookieArr[1].Trim();
try
{
_cookieContainer.Add(new Cookie(key, value, "/", "." + domain));
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
}
}
}
}
public void Dispose()

View File

@@ -26,14 +26,8 @@ using ComposableAsync;
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili;
public class BilibiliApi : IDisposable
public class BilibiliApi : AbstractApi
{
const string HTTP_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36 Edg/93.0.961.44";
private readonly ILogger<BilibiliApi> _logger;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private HttpClient httpClient;
private CookieContainer _cookieContainer;
private readonly IMemoryCache _memoryCache;
private static readonly object _lock = new object();
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000));
@@ -42,14 +36,8 @@ public class BilibiliApi : IDisposable
/// </summary>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public BilibiliApi(ILoggerFactory loggerFactory)
: base(loggerFactory.CreateLogger<BilibiliApi>())
{
_logger = loggerFactory.CreateLogger<BilibiliApi>();
var handler = new HttpClientHandlerEx();
_cookieContainer = handler.CookieContainer;
httpClient = new HttpClient(handler, true);
httpClient.DefaultRequestHeaders.Add("user-agent", HTTP_USER_AGENT);
_memoryCache = new MemoryCache(new MemoryCacheOptions());
}
@@ -284,18 +272,5 @@ public class BilibiliApi : IDisposable
await this._timeConstraint;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_memoryCache.Dispose();
}
}
}

View File

@@ -85,8 +85,14 @@ public class ScraperDanmaku
public class ScraperDanmakuText : IXmlSerializable
{
public long Id { get; set; } //弹幕dmID
public int Progress { get; set; } //出现时间(单位ms)
public int Mode { get; set; } //弹幕类型 1 2 3:普通弹幕 4:底部弹幕 5:顶部弹幕 6:逆向弹幕 7:高级弹幕 8:代码弹幕 9:BAS弹幕(pool必须为2)
/// <summary>
/// 出现时间(单位ms)
/// </summary>
public int Progress { get; set; }
/// <summary>
/// 弹幕类型 1 2 3:普通弹幕 4:底部弹幕 5:顶部弹幕 6:逆向弹幕 7:高级弹幕 8:代码弹幕 9:BAS弹幕(pool必须为2)
/// </summary>
public int Mode { get; set; }
public int Fontsize { get; set; } = 25; //文字大小
public uint Color { get; set; } //弹幕颜色
public string MidHash { get; set; } //发送者UID的HASH

View File

@@ -25,7 +25,6 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Iqiyi;
public class IqiyiApi : AbstractApi
{
const string HTTP_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1 Edg/93.0.4577.63";
private static readonly object _lock = new object();
private static readonly Regex yearReg = new Regex(@"[12][890][0-9][0-9]", RegexOptions.Compiled);
private static readonly Regex moviesReg = new Regex(@"<a.*?h5-show-card.*?>([\w\W]+?)</a>", RegexOptions.Compiled);
@@ -38,6 +37,7 @@ public class IqiyiApi : AbstractApi
private DateTime lastRequestTime = DateTime.Now.AddDays(-1);
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000));
private TimeLimiter _delayExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(100));
protected string _cna = string.Empty;
protected string _token = string.Empty;
@@ -50,7 +50,6 @@ public class IqiyiApi : AbstractApi
public IqiyiApi(ILoggerFactory loggerFactory)
: base(loggerFactory.CreateLogger<IqiyiApi>())
{
httpClient.DefaultRequestHeaders.Add("user-agent", HTTP_USER_AGENT);
}
@@ -258,7 +257,9 @@ public class IqiyiApi : AbstractApi
try
{
var comments = await this.GetDanmuContentByMatAsync(tvId, mat, cancellationToken);
danmuList.AddRange(comments);
// 每段有300秒弹幕为避免弹幕太大从中间隔抽取最大60秒200条弹幕
danmuList.AddRange(comments.ExtractToNumber(1000));
}
catch (Exception ex)
{
@@ -266,6 +267,9 @@ public class IqiyiApi : AbstractApi
}
mat++;
// 等待一段时间避免api请求太快
await _delayExecuteConstraint;
} while (mat < 1000);
return danmuList;

View File

@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentComment
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("content")]
public string Content { get; set; }
[JsonPropertyName("content_score")]
public double ContentScore { get; set; }
[JsonPropertyName("content_style")]
public string ContentStyle { get; set; }
[JsonPropertyName("create_time")]
public string CreateTime { get; set; }
[JsonPropertyName("show_weight")]
public int ShowWeight { get; set; }
[JsonPropertyName("time_offset")]
public string TimeOffset { get; set; }
[JsonPropertyName("nick")]
public string Nick { get; set; }
}
public class TencentCommentContentStyle
{
[JsonPropertyName("color")]
public string Color { get; set; }
[JsonPropertyName("position")]
public int Position { get; set; }
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentCommentResult
{
[JsonPropertyName("segment_span")]
public string SegmentSpan { get; set; }
[JsonPropertyName("segment_start")]
public string SegmentStart { get; set; }
[JsonPropertyName("segment_index")]
public Dictionary<long, TencentCommentSegment> SegmentIndex { get; set; }
}
public class TencentCommentSegment
{
[JsonPropertyName("segment_name")]
public string SegmentName { get; set; }
[JsonPropertyName("segment_start")]
public string SegmentStart { get; set; }
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentCommentSegmentResult
{
[JsonPropertyName("barrage_list")]
public List<TencentComment> BarrageList { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentEpisode
{
[JsonPropertyName("vid")]
public string Vid { get; set; }
[JsonPropertyName("cid")]
public string Cid { get; set; }
[JsonPropertyName("duration")]
public string Duration { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("is_trailer")]
public string IsTrailer { get; set; }
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentEpisodeListRequest
{
[JsonPropertyName("page_params")]
public TencentPageParams PageParams { get; set; }
}
public class TencentPageParams
{
[JsonPropertyName("page_type")]
public string PageType { get; set; } = "detail_operation";
[JsonPropertyName("page_id")]
public string PageId { get; set; } = "vsite_episode_list";
[JsonPropertyName("id_type")]
public string IdType { get; set; } = "1";
[JsonPropertyName("page_size")]
public string PageSize { get; set; } = "100";
[JsonPropertyName("cid")]
public string Cid { get; set; }
[JsonPropertyName("lid")]
public string Lid { get; set; } = "0";
[JsonPropertyName("req_from")]
public string ReqFrom { get; set; } = "web_mobile";
[JsonPropertyName("page_context")]
public string PageContext { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentEpisodeListResult
{
[JsonPropertyName("data")]
public TencentModuleDataList Data { get; set; }
}
public class TencentModuleDataList
{
[JsonPropertyName("module_list_datas")]
public List<TencentModuleList> ModuleListDatas { get; set; }
}
public class TencentModuleList
{
[JsonPropertyName("module_datas")]
public List<TencentModule> ModuleDatas { get; set; }
}
public class TencentModule
{
[JsonPropertyName("item_data_lists")]
public TencentModuleItemList ItemDataLists { get; set; }
}
public class TencentModuleItemList
{
[JsonPropertyName("item_datas")]
public List<TencentModuleItem> ItemDatas { get; set; }
}
public class TencentModuleItem
{
[JsonPropertyName("item_id")]
public string ItemId { get; set; }
[JsonPropertyName("item_type")]
public string ItemType { get; set; }
[JsonPropertyName("item_params")]
public TencentEpisode ItemParams { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentSearchDoc
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("dataType")]
public int DataType { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentSearchRequest
{
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("filterValue")]
public string FilterValue { get; set; } = "firstTabid=150";
[JsonPropertyName("retry")]
public int Retry { get; set; } = 0;
[JsonPropertyName("query")]
public string Query { get; set; }
[JsonPropertyName("pagenum")]
public int PageNum { get; set; } = 0;
[JsonPropertyName("pagesize")]
public int PageSize { get; set; } = 20;
[JsonPropertyName("queryFrom")]
public int QueryFrom { get; set; } = 4;
[JsonPropertyName("isneedQc")]
public bool IsneedQc { get; set; } = true;
[JsonPropertyName("adRequestInfo")]
public string AdRequestInfo { get; set; } = string.Empty;
[JsonPropertyName("sdkRequestInfo")]
public string SdkRequestInfo { get; set; } = string.Empty;
[JsonPropertyName("sceneId")]
public int SceneId { get; set; } = 21;
[JsonPropertyName("platform")]
public string Platform { get; set; } = "23";
}

View File

@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentSearchResult
{
[JsonPropertyName("data")]
public TencentSearchData Data { get; set; }
}
public class TencentSearchData
{
[JsonPropertyName("normalList")]
public TencentSearchBox NormalList { get; set; }
}
public class TencentSearchBox
{
[JsonPropertyName("itemList")]
public List<TencentSearchItem> ItemList { get; set; }
}
public class TencentSearchItem
{
[JsonPropertyName("doc")]
public TencentSearchDoc Doc { get; set; }
[JsonPropertyName("videoInfo")]
public TencentVideo VideoInfo { get; set; }
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
public class TencentVideo
{
private static readonly Regex regHtml = new Regex(@"<.+?>", RegexOptions.Compiled);
[JsonIgnore]
public string Id { get; set; }
[JsonPropertyName("videoType")]
public int VideoType { get; set; }
[JsonPropertyName("typeName")]
public string TypeName { get; set; }
private string _title = string.Empty;
[JsonPropertyName("title")]
public string Title
{
get
{
return regHtml.Replace(_title, "");
}
set
{
_title = value;
}
}
[JsonPropertyName("year")]
public int? Year { get; set; }
[JsonPropertyName("subjectDoc")]
public TencentSubjectDoc SubjectDoc { get; set; }
[JsonIgnore]
public List<TencentEpisode> EpisodeList { get; set; }
}
public class TencentSubjectDoc
{
[JsonPropertyName("videoNum")]
public int VideoNum { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.ExternalId
{
/// <inheritdoc />
public class EpisodeExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => Tencent.ScraperProviderName;
/// <inheritdoc />
public string Key => Tencent.ScraperProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Episode;
/// <inheritdoc />
public string UrlFormatString => "#";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Episode;
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.ExternalId
{
/// <inheritdoc />
public class MovieExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => Tencent.ScraperProviderName;
/// <inheritdoc />
public string Key => Tencent.ScraperProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string UrlFormatString => "https://v.qq.com/x/cover/{0}.html";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Movie;
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent.ExternalId
{
/// <inheritdoc />
public class SeasonExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => Tencent.ScraperProviderName;
/// <inheritdoc />
public string Key => Tencent.ScraperProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string UrlFormatString => "https://v.qq.com/x/cover/{0}.html";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Season;
}
}

View File

@@ -0,0 +1,243 @@
using System.Web;
using System.Linq;
using System;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Danmu.Core;
using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.Danmu.Scrapers.Entity;
using System.Collections.Generic;
using System.Xml;
using Jellyfin.Plugin.Danmu.Core.Extensions;
using System.Text.Json;
using Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent;
public class Tencent : AbstractScraper
{
public const string ScraperProviderName = "腾讯";
public const string ScraperProviderId = "TencentID";
private readonly TencentApi _api;
public Tencent(ILoggerFactory logManager)
: base(logManager.CreateLogger<Tencent>())
{
_api = new TencentApi(logManager);
}
public override int DefaultOrder => 3;
public override bool DefaultEnable => false;
public override string Name => "腾讯";
public override string ProviderName => ScraperProviderName;
public override string ProviderId => ScraperProviderId;
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 videos = await this._api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false);
foreach (var video in videos)
{
var videoId = video.Id;
var title = video.Title;
var pubYear = video.Year;
if (isMovieItemType && video.TypeName != "电影")
{
continue;
}
if (!isMovieItemType && video.TypeName == "电影")
{
continue;
}
// 检测标题是否相似(越大越相似)
var score = searchName.Distance(title);
if (score < 0.7)
{
continue;
}
list.Add(new ScraperSearchInfo()
{
Id = $"{videoId}",
Name = title,
Category = video.TypeName,
Year = pubYear,
});
}
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 videos = await this._api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false);
foreach (var video in videos)
{
var videoId = video.Id;
var title = video.Title;
var pubYear = video.Year;
if (isMovieItemType && video.TypeName != "电影")
{
continue;
}
if (!isMovieItemType && video.TypeName == "电影")
{
continue;
}
// 检测标题是否相似(越大越相似)
var score = searchName.Distance(title);
if (score < 0.7)
{
log.LogInformation("[{0}] 标题差异太大,忽略处理. 搜索词:{1}, score: {2}", title, searchName, score);
continue;
}
// 检测年份是否一致
var itemPubYear = item.ProductionYear ?? 0;
if (itemPubYear > 0 && pubYear > 0 && itemPubYear != pubYear)
{
log.LogInformation("[{0}] 发行年份不一致,忽略处理. year: {1} jellyfin: {2}", title, pubYear, itemPubYear);
continue;
}
return video.Id;
}
return null;
}
public override async Task<ScraperMedia?> GetMedia(BaseItem item, string id)
{
if (string.IsNullOrEmpty(id))
{
return null;
}
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
var video = await _api.GetVideoAsync(id, CancellationToken.None).ConfigureAwait(false);
if (video == null)
{
log.LogInformation("[{0}]获取不到视频信息id={1}", this.Name, id);
return null;
}
var media = new ScraperMedia();
media.Id = id;
if (isMovieItemType && video.EpisodeList != null && video.EpisodeList.Count > 0)
{
media.CommentId = $"{video.EpisodeList[0].Vid}";
}
if (video.EpisodeList != null && video.EpisodeList.Count > 0)
{
foreach (var ep in video.EpisodeList)
{
media.Episodes.Add(new ScraperEpisode() { Id = $"{ep.Vid}", CommentId = $"{ep.Vid}" });
}
}
return media;
}
public override async Task<ScraperEpisode?> GetMediaEpisode(BaseItem item, string id)
{
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
if (isMovieItemType)
{
var video = await _api.GetVideoAsync(id, CancellationToken.None).ConfigureAwait(false);
if (video == null || video.EpisodeList == null || video.EpisodeList.Count <= 0)
{
return null;
}
return new ScraperEpisode() { Id = id, CommentId = $"{video.EpisodeList[0].Vid}" };
}
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.GetDanmuContentAsync(commentId, CancellationToken.None).ConfigureAwait(false);
var danmaku = new ScraperDanmaku();
danmaku.ChatId = 1000;
danmaku.ChatServer = "dm.video.qq.com";
foreach (var comment in comments)
{
try
{
var midHash = string.IsNullOrEmpty(comment.Nick) ? "anonymous".ToBase64() : comment.Nick.ToBase64();
var mode = 1;
var danmakuText = new ScraperDanmakuText();
danmakuText.Progress = comment.TimeOffset.ToInt();
danmakuText.Mode = 1;
danmakuText.MidHash = $"[tencent]{midHash}";
danmakuText.Id = comment.Id.ToLong();
danmakuText.Content = comment.Content.Replace("VIP :", "");
if (!string.IsNullOrEmpty(comment.ContentStyle))
{
var style = comment.ContentStyle.FromJson<TencentCommentContentStyle>();
if (style != null && uint.TryParse(style.Color, System.Globalization.NumberStyles.HexNumber, null, out var color))
{
danmakuText.Color = color;
}
if (style != null && style.Position > 0)
{
switch (style.Position)
{
case 2:// top
danmakuText.Mode = 5;
break;
case 3:// bottom
danmakuText.Mode = 4;
break;
}
}
}
danmaku.Items.Add(danmakuText);
}
catch (Exception ex)
{
}
}
return danmaku;
}
private string NormalizeSearchName(string name)
{
// 去掉可能存在的季名称
return Regex.Replace(name, @"\s*第.季", "");
}
}

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using ComposableAsync;
using Jellyfin.Plugin.Danmu.Core.Extensions;
using Jellyfin.Plugin.Danmu.Scrapers.Entity;
using Jellyfin.Plugin.Danmu.Scrapers.Tencent.Entity;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using RateLimiter;
namespace Jellyfin.Plugin.Danmu.Scrapers.Tencent;
public class TencentApi : AbstractApi
{
private static readonly object _lock = new object();
private static readonly Regex yearReg = new Regex(@"[12][890][0-9][0-9]", RegexOptions.Compiled);
private static readonly Regex moviesReg = new Regex(@"<a.*?h5-show-card.*?>([\w\W]+?)</a>", RegexOptions.Compiled);
private static readonly Regex trackInfoReg = new Regex(@"data-trackinfo=""(\{[\w\W]+?\})""", RegexOptions.Compiled);
private static readonly Regex featureReg = new Regex(@"<div.*?show-feature.*?>([\w\W]+?)</div>", RegexOptions.Compiled);
private static readonly Regex unusedReg = new Regex(@"\[.+?\]|\(.+?\)|【.+?】", RegexOptions.Compiled);
private DateTime lastRequestTime = DateTime.Now.AddDays(-1);
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000));
private TimeLimiter _delayExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(100));
protected string _cna = string.Empty;
protected string _token = string.Empty;
protected string _tokenEnc = string.Empty;
/// <summary>
/// Initializes a new instance of the <see cref="TencentApi"/> class.
/// </summary>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public TencentApi(ILoggerFactory loggerFactory)
: base(loggerFactory.CreateLogger<TencentApi>())
{
httpClient.DefaultRequestHeaders.Add("referer", "https://v.qq.com/");
this.AddCookies("pgv_pvid=40b67e3b06027f3d; video_platform=2; vversion_name=8.2.95; video_bucketid=4; video_omgid=0a1ff6bc9407c0b1cff86ee5d359614d", new Uri("https://v.qq.com"));
}
public async Task<List<TencentVideo>> SearchAsync(string keyword, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(keyword))
{
return new List<TencentVideo>();
}
var cacheKey = $"search_{keyword}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) };
if (_memoryCache.TryGetValue<List<TencentVideo>>(cacheKey, out var cacheValue))
{
return cacheValue;
}
await this.LimitRequestFrequently();
var postData = new TencentSearchRequest() { Query = keyword };
var url = $"https://pbaccess.video.qq.com/trpc.videosearch.mobile_search.HttpMobileRecall/MbSearchHttp";
var response = await httpClient.PostAsJsonAsync<TencentSearchRequest>(url, postData, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = new List<TencentVideo>();
var searchResult = await response.Content.ReadFromJsonAsync<TencentSearchResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (searchResult != null && searchResult.Data != null && searchResult.Data.NormalList != null && searchResult.Data.NormalList.ItemList != null)
{
foreach (var item in searchResult.Data.NormalList.ItemList)
{
if (item.VideoInfo.Year == null || item.VideoInfo.Year == 0)
{
continue;
}
var video = item.VideoInfo;
video.Id = item.Doc.Id;
result.Add(video);
}
}
_memoryCache.Set<List<TencentVideo>>(cacheKey, result, expiredOption);
return result;
}
public async Task<TencentVideo?> GetVideoAsync(string id, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(id))
{
return null;
}
var cacheKey = $"media_{id}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
if (_memoryCache.TryGetValue<TencentVideo?>(cacheKey, out var video))
{
return video;
}
var postData = new TencentEpisodeListRequest() { PageParams = new TencentPageParams() { Cid = id } };
var url = $"https://pbaccess.video.qq.com/trpc.universal_backend_service.page_server_rpc.PageServer/GetPageData?video_appid=3000010&vplatform=2";
var response = await httpClient.PostAsJsonAsync<TencentEpisodeListRequest>(url, postData, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<TencentEpisodeListResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Data != null && result.Data.ModuleListDatas != null)
{
var videoInfo = new TencentVideo();
videoInfo.Id = id;
videoInfo.EpisodeList = result.Data.ModuleListDatas.First().ModuleDatas.First().ItemDataLists.ItemDatas.Select(x => x.ItemParams).Where(x => x.IsTrailer != "1").ToList();
_memoryCache.Set<TencentVideo?>(cacheKey, videoInfo, expiredOption);
return videoInfo;
}
_memoryCache.Set<TencentVideo?>(cacheKey, null, expiredOption);
return null;
}
public async Task<List<TencentComment>> GetDanmuContentAsync(string vid, CancellationToken cancellationToken)
{
var danmuList = new List<TencentComment>();
if (string.IsNullOrEmpty(vid))
{
return danmuList;
}
var url = $"https://dm.video.qq.com/barrage/base/{vid}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<TencentCommentResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.SegmentIndex != null)
{
var start = result.SegmentStart.ToLong();
var size = result.SegmentSpan.ToLong();
for (long i = start; result.SegmentIndex.ContainsKey(i) && size > 0; i += size)
{
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();
var segmentResult = await segmentResponse.Content.ReadFromJsonAsync<TencentCommentSegmentResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (segmentResult != null && segmentResult.BarrageList != null)
{
// 30秒每segment为避免弹幕太大从中间隔抽取最大60秒200条弹幕
danmuList.AddRange(segmentResult.BarrageList.ExtractToNumber(100));
}
// 等待一段时间避免api请求太快
await _delayExecuteConstraint;
}
}
return danmuList;
}
protected async Task LimitRequestFrequently()
{
await this._timeConstraint;
}
}

View File

@@ -21,7 +21,6 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Youku;
public class YoukuApi : AbstractApi
{
const string HTTP_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1 Edg/93.0.4577.63";
private static readonly object _lock = new object();
private static readonly Regex yearReg = new Regex(@"[12][890][0-9][0-9]", RegexOptions.Compiled);
private static readonly Regex moviesReg = new Regex(@"<a.*?h5-show-card.*?>([\w\W]+?)</a>", RegexOptions.Compiled);
@@ -32,6 +31,7 @@ public class YoukuApi : AbstractApi
private DateTime lastRequestTime = DateTime.Now.AddDays(-1);
private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000));
private TimeLimiter _delayExecuteConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(100));
protected string _cna = string.Empty;
protected string _token = string.Empty;
@@ -44,7 +44,6 @@ public class YoukuApi : AbstractApi
public YoukuApi(ILoggerFactory loggerFactory)
: base(loggerFactory.CreateLogger<YoukuApi>())
{
httpClient.DefaultRequestHeaders.Add("user-agent", HTTP_USER_AGENT);
}
@@ -65,7 +64,7 @@ public class YoukuApi : AbstractApi
await this.LimitRequestFrequently();
keyword = HttpUtility.UrlEncode(keyword);
var ua = HttpUtility.UrlEncode(HTTP_USER_AGENT);
var ua = HttpUtility.UrlEncode(AbstractApi.HTTP_USER_AGENT);
var url = $"https://search.youku.com/api/search?keyword={keyword}&userAgent={ua}&site=1&categories=0&ftype=0&ob=0&pg=1";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
@@ -189,6 +188,9 @@ public class YoukuApi : AbstractApi
{
var comments = await this.GetDanmuContentByMatAsync(vid, mat, cancellationToken);
danmuList.AddRange(comments);
// 等待一段时间避免api请求太快
await _delayExecuteConstraint;
}
return danmuList;
@@ -264,7 +266,8 @@ public class YoukuApi : AbstractApi
var commentResult = JsonSerializer.Deserialize<YoukuCommentResult>(result.Data.Result);
if (commentResult != null && commentResult.Data != null)
{
return commentResult.Data.Result;
// 每段有60秒弹幕为避免弹幕太大从中间隔抽取最大60秒200条弹幕
return commentResult.Data.Result.ExtractToNumber(200).ToList();
}
}