feat: add dandanplay danmu. #3

This commit is contained in:
cxfksword
2022-12-08 19:05:21 +08:00
parent d84a5d6cbc
commit 74786bfc07
40 changed files with 1655 additions and 511 deletions

View File

@@ -3,8 +3,9 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Plugin.Danmu.Api;
using Jellyfin.Plugin.Danmu.Model;
using Jellyfin.Plugin.Danmu.Scrapers.Bilibili;
using Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Danmu.Test

View File

@@ -0,0 +1,106 @@
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.Dandan;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Danmu.Test
{
[TestClass]
public class DandanApiTest
{
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
builder.AddSimpleConsole(options =>
{
options.IncludeScopes = true;
options.SingleLine = true;
options.TimestampFormat = "hh:mm:ss ";
}));
[TestMethod]
public void TestSearch()
{
var keyword = "混沌武士";
var _api = new DandanApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await _api.SearchAsync(keyword, CancellationToken.None);
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestSearchFrequently()
{
var _api = new DandanApi(loggerFactory);
Task.Run(async () =>
{
try
{
var keyword = "剑风传奇";
var result = await _api.SearchAsync(keyword, CancellationToken.None);
keyword = "哆啦A梦";
result = await _api.SearchAsync(keyword, CancellationToken.None);
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetAnimeAsync()
{
long animeID = 11829;
var _api = new DandanApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await _api.GetAnimeAsync(animeID, CancellationToken.None);
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestGetCommentsAsync()
{
long epId = 118290001;
var _api = new DandanApi(loggerFactory);
Task.Run(async () =>
{
try
{
var result = await _api.GetCommentsAsync(epId, CancellationToken.None);
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
}
}

View File

@@ -4,9 +4,12 @@ using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Plugin.Danmu.Api;
using Jellyfin.Plugin.Danmu.Model;
using Jellyfin.Plugin.Danmu.Scrapers;
using Jellyfin.Plugin.Danmu.Scrapers.Bilibili;
using Jellyfin.Plugin.Danmu.Scrapers.Dandan;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
@@ -33,15 +36,14 @@ namespace Jellyfin.Plugin.Danmu.Test
[TestMethod]
public void TestAddMovie()
{
var _bilibiliApi = new BilibiliApi(loggerFactory);
var scraperFactory = new ScraperFactory(loggerFactory, _bilibiliApi);
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili(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, scraperFactory);
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Movie
{
@@ -68,9 +70,8 @@ namespace Jellyfin.Plugin.Danmu.Test
[TestMethod]
public void TestUpdateMovie()
{
var _bilibiliApi = new BilibiliApi(loggerFactory);
var scraperFactory = new ScraperFactory(loggerFactory, _bilibiliApi);
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili(loggerFactory));
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
fileSystemStub.Setup(x => x.Exists(It.IsAny<string>())).Returns(true);
@@ -80,7 +81,7 @@ namespace Jellyfin.Plugin.Danmu.Test
mediaSourceManagerStub.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaBrowser.Model.MediaInfo.MediaProtocol.File);
var directoryServiceStub = new Mock<IDirectoryService>();
var libraryManagerStub = new Mock<ILibraryManager>();
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperFactory);
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Movie
{
@@ -110,13 +111,13 @@ namespace Jellyfin.Plugin.Danmu.Test
[TestMethod]
public void TestAddSeason()
{
var _bilibiliApi = new BilibiliApi(loggerFactory);
var scraperFactory = new ScraperFactory(loggerFactory, _bilibiliApi);
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili(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, scraperFactory);
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Season
{
@@ -143,13 +144,13 @@ namespace Jellyfin.Plugin.Danmu.Test
[TestMethod]
public void TestUpdateSeason()
{
var _bilibiliApi = new BilibiliApi(loggerFactory);
var scraperFactory = new ScraperFactory(loggerFactory, _bilibiliApi);
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili(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, scraperFactory);
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Season
{
@@ -173,6 +174,154 @@ namespace Jellyfin.Plugin.Danmu.Test
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestAddMovieByDandan()
{
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Dandan.Dandan(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 TestAddSeasonByDandan()
{
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Dandan.Dandan(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 = "混沌武士"
};
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 TestAddMovieByMultiScrapers()
{
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili(loggerFactory));
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Dandan.Dandan(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 TestDownloadDanmu()
{
var dandanScraper = new Jellyfin.Plugin.Danmu.Scrapers.Dandan.Dandan(loggerFactory);
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(dandanScraper);
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
fileSystemStub.Setup(x => x.Exists(It.IsAny<string>())).Returns(true);
fileSystemStub.Setup(x => x.GetLastWriteTime(It.IsAny<string>())).Returns(DateTime.Now.AddDays(-1));
fileSystemStub.Setup(x => x.WriteAllBytesAsync(It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<CancellationToken>()));
var mediaSourceManagerStub = new Mock<IMediaSourceManager>();
mediaSourceManagerStub.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaBrowser.Model.MediaInfo.MediaProtocol.File);
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>() { { "DandanID", "280001" } },
Path = "/tmp/test.mp4",
};
Movie.MediaSourceManager = mediaSourceManagerStub.Object;
var list = new List<LibraryEvent>();
list.Add(new LibraryEvent { Item = item, EventType = EventType.Add });
Task.Run(async () =>
{
try
{
await libraryManagerEventsHelper.DownloadDanmu(dandanScraper, item, "280001");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
}
}

View File

@@ -1,310 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.Danmu.Model;
using System.Threading;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Common.Net;
using System.Net.Http.Json;
using Jellyfin.Plugin.Danmu.Api.Entity;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using System.Net;
using Jellyfin.Plugin.Danmu.Api.Http;
using System.Web;
using static Microsoft.Extensions.Logging.EventSource.LoggingEventSource;
using Microsoft.Extensions.Caching.Memory;
namespace Jellyfin.Plugin.Danmu.Api
{
public class BilibiliApi : 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";
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 DateTime lastRequestTime = DateTime.Now.AddDays(-1);
/// <summary>
/// Initializes a new instance of the <see cref="BilibiliApi"/> class.
/// </summary>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public BilibiliApi(ILoggerFactory loggerFactory)
{
_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());
}
/// <summary>
/// Get bilibili danmu data.
/// </summary>
/// <param name="bvid">The Bilibili bvid.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
/// <returns>Task{TraktResponseDataContract}.</returns>
public async Task<byte[]> GetDanmuContentAsync(string bvid, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(bvid))
{
throw new ArgumentNullException(nameof(bvid));
}
// http://api.bilibili.com/x/player/pagelist?bvid={bvid}
// https://api.bilibili.com/x/v1/dm/list.so?oid={cid}
bvid = bvid.Trim();
var pageUrl = $"http://api.bilibili.com/x/player/pagelist?bvid={bvid}";
var response = await httpClient.GetAsync(pageUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<VideoPart[]>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Data != null)
{
var part = result.Data.FirstOrDefault();
if (part != null)
{
return await GetDanmuContentByCidAsync(part.Cid, cancellationToken).ConfigureAwait(false);
}
}
throw new Exception($"Request fail. bvid={bvid}");
}
/// <summary>
/// Get bilibili danmu data.
/// </summary>
/// <param name="bvid">The Bilibili bvid.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
/// <returns>Task{TraktResponseDataContract}.</returns>
public async Task<byte[]> GetDanmuContentAsync(long epId, CancellationToken cancellationToken)
{
if (epId <= 0)
{
throw new ArgumentNullException(nameof(epId));
}
var season = await GetEpisodeAsync(epId, cancellationToken).ConfigureAwait(false);
if (season != null && season.Episodes.Length > 0)
{
var episode = season.Episodes.First(x => x.Id == epId);
if (episode != null)
{
return await GetDanmuContentByCidAsync(episode.CId, cancellationToken).ConfigureAwait(false);
}
}
throw new Exception($"Request fail. epId={epId}");
}
public async Task<byte[]> GetDanmuContentByCidAsync(long cid, CancellationToken cancellationToken)
{
if (cid <= 0)
{
throw new ArgumentNullException(nameof(cid));
}
var url = $"https://api.bilibili.com/x/v1/dm/list.so?oid={cid}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new Exception($"Request fail. url={url} status_code={response.StatusCode}");
}
// 数据太小可能是已经被b站下架返回了出错信息
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
if (bytes == null || bytes.Length < 2000)
{
throw new Exception($"弹幕获取失败,可能视频已下架或弹幕太少. url: {url}");
}
return bytes;
}
public async Task<SearchResult> SearchAsync(string keyword, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(keyword))
{
return new SearchResult();
}
var cacheKey = $"search_{keyword}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
SearchResult searchResult;
if (_memoryCache.TryGetValue<SearchResult>(cacheKey, out searchResult))
{
return searchResult;
}
this.LimitRequestFrequently();
await EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
keyword = HttpUtility.UrlEncode(keyword);
var url = $"http://api.bilibili.com/x/web-interface/search/all/v2?keyword={keyword}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<SearchResult>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Data != null)
{
_memoryCache.Set<SearchResult>(cacheKey, result.Data, expiredOption);
return result.Data;
}
_memoryCache.Set<SearchResult>(cacheKey, new SearchResult(), expiredOption);
return new SearchResult();
}
public async Task<VideoSeason?> GetSeasonAsync(long seasonId, CancellationToken cancellationToken)
{
if (seasonId <= 0)
{
return null;
}
var cacheKey = $"season_{seasonId}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
VideoSeason? seasonData;
if (_memoryCache.TryGetValue<VideoSeason?>(cacheKey, out seasonData))
{
return seasonData;
}
await EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
var url = $"http://api.bilibili.com/pgc/view/web/season?season_id={seasonId}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<VideoSeason>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Result != null)
{
_memoryCache.Set<VideoSeason?>(cacheKey, result.Result, expiredOption);
return result.Result;
}
_memoryCache.Set<VideoSeason?>(cacheKey, null, expiredOption);
return null;
}
public async Task<VideoSeason?> GetEpisodeAsync(long epId, CancellationToken cancellationToken)
{
if (epId <= 0)
{
return null;
}
var cacheKey = $"episode_{epId}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
VideoSeason? seasonData;
if (_memoryCache.TryGetValue<VideoSeason?>(cacheKey, out seasonData))
{
return seasonData;
}
await EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
var url = $"http://api.bilibili.com/pgc/view/web/season?ep_id={epId}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<VideoSeason>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Result != null)
{
_memoryCache.Set<VideoSeason?>(cacheKey, result.Result, expiredOption);
return result.Result;
}
_memoryCache.Set<VideoSeason?>(cacheKey, null, expiredOption);
return null;
}
public async Task<Video?> GetVideoByBvidAsync(string bvid, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(bvid))
{
return null;
}
var cacheKey = $"video_{bvid}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
Video? videoData;
if (_memoryCache.TryGetValue<Video?>(cacheKey, out videoData))
{
return videoData;
}
await EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
var url = $"https://api.bilibili.com/x/web-interface/view?bvid={bvid}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<Video>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Data != null)
{
_memoryCache.Set<Video?>(cacheKey, result.Data, expiredOption);
return result.Data;
}
_memoryCache.Set<Video?>(cacheKey, null, expiredOption);
return null;
}
private async Task EnsureSessionCookie(CancellationToken cancellationToken)
{
var url = "https://www.bilibili.com";
var cookies = _cookieContainer.GetCookies(new Uri(url, UriKind.Absolute));
var existCookie = cookies.FirstOrDefault(x => x.Name == "buvid3");
if (existCookie != null)
{
return;
}
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
protected void LimitRequestFrequently()
{
var diff = 0;
lock (_lock)
{
var ts = DateTime.Now - lastRequestTime;
diff = (int)(1000 - ts.TotalMilliseconds);
lastRequestTime = DateTime.Now;
}
if (diff > 0)
{
this._logger.LogDebug("请求太频繁,等待{0}毫秒后继续执行...", diff);
Thread.Sleep(diff);
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_memoryCache.Dispose();
}
}
}
}

View File

@@ -1,22 +1,13 @@
using System.Diagnostics;
using System.Collections.Generic;
using MediaBrowser.Model.Plugins;
using System.Linq;
using System.Xml.Serialization;
using System.Reflection;
using Jellyfin.Plugin.Danmu.Core.Extensions;
namespace Jellyfin.Plugin.Danmu.Configuration;
/// <summary>
/// The configuration options.
/// </summary>
public enum SomeOptions
{
/// <summary>
/// Option one.
/// </summary>
OneOption,
/// <summary>
/// Second option.
/// </summary>
AnotherOption
}
/// <summary>
/// Plugin configuration.
@@ -24,47 +15,129 @@ public enum SomeOptions
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// 版本信息
/// </summary>
public PluginConfiguration()
{
ToAss = false;
AssFont = string.Empty;
AssFontSize = string.Empty;
AssLineCount = string.Empty;
AssSpeed = string.Empty;
AssTextOpacity = string.Empty;
}
public string Version { get; } = Assembly.GetExecutingAssembly().GetName().Version.ToString();
/// <summary>
/// 是否同时生成ASS格式弹幕.
/// 是否同时生成ASS格式弹幕.
/// </summary>
public bool ToAss { get; set; }
public bool ToAss { get; set; } = false;
/// <summary>
/// 字体.
/// <summary>
/// 字体.
/// </summary>
public string AssFont { get; set; }
public string AssFont { get; set; } = string.Empty;
/// <summary>
/// 字体大小.
/// <summary>
/// 字体大小.
/// </summary>
public string AssFontSize { get; set; }
public string AssFontSize { get; set; } = string.Empty;
/// <summary>
/// 限制行数.
/// <summary>
/// 限制行数.
/// </summary>
public string AssLineCount { get; set; }
public string AssLineCount { get; set; } = string.Empty;
/// <summary>
/// 移动速度.
/// <summary>
/// 移动速度.
/// </summary>
public string AssSpeed { get; set; }
/// <summary>
/// 透明度.
public string AssSpeed { get; set; } = string.Empty;
/// <summary>
/// 透明度.
/// </summary>
public string AssTextOpacity { get; set; }
public string AssTextOpacity { get; set; } = string.Empty;
public DandanOption Dandan { get; set; } = new DandanOption();
/// <summary>
/// 弹幕源.
/// </summary>
private List<ScraperConfigItem> _scrapers;
[XmlArrayItem(ElementName = "Scraper")]
public ScraperConfigItem[] Scrapers
{
get
{
var defaultScrapers = new List<ScraperConfigItem>();
if (Plugin.Instance?.Scrapers != null)
{
foreach (var scaper in Plugin.Instance.Scrapers)
{
defaultScrapers.Add(new ScraperConfigItem(scaper.Name, scaper.DefaultEnable));
}
};
if (_scrapers?.Any() != true)
{// 没旧配置,返回默认列表
return defaultScrapers.ToArray();
}
else
{// 已保存有配置
// 删除已废弃的插件配置
var allValidScaperNames = defaultScrapers.Select(o => o.Name).ToList();
_scrapers.RemoveAll(o => !allValidScaperNames.Contains(o.Name));
// 找出新增的插件
var oldScrapers = _scrapers.Select(o => o.Name).ToList();
defaultScrapers.RemoveAll(o => oldScrapers.Contains(o.Name));
// 合并新增的scrapers
_scrapers.AddRange(defaultScrapers);
}
return _scrapers.ToArray();
}
set
{
_scrapers = value.ToList();
}
}
}
/// <summary>
/// 弹幕源配置
/// </summary>
public class ScraperConfigItem
{
public bool Enable { get; set; }
public string Name { get; set; }
public ScraperConfigItem()
{
this.Name = "";
this.Enable = false;
}
public ScraperConfigItem(string name, bool enable)
{
this.Name = name;
this.Enable = enable;
}
}
/// <summary>
/// 弹弹play配置
/// </summary>
public class DandanOption
{
/// <summary>
/// 同时获取关联的第三方弹幕
/// </summary>
public bool WithRelatedDanmu { get; set; } = true;
/// <summary>
/// 中文简繁转换。0-不转换1-转换为简体2-转换为繁体
/// </summary>
public int ChConvert { get; set; } = 0;
}

View File

@@ -1,46 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Template</title>
</head>
<body>
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<div class="verticalSection verticalSection">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">Danmu 配置</h2><span id="current_version" name="current_version"
is="emby-linkbutton" class="emby-button"></span>
<a is="emby-linkbutton" class="raised button-alt headerHelpButton emby-button" target="_blank"
href="https://github.com/cxfksword/jellyfin-plugin-danmu">源码</a>
</div>
</div>
<form id="TemplateConfigForm">
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="ToAss" name="ToAss" type="checkbox" is="emby-checkbox" />
<span>同时生成ASS格式弹幕</span>
</label>
<div class="fieldDescription">勾选后会在视频目录下生成ass格式的弹幕命名格式[视频名].danmu.ass</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssFont">ass弹幕字体</label>
<input id="AssFont" name="AssFont" type="text" is="emby-input" />
<div class="fieldDescription">可为空,默认黑体.</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssFontSize">ass弹幕字体大小</label>
<input id="AssFontSize" name="AssFontSize" type="number" is="emby-input" min="25" />
<div class="fieldDescription">可为空默认60可以此为基准增大或缩小.</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssTextOpacity">ass弹幕字体透明度</label>
<input id="AssTextOpacity" name="AssTextOpacity" type="text" is="emby-input" />
<div class="fieldDescription">可为空默认1表示不透明数值在0.0~1.0之间</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssLineCount">ass弹幕显示行数</label>
<input id="AssLineCount" name="AssLineCount" type="number" is="emby-input" min="0" />
<div class="fieldDescription">可为空默认全屏显示1/4屏可填5半屏可填9</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssSpeed">ass弹幕移动速度</label>
<input id="AssSpeed" name="AssSpeed" type="number" is="emby-input" min="0" />
<div class="fieldDescription">可为空默认8秒</div>
</div>
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend>
<h3>弹幕源配置</h3>
</legend>
<div class="checkboxList paperList checkboxList-paperList" id="Scrapers" name="Scrapers">
</div>
</fieldset>
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend>
<h3>弹弹play配置</h3>
</legend>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="WithRelatedDanmu" name="WithRelatedDanmu" type="checkbox"
is="emby-checkbox" />
<span>同时获取关联的第三方弹幕</span>
</label>
<div class="fieldDescription">勾选后,返回此弹幕库对应的所有第三方关联网址的弹幕.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ChConvert">弹幕中文简繁转换</label>
<input id="ChConvert" name="ChConvert" type="number" is="emby-input" />
<div class="fieldDescription">中文简繁转换。0-不转换1-转换为简体2-转换为繁体。</div>
</div>
</fieldset>
<fieldset class="verticalSection verticalSection-extrabottompadding">
<legend>
<h3>生成ASS配置</h3>
</legend>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="ToAss" name="ToAss" type="checkbox" is="emby-checkbox" />
<span>同时生成ASS格式弹幕</span>
</label>
<div class="fieldDescription">勾选后会在视频目录下生成ass格式的弹幕命名格式[视频名].danmu.ass</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssFont">ass弹幕字体</label>
<input id="AssFont" name="AssFont" type="text" is="emby-input" />
<div class="fieldDescription">可为空,默认黑体.</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssFontSize">ass弹幕字体大小</label>
<input id="AssFontSize" name="AssFontSize" type="number" is="emby-input" min="25" />
<div class="fieldDescription">可为空默认60可以此为基准增大或缩小.</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssTextOpacity">ass弹幕字体透明度</label>
<input id="AssTextOpacity" name="AssTextOpacity" type="text" is="emby-input" />
<div class="fieldDescription">可为空默认1表示不透明数值在0.0~1.0之间</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssLineCount">ass弹幕显示行数</label>
<input id="AssLineCount" name="AssLineCount" type="number" is="emby-input" min="0" />
<div class="fieldDescription">可为空默认全屏显示1/4屏可填5半屏可填9</div>
</div>
<div class="inputContainer ass">
<label class="inputLabel inputLabelUnfocused" for="AssSpeed">ass弹幕移动速度</label>
<input id="AssSpeed" name="AssSpeed" type="number" is="emby-input" min="0" />
<div class="fieldDescription">可为空默认8秒</div>
</div>
</fieldset>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
@@ -55,38 +104,109 @@
};
document.querySelector('#TemplateConfigPage')
.addEventListener('pageshow', function() {
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
$('#current_version').text("v" + config.Version);
document.querySelector('#ToAss').checked = config.ToAss;
document.querySelector('#AssFont').value = config.AssFont;
document.querySelector('#AssFontSize').value = config.AssFontSize;
document.querySelector('#AssTextOpacity').value = config.AssTextOpacity;
document.querySelector('#AssLineCount').value = config.AssLineCount;
document.querySelector('#AssSpeed').value = config.AssSpeed;
document.querySelector('#WithRelatedDanmu').checked = config.Dandan.WithRelatedDanmu;
document.querySelector('#ChConvert').value = config.Dandan.ChConvert;
var html = '';
config.Scrapers.forEach(function (e) {
html += '<div class="listItem listItem-border sortableOption sortItem" data-sort="' + e + '">';
html += ' <label class="listItemCheckboxContainer emby-checkbox-label">';
html += ' <input type="checkbox" is="emby-checkbox" class="chkEnableCodec emby-checkbox emby-checkbox-focusring" name="ScraperItem" ' + (e.Enable ? 'checked' : '') + ' value="' + e.Name + '" >';
html += ' <span class="checkboxLabel" style="width:200px" >' + e.Name + '</span>';
html += ' </label>';
html += ' <div class="listItemBody two-line listItemBodyText"></div>';
html += ' <button type="button" is="paper-icon-button-light" title="上" class="btnSortable paper-icon-button-light btnSortableMoveUp btnViewItemUp" data-pluginindex="2"><span class="material-icons keyboard_arrow_up"></span></button>';
html += ' <button type="button" is="paper-icon-button-light" title="下" class="btnSortable paper-icon-button-light btnSortableMoveDown btnViewItemDown" data-pluginindex="0"><span class="material-icons keyboard_arrow_down"></span></button>';
html += '</div>';
html += '\r\n';
});
$('#Scrapers').empty().append(html);
setButtons();
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#TemplateConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
config.ToAss = document.querySelector('#ToAss').checked;
config.AssFont = document.querySelector('#AssFont').value;
config.AssFontSize = document.querySelector('#AssFontSize').value;
config.AssTextOpacity = document.querySelector('#AssTextOpacity').value;
config.AssLineCount = document.querySelector('#AssLineCount').value;
config.AssSpeed = document.querySelector('#AssSpeed').value;
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
config.ToAss = document.querySelector('#ToAss').checked;
config.AssFont = document.querySelector('#AssFont').value;
config.AssFontSize = document.querySelector('#AssFontSize').value;
config.AssTextOpacity = document.querySelector('#AssTextOpacity').value;
config.AssLineCount = document.querySelector('#AssLineCount').value;
config.AssSpeed = document.querySelector('#AssSpeed').value;
var scrapers = [];
$('input[name=ScraperItem]').each(function (index) {
var scraper = new Object();
scraper.Name = $(this).prop('value');
scraper.Enable = $(this).prop('checked');
scrapers.push(scraper);
});
config.Scrapers = scrapers;
var dandan = new Object();
dandan.WithRelatedDanmu = document.querySelector('#WithRelatedDanmu').checked;
dandan.ChConvert = document.querySelector('#ChConvert').value;
config.Dandan = dandan;
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
e.preventDefault();
return false;
function setButtons() {
$('.sortItem button').css('visibility', 'visible')
$('.sortItem:first-child button.btnViewItemUp').css('visibility', 'hidden')
$('.sortItem:last-child button.btnViewItemDown').css('visibility', 'hidden')
$(".sortItem").addClass("listItem-border");
// $(".sortItem:last-child").removeClass("listItem-border");
var i = 0;
$('.sortItem').each(function () {
$(this).attr("data-sort", i);
i++;
});
}
$(document).ready(function () {
setButtons();
$(document).on('click', '.btnViewItemDown', function (e) {
var cCard = $(this).closest('.sortItem');
var tCard = cCard.next('.sortItem');
cCard.insertAfter(tCard);
setButtons();
});
$(document).on('click', '.btnViewItemUp', function (e) {
var cCard = $(this).closest('.sortItem');
var tCard = cCard.prev('.sortItem');
cCard.insertBefore(tCard);
setButtons();
});
});
</script>
</div>
</body>
</html>
</html>

View File

@@ -7,7 +7,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Api.Http
namespace Jellyfin.Plugin.Danmu.Core.Http
{
public class HttpClientHandlerEx : HttpClientHandler
{

View File

@@ -6,7 +6,6 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>

View File

@@ -11,8 +11,6 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Danmu.Api;
using Jellyfin.Plugin.Danmu.Api.Entity;
using Jellyfin.Plugin.Danmu.Core;
using Jellyfin.Plugin.Danmu.Model;
using MediaBrowser.Common.Extensions;
@@ -29,6 +27,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Caching.Memory;
using Jellyfin.Plugin.Danmu.Scrapers;
using Jellyfin.Plugin.Danmu.Core.Extensions;
using Jellyfin.Plugin.Danmu.Configuration;
namespace Jellyfin.Plugin.Danmu;
@@ -42,7 +41,15 @@ public class LibraryManagerEventsHelper : IDisposable
private readonly ILogger<LibraryManagerEventsHelper> _logger;
private readonly Jellyfin.Plugin.Danmu.Core.IFileSystem _fileSystem;
private Timer _queueTimer;
private readonly ScraperFactory _scraperFactory;
private readonly ScraperManager _scraperManager;
public PluginConfiguration Config
{
get
{
return Plugin.Instance?.Configuration ?? new Configuration.PluginConfiguration();
}
}
/// <summary>
@@ -52,7 +59,7 @@ public class LibraryManagerEventsHelper : IDisposable
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
/// <param name="api">The <see cref="BilibiliApi"/>.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
public LibraryManagerEventsHelper(ILibraryManager libraryManager, ILoggerFactory loggerFactory, Jellyfin.Plugin.Danmu.Core.IFileSystem fileSystem, ScraperFactory scraperFactory)
public LibraryManagerEventsHelper(ILibraryManager libraryManager, ILoggerFactory loggerFactory, Jellyfin.Plugin.Danmu.Core.IFileSystem fileSystem, ScraperManager scraperManager)
{
_queuedEvents = new List<LibraryEvent>();
_pendingAddEventCache = new MemoryCache(new MemoryCacheOptions());
@@ -60,7 +67,7 @@ public class LibraryManagerEventsHelper : IDisposable
_libraryManager = libraryManager;
_logger = loggerFactory.CreateLogger<LibraryManagerEventsHelper>();
_fileSystem = fileSystem;
_scraperFactory = scraperFactory;
_scraperManager = scraperManager;
}
/// <summary>
@@ -228,7 +235,7 @@ public class LibraryManagerEventsHelper : IDisposable
var queueUpdateMeta = new List<BaseItem>();
foreach (var item in movies)
{
foreach (var scraper in _scraperFactory.All())
foreach (var scraper in _scraperManager.All())
{
try
{
@@ -260,11 +267,11 @@ public class LibraryManagerEventsHelper : IDisposable
}
catch (FrequentlyRequestException ex)
{
_logger.LogError(ex, "api接口触发风控中止执行请稍候再试.");
_logger.LogError(ex, "[{0}]api接口触发风控中止执行请稍候再试.", scraper.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception handled processing queued movie events");
_logger.LogError(ex, "[{0}]Exception handled processing queued movie events", scraper.Name);
}
}
}
@@ -278,7 +285,7 @@ public class LibraryManagerEventsHelper : IDisposable
{
foreach (var item in movies)
{
foreach (var scraper in _scraperFactory.All())
foreach (var scraper in _scraperManager.All())
{
try
{
@@ -291,6 +298,8 @@ public class LibraryManagerEventsHelper : IDisposable
// 下载弹幕xml文件
await this.DownloadDanmu(scraper, item, episode.CommentId).ConfigureAwait(false);
}
// TODO兼容支持用户设置seasonId
break;
}
}
@@ -383,7 +392,7 @@ public class LibraryManagerEventsHelper : IDisposable
}
var series = season.GetParent();
foreach (var scraper in _scraperFactory.All())
foreach (var scraper in _scraperManager.All())
{
try
{
@@ -432,7 +441,7 @@ public class LibraryManagerEventsHelper : IDisposable
continue;
}
foreach (var scraper in _scraperFactory.All())
foreach (var scraper in _scraperManager.All())
{
try
{
@@ -533,7 +542,7 @@ public class LibraryManagerEventsHelper : IDisposable
{
foreach (var item in episodes)
{
foreach (var scraper in _scraperFactory.All())
foreach (var scraper in _scraperManager.All())
{
try
{
@@ -590,7 +599,7 @@ public class LibraryManagerEventsHelper : IDisposable
_logger.LogInformation("更新epid到元数据完成。item数{0}", queue.Count);
}
private async Task DownloadDanmu(AbstractScraper scraper, BaseItem item, string commentId)
public async Task DownloadDanmu(AbstractScraper scraper, BaseItem item, string commentId)
{
// 下载弹幕xml文件
try
@@ -611,7 +620,7 @@ public class LibraryManagerEventsHelper : IDisposable
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception handled download danmu file");
_logger.LogError(ex, "[{0}]Exception handled download danmu file. name={1}", scraper.Name, item.Name);
}
}
@@ -634,30 +643,29 @@ public class LibraryManagerEventsHelper : IDisposable
var danmuPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".xml");
await this._fileSystem.WriteAllBytesAsync(danmuPath, bytes, CancellationToken.None).ConfigureAwait(false);
var config = Plugin.Instance.Configuration;
if (config.ToAss && bytes.Length > 0)
if (this.Config.ToAss && bytes.Length > 0)
{
var assConfig = new Danmaku2Ass.Config();
assConfig.Title = item.Name;
if (!string.IsNullOrEmpty(config.AssFont.Trim()))
if (!string.IsNullOrEmpty(this.Config.AssFont.Trim()))
{
assConfig.FontName = config.AssFont;
assConfig.FontName = this.Config.AssFont;
}
if (!string.IsNullOrEmpty(config.AssFontSize.Trim()))
if (!string.IsNullOrEmpty(this.Config.AssFontSize.Trim()))
{
assConfig.BaseFontSize = config.AssFontSize.Trim().ToInt();
assConfig.BaseFontSize = this.Config.AssFontSize.Trim().ToInt();
}
if (!string.IsNullOrEmpty(config.AssTextOpacity.Trim()))
if (!string.IsNullOrEmpty(this.Config.AssTextOpacity.Trim()))
{
assConfig.TextOpacity = config.AssTextOpacity.Trim().ToFloat();
assConfig.TextOpacity = this.Config.AssTextOpacity.Trim().ToFloat();
}
if (!string.IsNullOrEmpty(config.AssLineCount.Trim()))
if (!string.IsNullOrEmpty(this.Config.AssLineCount.Trim()))
{
assConfig.LineCount = config.AssLineCount.Trim().ToInt();
assConfig.LineCount = this.Config.AssLineCount.Trim().ToInt();
}
if (!string.IsNullOrEmpty(config.AssSpeed.Trim()))
if (!string.IsNullOrEmpty(this.Config.AssSpeed.Trim()))
{
assConfig.TuneDuration = config.AssSpeed.Trim().ToInt() - 8;
assConfig.TuneDuration = this.Config.AssSpeed.Trim().ToInt() - 8;
}
var assPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".danmu.ass");

View File

@@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using Jellyfin.Plugin.Danmu.Configuration;
using Jellyfin.Plugin.Danmu.Scrapers;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
@@ -26,6 +27,7 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
: base(applicationPaths, xmlSerializer)
{
Instance = this;
Scrapers = applicationHost.GetExports<AbstractScraper>(false).Where(o => o != null).OrderBy(x => x.DefaultOrder).ToList().AsReadOnly();
}
/// <inheritdoc />
@@ -39,6 +41,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary>
public static Plugin? Instance { get; private set; }
/// <summary>
/// 全部的弹幕源
/// </summary>
public ReadOnlyCollection<AbstractScraper> Scrapers { get; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{

View File

@@ -9,7 +9,6 @@ using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.Danmu.Api;
using MediaBrowser.Common.Net;
using Jellyfin.Plugin.Danmu.Model;
using MediaBrowser.Model.Entities;

View File

@@ -7,8 +7,6 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Danmu.Api;
using Jellyfin.Plugin.Danmu.Core;
using Jellyfin.Plugin.Danmu.Model;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -26,7 +24,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
public class RefreshDanmuTask : IScheduledTask
{
private readonly ILibraryManager _libraryManager;
private readonly ScraperFactory _scraperFactory;
private readonly ScraperManager _scraperManager;
private readonly ILogger _logger;
private readonly LibraryManagerEventsHelper _libraryManagerEventsHelper;
@@ -43,13 +41,12 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
/// Initializes a new instance of the <see cref="RefreshDanmuTask"/> class.
/// </summary>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
/// <param name="api">Instance of the <see cref="BilibiliApi"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public RefreshDanmuTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperFactory scraperFactory)
public RefreshDanmuTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperManager scraperManager)
{
_logger = loggerFactory.CreateLogger<RefreshDanmuTask>();
_libraryManager = libraryManager;
_scraperFactory = scraperFactory;
_scraperManager = scraperManager;
_libraryManagerEventsHelper = libraryManagerEventsHelper;
}
@@ -69,7 +66,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
progress?.Report(0);
var scrapers = this._scraperFactory.All();
var scrapers = this._scraperManager.All();
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
// MediaTypes = new[] { MediaType.Video },
@@ -91,6 +88,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
// 没epid元数据的不处理
if (!this.HasAnyScraperProviderId(scrapers, item))
{
successCount++;
continue;
}

View File

@@ -7,8 +7,6 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Danmu.Api;
using Jellyfin.Plugin.Danmu.Core;
using Jellyfin.Plugin.Danmu.Core.Extensions;
using Jellyfin.Plugin.Danmu.Model;
using Jellyfin.Plugin.Danmu.Scrapers;
@@ -25,7 +23,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
public class ScanLibraryTask : IScheduledTask
{
private readonly ILibraryManager _libraryManager;
private readonly ScraperFactory _scraperFactory;
private readonly ScraperManager _scraperManager;
private readonly ILogger _logger;
private readonly LibraryManagerEventsHelper _libraryManagerEventsHelper;
@@ -42,13 +40,12 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
/// Initializes a new instance of the <see cref="ScanLibraryTask"/> class.
/// </summary>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
/// <param name="api">Instance of the <see cref="BilibiliApi"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public ScanLibraryTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperFactory scraperFactory)
public ScanLibraryTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperManager scraperManager)
{
_logger = loggerFactory.CreateLogger<ScanLibraryTask>();
_libraryManager = libraryManager;
_scraperFactory = scraperFactory;
_scraperManager = scraperManager;
_libraryManagerEventsHelper = libraryManagerEventsHelper;
}
@@ -63,7 +60,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
progress?.Report(0);
var scrapers = this._scraperFactory.All();
var scrapers = this._scraperManager.All();
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
// MediaTypes = new[] { MediaType.Video },
@@ -86,6 +83,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
// 有epid的忽略处理不需要再匹配
if (this.HasAnyScraperProviderId(scrapers, item))
{
successCount++;
continue;
}
@@ -129,7 +127,6 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
var providerVal = item.GetProviderId(scraper.ProviderId);
if (!string.IsNullOrEmpty(providerVal))
{
_logger.LogInformation(scraper.Name + " -> " + providerVal);
return true;
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using Jellyfin.Extensions.Json;
using Jellyfin.Plugin.Danmu.Core.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
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";
protected ILogger _logger;
protected JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
protected HttpClient httpClient;
protected CookieContainer _cookieContainer;
protected IMemoryCache _memoryCache;
public AbstractApi(ILogger log)
{
this._logger = log;
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());
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_memoryCache.Dispose();
}
}
}

View File

@@ -9,6 +9,10 @@ public abstract class AbstractScraper
{
protected ILogger log;
public virtual int DefaultOrder => 999;
public virtual bool DefaultEnable => false;
public abstract string Name { get; }
/// <summary>

View File

@@ -4,8 +4,6 @@ using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Danmu.Api;
using Jellyfin.Plugin.Danmu.Core;
using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.Danmu.Scrapers.Entity;
@@ -22,12 +20,16 @@ public class Bilibili : AbstractScraper
private readonly BilibiliApi _api;
public Bilibili(ILoggerFactory logManager, BilibiliApi api)
public Bilibili(ILoggerFactory logManager)
: base(logManager.CreateLogger<Bilibili>())
{
_api = api;
_api = new BilibiliApi(logManager);
}
public override int DefaultOrder => 1;
public override bool DefaultEnable => true;
public override string Name => "bilibili";
public override string ProviderName => ScraperProviderName;

View File

@@ -0,0 +1,309 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.Danmu.Model;
using System.Threading;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Common.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using System.Net;
using System.Web;
using Microsoft.Extensions.Caching.Memory;
using Jellyfin.Plugin.Danmu.Core.Http;
using Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity;
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili;
public class BilibiliApi : 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";
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 DateTime lastRequestTime = DateTime.Now.AddDays(-1);
/// <summary>
/// Initializes a new instance of the <see cref="BilibiliApi"/> class.
/// </summary>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public BilibiliApi(ILoggerFactory loggerFactory)
{
_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());
}
/// <summary>
/// Get bilibili danmu data.
/// </summary>
/// <param name="bvid">The Bilibili bvid.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
/// <returns>Task{TraktResponseDataContract}.</returns>
public async Task<byte[]> GetDanmuContentAsync(string bvid, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(bvid))
{
throw new ArgumentNullException(nameof(bvid));
}
// http://api.bilibili.com/x/player/pagelist?bvid={bvid}
// https://api.bilibili.com/x/v1/dm/list.so?oid={cid}
bvid = bvid.Trim();
var pageUrl = $"http://api.bilibili.com/x/player/pagelist?bvid={bvid}";
var response = await httpClient.GetAsync(pageUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<VideoPart[]>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Data != null)
{
var part = result.Data.FirstOrDefault();
if (part != null)
{
return await GetDanmuContentByCidAsync(part.Cid, cancellationToken).ConfigureAwait(false);
}
}
throw new Exception($"Request fail. bvid={bvid}");
}
/// <summary>
/// Get bilibili danmu data.
/// </summary>
/// <param name="bvid">The Bilibili bvid.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
/// <returns>Task{TraktResponseDataContract}.</returns>
public async Task<byte[]> GetDanmuContentAsync(long epId, CancellationToken cancellationToken)
{
if (epId <= 0)
{
throw new ArgumentNullException(nameof(epId));
}
var season = await GetEpisodeAsync(epId, cancellationToken).ConfigureAwait(false);
if (season != null && season.Episodes.Length > 0)
{
var episode = season.Episodes.First(x => x.Id == epId);
if (episode != null)
{
return await GetDanmuContentByCidAsync(episode.CId, cancellationToken).ConfigureAwait(false);
}
}
throw new Exception($"Request fail. epId={epId}");
}
public async Task<byte[]> GetDanmuContentByCidAsync(long cid, CancellationToken cancellationToken)
{
if (cid <= 0)
{
throw new ArgumentNullException(nameof(cid));
}
var url = $"https://api.bilibili.com/x/v1/dm/list.so?oid={cid}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new Exception($"Request fail. url={url} status_code={response.StatusCode}");
}
// 数据太小可能是已经被b站下架返回了出错信息
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
if (bytes == null || bytes.Length < 2000)
{
throw new Exception($"弹幕获取失败,可能视频已下架或弹幕太少. url: {url}");
}
return bytes;
}
public async Task<SearchResult> SearchAsync(string keyword, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(keyword))
{
return new SearchResult();
}
var cacheKey = $"search_{keyword}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
SearchResult searchResult;
if (_memoryCache.TryGetValue<SearchResult>(cacheKey, out searchResult))
{
return searchResult;
}
this.LimitRequestFrequently();
await EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
keyword = HttpUtility.UrlEncode(keyword);
var url = $"http://api.bilibili.com/x/web-interface/search/all/v2?keyword={keyword}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<SearchResult>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Data != null)
{
_memoryCache.Set<SearchResult>(cacheKey, result.Data, expiredOption);
return result.Data;
}
_memoryCache.Set<SearchResult>(cacheKey, new SearchResult(), expiredOption);
return new SearchResult();
}
public async Task<VideoSeason?> GetSeasonAsync(long seasonId, CancellationToken cancellationToken)
{
if (seasonId <= 0)
{
return null;
}
var cacheKey = $"season_{seasonId}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
VideoSeason? seasonData;
if (_memoryCache.TryGetValue<VideoSeason?>(cacheKey, out seasonData))
{
return seasonData;
}
await EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
var url = $"http://api.bilibili.com/pgc/view/web/season?season_id={seasonId}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<VideoSeason>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Result != null)
{
_memoryCache.Set<VideoSeason?>(cacheKey, result.Result, expiredOption);
return result.Result;
}
_memoryCache.Set<VideoSeason?>(cacheKey, null, expiredOption);
return null;
}
public async Task<VideoSeason?> GetEpisodeAsync(long epId, CancellationToken cancellationToken)
{
if (epId <= 0)
{
return null;
}
var cacheKey = $"episode_{epId}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
VideoSeason? seasonData;
if (_memoryCache.TryGetValue<VideoSeason?>(cacheKey, out seasonData))
{
return seasonData;
}
await EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
var url = $"http://api.bilibili.com/pgc/view/web/season?ep_id={epId}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<VideoSeason>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Result != null)
{
_memoryCache.Set<VideoSeason?>(cacheKey, result.Result, expiredOption);
return result.Result;
}
_memoryCache.Set<VideoSeason?>(cacheKey, null, expiredOption);
return null;
}
public async Task<Video?> GetVideoByBvidAsync(string bvid, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(bvid))
{
return null;
}
var cacheKey = $"video_{bvid}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
Video? videoData;
if (_memoryCache.TryGetValue<Video?>(cacheKey, out videoData))
{
return videoData;
}
await EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
var url = $"https://api.bilibili.com/x/web-interface/view?bvid={bvid}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ApiResult<Video>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Data != null)
{
_memoryCache.Set<Video?>(cacheKey, result.Data, expiredOption);
return result.Data;
}
_memoryCache.Set<Video?>(cacheKey, null, expiredOption);
return null;
}
private async Task EnsureSessionCookie(CancellationToken cancellationToken)
{
var url = "https://www.bilibili.com";
var cookies = _cookieContainer.GetCookies(new Uri(url, UriKind.Absolute));
var existCookie = cookies.FirstOrDefault(x => x.Name == "buvid3");
if (existCookie != null)
{
return;
}
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
protected void LimitRequestFrequently()
{
var diff = 0;
lock (_lock)
{
var ts = DateTime.Now - lastRequestTime;
diff = (int)(1000 - ts.TotalMilliseconds);
lastRequestTime = DateTime.Now;
}
if (diff > 0)
{
this._logger.LogDebug("请求太频繁,等待{0}毫秒后继续执行...", diff);
Thread.Sleep(diff);
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_memoryCache.Dispose();
}
}
}

View File

@@ -5,7 +5,7 @@ using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Api.Entity
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity
{
public class ApiResult<T>
{

View File

@@ -7,7 +7,7 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Extensions.FileSystemGlobbing.Internal;
namespace Jellyfin.Plugin.Danmu.Api.Entity
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity
{
public class Media
{

View File

@@ -6,7 +6,7 @@ using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace Jellyfin.Plugin.Danmu.Api.Entity
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity
{
public class SearchResult
{

View File

@@ -5,7 +5,7 @@ using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Api.Entity
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity
{
public class Video
{

View File

@@ -5,7 +5,7 @@ using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Api.Entity
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity
{
public class VideoEpisode
{

View File

@@ -5,7 +5,7 @@ using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Api.Entity
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity
{
public class VideoPart
{

View File

@@ -5,7 +5,7 @@ using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Api.Entity
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity
{
public class VideoSeason
{

View File

@@ -0,0 +1,160 @@
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;
namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan;
public class Dandan : AbstractScraper
{
public const string ScraperProviderName = "弹弹play";
public const string ScraperProviderId = "DandanID";
private readonly DandanApi _api;
public Dandan(ILoggerFactory logManager)
: base(logManager.CreateLogger<Dandan>())
{
_api = new DandanApi(logManager);
}
public override int DefaultOrder => 2;
public override bool DefaultEnable => true;
public override string Name => "弹弹play";
public override string ProviderName => ScraperProviderName;
public override string ProviderId => ScraperProviderId;
public override async Task<string?> GetMatchMediaId(BaseItem item)
{
var searchName = this.NormalizeSearchName(item.Name);
var animes = await this._api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false);
foreach (var anime in animes)
{
var animeId = anime.AnimeId;
var title = anime.AnimeTitle;
var pubYear = anime.Year;
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
if (isMovieItemType && anime.Type != "movie")
{
continue;
}
if (!isMovieItemType && anime.Type == "movie")
{
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}] 发行年份不一致,忽略处理. dandan{1} jellyfin: {2}", title, pubYear, itemPubYear);
continue;
}
return $"{animeId}";
}
return null;
}
public override async Task<ScraperMedia?> GetMedia(string id)
{
var animeId = id.ToLong();
if (animeId <= 0)
{
return null;
}
var anime = await _api.GetAnimeAsync(animeId, CancellationToken.None).ConfigureAwait(false);
if (anime == null)
{
log.LogInformation("[{0}]获取不到视频信息id={1}", this.Name, animeId);
return null;
}
var media = new ScraperMedia();
media.Id = id;
media.Name = anime.AnimeTitle;
if (anime.Episodes != null && anime.Episodes.Count > 0)
{
foreach (var item in anime.Episodes)
{
media.Episodes.Add(new ScraperEpisode() { Id = $"{item.EpisodeId}", CommentId = $"{item.EpisodeId}" });
}
}
return media;
}
public override async Task<ScraperEpisode?> GetMediaEpisode(string id)
{
var epId = id.ToLong();
if (epId <= 0)
{
return null;
}
return new ScraperEpisode() { Id = id, CommentId = id };
}
public override async Task<ScraperDanmaku?> GetDanmuContent(string commentId)
{
var cid = commentId.ToLong();
if (cid <= 0)
{
return null;
}
var comments = await _api.GetCommentsAsync(cid, CancellationToken.None).ConfigureAwait(false);
var danmaku = new ScraperDanmaku();
danmaku.ChatId = cid;
danmaku.ChatServer = "api.dandanplay.net";
foreach (var item in comments)
{
var danmakuText = new ScraperDanmakuText();
var arr = item.P.Split(",");
danmakuText.Progress = (int)(Convert.ToDouble(arr[0]) * 1000);
danmakuText.Mode = Convert.ToInt32(arr[1]);
danmakuText.Color = Convert.ToUInt32(arr[2]);
danmakuText.MidHash = arr[3];
danmakuText.Id = item.Cid;
danmakuText.Content = item.Text;
danmaku.Items.Add(danmakuText);
}
return danmaku;
}
private string NormalizeSearchName(string name)
{
// 去掉可能存在的季名称
return Regex.Replace(name, @"\s*第.季", "");
}
}

View File

@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.Danmu.Model;
using System.Threading;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Common.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using System.Net;
using System.Web;
using Microsoft.Extensions.Caching.Memory;
using Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity;
using Jellyfin.Plugin.Danmu.Configuration;
namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan;
public class DandanApi : AbstractApi
{
private static readonly object _lock = new object();
private DateTime lastRequestTime = DateTime.Now.AddDays(-1);
public DandanOption Config
{
get
{
return Plugin.Instance?.Configuration.Dandan ?? new DandanOption();
}
}
/// <summary>
/// Initializes a new instance of the <see cref="DandanApi"/> class.
/// </summary>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public DandanApi(ILoggerFactory loggerFactory)
: base(loggerFactory.CreateLogger<DandanApi>())
{
}
public async Task<List<Anime>> SearchAsync(string keyword, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(keyword))
{
return new List<Anime>();
}
var cacheKey = $"search_{keyword}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
List<Anime> searchResult;
if (_memoryCache.TryGetValue<List<Anime>>(cacheKey, out searchResult))
{
return searchResult;
}
this.LimitRequestFrequently();
keyword = HttpUtility.UrlEncode(keyword);
var url = $"https://api.dandanplay.net/api/v2/search/anime?keyword={keyword}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SearchResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Success)
{
_memoryCache.Set<List<Anime>>(cacheKey, result.Animes, expiredOption);
return result.Animes;
}
_memoryCache.Set<List<Anime>>(cacheKey, new List<Anime>(), expiredOption);
return new List<Anime>();
}
public async Task<Anime?> GetAnimeAsync(long animeId, CancellationToken cancellationToken)
{
if (animeId <= 0)
{
return null;
}
var cacheKey = $"anime_{animeId}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
Anime? anime;
if (_memoryCache.TryGetValue<Anime?>(cacheKey, out anime))
{
return anime;
}
var url = $"https://api.dandanplay.net/api/v2/bangumi/{animeId}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var ddd = response.Content.ToString();
var result = await response.Content.ReadFromJsonAsync<AnimeResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Success)
{
_memoryCache.Set<Anime?>(cacheKey, result.Bangumi, expiredOption);
return result.Bangumi;
}
_memoryCache.Set<Anime?>(cacheKey, null, expiredOption);
return null;
}
public async Task<List<Comment>> GetCommentsAsync(long epId, CancellationToken cancellationToken)
{
if (epId <= 0)
{
throw new ArgumentNullException(nameof(epId));
}
var withRelated = this.Config.WithRelatedDanmu ? "true" : "false";
var chConvert = this.Config.ChConvert;
var url = $"https://api.dandanplay.net/api/v2/comment/{epId}?withRelated={withRelated}&chConvert={chConvert}";
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CommentResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null)
{
return result.Comments;
}
throw new Exception($"Request fail. epId={epId}");
}
protected void LimitRequestFrequently(double intervalMilliseconds = 1000)
{
var diff = 0;
lock (_lock)
{
var ts = DateTime.Now - lastRequestTime;
diff = (int)(intervalMilliseconds - ts.TotalMilliseconds);
lastRequestTime = DateTime.Now;
}
if (diff > 0)
{
this._logger.LogDebug("请求太频繁,等待{0}毫秒后继续执行...", diff);
Thread.Sleep(diff);
}
}
}

View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
{
public class Anime
{
[JsonPropertyName("animeId")]
public long AnimeId { get; set; }
[JsonPropertyName("animeTitle")]
public string AnimeTitle { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("typeDescription")]
public string TypeDescription { get; set; }
[JsonPropertyName("imageUrl")]
public string ImageUrl { get; set; }
[JsonPropertyName("startDate")]
public string? StartDate { get; set; }
[JsonPropertyName("episodeCount")]
public int? EpisodeCount { get; set; }
[JsonPropertyName("episodes")]
public List<Episode>? Episodes { get; set; }
public int? Year
{
get
{
try
{
if (StartDate == null)
{
return null;
}
return DateTime.Parse(StartDate).Year;
}
catch
{
return null;
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
{
public class AnimeResult
{
[JsonPropertyName("errorCode")]
public int ErrorCode { get; set; }
[JsonPropertyName("errorMessage")]
public string ErrorMessage { get; set; }
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("bangumi")]
public Anime? Bangumi { get; set; }
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
{
public class Comment
{
[JsonPropertyName("cid")]
public long Cid { get; set; }
// p参数格式为出现时间,模式,颜色,用户ID各个参数之间使用英文逗号分隔
[JsonPropertyName("p")]
public string P { get; set; }
[JsonPropertyName("m")]
public string Text { get; set; }
}
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
{
public class CommentResult
{
[JsonPropertyName("comments")]
public List<Comment> Comments { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
{
public class Episode
{
[JsonPropertyName("episodeId")]
public long EpisodeId { get; set; }
[JsonPropertyName("episodeTitle")]
public string EpisodeTitle { get; set; }
[JsonPropertyName("episodeNumber")]
public int EpisodeNumber { get; set; }
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
{
public class SearchResult
{
[JsonPropertyName("errorCode")]
public int ErrorCode { get; set; }
[JsonPropertyName("errorMessage")]
public string ErrorMessage { get; set; }
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("animes")]
public List<Anime> Animes { 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.Dandan.ExternalId
{
/// <inheritdoc />
public class EpisodeExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => Dandan.ScraperProviderName;
/// <inheritdoc />
public string Key => Dandan.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.Dandan.ExternalId
{
/// <inheritdoc />
public class MovieExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => Dandan.ScraperProviderName;
/// <inheritdoc />
public string Key => Dandan.ScraperProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string UrlFormatString => "#";
/// <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.Dandan.ExternalId
{
/// <inheritdoc />
public class SeasonExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => Dandan.ScraperProviderName;
/// <inheritdoc />
public string Key => Dandan.ScraperProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string UrlFormatString => "#";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Season;
}
}

View File

@@ -5,6 +5,8 @@ using System.Xml.Serialization;
using System.IO;
using System.Xml.Schema;
using System.Xml;
using System.Text.RegularExpressions;
using System.Linq;
namespace Jellyfin.Plugin.Danmu.Scrapers.Entity;
@@ -85,13 +87,13 @@ 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)
public int Fontsize { get; set; } //文字大小
public int Fontsize { get; set; } = 25; //文字大小
public uint Color { get; set; } //弹幕颜色
public string MidHash { get; set; } //发送者UID的HASH
public string Content { get; set; } //弹幕内容
public long Ctime { get; set; } //发送时间
public int Weight { get; set; } //权重
//public string Action { get; set; } //动作?
public int Weight { get; set; } = 1; //权重
//public string Action { get; set; } //动作?
public int Pool { get; set; } //弹幕池
public XmlSchema? GetSchema()
@@ -106,12 +108,39 @@ public class ScraperDanmakuText : IXmlSerializable
public void WriteXml(XmlWriter writer)
{
// bilibili弹幕格式
// <d p="944.95400,5,25,16707842,1657598634,0,ece5c9d1,1094775706690331648,11">今天的风儿甚是喧嚣</d>
// time, mode, size, color, create, pool, sender, id, weight(屏蔽等级)
var time = (Convert.ToDouble(Progress) / 1000).ToString("F05");
var attr = string.Format("{0},{1},{2},{3},{4},{5},{6},{7},{8}", time, Mode, Fontsize, Color, Ctime, Pool, MidHash, Id, Weight);
writer.WriteAttributeString("p", attr);
writer.WriteString(Content);
if (IsValidXmlString(Content))
{
writer.WriteString(Content);
}
else
{
writer.WriteString(RemoveInvalidXmlChars(Content));
}
}
private string RemoveInvalidXmlChars(string text)
{
var validXmlChars = text.Where(ch => XmlConvert.IsXmlChar(ch)).ToArray();
return new string(validXmlChars);
}
private bool IsValidXmlString(string text)
{
try
{
XmlConvert.VerifyXmlChars(text);
return true;
}
catch
{
return false;
}
}
}

View File

@@ -1,23 +0,0 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Jellyfin.Plugin.Danmu.Api;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Danmu.Scrapers;
public class ScraperFactory
{
private List<AbstractScraper> scrapers { get; }
public ScraperFactory(ILoggerFactory logManager, BilibiliApi api)
{
scrapers = new List<AbstractScraper>() {
new Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili(logManager, api)
};
}
public ReadOnlyCollection<AbstractScraper> All()
{
return new ReadOnlyCollection<AbstractScraper>(scrapers);
}
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Jellyfin.Plugin.Danmu.Core.Extensions;
using MediaBrowser.Common;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Danmu.Scrapers;
public class ScraperManager
{
protected ILogger log;
private List<AbstractScraper> _scrapers = new List<AbstractScraper>();
public ScraperManager(ILoggerFactory logManager)
{
log = logManager.CreateLogger<ScraperManager>();
if (Plugin.Instance?.Scrapers != null)
{
this._scrapers.AddRange(Plugin.Instance.Scrapers);
}
}
public void register(AbstractScraper scraper)
{
this._scrapers.Add(scraper);
}
public ReadOnlyCollection<AbstractScraper> All()
{
// 存在配置时,根据配置调整源顺序,并删除不启用的源
if (Plugin.Instance?.Configuration.Scrapers != null)
{
var orderScrapers = new List<AbstractScraper>();
var scraperMap = this._scrapers.ToDictionary(x => x.Name, x => x);
var configScrapers = Plugin.Instance.Configuration.Scrapers;
foreach (var config in configScrapers)
{
if (scraperMap.ContainsKey(config.Name) && config.Enable)
{
orderScrapers.Add(scraperMap[config.Name]);
}
}
// 添加新增并默认启用的源
var allOldScaperNames = configScrapers.Select(o => o.Name).ToList();
foreach (var scraper in this._scrapers)
{
if (!allOldScaperNames.Contains(scraper.Name) && scraper.DefaultEnable)
{
orderScrapers.Add(scraper);
}
}
return orderScrapers.AsReadOnly();
}
return this._scrapers.AsReadOnly();
}
}

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Plugin.Danmu.Api;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Library;
@@ -12,6 +11,9 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using MediaBrowser.Controller.Persistence;
using Jellyfin.Plugin.Danmu.Scrapers;
using Jellyfin.Plugin.Danmu.Scrapers.Bilibili;
using Jellyfin.Plugin.Danmu.Scrapers.Dandan;
using MediaBrowser.Common;
namespace Jellyfin.Plugin.Danmu
{
@@ -25,17 +27,13 @@ namespace Jellyfin.Plugin.Danmu
{
return new Jellyfin.Plugin.Danmu.Core.FileSystem();
});
serviceCollection.AddSingleton<BilibiliApi>((ctx) =>
serviceCollection.AddSingleton<ScraperManager>((ctx) =>
{
return new BilibiliApi(ctx.GetRequiredService<ILoggerFactory>());
});
serviceCollection.AddSingleton<ScraperFactory>((ctx) =>
{
return new ScraperFactory(ctx.GetRequiredService<ILoggerFactory>(), ctx.GetRequiredService<BilibiliApi>());
return new ScraperManager(ctx.GetRequiredService<ILoggerFactory>());
});
serviceCollection.AddSingleton<LibraryManagerEventsHelper>((ctx) =>
{
return new LibraryManagerEventsHelper(ctx.GetRequiredService<ILibraryManager>(), ctx.GetRequiredService<ILoggerFactory>(), ctx.GetRequiredService<Jellyfin.Plugin.Danmu.Core.IFileSystem>(), ctx.GetRequiredService<ScraperFactory>());
return new LibraryManagerEventsHelper(ctx.GetRequiredService<ILibraryManager>(), ctx.GetRequiredService<ILoggerFactory>(), ctx.GetRequiredService<Jellyfin.Plugin.Danmu.Core.IFileSystem>(), ctx.GetRequiredService<ScraperManager>());
});
}

View File

@@ -4,7 +4,7 @@
[![Danmu](https://img.shields.io/badge/jellyfin-10.8.x-lightgrey)](https://github.com/cxfksword/jellyfin-plugin-danmu/releases)
[![Danmu](https://img.shields.io/github/license/cxfksword/jellyfin-plugin-danmu)](https://github.com/cxfksword/jellyfin-plugin-danmu/main/LICENSE)
jellyfin的b站弹幕自动下载插件,会匹配b站番剧和电影视频自动下载对应弹幕并定时更新
jellyfin的弹幕自动下载插件已支持的弹幕来源b站弹弹play
支持功能:
@@ -76,7 +76,7 @@ $ dotnet publish Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj
2. Create a folder, like `Danmu` and copy `bin/Release/Jellyfin.Plugin.Danmu.dll` into it
3. Move folder `Danmu` to jellyfin `data/plugin` folder
3. Move folder `Danmu` to jellyfin `data/plugins` folder
## Thanks