diff --git a/Jellyfin.Plugin.Danmu.Test/BilibiliApiTest.cs b/Jellyfin.Plugin.Danmu.Test/BilibiliApiTest.cs index abcf57d..32d019f 100644 --- a/Jellyfin.Plugin.Danmu.Test/BilibiliApiTest.cs +++ b/Jellyfin.Plugin.Danmu.Test/BilibiliApiTest.cs @@ -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 diff --git a/Jellyfin.Plugin.Danmu.Test/DandanApiTest.cs b/Jellyfin.Plugin.Danmu.Test/DandanApiTest.cs new file mode 100644 index 0000000..705647a --- /dev/null +++ b/Jellyfin.Plugin.Danmu.Test/DandanApiTest.cs @@ -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(); + } + } +} diff --git a/Jellyfin.Plugin.Danmu.Test/LibraryManagerEventsHelperTest.cs b/Jellyfin.Plugin.Danmu.Test/LibraryManagerEventsHelperTest.cs index d92f2ba..ca61a65 100644 --- a/Jellyfin.Plugin.Danmu.Test/LibraryManagerEventsHelperTest.cs +++ b/Jellyfin.Plugin.Danmu.Test/LibraryManagerEventsHelperTest.cs @@ -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(); var directoryServiceStub = new Mock(); var libraryManagerStub = new Mock(); - 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(); fileSystemStub.Setup(x => x.Exists(It.IsAny())).Returns(true); @@ -80,7 +81,7 @@ namespace Jellyfin.Plugin.Danmu.Test mediaSourceManagerStub.Setup(x => x.GetPathProtocol(It.IsAny())).Returns(MediaBrowser.Model.MediaInfo.MediaProtocol.File); var directoryServiceStub = new Mock(); var libraryManagerStub = new Mock(); - 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(); var directoryServiceStub = new Mock(); var libraryManagerStub = new Mock(); - 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(); var directoryServiceStub = new Mock(); var libraryManagerStub = new Mock(); - 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(); + + var directoryServiceStub = new Mock(); + var libraryManagerStub = new Mock(); + var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager); + + var item = new Movie + { + Name = "你的名字" + }; + + var list = new List(); + 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(); + + var directoryServiceStub = new Mock(); + var libraryManagerStub = new Mock(); + var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager); + + var item = new Season + { + Name = "混沌武士" + }; + + var list = new List(); + 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(); + + var directoryServiceStub = new Mock(); + var libraryManagerStub = new Mock(); + var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager); + + var item = new Movie + { + Name = "你的名字" + }; + + var list = new List(); + 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(); + fileSystemStub.Setup(x => x.Exists(It.IsAny())).Returns(true); + fileSystemStub.Setup(x => x.GetLastWriteTime(It.IsAny())).Returns(DateTime.Now.AddDays(-1)); + fileSystemStub.Setup(x => x.WriteAllBytesAsync(It.IsAny(), It.IsAny(), It.IsAny())); + var mediaSourceManagerStub = new Mock(); + mediaSourceManagerStub.Setup(x => x.GetPathProtocol(It.IsAny())).Returns(MediaBrowser.Model.MediaInfo.MediaProtocol.File); + var directoryServiceStub = new Mock(); + var libraryManagerStub = new Mock(); + var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager); + + var item = new Movie + { + Name = "阿基拉", + ProviderIds = new Dictionary() { { "DandanID", "280001" } }, + Path = "/tmp/test.mp4", + }; + Movie.MediaSourceManager = mediaSourceManagerStub.Object; + + var list = new List(); + 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(); + + } } } diff --git a/Jellyfin.Plugin.Danmu/Api/BilibiliApi.cs b/Jellyfin.Plugin.Danmu/Api/BilibiliApi.cs deleted file mode 100644 index 001f1df..0000000 --- a/Jellyfin.Plugin.Danmu/Api/BilibiliApi.cs +++ /dev/null @@ -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 _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); - - /// - /// Initializes a new instance of the class. - /// - /// The . - public BilibiliApi(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - - 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()); - } - - /// - /// Get bilibili danmu data. - /// - /// The Bilibili bvid. - /// The . - /// Task{TraktResponseDataContract}. - public async Task 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>(_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}"); - } - - /// - /// Get bilibili danmu data. - /// - /// The Bilibili bvid. - /// The . - /// Task{TraktResponseDataContract}. - public async Task 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 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 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(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>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (result != null && result.Code == 0 && result.Data != null) - { - _memoryCache.Set(cacheKey, result.Data, expiredOption); - return result.Data; - } - - _memoryCache.Set(cacheKey, new SearchResult(), expiredOption); - return new SearchResult(); - } - - public async Task 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(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>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (result != null && result.Code == 0 && result.Result != null) - { - _memoryCache.Set(cacheKey, result.Result, expiredOption); - return result.Result; - } - - _memoryCache.Set(cacheKey, null, expiredOption); - return null; - } - - public async Task 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(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>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (result != null && result.Code == 0 && result.Result != null) - { - _memoryCache.Set(cacheKey, result.Result, expiredOption); - return result.Result; - } - - _memoryCache.Set(cacheKey, null, expiredOption); - return null; - } - - public async Task 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(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>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (result != null && result.Code == 0 && result.Data != null) - { - _memoryCache.Set(cacheKey, result.Data, expiredOption); - return result.Data; - } - - _memoryCache.Set(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(); - } - } - } -} diff --git a/Jellyfin.Plugin.Danmu/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Danmu/Configuration/PluginConfiguration.cs index 49561c8..836562f 100644 --- a/Jellyfin.Plugin.Danmu/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.Danmu/Configuration/PluginConfiguration.cs @@ -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; -/// -/// The configuration options. -/// -public enum SomeOptions -{ - /// - /// Option one. - /// - OneOption, - - /// - /// Second option. - /// - AnotherOption -} /// /// Plugin configuration. @@ -24,47 +15,129 @@ public enum SomeOptions public class PluginConfiguration : BasePluginConfiguration { /// - /// Initializes a new instance of the class. + /// 版本信息 /// - 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(); /// - /// ǷͬʱASSʽĻ. + /// 是否同时生成ASS格式弹幕. /// - public bool ToAss { get; set; } + public bool ToAss { get; set; } = false; - /// - /// . + /// + /// 字体. /// - public string AssFont { get; set; } + public string AssFont { get; set; } = string.Empty; - /// - /// С. + /// + /// 字体大小. /// - public string AssFontSize { get; set; } + public string AssFontSize { get; set; } = string.Empty; - /// - /// . + /// + /// 限制行数. /// - public string AssLineCount { get; set; } + public string AssLineCount { get; set; } = string.Empty; - /// - /// ƶٶ. + /// + /// 移动速度. /// - public string AssSpeed { get; set; } - - /// - /// ͸. + public string AssSpeed { get; set; } = string.Empty; + + /// + /// 透明度. /// - public string AssTextOpacity { get; set; } - + public string AssTextOpacity { get; set; } = string.Empty; + + public DandanOption Dandan { get; set; } = new DandanOption(); + + + /// + /// 弹幕源. + /// + private List _scrapers; + + [XmlArrayItem(ElementName = "Scraper")] + public ScraperConfigItem[] Scrapers + { + get + { + + var defaultScrapers = new List(); + 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(); + } + } } + + +/// +/// 弹幕源配置 +/// +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; + } + +} + +/// +/// 弹弹play配置 +/// +public class DandanOption +{ + /// + /// 同时获取关联的第三方弹幕 + /// + public bool WithRelatedDanmu { get; set; } = true; + + /// + /// 中文简繁转换。0-不转换,1-转换为简体,2-转换为繁体 + /// + public int ChConvert { get; set; } = 0; +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Configuration/configPage.html b/Jellyfin.Plugin.Danmu/Configuration/configPage.html index 66431d2..05cc2d3 100644 --- a/Jellyfin.Plugin.Danmu/Configuration/configPage.html +++ b/Jellyfin.Plugin.Danmu/Configuration/configPage.html @@ -1,46 +1,95 @@ + Template + -
+
+
+
+

