diff --git a/Jellyfin.Plugin.Danmu.Test/BilibiliTest.cs b/Jellyfin.Plugin.Danmu.Test/BilibiliTest.cs new file mode 100644 index 0000000..378cff0 --- /dev/null +++ b/Jellyfin.Plugin.Danmu.Test/BilibiliTest.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Jellyfin.Plugin.Danmu.Model; +using Jellyfin.Plugin.Danmu.Scrapers; +using Jellyfin.Plugin.Danmu.Scrapers.Bilibili; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Jellyfin.Plugin.Danmu.Test +{ + + [TestClass] + public class BilibiliTest + { + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => + builder.AddSimpleConsole(options => + { + options.IncludeScopes = true; + options.SingleLine = true; + options.TimestampFormat = "hh:mm:ss "; + })); + + [TestMethod] + public void TestSearch() + { + 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, scraperManager); + + var item = new Movie + { + Name = "扬名立万" + }; + + Task.Run(async () => + { + try + { + var scraper = new Bilibili(loggerFactory); + var result = await scraper.Search(item); + Console.WriteLine(result); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + }).GetAwaiter().GetResult(); + + } + + + [TestMethod] + public void TestAddMovie() + { + 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, 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 TestUpdateMovie() + { + 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, scraperManager); + + var item = new Movie + { + Name = "异邦人:无皇刃谭", + ProviderIds = new Dictionary() { { Bilibili.ScraperProviderId, "2185" } }, + }; + + var list = new List(); + list.Add(new LibraryEvent { Item = item, EventType = EventType.Update }); + + Task.Run(async () => + { + try + { + await libraryManagerEventsHelper.ProcessQueuedMovieEvents(list, EventType.Update); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + }).GetAwaiter().GetResult(); + + } + + } +} diff --git a/Jellyfin.Plugin.Danmu/Configuration/configPage.html b/Jellyfin.Plugin.Danmu/Configuration/configPage.html index 05cc2d3..b83dc86 100644 --- a/Jellyfin.Plugin.Danmu/Configuration/configPage.html +++ b/Jellyfin.Plugin.Danmu/Configuration/configPage.html @@ -46,8 +46,12 @@
- -
中文简繁转换。0-不转换,1-转换为简体,2-转换为繁体。
+
diff --git a/Jellyfin.Plugin.Danmu/Core/Extensions/JsonExtension.cs b/Jellyfin.Plugin.Danmu/Core/Extensions/JsonExtension.cs index 159fcd6..12ae540 100644 --- a/Jellyfin.Plugin.Danmu/Core/Extensions/JsonExtension.cs +++ b/Jellyfin.Plugin.Danmu/Core/Extensions/JsonExtension.cs @@ -18,5 +18,22 @@ namespace Jellyfin.Plugin.Danmu.Core.Extensions jso.Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping; return JsonSerializer.Serialize(obj, jso); } + + public static T? FromJson(this string str) + { + if (string.IsNullOrEmpty(str)) return default(T?); + + // 不指定UnsafeRelaxedJsonEscaping,+号会被转码为unicode字符,和js/java的序列化不一致 + var jso = new JsonSerializerOptions(); + jso.Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping; + try + { + return JsonSerializer.Deserialize(str, jso); + } + catch (Exception ex) + { + return default(T?); + } + } } } diff --git a/Jellyfin.Plugin.Danmu/Core/Extensions/StringExtension.cs b/Jellyfin.Plugin.Danmu/Core/Extensions/StringExtension.cs index af4fc78..0ac1fec 100644 --- a/Jellyfin.Plugin.Danmu/Core/Extensions/StringExtension.cs +++ b/Jellyfin.Plugin.Danmu/Core/Extensions/StringExtension.cs @@ -80,6 +80,12 @@ namespace Jellyfin.Plugin.Danmu.Core.Extensions } } + public static string ToBase64(this string str) + { + var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(str); + return System.Convert.ToBase64String(plainTextBytes); + } + public static double Distance(this string s1, string s2) { var jw = new JaroWinkler(); diff --git a/Jellyfin.Plugin.Danmu/DanmuSubtitleProvider.cs b/Jellyfin.Plugin.Danmu/DanmuSubtitleProvider.cs new file mode 100644 index 0000000..0c44832 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/DanmuSubtitleProvider.cs @@ -0,0 +1,153 @@ +using System.Linq; +using System.Net.Mime; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Danmu.Core.Extensions; +using Jellyfin.Plugin.Danmu.Scrapers; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Jellyfin.Plugin.Danmu.Model; +using System.Text.Json; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Dto; + +namespace Jellyfin.Plugin.Danmu; + +public class DanmuSubtitleProvider : ISubtitleProvider +{ + public string Name => "Danmu"; + + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly LibraryManagerEventsHelper _libraryManagerEventsHelper; + + private readonly ScraperManager _scraperManager; + + public IEnumerable SupportedMediaTypes => new List() { VideoContentType.Movie, VideoContentType.Episode }; + + public DanmuSubtitleProvider(ILibraryManager libraryManager, ILoggerFactory loggerFactory, ScraperManager scraperManager, LibraryManagerEventsHelper libraryManagerEventsHelper) + { + _libraryManager = libraryManager; + _logger = loggerFactory.CreateLogger(); + _scraperManager = scraperManager; + _libraryManagerEventsHelper = libraryManagerEventsHelper; + } + + public async Task GetSubtitles(string id, CancellationToken cancellationToken) + { + var base64EncodedBytes = System.Convert.FromBase64String(id); + id = System.Text.Encoding.UTF8.GetString(base64EncodedBytes); + var info = id.FromJson(); + if (info == null) + { + throw new ArgumentException(); + } + + var item = _libraryManager.GetItemById(info.ItemId); + if (item == null) + { + throw new ArgumentException(); + } + + var scraper = _scraperManager.All().FirstOrDefault(x => x.ProviderId == info.ProviderId); + if (scraper != null) + { + UpdateDanmuMetadata(item, scraper.ProviderId, info.Id); + _libraryManagerEventsHelper.QueueItem(item, EventType.Force); + } + + throw new Exception($"弹幕下载已由{Plugin.Instance?.Name}插件接管,请忽略本异常."); + } + + public async Task> Search(SubtitleSearchRequest request, CancellationToken cancellationToken) + { + var list = new List(); + if (request.IsAutomated || string.IsNullOrEmpty(request.MediaPath)) + { + return list; + } + + var item = _libraryManager.GetItemList(new InternalItemsQuery + { + Path = request.MediaPath, + }).FirstOrDefault(); + + if (item == null) + { + return list; + } + + // 媒体库未启用就不处理 + if (_libraryManagerEventsHelper.IsIgnoreItem(item)) + { + return list; + } + + // 剧集使用series名称进行搜索 + if (item is Episode) + { + item.Name = request.SeriesName; + } + + foreach (var scraper in _scraperManager.All()) + { + try + { + + var result = await scraper.Search(item); + foreach (var searchInfo in result) + { + var title = searchInfo.Name; + if (!string.IsNullOrEmpty(searchInfo.Category)) + { + title = $"[{searchInfo.Category}] {searchInfo.Name}"; + } + if (searchInfo.Year != null) + { + title += $" ({searchInfo.Year})"; + } + if (searchInfo.EpisodeSize > 1) + { + title += $"【共{searchInfo.EpisodeSize}集】"; + } + var idInfo = new SubtitleId() { ItemId = item.Id.ToString(), Id = searchInfo.Id.ToString(), ProviderId = scraper.ProviderId }; + list.Add(new RemoteSubtitleInfo() + { + Id = idInfo.ToJson().ToBase64(), // Id不允许特殊字幕,做base64编码处理 + Name = title, + ProviderName = $"{Name}", + Format = "xml", + Comment = $"来源:{scraper.Name}", + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{0}]Exception handled processing queued movie events", scraper.Name); + } + } + + + return list; + } + + private void UpdateDanmuMetadata(BaseItem item, string providerId, string providerVal) + { + // 先清空旧弹幕的所有元数据 + foreach (var s in _scraperManager.All()) + { + item.ProviderIds.Remove(s.ProviderId); + } + // 保存指定弹幕元数据 + item.ProviderIds[providerId] = providerVal; + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs b/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs index 5b43640..56fb9c1 100644 --- a/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs +++ b/Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs @@ -138,8 +138,10 @@ public class LibraryManagerEventsHelper : IDisposable var queuedMovieAdds = new List(); var queuedMovieUpdates = new List(); + var queuedMovieForces = new List(); var queuedEpisodeAdds = new List(); - var queuedEpisodeUpdates = new List(); ; + var queuedEpisodeUpdates = new List(); + var queuedEpisodeForces = new List(); var queuedShowAdds = new List(); var queuedShowUpdates = new List(); var queuedSeasonAdds = new List(); @@ -148,6 +150,14 @@ public class LibraryManagerEventsHelper : IDisposable // add事件可能会在获取元数据完之前执行,导致可能会中断元数据获取,通过pending集合把add事件延缓到获取元数据后再执行(获取完元数据后,一般会多推送一个update事件) foreach (var ev in queue) { + + // item所在的媒体库不启用弹幕插件,忽略处理 + if (IsIgnoreItem(ev.Item)) + { + continue; + } + + switch (ev.Item) { case Movie when ev.EventType is EventType.Add: @@ -166,6 +176,10 @@ public class LibraryManagerEventsHelper : IDisposable queuedMovieUpdates.Add(ev); } break; + case Movie when ev.EventType is EventType.Force: + _logger.LogInformation("Movie force: {0}", ev.Item.Name); + queuedMovieForces.Add(ev); + break; case Series when ev.EventType is EventType.Add: _logger.LogInformation("Series add: {0}", ev.Item.Name); // _pendingAddEventCache.Set(ev.Item.Id, ev, _expiredOption); @@ -202,6 +216,10 @@ public class LibraryManagerEventsHelper : IDisposable _logger.LogInformation("Episode update: {0}", ev.Item.Name); queuedEpisodeUpdates.Add(ev); break; + case Episode when ev.EventType is EventType.Force: + _logger.LogInformation("Episode force: {0}", ev.Item.Name); + queuedEpisodeForces.Add(ev); + break; } } @@ -217,6 +235,22 @@ public class LibraryManagerEventsHelper : IDisposable await ProcessQueuedShowEvents(queuedShowUpdates, EventType.Update).ConfigureAwait(false); await ProcessQueuedSeasonEvents(queuedSeasonUpdates, EventType.Update).ConfigureAwait(false); await ProcessQueuedEpisodeEvents(queuedEpisodeUpdates, EventType.Update).ConfigureAwait(false); + + await ProcessQueuedMovieEvents(queuedMovieForces, EventType.Force).ConfigureAwait(false); + await ProcessQueuedEpisodeEvents(queuedEpisodeForces, EventType.Force).ConfigureAwait(false); + } + + public bool IsIgnoreItem(BaseItem item) + { + // item所在的媒体库不启用弹幕插件,忽略处理 + var libraryOptions = _libraryManager.GetLibraryOptions(item); + if (libraryOptions == null || libraryOptions.DisabledSubtitleFetchers.Contains(Plugin.Instance?.Name)) + { + // Console.WriteLine($"ignore {item.Path} {item.Name}"); + return true; + } + + return false; } @@ -327,7 +361,39 @@ public class LibraryManagerEventsHelper : IDisposable } } + // 强制刷新指定来源弹幕 + if (eventType == EventType.Force) + { + foreach (var item in movies) + { + // 找到强制的scraper + var scraper = _scraperManager.All().FirstOrDefault(x => item.ProviderIds.ContainsKey(x.ProviderId)); + if (scraper == null) + { + continue; + } + var mediaId = item.GetProviderId(scraper.ProviderId); + if (string.IsNullOrEmpty(mediaId)) + { + continue; + } + + + var media = await scraper.GetMedia(item, mediaId); + if (media != null) + { + var episode = await scraper.GetMediaEpisode(item, media.Id); + if (episode != null) + { + // 下载弹幕xml文件 + await this.DownloadDanmu(scraper, item, episode.CommentId, true).ConfigureAwait(false); + } + + await this.ForceSaveProviderId(item, scraper.ProviderId, media.Id); + } + } + } } @@ -450,7 +516,8 @@ public class LibraryManagerEventsHelper : IDisposable foreach (var season in seasons) { var queueUpdateMeta = new List(); - var episodes = season.GetEpisodes(null, new DtoOptions(false)); + // GetEpisodes一定要取所有fields,要不然更新会导致重建虚拟season季信息 + var episodes = season.GetEpisodes(null, new DtoOptions(true)); if (episodes == null) { continue; @@ -587,6 +654,63 @@ public class LibraryManagerEventsHelper : IDisposable } } + + + // 强制刷新指定来源弹幕 + if (eventType == EventType.Force) + { + foreach (var item in episodes) + { + // 找到强制的scraper + var scraper = _scraperManager.All().FirstOrDefault(x => item.ProviderIds.ContainsKey(x.ProviderId)); + if (scraper == null) + { + continue; + } + + var mediaId = item.GetProviderId(scraper.ProviderId); + if (string.IsNullOrEmpty(mediaId)) + { + continue; + } + + + var season = ((Episode)item).Season; + if (season == null) + { + continue; + } + + var media = await scraper.GetMedia(season, mediaId); + if (media != null) + { + // 更新所有剧集元数据,GetEpisodes一定要取所有fields,要不然更新会导致重建虚拟season季信息 + var episodeList = season.GetEpisodes(null, new DtoOptions(true)); + foreach (var (episode, idx) in episodeList.WithIndex()) + { + // 没对应剧集号的,忽略处理 + var indexNumber = episode.IndexNumber ?? 0; + if (indexNumber < 1 || indexNumber > media.Episodes.Count) + { + continue; + } + + var epId = media.Episodes[indexNumber - 1].Id; + var commentId = media.Episodes[indexNumber - 1].CommentId; + + // 下载弹幕xml文件 + await this.DownloadDanmu(scraper, episode, commentId, true).ConfigureAwait(false); + + // 更新剧集元数据 + await ForceSaveProviderId(episode, scraper.ProviderId, epId); + } + + // 更新季元数据 + await ForceSaveProviderId(season, scraper.ProviderId, media.Id); + + } + } + } } @@ -607,6 +731,11 @@ public class LibraryManagerEventsHelper : IDisposable // 合并新添加的provider id foreach (var pair in queueItem.ProviderIds) { + if (string.IsNullOrEmpty(pair.Value)) + { + continue; + } + item.ProviderIds[pair.Key] = pair.Value; } @@ -617,13 +746,13 @@ public class LibraryManagerEventsHelper : IDisposable _logger.LogInformation("更新epid到元数据完成。item数:{0}", queue.Count); } - public async Task DownloadDanmu(AbstractScraper scraper, BaseItem item, string commentId) + public async Task DownloadDanmu(AbstractScraper scraper, BaseItem item, string commentId, bool ignoreCheck = false) { // 下载弹幕xml文件 try { // 弹幕5分钟内更新过,忽略处理(有时Update事件会重复执行) - if (IsRepeatAction(item)) + if (!ignoreCheck && IsRepeatAction(item)) { _logger.LogInformation("[{0}]最近5分钟已更新过弹幕xml,忽略处理:{1}", scraper.Name, item.Name); return; @@ -697,6 +826,19 @@ public class LibraryManagerEventsHelper : IDisposable } } + private async Task ForceSaveProviderId(BaseItem item, string providerId, string providerVal) + { + // 先清空旧弹幕的所有元数据 + foreach (var s in _scraperManager.All()) + { + item.ProviderIds.Remove(s.ProviderId); + } + // 保存指定弹幕元数据 + item.ProviderIds[providerId] = providerVal; + + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + public void Dispose() { diff --git a/Jellyfin.Plugin.Danmu/Model/EventType.cs b/Jellyfin.Plugin.Danmu/Model/EventType.cs index 2560cb9..187239d 100644 --- a/Jellyfin.Plugin.Danmu/Model/EventType.cs +++ b/Jellyfin.Plugin.Danmu/Model/EventType.cs @@ -24,5 +24,10 @@ public enum EventType /// /// The update event. /// - Update + Update, + + /// + /// The force update event. + /// + Force } diff --git a/Jellyfin.Plugin.Danmu/Model/SubtitleId.cs b/Jellyfin.Plugin.Danmu/Model/SubtitleId.cs new file mode 100644 index 0000000..deaf164 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Model/SubtitleId.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.Plugin.Danmu.Model; + +public class SubtitleId +{ + public string ItemId { get; set; } + + public string Id { get; set; } + + public string ProviderId { get; set; } +} diff --git a/Jellyfin.Plugin.Danmu/PluginStartup.cs b/Jellyfin.Plugin.Danmu/PluginStartup.cs index dbfde1c..b963cfb 100644 --- a/Jellyfin.Plugin.Danmu/PluginStartup.cs +++ b/Jellyfin.Plugin.Danmu/PluginStartup.cs @@ -95,6 +95,7 @@ namespace Jellyfin.Plugin.Danmu return; } + // 当剧集没有SXX/Season XX季文件夹时,LocationType就是Virtual,动画经常没有季文件夹 if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual && itemChangeEventArgs.Item is not Season) { return; @@ -103,25 +104,6 @@ namespace Jellyfin.Plugin.Danmu _libraryManagerEventsHelper.QueueItem(itemChangeEventArgs.Item, EventType.Update); } - /// - /// Library item was removed. - /// - /// The sending entity. - /// The . - private void LibraryManagerItemRemoved(object sender, ItemChangeEventArgs itemChangeEventArgs) - { - if (itemChangeEventArgs.Item is not Movie and not Episode and not Series and not Season) - { - return; - } - - if (itemChangeEventArgs.Item.LocationType == LocationType.Virtual) - { - return; - } - - _libraryManagerEventsHelper.QueueItem(itemChangeEventArgs.Item, EventType.Remove); - } /// public void Dispose() diff --git a/Jellyfin.Plugin.Danmu/ScheduledTasks/RefreshDanmakuTask.cs b/Jellyfin.Plugin.Danmu/ScheduledTasks/RefreshDanmakuTask.cs index 9cb9878..45aa830 100644 --- a/Jellyfin.Plugin.Danmu/ScheduledTasks/RefreshDanmakuTask.cs +++ b/Jellyfin.Plugin.Danmu/ScheduledTasks/RefreshDanmakuTask.cs @@ -52,12 +52,13 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks public IEnumerable GetDefaultTriggers() { - yield return new TaskTriggerInfo - { - Type = TaskTriggerInfo.TriggerWeekly, - DayOfWeek = DayOfWeek.Monday, - TimeOfDayTicks = TimeSpan.FromHours(4).Ticks - }; + return new List(); + // yield return new TaskTriggerInfo + // { + // Type = TaskTriggerInfo.TriggerWeekly, + // DayOfWeek = DayOfWeek.Monday, + // TimeOfDayTicks = TimeSpan.FromHours(4).Ticks + // }; } public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) @@ -92,6 +93,12 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks continue; } + // item所在的媒体库不启用弹幕插件,忽略处理 + if (_libraryManagerEventsHelper.IsIgnoreItem(item)) + { + continue; + } + // 推送下载最新的xml (season刷新会同时刷新episode,所以不需要再推送episode,而且season是bv号的,只能通过season来刷新) switch (item) diff --git a/Jellyfin.Plugin.Danmu/ScheduledTasks/ScanLibraryTask.cs b/Jellyfin.Plugin.Danmu/ScheduledTasks/ScanLibraryTask.cs index 85c8bc6..93911d9 100644 --- a/Jellyfin.Plugin.Danmu/ScheduledTasks/ScanLibraryTask.cs +++ b/Jellyfin.Plugin.Danmu/ScheduledTasks/ScanLibraryTask.cs @@ -87,6 +87,12 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks continue; } + // item所在的媒体库不启用弹幕插件,忽略处理 + if (_libraryManagerEventsHelper.IsIgnoreItem(item)) + { + continue; + } + // 推送刷新 (season刷新会同时刷新episode,所以不需要再推送episode,而且season是bv号的,只能通过season来刷新) switch (item) { diff --git a/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs b/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs index fc1bea9..827f5f9 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using Jellyfin.Plugin.Danmu.Scrapers.Entity; using MediaBrowser.Controller.Entities; @@ -32,7 +33,14 @@ public abstract class AbstractScraper } /// - /// 搜索影片id + /// 搜索影片 + /// + /// 元数据item + /// 影片列表 + public abstract Task> Search(BaseItem item); + + /// + /// 搜索匹配的影片id /// /// 元数据item /// 影片id diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs index a6d5d6e..a66dc93 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs @@ -36,6 +36,47 @@ public class Bilibili : AbstractScraper public override string ProviderId => ScraperProviderId; + public override async Task> Search(BaseItem item) + { + var list = new List(); + var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; + var searchName = this.NormalizeSearchName(item.Name); + try + { + var searchResult = await _api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false); + if (searchResult != null && searchResult.Result != null) + { + foreach (var result in searchResult.Result) + { + if ((result.ResultType == "media_ft" || result.ResultType == "media_bangumi") && result.Data.Length > 0) + { + foreach (var media in result.Data) + { + var seasonId = media.SeasonId; + var title = media.Title; + var pubYear = Jellyfin.Plugin.Danmu.Core.Utils.UnixTimeStampToDateTime(media.PublishTime).Year; + + list.Add(new ScraperSearchInfo() + { + Id = $"{seasonId}", + Name = title, + Category = media.SeasonTypeName, + Year = pubYear, + EpisodeSize = media.EpisodeSize, + }); + } + } + } + } + } + catch (Exception ex) + { + log.LogError(ex, "Exception handled GetMatchSeasonId. {0}", searchName); + } + + return list; + } + public override async Task SearchMediaId(BaseItem item) { var searchName = this.NormalizeSearchName(item.Name); diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/BilibiliApi.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/BilibiliApi.cs index ad09e73..ed379a9 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/BilibiliApi.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/BilibiliApi.cs @@ -52,6 +52,40 @@ public class BilibiliApi : IDisposable _memoryCache = new MemoryCache(new MemoryCacheOptions()); } + + 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(5) }; + SearchResult searchResult; + if (_memoryCache.TryGetValue(cacheKey, out searchResult)) + { + return searchResult; + } + + await 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(); + } + /// /// Get bilibili danmu data. /// @@ -136,38 +170,6 @@ public class BilibiliApi : IDisposable } - 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(5) }; - SearchResult searchResult; - if (_memoryCache.TryGetValue(cacheKey, out searchResult)) - { - return searchResult; - } - - await 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) { diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Media.cs b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Media.cs index 4353f94..35e490c 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Media.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Entity/Media.cs @@ -20,6 +20,10 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Entity [JsonPropertyName("season_type")] public int SeasonType { get; set; } + [JsonPropertyName("season_type_name")] + public string SeasonTypeName { get; set; } + [JsonPropertyName("ep_size")] + public int EpisodeSize { get; set; } [JsonPropertyName("season_id")] public long SeasonId { get; set; } diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs index a3d90b3..4a6dba9 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/Dandan.cs @@ -37,6 +37,41 @@ public class Dandan : AbstractScraper public override string ProviderId => ScraperProviderId; + public override async Task> Search(BaseItem item) + { + var list = new List(); + var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; + 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; + + if (isMovieItemType && anime.Type != "movie") + { + continue; + } + + if (!isMovieItemType && anime.Type == "movie") + { + continue; + } + + list.Add(new ScraperSearchInfo() + { + Id = $"{animeId}", + Name = title, + Category = anime.TypeDescription, + Year = pubYear, + EpisodeSize = anime.EpisodeCount ?? 0, + }); + } + + return list; + } + public override async Task SearchMediaId(BaseItem item) { var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs index 3b7fcc2..6abd194 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs @@ -44,6 +44,7 @@ public class DandanApi : AbstractApi public DandanApi(ILoggerFactory loggerFactory) : base(loggerFactory.CreateLogger()) { + httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); } diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperSearchInfo.cs b/Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperSearchInfo.cs new file mode 100644 index 0000000..e2997ce --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperSearchInfo.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Entity; + +public class ScraperSearchInfo +{ + public string Id { get; set; } + public string Name { get; set; } + public string Category { get; set; } = string.Empty; + public int? Year { get; set; } + public int EpisodeSize { get; set; } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Iqiyi/Iqiyi.cs b/Jellyfin.Plugin.Danmu/Scrapers/Iqiyi/Iqiyi.cs index 1ca9f02..c4b9b90 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Iqiyi/Iqiyi.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Iqiyi/Iqiyi.cs @@ -39,8 +39,44 @@ public class Iqiyi : AbstractScraper public override string ProviderId => ScraperProviderId; - public override async Task SearchMediaId(BaseItem item) + public override async Task> Search(BaseItem item) { + var list = new List(); + var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; + var searchName = this.NormalizeSearchName(item.Name); + var videos = await this._api.GetSuggestAsync(searchName, CancellationToken.None).ConfigureAwait(false); + foreach (var video in videos) + { + var videoId = video.VideoId; + var title = video.Name; + var pubYear = video.Year; + + if (isMovieItemType && video.ChannelName != "电影") + { + continue; + } + + if (!isMovieItemType && video.ChannelName == "电影") + { + continue; + } + + list.Add(new ScraperSearchInfo() + { + Id = $"{video.LinkId}", + Name = title, + Category = video.ChannelName, + Year = pubYear, + }); + } + + + return list; + } + + public override async Task SearchMediaId(BaseItem item) + { + var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; var searchName = this.NormalizeSearchName(item.Name); var videos = await this._api.GetSuggestAsync(searchName, CancellationToken.None).ConfigureAwait(false); foreach (var video in videos) @@ -48,7 +84,6 @@ public class Iqiyi : AbstractScraper var videoId = video.VideoId; var title = video.Name; var pubYear = video.Year; - var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; if (isMovieItemType && video.ChannelName != "电影") { diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Youku.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Youku.cs index 99f8d37..41a3fbd 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Youku.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Youku.cs @@ -1,3 +1,4 @@ +using System.Web; using System.Linq; using System; using System.Net.Http; @@ -39,8 +40,44 @@ public class Youku : AbstractScraper public override string ProviderId => ScraperProviderId; - public override async Task SearchMediaId(BaseItem item) + public override async Task> Search(BaseItem item) { + var list = new List(); + var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; + var searchName = this.NormalizeSearchName(item.Name); + var videos = await this._api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false); + foreach (var video in videos) + { + var videoId = video.ID; + var title = video.Title; + var pubYear = video.Year; + + if (isMovieItemType && video.Type != "movie") + { + continue; + } + + if (!isMovieItemType && video.Type == "movie") + { + continue; + } + + list.Add(new ScraperSearchInfo() + { + Id = $"{videoId}", + Name = title, + Category = video.Type == "movie" ? "电影" : "电视剧", + Year = pubYear, + EpisodeSize = video.Total, + }); + } + + return list; + } + + public override async Task SearchMediaId(BaseItem item) + { + var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; var searchName = this.NormalizeSearchName(item.Name); var videos = await this._api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false); foreach (var video in videos) @@ -48,7 +85,6 @@ public class Youku : AbstractScraper var videoId = video.ID; var title = video.Title; var pubYear = video.Year; - var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; if (isMovieItemType && video.Type != "movie") { @@ -90,6 +126,7 @@ public class Youku : AbstractScraper return null; } + id = HttpUtility.UrlDecode(id); var video = await _api.GetVideoAsync(id, CancellationToken.None).ConfigureAwait(false); if (video == null) { @@ -107,14 +144,15 @@ public class Youku : AbstractScraper } } + // 优酷的id包括非法的=符号,会导致jellyfin自动删除,这里做下encode if (isMovieItemType) { - media.Id = media.Episodes.Count > 0 ? $"{media.Episodes[0].Id}" : ""; + media.Id = HttpUtility.UrlEncode(media.Episodes.Count > 0 ? $"{media.Episodes[0].Id}" : ""); media.CommentId = media.Episodes.Count > 0 ? $"{media.Episodes[0].CommentId}" : ""; } else { - media.Id = id; + media.Id = HttpUtility.UrlEncode(id); } return media; @@ -127,6 +165,7 @@ public class Youku : AbstractScraper return null; } + id = HttpUtility.UrlDecode(id); return new ScraperEpisode() { Id = id, CommentId = id }; } diff --git a/README.md b/README.md index 3b83af5..cf499a7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ jellyfin弹幕自动下载插件,已支持的弹幕来源:b站,弹弹play * 自动下载xml格式弹幕 * 生成ass格式弹幕 -* 定时更新 +* 可配置定时更新 * 支持api访问弹幕 ![logo](doc/logo.png) @@ -27,10 +27,18 @@ jellyfin弹幕自动下载插件,已支持的弹幕来源:b站,弹弹play ## 如何使用 -* 新加入的影片会自动获取弹幕(只匹配番剧和电影视频),旧影片可以通过计划任务**扫描媒体库匹配弹幕**手动执行获取 -* 可以在元数据中手动指定匹配的视频ID,如播放链接`https://www.bilibili.com/bangumi/play/ep682965`,对应的视频ID就是`682965` -* 对于电视剧和动画,可以在元数据中指定季ID,如播放链接`https://www.bilibili.com/bangumi/play/ss1564`,对应的季ID就是`1564`,只要集数和b站的集数的一致,并正确填写了集号,每季视频的弹幕会自动获取 -* 同时生成ass弹幕,需要在插件配置中打开,默认是关闭的 +1. 安装后,进入`控制台 -> 插件`,查看下`Danmu`插件是否是**Active**状态 +2. 进入`控制台 -> 媒体库`,点击任一媒体库进入配置页,在最下面的`字幕下载`选项中勾选**Danmu**,并保存 + + + + 假如想修正匹配错误的弹幕,请在电影或剧集中使用jellyfin的**修改字幕**功能 + +3. 新加入的影片会自动获取弹幕(只匹配番剧和电影视频),旧影片可以通过计划任务**扫描媒体库匹配弹幕**手动执行获取 +4. 可以在元数据中手动指定匹配的视频ID,如播放链接`https://www.bilibili.com/bangumi/play/ep682965`,对应的视频ID就是`682965` +5. 对于电视剧和动画,可以在元数据中指定季ID,如播放链接`https://www.bilibili.com/bangumi/play/ss1564`,对应的季ID就是`1564`,只要集数和b站的集数的一致,并正确填写了集号,每季视频的弹幕会自动获取 +6. 同时生成ass弹幕,需要在插件配置中打开,默认是关闭的 +7. 定时更新需要自己到计划任务中添加定时时间,默认手工执行更新 > 电影或季元数据也支持手动指定BV号,来匹配UP主上传的视频弹幕。多P视频和剧集是按顺序一一对应匹配的,所以保证jellyfin中剧集有正确的集号很重要 @@ -65,8 +73,12 @@ ass格式: 3. Build plugin with following command. ```sh -$ dotnet restore -$ dotnet publish Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj +dotnet restore +dotnet publish --output=artifacts Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj + +# remove unused dll +cd artifacts +rm -rf MediaBrowser*.dll Microsoft*.dll Newtonsoft*.dll System*.dll Emby*.dll Jellyfin.Data*.dll Jellyfin.Extensions*.dll *.json *.pdb ``` @@ -74,9 +86,9 @@ $ dotnet publish Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj 1. Build the plugin -2. Create a folder, like `Danmu` and copy `bin/Release/Jellyfin.Plugin.Danmu.dll` into it +2. Create a folder, like `danmu` and copy `artifacts/*.dll` into it -3. Move folder `Danmu` to jellyfin `data/plugins` folder +3. Move folder `danmu` to jellyfin `data/plugins` folder ## Thanks diff --git a/doc/tutorial.png b/doc/tutorial.png new file mode 100644 index 0000000..2d62f3b Binary files /dev/null and b/doc/tutorial.png differ