Support like subtitle plugin

This commit is contained in:
cxfksword
2023-02-15 17:28:56 +08:00
parent c32b936647
commit 2e7b3e56fe
20 changed files with 751 additions and 76 deletions

View File

@@ -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<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
var directoryServiceStub = new Mock<IDirectoryService>();
var libraryManagerStub = new Mock<ILibraryManager>();
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Movie
{
Name = "扬名立万"
};
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<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
var directoryServiceStub = new Mock<IDirectoryService>();
var libraryManagerStub = new Mock<ILibraryManager>();
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Movie
{
Name = "异邦人:无皇刃谭"
};
var list = new List<LibraryEvent>();
list.Add(new LibraryEvent { Item = item, EventType = EventType.Add });
Task.Run(async () =>
{
try
{
await libraryManagerEventsHelper.ProcessQueuedMovieEvents(list, EventType.Add);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
[TestMethod]
public void TestUpdateMovie()
{
var scraperManager = new ScraperManager(loggerFactory);
scraperManager.register(new Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili(loggerFactory));
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
var directoryServiceStub = new Mock<IDirectoryService>();
var libraryManagerStub = new Mock<ILibraryManager>();
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperManager);
var item = new Movie
{
Name = "异邦人:无皇刃谭",
ProviderIds = new Dictionary<string, string>() { { Bilibili.ScraperProviderId, "2185" } },
};
var list = new List<LibraryEvent>();
list.Add(new LibraryEvent { Item = item, EventType = EventType.Update });
Task.Run(async () =>
{
try
{
await libraryManagerEventsHelper.ProcessQueuedMovieEvents(list, EventType.Update);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}).GetAwaiter().GetResult();
}
}
}

View File

@@ -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<T>(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<T>(str, jso);
}
catch (Exception ex)
{
return default(T?);
}
}
}
}

View File

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

View File

@@ -0,0 +1,184 @@
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<LibraryManagerEventsHelper> _logger;
private readonly LibraryManagerEventsHelper _libraryManagerEventsHelper;
private readonly ScraperManager _scraperManager;
public IEnumerable<VideoContentType> SupportedMediaTypes => new List<VideoContentType>() { VideoContentType.Movie, VideoContentType.Episode };
public DanmuSubtitleProvider(ILibraryManager libraryManager, ILoggerFactory loggerFactory, ScraperManager scraperManager, LibraryManagerEventsHelper libraryManagerEventsHelper)
{
_libraryManager = libraryManager;
_logger = loggerFactory.CreateLogger<LibraryManagerEventsHelper>();
_scraperManager = scraperManager;
_libraryManagerEventsHelper = libraryManagerEventsHelper;
}
public async Task<SubtitleResponse> GetSubtitles(string id, CancellationToken cancellationToken)
{
var base64EncodedBytes = System.Convert.FromBase64String(id);
id = System.Text.Encoding.UTF8.GetString(base64EncodedBytes);
var info = id.FromJson<SubtitleId>();
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);
// if (item is Movie)
// {
// var media = await scraper.GetMedia(item, info.Id);
// if (media != null)
// {
// await ForceSaveProviderId(item, scraper.ProviderId, media.Id);
// }
// }
// if (item is Episode)
// {
// var season = ((Episode)item).Season;
// if (season != null)
// {
// var media = await scraper.GetMedia(season, info.Id);
// if (media != null)
// {
// // 更新季元数据
// await ForceSaveProviderId(season, scraper.ProviderId, media.Id);
// // 更新所有剧集元数据GetEpisodes一定要取所有fields要不然更新会导致重建虚拟season季信息
// var episodes = season.GetEpisodes(null, new DtoOptions(true));
// foreach (var (episode, idx) in episodes.WithIndex())
// {
// // 没对应剧集号的,忽略处理
// var indexNumber = episode.IndexNumber ?? 0;
// if (indexNumber < 1 || indexNumber > media.Episodes.Count)
// {
// continue;
// }
// var epId = media.Episodes[indexNumber - 1].Id;
// await ForceSaveProviderId(episode, scraper.ProviderId, epId);
// }
// }
// }
// }
}
throw new Exception($"弹幕下载已由{Plugin.Instance?.Name}插件接管,请忽略本异常.");
}
public async Task<IEnumerable<RemoteSubtitleInfo>> Search(SubtitleSearchRequest request, CancellationToken cancellationToken)
{
var list = new List<RemoteSubtitleInfo>();
if (request.IsAutomated || string.IsNullOrEmpty(request.MediaPath))
{
return list;
}
var item = _libraryManager.GetItemList(new InternalItemsQuery
{
Path = request.MediaPath,
}).FirstOrDefault();
if (item == null)
{
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})";
}
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;
}
}