Danmu 配置

+ 源码 +
+
+ +
-
- -
勾选后,会在视频目录下生成ass格式的弹幕,命名格式:[视频名].danmu.ass
-
-
- - -
可为空,默认黑体.
-
-
- - -
可为空,默认60,可以此为基准,增大或缩小.
-
-
- - -
可为空,默认1,表示不透明,数值在0.0~1.0之间
-
-
- - -
可为空,默认全屏显示,1/4屏可填5,半屏可填9
-
-
- - -
可为空,默认8秒
-
+ +
+ +

弹幕源配置

+
+
+
+
+ +
+ +

弹弹play配置

+
+ +
+ +
勾选后,返回此弹幕库对应的所有第三方关联网址的弹幕.
+
+
+ + +
中文简繁转换。0-不转换,1-转换为简体,2-转换为繁体。
+
+
+ +
+ +

生成ASS配置

+
+ +
+ +
勾选后,会在视频目录下生成ass格式的弹幕,命名格式:[视频名].danmu.ass
+
+
+ + +
可为空,默认黑体.
+
+
+ + +
可为空,默认60,可以此为基准,增大或缩小.
+
+
+ + +
可为空,默认1,表示不透明,数值在0.0~1.0之间
+
+
+ + +
可为空,默认全屏显示,1/4屏可填5,半屏可填9
+
+
+ + +
可为空,默认8秒
+
+
+
'; + html += ' '; + html += '
'; + 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(); + }); });
- + + \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Api/Http/HttpClientHandlerEx.cs b/Jellyfin.Plugin.Danmu/Core/Http/HttpClientHandlerEx.cs similarity index 95% rename from Jellyfin.Plugin.Danmu/Api/Http/HttpClientHandlerEx.cs rename to Jellyfin.Plugin.Danmu/Core/Http/HttpClientHandlerEx.cs index 109bf25..9c06fd3 100644 --- a/Jellyfin.Plugin.Danmu/Api/Http/HttpClientHandlerEx.cs +++ b/Jellyfin.Plugin.Danmu/Core/Http/HttpClientHandlerEx.cs @@ -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 { diff --git a/Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj b/Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj index b41c375..47c9283 100644 --- a/Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj +++ b/Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj @@ -6,7 +6,6 @@ true enable AllEnabledByDefault - ../jellyfin.ruleset False diff --git a/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs b/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs index 56bf1d1..99d0266 100644 --- a/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs +++ b/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs @@ -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 _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(); + } + } /// @@ -52,7 +59,7 @@ public class LibraryManagerEventsHelper : IDisposable /// The . /// The . /// Instance of the interface. - 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(); _pendingAddEventCache = new MemoryCache(new MemoryCacheOptions()); @@ -60,7 +67,7 @@ public class LibraryManagerEventsHelper : IDisposable _libraryManager = libraryManager; _logger = loggerFactory.CreateLogger(); _fileSystem = fileSystem; - _scraperFactory = scraperFactory; + _scraperManager = scraperManager; } /// @@ -228,7 +235,7 @@ public class LibraryManagerEventsHelper : IDisposable var queueUpdateMeta = new List(); 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"); diff --git a/Jellyfin.Plugin.Danmu/Plugin.cs b/Jellyfin.Plugin.Danmu/Plugin.cs index 34bd9a0..681bed4 100644 --- a/Jellyfin.Plugin.Danmu/Plugin.cs +++ b/Jellyfin.Plugin.Danmu/Plugin.cs @@ -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, IHasWebPages : base(applicationPaths, xmlSerializer) { Instance = this; + Scrapers = applicationHost.GetExports(false).Where(o => o != null).OrderBy(x => x.DefaultOrder).ToList().AsReadOnly(); } /// @@ -39,6 +41,11 @@ public class Plugin : BasePlugin, IHasWebPages /// public static Plugin? Instance { get; private set; } + /// + /// 全部的弹幕源 + /// + public ReadOnlyCollection Scrapers { get; } + /// public IEnumerable GetPages() { diff --git a/Jellyfin.Plugin.Danmu/PluginStartup.cs b/Jellyfin.Plugin.Danmu/PluginStartup.cs index e5b3d21..14c20a1 100644 --- a/Jellyfin.Plugin.Danmu/PluginStartup.cs +++ b/Jellyfin.Plugin.Danmu/PluginStartup.cs @@ -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; diff --git a/Jellyfin.Plugin.Danmu/ScheduledTasks/RefreshDanmakuTask.cs b/Jellyfin.Plugin.Danmu/ScheduledTasks/RefreshDanmakuTask.cs index dad54e9..9cb9878 100644 --- a/Jellyfin.Plugin.Danmu/ScheduledTasks/RefreshDanmakuTask.cs +++ b/Jellyfin.Plugin.Danmu/ScheduledTasks/RefreshDanmakuTask.cs @@ -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 class. /// /// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. - public RefreshDanmuTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperFactory scraperFactory) + public RefreshDanmuTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperManager scraperManager) { _logger = loggerFactory.CreateLogger(); _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; } diff --git a/Jellyfin.Plugin.Danmu/ScheduledTasks/ScanLibraryTask.cs b/Jellyfin.Plugin.Danmu/ScheduledTasks/ScanLibraryTask.cs index 1d82a61..85c8bc6 100644 --- a/Jellyfin.Plugin.Danmu/ScheduledTasks/ScanLibraryTask.cs +++ b/Jellyfin.Plugin.Danmu/ScheduledTasks/ScanLibraryTask.cs @@ -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 class. ///
/// Instance of the interface. - /// Instance of the interface. /// Instance of the interface. - public ScanLibraryTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperFactory scraperFactory) + public ScanLibraryTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperManager scraperManager) { _logger = loggerFactory.CreateLogger(); _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; } } diff --git a/Jellyfin.Plugin.Danmu/Scrapers/AbstractApi.cs b/Jellyfin.Plugin.Danmu/Scrapers/AbstractApi.cs new file mode 100644 index 0000000..dd3438c --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/AbstractApi.cs @@ -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(); + } + } + +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs b/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs index e220e39..64e2fac 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs @@ -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; } /// diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs index 41877af..38ab2ed 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs @@ -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()) { - _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; diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/BilibiliApi.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/BilibiliApi.cs new file mode 100644 index 0000000..5c58030 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/BilibiliApi.cs @@ -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 _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); + + /// + /// Initializes a new instance of the class. + /// + /// The . + public BilibiliApi(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + + 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()); + } + + /// + /// Get bilibili danmu data. + /// + /// The Bilibili bvid. + /// The . + /// Task{TraktResponseDataContract}. + public async Task 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>(_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}"); + } + + /// + /// Get bilibili danmu data. + /// + /// The Bilibili bvid. + /// The . + /// Task{TraktResponseDataContract}. + public async Task 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 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 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(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>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null && result.Code == 0 && result.Data != null) + { + _memoryCache.Set(cacheKey, result.Data, expiredOption); + return result.Data; + } + + _memoryCache.Set(cacheKey, new SearchResult(), expiredOption); + return new SearchResult(); + } + + public async Task 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(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>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null && result.Code == 0 && result.Result != null) + { + _memoryCache.Set(cacheKey, result.Result, expiredOption); + return result.Result; + } + + _memoryCache.Set(cacheKey, null, expiredOption); + return null; + } + + public async Task 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(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>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null && result.Code == 0 && result.Result != null) + { + _memoryCache.Set(cacheKey, result.Result, expiredOption); + return result.Result; + } + + _memoryCache.Set(cacheKey, null, expiredOption); + return null; + } + + public async Task 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(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>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null && result.Code == 0 && result.Data != null) + { + _memoryCache.Set(cacheKey, result.Data, expiredOption); + return result.Data; + } + + _memoryCache.Set(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(); + } + } +} + diff --git a/Jellyfin.Plugin.Danmu/Api/Entity/ApiResult.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/ApiResult.cs similarity index 89% rename from Jellyfin.Plugin.Danmu/Api/Entity/ApiResult.cs rename to Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/ApiResult.cs index 6eba8e4..e049699 100644 --- a/Jellyfin.Plugin.Danmu/Api/Entity/ApiResult.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/ApiResult.cs @@ -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 { diff --git a/Jellyfin.Plugin.Danmu/Api/Entity/Media.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Media.cs similarity index 95% rename from Jellyfin.Plugin.Danmu/Api/Entity/Media.cs rename to Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Media.cs index cab5f7f..4353f94 100644 --- a/Jellyfin.Plugin.Danmu/Api/Entity/Media.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Media.cs @@ -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 { diff --git a/Jellyfin.Plugin.Danmu/Api/Entity/SearchResult.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/SearchResult.cs similarity index 90% rename from Jellyfin.Plugin.Danmu/Api/Entity/SearchResult.cs rename to Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/SearchResult.cs index 47424e8..8a48cca 100644 --- a/Jellyfin.Plugin.Danmu/Api/Entity/SearchResult.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/SearchResult.cs @@ -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 { diff --git a/Jellyfin.Plugin.Danmu/Api/Entity/Video.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Video.cs similarity index 91% rename from Jellyfin.Plugin.Danmu/Api/Entity/Video.cs rename to Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Video.cs index a51ba1f..3c41e7e 100644 --- a/Jellyfin.Plugin.Danmu/Api/Entity/Video.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Video.cs @@ -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 { diff --git a/Jellyfin.Plugin.Danmu/Api/Entity/VideoEpisode.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/VideoEpisode.cs similarity index 90% rename from Jellyfin.Plugin.Danmu/Api/Entity/VideoEpisode.cs rename to Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/VideoEpisode.cs index e6566f0..e005618 100644 --- a/Jellyfin.Plugin.Danmu/Api/Entity/VideoEpisode.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/VideoEpisode.cs @@ -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 { diff --git a/Jellyfin.Plugin.Danmu/Api/Entity/VideoPart.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/VideoPart.cs similarity index 85% rename from Jellyfin.Plugin.Danmu/Api/Entity/VideoPart.cs rename to Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/VideoPart.cs index 8a45d25..a516a90 100644 --- a/Jellyfin.Plugin.Danmu/Api/Entity/VideoPart.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/VideoPart.cs @@ -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 { diff --git a/Jellyfin.Plugin.Danmu/Api/Entity/VideoSeason.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/VideoSeason.cs similarity index 88% rename from Jellyfin.Plugin.Danmu/Api/Entity/VideoSeason.cs rename to Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/VideoSeason.cs index 79bf2e1..383e847 100644 --- a/Jellyfin.Plugin.Danmu/Api/Entity/VideoSeason.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/VideoSeason.cs @@ -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 { diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs new file mode 100644 index 0000000..e575799 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs @@ -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()) + { + _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 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 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 GetMediaEpisode(string id) + { + var epId = id.ToLong(); + if (epId <= 0) + { + return null; + } + + return new ScraperEpisode() { Id = id, CommentId = id }; + } + + public override async Task 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*第.季", ""); + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs new file mode 100644 index 0000000..ad1c51d --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs @@ -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(); + } + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + public DandanApi(ILoggerFactory loggerFactory) + : base(loggerFactory.CreateLogger()) + { + } + + + public async Task> SearchAsync(string keyword, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(keyword)) + { + return new List(); + } + + var cacheKey = $"search_{keyword}"; + var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }; + List searchResult; + if (_memoryCache.TryGetValue>(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(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null && result.Success) + { + _memoryCache.Set>(cacheKey, result.Animes, expiredOption); + return result.Animes; + } + + _memoryCache.Set>(cacheKey, new List(), expiredOption); + return new List(); + } + + public async Task 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(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(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null && result.Success) + { + _memoryCache.Set(cacheKey, result.Bangumi, expiredOption); + return result.Bangumi; + } + + _memoryCache.Set(cacheKey, null, expiredOption); + return null; + } + + public async Task> 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(_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); + } + } + +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Anime.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Anime.cs new file mode 100644 index 0000000..af7e05c --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Anime.cs @@ -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? Episodes { get; set; } + + public int? Year + { + get + { + try + { + if (StartDate == null) + { + return null; + } + + return DateTime.Parse(StartDate).Year; + } + catch + { + return null; + } + } + } + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/AnimeResult.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/AnimeResult.cs new file mode 100644 index 0000000..c5f5eef --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/AnimeResult.cs @@ -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; } + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Comment.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Comment.cs new file mode 100644 index 0000000..062b297 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Comment.cs @@ -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; } + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/CommentResult.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/CommentResult.cs new file mode 100644 index 0000000..d929b87 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/CommentResult.cs @@ -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 Comments { get; set; } + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Episode.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Episode.cs new file mode 100644 index 0000000..fce1bdf --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/Episode.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/SearchResult.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/SearchResult.cs new file mode 100644 index 0000000..bc96aff --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Entity/SearchResult.cs @@ -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 Animes { get; set; } + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/ExternalId/EpisodeExternalId.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/ExternalId/EpisodeExternalId.cs new file mode 100644 index 0000000..307abc7 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/ExternalId/EpisodeExternalId.cs @@ -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 +{ + /// + public class EpisodeExternalId : IExternalId + { + /// + public string ProviderName => Dandan.ScraperProviderName; + + /// + public string Key => Dandan.ScraperProviderId; + + /// + public ExternalIdMediaType? Type => ExternalIdMediaType.Episode; + + /// + public string UrlFormatString => "#"; + + /// + public bool Supports(IHasProviderIds item) => item is Episode; + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/ExternalId/MovieExternalId.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/ExternalId/MovieExternalId.cs new file mode 100644 index 0000000..1e451af --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/ExternalId/MovieExternalId.cs @@ -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 +{ + /// + public class MovieExternalId : IExternalId + { + /// + public string ProviderName => Dandan.ScraperProviderName; + + /// + public string Key => Dandan.ScraperProviderId; + + /// + public ExternalIdMediaType? Type => null; + + /// + public string UrlFormatString => "#"; + + /// + public bool Supports(IHasProviderIds item) => item is Movie; + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/ExternalId/SeasonExternalId.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/ExternalId/SeasonExternalId.cs new file mode 100644 index 0000000..2519921 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/ExternalId/SeasonExternalId.cs @@ -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 +{ + + /// + public class SeasonExternalId : IExternalId + { + /// + public string ProviderName => Dandan.ScraperProviderName; + + /// + public string Key => Dandan.ScraperProviderId; + + /// + public ExternalIdMediaType? Type => null; + + /// + public string UrlFormatString => "#"; + + /// + public bool Supports(IHasProviderIds item) => item is Season; + } + +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperDanmaku.cs b/Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperDanmaku.cs index c29462e..d753212 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperDanmaku.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperDanmaku.cs @@ -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弹幕格式: // 今天的风儿甚是喧嚣 // 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; + } } } \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Scrapers/ScraperFactory.cs b/Jellyfin.Plugin.Danmu/Scrapers/ScraperFactory.cs deleted file mode 100644 index b073ebe..0000000 --- a/Jellyfin.Plugin.Danmu/Scrapers/ScraperFactory.cs +++ /dev/null @@ -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 scrapers { get; } - - public ScraperFactory(ILoggerFactory logManager, BilibiliApi api) - { - scrapers = new List() { - new Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili(logManager, api) - }; - } - - public ReadOnlyCollection All() - { - return new ReadOnlyCollection(scrapers); - } -} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/ScraperManager.cs b/Jellyfin.Plugin.Danmu/Scrapers/ScraperManager.cs new file mode 100644 index 0000000..3f8897a --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/ScraperManager.cs @@ -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 _scrapers = new List(); + + public ScraperManager(ILoggerFactory logManager) + { + log = logManager.CreateLogger(); + if (Plugin.Instance?.Scrapers != null) + { + this._scrapers.AddRange(Plugin.Instance.Scrapers); + } + } + + public void register(AbstractScraper scraper) + { + this._scrapers.Add(scraper); + } + + public ReadOnlyCollection All() + { + // 存在配置时,根据配置调整源顺序,并删除不启用的源 + if (Plugin.Instance?.Configuration.Scrapers != null) + { + var orderScrapers = new List(); + + 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(); + } +} diff --git a/Jellyfin.Plugin.Danmu/ServiceRegistrator.cs b/Jellyfin.Plugin.Danmu/ServiceRegistrator.cs index db40e60..6a9de51 100644 --- a/Jellyfin.Plugin.Danmu/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.Danmu/ServiceRegistrator.cs @@ -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((ctx) => + serviceCollection.AddSingleton((ctx) => { - return new BilibiliApi(ctx.GetRequiredService()); - }); - serviceCollection.AddSingleton((ctx) => - { - return new ScraperFactory(ctx.GetRequiredService(), ctx.GetRequiredService()); + return new ScraperManager(ctx.GetRequiredService()); }); serviceCollection.AddSingleton((ctx) => { - return new LibraryManagerEventsHelper(ctx.GetRequiredService(), ctx.GetRequiredService(), ctx.GetRequiredService(), ctx.GetRequiredService()); + return new LibraryManagerEventsHelper(ctx.GetRequiredService(), ctx.GetRequiredService(), ctx.GetRequiredService(), ctx.GetRequiredService()); }); } diff --git a/README.md b/README.md index a4e2a50..2582528 100644 --- a/README.md +++ b/README.md @@ -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