mirror of
https://github.com/cxfksword/jellyfin-plugin-danmu.git
synced 2026-02-02 17:59:58 +08:00
Support like subtitle plugin
This commit is contained in:
132
Jellyfin.Plugin.Danmu.Test/BilibiliTest.cs
Normal file
132
Jellyfin.Plugin.Danmu.Test/BilibiliTest.cs
Normal 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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
184
Jellyfin.Plugin.Danmu/DanmuSubtitleProvider.cs
Normal file
184
Jellyfin.Plugin.Danmu/DanmuSubtitleProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -24,5 +24,10 @@ public enum EventType
|
||||
/// <summary>
|
||||
/// The update event.
|
||||
/// </summary>
|
||||
Update
|
||||
Update,
|
||||
|
||||
/// <summary>
|
||||
/// The force update event.
|
||||
/// </summary>
|
||||
Force
|
||||
}
|
||||
|
||||
18
Jellyfin.Plugin.Danmu/Model/SubtitleId.cs
Normal file
18
Jellyfin.Plugin.Danmu/Model/SubtitleId.cs
Normal 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; }
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -87,6 +87,12 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
continue;
|
||||
}
|
||||
|
||||
// item所在的媒体库不启用弹幕插件,忽略处理
|
||||
if (_libraryManagerEventsHelper.IsIgnoreItem(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 推送刷新 (season刷新会同时刷新episode,所以不需要再推送episode,而且season是bv号的,只能通过season来刷新)
|
||||
switch (item)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperSearchInfo.cs
Normal file
11
Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperSearchInfo.cs
Normal 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; }
|
||||
}
|
||||
@@ -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 != "电影")
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
28
README.md
28
README.md
@@ -10,7 +10,7 @@ jellyfin弹幕自动下载插件,已支持的弹幕来源:b站,弹弹play
|
||||
|
||||
* 自动下载xml格式弹幕
|
||||
* 生成ass格式弹幕
|
||||
* 定时更新
|
||||
* 可配置定时更新
|
||||
* 支持api访问弹幕
|
||||
|
||||

|
||||
@@ -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
BIN
doc/tutorial.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Reference in New Issue
Block a user