View File

@@ -138,8 +138,10 @@ public class LibraryManagerEventsHelper : IDisposable
var queuedMovieAdds = new List<LibraryEvent>();
var queuedMovieUpdates = new List<LibraryEvent>();
var queuedMovieForces = new List<LibraryEvent>();
var queuedEpisodeAdds = new List<LibraryEvent>();
var queuedEpisodeUpdates = new List<LibraryEvent>(); ;
var queuedEpisodeUpdates = new List<LibraryEvent>();
var queuedEpisodeForces = new List<LibraryEvent>();
var queuedShowAdds = new List<LibraryEvent>();
var queuedShowUpdates = new List<LibraryEvent>();
var queuedSeasonAdds = new List<LibraryEvent>();
@@ -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<LibraryEvent>(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<BaseItem>();
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()
{

View File

@@ -24,5 +24,10 @@ public enum EventType
/// <summary>
/// The update event.
/// </summary>
Update
Update,
/// <summary>
/// The force update event.
/// </summary>
Force
}

View File

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

View File

@@ -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);
}
/// <summary>
/// Library item was removed.
/// </summary>
/// <param name="sender">The sending entity.</param>
/// <param name="itemChangeEventArgs">The <see cref="ItemChangeEventArgs"/>.</param>
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);
}
/// <inheritdoc />
public void Dispose()

View File

@@ -52,12 +52,13 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
yield return new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerWeekly,
DayOfWeek = DayOfWeek.Monday,
TimeOfDayTicks = TimeSpan.FromHours(4).Ticks
};
return new List<TaskTriggerInfo>();
// yield return new TaskTriggerInfo
// {
// Type = TaskTriggerInfo.TriggerWeekly,
// DayOfWeek = DayOfWeek.Monday,
// TimeOfDayTicks = TimeSpan.FromHours(4).Ticks
// };
}
public async Task ExecuteAsync(IProgress<double> 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)

View File

@@ -87,6 +87,12 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
continue;
}
// item所在的媒体库不启用弹幕插件忽略处理
if (_libraryManagerEventsHelper.IsIgnoreItem(item))
{
continue;
}
// 推送刷新 (season刷新会同时刷新episode所以不需要再推送episode而且season是bv号的只能通过season来刷新)
switch (item)
{

View File

@@ -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
}
/// <summary>
/// 搜索影片id
/// 搜索影片
/// </summary>
/// <param name="item">元数据item</param>
/// <returns>影片列表</returns>
public abstract Task<List<ScraperSearchInfo>> Search(BaseItem item);
/// <summary>
/// 搜索匹配的影片id
/// </summary>
/// <param name="item">元数据item</param>
/// <returns>影片id</returns>

View File

@@ -36,6 +36,46 @@ public class Bilibili : AbstractScraper
public override string ProviderId => ScraperProviderId;
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
{
var list = new List<ScraperSearchInfo>();
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
var searchName = this.NormalizeSearchName(item.Name);
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,
});
}
}
}
}
}
catch (Exception ex)
{
log.LogError(ex, "Exception handled GetMatchSeasonId. {0}", searchName);
}
return list;
}
public override async Task<string?> SearchMediaId(BaseItem item)
{
var searchName = this.NormalizeSearchName(item.Name);

View File

@@ -52,6 +52,40 @@ public class BilibiliApi : IDisposable
_memoryCache = new MemoryCache(new MemoryCacheOptions());
}
public async Task<SearchResult> SearchAsync(string keyword, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(keyword))
{
return new SearchResult();
}
var cacheKey = $"search_{keyword}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) };
SearchResult searchResult;
if (_memoryCache.TryGetValue<SearchResult>(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<ApiResult<SearchResult>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Data != null)
{
_memoryCache.Set<SearchResult>(cacheKey, result.Data, expiredOption);
return result.Data;
}
_memoryCache.Set<SearchResult>(cacheKey, new SearchResult(), expiredOption);
return new SearchResult();
}
/// <summary>
/// Get bilibili danmu data.
/// </summary>
@@ -136,38 +170,6 @@ public class BilibiliApi : IDisposable
}
public async Task<SearchResult> SearchAsync(string keyword, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(keyword))
{
return new SearchResult();
}
var cacheKey = $"search_{keyword}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) };
SearchResult searchResult;
if (_memoryCache.TryGetValue<SearchResult>(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<ApiResult<SearchResult>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Code == 0 && result.Data != null)
{
_memoryCache.Set<SearchResult>(cacheKey, result.Data, expiredOption);
return result.Data;
}
_memoryCache.Set<SearchResult>(cacheKey, new SearchResult(), expiredOption);
return new SearchResult();
}
public async Task<VideoSeason?> GetSeasonAsync(long seasonId, CancellationToken cancellationToken)
{

View File

@@ -20,6 +20,8 @@ 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("season_id")]
public long SeasonId { get; set; }

View File

@@ -37,6 +37,40 @@ public class Dandan : AbstractScraper
public override string ProviderId => ScraperProviderId;
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
{
var list = new List<ScraperSearchInfo>();
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
var searchName = this.NormalizeSearchName(item.Name);
var 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.Type == "movie" ? "电影" : "电视剧",
Year = pubYear,
});
}
return list;
}
public override async Task<string?> SearchMediaId(BaseItem item)
{
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;

View File

@@ -0,0 +1,11 @@
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; }
}

View File

@@ -39,8 +39,44 @@ public class Iqiyi : AbstractScraper
public override string ProviderId => ScraperProviderId;
public override async Task<string?> SearchMediaId(BaseItem item)
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
{
var list = new List<ScraperSearchInfo>();
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
var searchName = this.NormalizeSearchName(item.Name);
var videos = await this._api.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<string?> 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 != "电影")
{

View File

@@ -39,8 +39,43 @@ public class Youku : AbstractScraper
public override string ProviderId => ScraperProviderId;
public override async Task<string?> SearchMediaId(BaseItem item)
public override async Task<List<ScraperSearchInfo>> Search(BaseItem item)
{
var list = new List<ScraperSearchInfo>();
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
var searchName = this.NormalizeSearchName(item.Name);
var videos = await this._api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false);
foreach (var video in videos)
{
var videoId = video.ID;
var title = video.Title;
var pubYear = video.Year;
if (isMovieItemType && video.Type != "movie")
{
continue;
}
if (!isMovieItemType && video.Type == "movie")
{
continue;
}
list.Add(new ScraperSearchInfo()
{
Id = $"{videoId}",
Name = title,
Category = video.Type == "movie" ? "电影" : "电视剧",
Year = pubYear,
});
}
return list;
}
public override async Task<string?> SearchMediaId(BaseItem item)
{
var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie;
var searchName = this.NormalizeSearchName(item.Name);
var videos = await this._api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false);
foreach (var video in videos)
@@ -48,7 +83,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")
{

View File

@@ -10,7 +10,7 @@ jellyfin弹幕自动下载插件已支持的弹幕来源b站弹弹play
* 自动下载xml格式弹幕
* 生成ass格式弹幕
* 定时更新
* 可配置定时更新
* 支持api访问弹幕
![logo](doc/logo.png)
@@ -27,10 +27,16 @@ 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**,并保存
<img src="doc/tutorial.png" width="400px" />
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 +71,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 +84,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

BIN
doc/tutorial.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB