mirror of
https://github.com/cxfksword/jellyfin-plugin-danmu.git
synced 2026-04-24 02:22:09 +08:00
Support multi scrapers
This commit is contained in:
@@ -5,7 +5,6 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Jellyfin.Plugin.Danmu.Model;
|
||||
using Jellyfin.Plugin.Danmu.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Test
|
||||
|
||||
@@ -6,7 +6,7 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Jellyfin.Plugin.Danmu.Model;
|
||||
using Jellyfin.Plugin.Danmu.Providers;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
@@ -22,28 +22,74 @@ namespace Jellyfin.Plugin.Danmu.Test
|
||||
[TestClass]
|
||||
public class LibraryManagerEventsHelperTest
|
||||
{
|
||||
[TestMethod]
|
||||
public void TestUpdateMovie()
|
||||
{
|
||||
var loggerFactory = LoggerFactory.Create(builder =>
|
||||
builder.AddSimpleConsole(options =>
|
||||
{
|
||||
options.IncludeScopes = true;
|
||||
options.SingleLine = true;
|
||||
options.TimestampFormat = "hh:mm:ss ";
|
||||
}));
|
||||
var _bilibiliApi = new BilibiliApi(loggerFactory);
|
||||
ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
|
||||
builder.AddSimpleConsole(options =>
|
||||
{
|
||||
options.IncludeScopes = true;
|
||||
options.SingleLine = true;
|
||||
options.TimestampFormat = "hh:mm:ss ";
|
||||
}));
|
||||
|
||||
[TestMethod]
|
||||
public void TestAddMovie()
|
||||
{
|
||||
|
||||
var _bilibiliApi = new BilibiliApi(loggerFactory);
|
||||
var scraperFactory = new ScraperFactory(loggerFactory, _bilibiliApi);
|
||||
|
||||
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
|
||||
|
||||
var fileSystemStub = new Mock<IFileSystem>();
|
||||
var directoryServiceStub = new Mock<IDirectoryService>();
|
||||
var libraryManagerStub = new Mock<ILibraryManager>();
|
||||
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, _bilibiliApi, fileSystemStub.Object);
|
||||
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperFactory);
|
||||
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "机器人总动员"
|
||||
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 _bilibiliApi = new BilibiliApi(loggerFactory);
|
||||
var scraperFactory = new ScraperFactory(loggerFactory, _bilibiliApi);
|
||||
|
||||
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
|
||||
fileSystemStub.Setup(x => x.Exists(It.IsAny<string>())).Returns(true);
|
||||
fileSystemStub.Setup(x => x.GetLastWriteTime(It.IsAny<string>())).Returns(DateTime.Now.AddDays(-1));
|
||||
fileSystemStub.Setup(x => x.WriteAllBytesAsync(It.IsAny<string>(), It.IsAny<byte[]>(), It.IsAny<CancellationToken>()));
|
||||
var mediaSourceManagerStub = new Mock<IMediaSourceManager>();
|
||||
mediaSourceManagerStub.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaBrowser.Model.MediaInfo.MediaProtocol.File);
|
||||
var directoryServiceStub = new Mock<IDirectoryService>();
|
||||
var libraryManagerStub = new Mock<ILibraryManager>();
|
||||
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperFactory);
|
||||
|
||||
var item = new Movie
|
||||
{
|
||||
Name = "四海",
|
||||
ProviderIds = new Dictionary<string, string>() { { "BilibiliID", "BV1Sx411c7wA" } },
|
||||
Path = "/tmp/test.mp4",
|
||||
};
|
||||
Movie.MediaSourceManager = mediaSourceManagerStub.Object;
|
||||
|
||||
var list = new List<LibraryEvent>();
|
||||
list.Add(new LibraryEvent { Item = item, EventType = EventType.Update });
|
||||
|
||||
@@ -62,27 +108,55 @@ namespace Jellyfin.Plugin.Danmu.Test
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestUpdateShow()
|
||||
public void TestAddSeason()
|
||||
{
|
||||
var loggerFactory = LoggerFactory.Create(builder =>
|
||||
builder.AddSimpleConsole(options =>
|
||||
{
|
||||
options.IncludeScopes = true;
|
||||
options.SingleLine = true;
|
||||
options.TimestampFormat = "hh:mm:ss ";
|
||||
}));
|
||||
var _bilibiliApi = new BilibiliApi(loggerFactory);
|
||||
var scraperFactory = new ScraperFactory(loggerFactory, _bilibiliApi);
|
||||
|
||||
var fileSystemStub = new Mock<IFileSystem>();
|
||||
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, _bilibiliApi, fileSystemStub.Object);
|
||||
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperFactory);
|
||||
|
||||
var item = new Series
|
||||
var item = new Season
|
||||
{
|
||||
Name = "奔跑吧兄弟"
|
||||
};
|
||||
|
||||
var list = new List<LibraryEvent>();
|
||||
list.Add(new LibraryEvent { Item = item, EventType = EventType.Add });
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await libraryManagerEventsHelper.ProcessQueuedSeasonEvents(list, EventType.Add);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.Message);
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestUpdateSeason()
|
||||
{
|
||||
var _bilibiliApi = new BilibiliApi(loggerFactory);
|
||||
var scraperFactory = new ScraperFactory(loggerFactory, _bilibiliApi);
|
||||
|
||||
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
|
||||
var directoryServiceStub = new Mock<IDirectoryService>();
|
||||
var libraryManagerStub = new Mock<ILibraryManager>();
|
||||
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(libraryManagerStub.Object, loggerFactory, fileSystemStub.Object, scraperFactory);
|
||||
|
||||
var item = new Season
|
||||
{
|
||||
Name = "奔跑吧兄弟",
|
||||
ProviderIds = new Dictionary<string, string>() { { "BilibiliID", "33930" } }
|
||||
};
|
||||
|
||||
var list = new List<LibraryEvent>();
|
||||
list.Add(new LibraryEvent { Item = item, EventType = EventType.Update });
|
||||
|
||||
@@ -90,7 +164,7 @@ namespace Jellyfin.Plugin.Danmu.Test
|
||||
{
|
||||
try
|
||||
{
|
||||
await libraryManagerEventsHelper.ProcessQueuedShowEvents(list, EventType.Update);
|
||||
await libraryManagerEventsHelper.ProcessQueuedSeasonEvents(list, EventType.Update);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using Jellyfin.Plugin.Danmu.Core.Extensions;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Test
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace Jellyfin.Plugin.Danmu.Test
|
||||
<d p=""55.38000,1,25,16777215,1660413677,0,9c28a5a9,1118390004910248704,11"">这个op看得我好迷茫</d></i>
|
||||
";
|
||||
|
||||
var ass = Bilibili.GetInstance().ToASS(xml, new Config());
|
||||
var ass = Danmaku2Ass.Bilibili.GetInstance().ToASS(Encoding.UTF8.GetBytes(xml), new Config());
|
||||
Console.WriteLine(ass);
|
||||
Assert.IsNotNull(ass);
|
||||
|
||||
@@ -33,11 +33,11 @@ namespace Jellyfin.Plugin.Danmu.Test
|
||||
[TestMethod]
|
||||
public void TestToAssFile()
|
||||
{
|
||||
var xml = File.ReadAllText(@"F:\ddd\11111.xml");
|
||||
var xml = File.ReadAllBytes(@"F:\ddd\11111.xml");
|
||||
|
||||
|
||||
Bilibili.GetInstance().Create(xml, new Config(), @"F:\ddd\11111.ass");
|
||||
|
||||
Danmaku2Ass.Bilibili.GetInstance().Create(xml, new Config(), @"F:\ddd\11111.ass");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ using Jellyfin.Plugin.Danmu.Api.Http;
|
||||
using System.Web;
|
||||
using static Microsoft.Extensions.Logging.EventSource.LoggingEventSource;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Jellyfin.Plugin.Danmu.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Api
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using Jellyfin.Plugin.Danmu.Providers;
|
||||
using System.Runtime.InteropServices;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
$"midHash: {MidHash}{separator}" +
|
||||
$"content: {Content}{separator}" +
|
||||
$"ctime: {Ctime}{separator}" +
|
||||
$"weight: {Weight}{separator}" +
|
||||
//$"action: {Action}{separator}" +
|
||||
$"weight: {Weight}{separator}" +
|
||||
//$"action: {Action}{separator}" +
|
||||
$"pool: {Pool}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Jellyfin.Plugin.Danmu.Core.Danmaku2Ass;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml;
|
||||
|
||||
@@ -128,7 +129,7 @@ namespace Danmaku2Ass
|
||||
//studio.CreateAssFile(assFile);
|
||||
}
|
||||
|
||||
public void Create(string xml, Config subtitleConfig, string assFile)
|
||||
public void Create(byte[] xml, Config subtitleConfig, string assFile)
|
||||
{
|
||||
var danmakus = ParseXml(xml);
|
||||
|
||||
@@ -143,7 +144,7 @@ namespace Danmaku2Ass
|
||||
studio.CreateAssFile(assFile);
|
||||
}
|
||||
|
||||
public string ToASS(string xml, Config subtitleConfig)
|
||||
public string ToASS(byte[] xml, Config subtitleConfig)
|
||||
{
|
||||
var danmakus = ParseXml(xml);
|
||||
|
||||
@@ -158,10 +159,13 @@ namespace Danmaku2Ass
|
||||
return studio.GetText();
|
||||
}
|
||||
|
||||
public List<Danmaku> ParseXml(string xml)
|
||||
public List<Danmaku> ParseXml(byte[] xml)
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
using (var stream = new MemoryStream(xml))
|
||||
{
|
||||
doc.Load(stream);
|
||||
}
|
||||
|
||||
var calFontSizeDict = new Dictionary<int, int>();
|
||||
var biliDanmakus = new List<BiliDanmaku>();
|
||||
@@ -202,6 +206,7 @@ namespace Danmaku2Ass
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 按弹幕出现顺序排序
|
||||
biliDanmakus.Sort((x, y) => { return x.Progress.CompareTo(y.Progress); });
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Core
|
||||
namespace Jellyfin.Plugin.Danmu.Core.Extensions
|
||||
{
|
||||
public static class JsonExtension
|
||||
{
|
||||
@@ -4,7 +4,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Core
|
||||
namespace Jellyfin.Plugin.Danmu.Core.Extensions
|
||||
{
|
||||
public static class ListExtension
|
||||
{
|
||||
@@ -6,7 +6,7 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using StringMetric;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Core
|
||||
namespace Jellyfin.Plugin.Danmu.Core.Extensions
|
||||
{
|
||||
public static class StringExtension
|
||||
{
|
||||
33
Jellyfin.Plugin.Danmu/Core/FileSystem.cs
Normal file
33
Jellyfin.Plugin.Danmu/Core/FileSystem.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Core;
|
||||
|
||||
public class FileSystem : IFileSystem
|
||||
{
|
||||
public Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||
}
|
||||
|
||||
public Task WriteAllTextAsync(string path, string? contents, Encoding encoding, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return File.WriteAllTextAsync(path, contents, cancellationToken);
|
||||
}
|
||||
|
||||
public DateTime GetLastWriteTime(string path)
|
||||
{
|
||||
return File.GetLastWriteTime(path);
|
||||
}
|
||||
|
||||
public bool Exists(string? path)
|
||||
{
|
||||
return File.Exists(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
15
Jellyfin.Plugin.Danmu/Core/IFileSystem.cs
Normal file
15
Jellyfin.Plugin.Danmu/Core/IFileSystem.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Core;
|
||||
|
||||
public interface IFileSystem
|
||||
{
|
||||
bool Exists(string? path);
|
||||
DateTime GetLastWriteTime(string path);
|
||||
Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken);
|
||||
|
||||
Task WriteAllTextAsync(string path, string? contents, Encoding encoding, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.Danmu</RootNamespace>
|
||||
@@ -9,29 +8,23 @@
|
||||
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
|
||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.8.0" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" />
|
||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
682
Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs
Normal file
682
Jellyfin.Plugin.Danmu/LibraryManagerEventsHelper.cs
Normal file
@@ -0,0 +1,682 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Net.Http;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Jellyfin.Plugin.Danmu.Api.Entity;
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using Jellyfin.Plugin.Danmu.Model;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using Jellyfin.Plugin.Danmu.Core.Extensions;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu;
|
||||
|
||||
public class LibraryManagerEventsHelper : IDisposable
|
||||
{
|
||||
private readonly List<LibraryEvent> _queuedEvents;
|
||||
private readonly IMemoryCache _pendingAddEventCache;
|
||||
private readonly MemoryCacheEntryOptions _expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<LibraryManagerEventsHelper> _logger;
|
||||
private readonly Jellyfin.Plugin.Danmu.Core.IFileSystem _fileSystem;
|
||||
private Timer _queueTimer;
|
||||
private readonly ScraperFactory _scraperFactory;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LibraryManagerEventsHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
|
||||
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
|
||||
/// <param name="api">The <see cref="BilibiliApi"/>.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
public LibraryManagerEventsHelper(ILibraryManager libraryManager, ILoggerFactory loggerFactory, Jellyfin.Plugin.Danmu.Core.IFileSystem fileSystem, ScraperFactory scraperFactory)
|
||||
{
|
||||
_queuedEvents = new List<LibraryEvent>();
|
||||
_pendingAddEventCache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_libraryManager = libraryManager;
|
||||
_logger = loggerFactory.CreateLogger<LibraryManagerEventsHelper>();
|
||||
_fileSystem = fileSystem;
|
||||
_scraperFactory = scraperFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues an item to be added to trakt.
|
||||
/// </summary>
|
||||
/// <param name="item"> The <see cref="BaseItem"/>.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
public void QueueItem(BaseItem item, EventType eventType)
|
||||
{
|
||||
lock (_queuedEvents)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(item));
|
||||
}
|
||||
|
||||
if (_queueTimer == null)
|
||||
{
|
||||
_queueTimer = new Timer(
|
||||
OnQueueTimerCallback,
|
||||
null,
|
||||
TimeSpan.FromMilliseconds(10000),
|
||||
Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
else
|
||||
{
|
||||
_queueTimer.Change(TimeSpan.FromMilliseconds(10000), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
_queuedEvents.Add(new LibraryEvent { Item = item, EventType = eventType });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer callback to be completed.
|
||||
/// </summary>
|
||||
private async void OnQueueTimerCallback(object state)
|
||||
{
|
||||
try
|
||||
{
|
||||
await OnQueueTimerCallbackInternal().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in OnQueueTimerCallbackInternal");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer to be completed.
|
||||
/// </summary>
|
||||
private async Task OnQueueTimerCallbackInternal()
|
||||
{
|
||||
// _logger.LogInformation("Timer elapsed - processing queued items");
|
||||
List<LibraryEvent> queue;
|
||||
|
||||
lock (_queuedEvents)
|
||||
{
|
||||
if (!_queuedEvents.Any())
|
||||
{
|
||||
_logger.LogInformation("No events... stopping queue timer");
|
||||
return;
|
||||
}
|
||||
|
||||
queue = _queuedEvents.ToList();
|
||||
_queuedEvents.Clear();
|
||||
}
|
||||
|
||||
var queuedMovieAdds = new List<LibraryEvent>();
|
||||
var queuedMovieUpdates = new List<LibraryEvent>();
|
||||
var queuedEpisodeAdds = new List<LibraryEvent>();
|
||||
var queuedEpisodeUpdates = new List<LibraryEvent>(); ;
|
||||
var queuedShowAdds = new List<LibraryEvent>();
|
||||
var queuedShowUpdates = new List<LibraryEvent>();
|
||||
var queuedSeasonAdds = new List<LibraryEvent>();
|
||||
var queuedSeasonUpdates = new List<LibraryEvent>();
|
||||
|
||||
// add事件可能会在获取元数据完之前执行,导致可能会中断元数据获取,通过pending集合把add事件延缓到获取元数据后再执行(获取完元数据后,一般会多推送一个update事件)
|
||||
foreach (var ev in queue)
|
||||
{
|
||||
switch (ev.Item)
|
||||
{
|
||||
case Movie when ev.EventType is EventType.Add:
|
||||
_logger.LogInformation("Movie add: {0}", ev.Item.Name);
|
||||
_pendingAddEventCache.Set<LibraryEvent>(ev.Item.Id, ev, _expiredOption);
|
||||
break;
|
||||
case Movie when ev.EventType is EventType.Update:
|
||||
_logger.LogInformation("Movie update: {0}", ev.Item.Name);
|
||||
if (_pendingAddEventCache.TryGetValue<LibraryEvent>(ev.Item.Id, out LibraryEvent addMovieEv))
|
||||
{
|
||||
queuedMovieAdds.Add(addMovieEv);
|
||||
_pendingAddEventCache.Remove(ev.Item.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
queuedMovieUpdates.Add(ev);
|
||||
}
|
||||
break;
|
||||
case Series when ev.EventType is EventType.Update:
|
||||
_logger.LogInformation("Series update: {0}", ev.Item.Name);
|
||||
queuedShowUpdates.Add(ev);
|
||||
break;
|
||||
case Season when ev.EventType is EventType.Add:
|
||||
_logger.LogInformation("Season add: {0}", ev.Item.Name);
|
||||
_pendingAddEventCache.Set<LibraryEvent>(ev.Item.Id, ev, _expiredOption);
|
||||
break;
|
||||
case Season when ev.EventType is EventType.Update:
|
||||
_logger.LogInformation("Season update: {0}", ev.Item.Name);
|
||||
if (_pendingAddEventCache.TryGetValue<LibraryEvent>(ev.Item.Id, out LibraryEvent addSeasonEv))
|
||||
{
|
||||
queuedSeasonAdds.Add(addSeasonEv);
|
||||
_pendingAddEventCache.Remove(ev.Item.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
queuedSeasonUpdates.Add(ev);
|
||||
}
|
||||
break;
|
||||
case Episode when ev.EventType is EventType.Update:
|
||||
_logger.LogInformation("Episode update: {0}", ev.Item.Name);
|
||||
queuedEpisodeUpdates.Add(ev);
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 对于剧集,处理顺序也很重要(Add事件后,会刷新元数据,导致会同时推送Update事件)
|
||||
await ProcessQueuedMovieEvents(queuedMovieAdds, EventType.Add).ConfigureAwait(false);
|
||||
await ProcessQueuedMovieEvents(queuedMovieUpdates, EventType.Update).ConfigureAwait(false);
|
||||
|
||||
await ProcessQueuedShowEvents(queuedShowAdds, EventType.Add).ConfigureAwait(false);
|
||||
await ProcessQueuedSeasonEvents(queuedSeasonAdds, EventType.Add).ConfigureAwait(false);
|
||||
await ProcessQueuedEpisodeEvents(queuedEpisodeAdds, EventType.Add).ConfigureAwait(false);
|
||||
|
||||
await ProcessQueuedShowEvents(queuedShowUpdates, EventType.Update).ConfigureAwait(false);
|
||||
await ProcessQueuedSeasonEvents(queuedSeasonUpdates, EventType.Update).ConfigureAwait(false);
|
||||
await ProcessQueuedEpisodeEvents(queuedEpisodeUpdates, EventType.Update).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes queued movie events.
|
||||
/// </summary>
|
||||
/// <param name="events">The <see cref="LibraryEvent"/> enumerable.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ProcessQueuedMovieEvents(IReadOnlyCollection<LibraryEvent> events, EventType eventType)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} movies with event type {EventType}", events.Count, eventType);
|
||||
|
||||
var movies = events.Select(lev => (Movie)lev.Item)
|
||||
.Where(lev => !string.IsNullOrEmpty(lev.Name))
|
||||
.ToHashSet();
|
||||
|
||||
|
||||
// 新增事件也会触发update,不需要处理Add
|
||||
// 更新,判断是否有bvid,有的话刷新弹幕文件
|
||||
if (eventType == EventType.Add)
|
||||
{
|
||||
var queueUpdateMeta = new List<BaseItem>();
|
||||
foreach (var item in movies)
|
||||
{
|
||||
foreach (var scraper in _scraperFactory.All())
|
||||
{
|
||||
try
|
||||
{
|
||||
// 读取最新数据,要不然取不到年份信息
|
||||
var currentItem = _libraryManager.GetItemById(item.Id) ?? item;
|
||||
|
||||
var mediaId = await scraper.GetMatchMediaId(currentItem);
|
||||
if (string.IsNullOrEmpty(mediaId))
|
||||
{
|
||||
_logger.LogInformation("[{0}]匹配失败:{1}", scraper.Name, item.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var media = await scraper.GetMedia(mediaId);
|
||||
if (media != null && media.Episodes.Count > 0)
|
||||
{
|
||||
var providerVal = media.Episodes[0].Id;
|
||||
var commentId = media.Episodes[0].CommentId;
|
||||
_logger.LogInformation("[{0}]匹配成功:name={1} ProviderId: {2}", scraper.Name, item.Name, providerVal);
|
||||
|
||||
// 更新epid元数据
|
||||
item.SetProviderId(scraper.ProviderId, providerVal);
|
||||
queueUpdateMeta.Add(item);
|
||||
|
||||
// 下载弹幕
|
||||
await this.DownloadDanmu(scraper, item, commentId).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (FrequentlyRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "api接口触发风控,中止执行,请稍候再试.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued movie events");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ProcessQueuedUpdateMeta(queueUpdateMeta).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
// 更新
|
||||
if (eventType == EventType.Update)
|
||||
{
|
||||
foreach (var item in movies)
|
||||
{
|
||||
foreach (var scraper in _scraperFactory.All())
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerVal = item.GetProviderId(scraper.ProviderId);
|
||||
if (!string.IsNullOrEmpty(providerVal))
|
||||
{
|
||||
var episode = await scraper.GetMediaEpisode(providerVal);
|
||||
if (episode != null)
|
||||
{
|
||||
// 下载弹幕xml文件
|
||||
await this.DownloadDanmu(scraper, item, episode.CommentId).ConfigureAwait(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (FrequentlyRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "api接口触发风控,中止执行,请稍候再试.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued movie events");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes queued show events.
|
||||
/// </summary>
|
||||
/// <param name="events">The <see cref="LibraryEvent"/> enumerable.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ProcessQueuedShowEvents(IReadOnlyCollection<LibraryEvent> events, EventType eventType)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} shows with event type {EventType}", events.Count, eventType);
|
||||
|
||||
var series = events.Select(lev => (Series)lev.Item)
|
||||
.Where(lev => !string.IsNullOrEmpty(lev.Name))
|
||||
.ToHashSet();
|
||||
|
||||
try
|
||||
{
|
||||
if (eventType == EventType.Update)
|
||||
{
|
||||
foreach (var item in series)
|
||||
{
|
||||
var seasons = item.GetSeasons(null, new DtoOptions(false));
|
||||
foreach (var season in seasons)
|
||||
{
|
||||
// 发现season保存元数据,不会推送update事件,这里通过series的update事件推送刷新
|
||||
QueueItem(season, eventType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued show events");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes queued season events.
|
||||
/// </summary>
|
||||
/// <param name="events">The <see cref="LibraryEvent"/> enumerable.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ProcessQueuedSeasonEvents(IReadOnlyCollection<LibraryEvent> events, EventType eventType)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} seasons with event type {EventType}", events.Count, eventType);
|
||||
|
||||
var seasons = events.Select(lev => (Season)lev.Item)
|
||||
.Where(lev => !string.IsNullOrEmpty(lev.Name))
|
||||
.ToHashSet();
|
||||
|
||||
|
||||
if (eventType == EventType.Add)
|
||||
{
|
||||
var queueUpdateMeta = new List<BaseItem>();
|
||||
foreach (var season in seasons)
|
||||
{
|
||||
if (season.IndexNumber.HasValue && season.IndexNumber == 0)
|
||||
{
|
||||
_logger.LogInformation("特典不处理:name={0} number={1}", season.Name, season.IndexNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
var series = season.GetParent();
|
||||
foreach (var scraper in _scraperFactory.All())
|
||||
{
|
||||
try
|
||||
{
|
||||
// 读取最新数据,要不然取不到年份信息
|
||||
var currentItem = _libraryManager.GetItemById(season.Id) ?? season;
|
||||
// 季的名称不准确,改使用series的名称
|
||||
if (series != null)
|
||||
{
|
||||
currentItem.Name = series.Name;
|
||||
}
|
||||
var mediaId = await scraper.GetMatchMediaId(currentItem);
|
||||
|
||||
if (!string.IsNullOrEmpty(mediaId))
|
||||
{
|
||||
// 更新seasonId元数据
|
||||
season.SetProviderId(scraper.ProviderId, mediaId);
|
||||
queueUpdateMeta.Add(season);
|
||||
|
||||
_logger.LogInformation("[{0}]匹配成功:name={1} season_number={2} ProviderId: {3}", scraper.Name, season.Name, season.IndexNumber, mediaId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (FrequentlyRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "api接口触发风控,中止执行,请稍候再试.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued movie events");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存元数据
|
||||
await ProcessQueuedUpdateMeta(queueUpdateMeta).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (eventType == EventType.Update)
|
||||
{
|
||||
foreach (var season in seasons)
|
||||
{
|
||||
var queueUpdateMeta = new List<BaseItem>();
|
||||
var episodes = season.GetEpisodes(null, new DtoOptions(false));
|
||||
if (episodes == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var scraper in _scraperFactory.All())
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerVal = season.GetProviderId(scraper.ProviderId);
|
||||
if (string.IsNullOrEmpty(providerVal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var media = await scraper.GetMedia(providerVal);
|
||||
if (media == null)
|
||||
{
|
||||
_logger.LogInformation("[{0}]获取不到视频信息. ProviderId: {1}", scraper.Name, providerVal);
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var (episode, idx) in episodes.WithIndex())
|
||||
{
|
||||
var indexNumber = episode.IndexNumber ?? 0;
|
||||
if (indexNumber <= 0)
|
||||
{
|
||||
_logger.LogInformation("[{0}]匹配失败,缺少集号. [{1}]{2}", scraper.Name, season.Name, episode.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indexNumber > media.Episodes.Count)
|
||||
{
|
||||
_logger.LogInformation("[{0}]匹配失败,集号超过总集数,可能集号错误. [{1}]{2} indexNumber: {3}", scraper.Name, season.Name, episode.Name, indexNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (media.Episodes.Count == episodes.Count)
|
||||
{
|
||||
var epId = media.Episodes[idx].Id;
|
||||
var commentId = media.Episodes[idx].CommentId;
|
||||
_logger.LogInformation("[{0}]成功匹配. {1} -> epId: {2} cid: {3}", scraper.Name, episode.Name, epId, commentId);
|
||||
|
||||
// 更新eposide元数据
|
||||
var episodeProviderVal = episode.GetProviderId(scraper.ProviderId);
|
||||
if (!string.IsNullOrEmpty(epId) && episodeProviderVal != epId)
|
||||
{
|
||||
episode.SetProviderId(scraper.ProviderId, epId);
|
||||
queueUpdateMeta.Add(episode);
|
||||
}
|
||||
|
||||
// 下载弹幕
|
||||
await this.DownloadDanmu(scraper, episode, commentId).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("[{0}]刷新弹幕失败, 集数不一致。video: {1} 弹幕数:{2} 集数:{3}", scraper.Name, season.Name, media.Episodes.Count, episodes.Count);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
}
|
||||
catch (FrequentlyRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "api接口触发风控,中止执行,请稍候再试.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued movie events");
|
||||
}
|
||||
}
|
||||
|
||||
// 保存元数据
|
||||
await ProcessQueuedUpdateMeta(queueUpdateMeta).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes queued episode events.
|
||||
/// </summary>
|
||||
/// <param name="events">The <see cref="LibraryEvent"/> enumerable.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ProcessQueuedEpisodeEvents(IReadOnlyCollection<LibraryEvent> events, EventType eventType)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} episodes with event type {EventType}", events.Count, eventType);
|
||||
|
||||
var episodes = events.Select(lev => (Episode)lev.Item)
|
||||
.Where(lev => !string.IsNullOrEmpty(lev.Name))
|
||||
.ToHashSet();
|
||||
|
||||
|
||||
// 判断epid,有的话刷新弹幕文件
|
||||
if (eventType == EventType.Update)
|
||||
{
|
||||
foreach (var item in episodes)
|
||||
{
|
||||
foreach (var scraper in _scraperFactory.All())
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerVal = item.GetProviderId(scraper.ProviderId);
|
||||
if (string.IsNullOrEmpty(providerVal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var episode = await scraper.GetMediaEpisode(providerVal);
|
||||
if (episode != null)
|
||||
{
|
||||
// 下载弹幕xml文件
|
||||
await this.DownloadDanmu(scraper, item, episode.CommentId).ConfigureAwait(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
catch (FrequentlyRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "api接口触发风控,中止执行,请稍候再试.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued movie events");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 调用UpdateToRepositoryAsync后,但未完成时,会导致GetEpisodes返回缺少正在处理的集数,所以采用统一最后处理
|
||||
private async Task ProcessQueuedUpdateMeta(List<BaseItem> queue)
|
||||
{
|
||||
if (queue.Count <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var queueItem in queue)
|
||||
{
|
||||
// 获取最新的item数据
|
||||
var item = _libraryManager.GetItemById(queueItem.Id);
|
||||
// 合并新添加的provider id
|
||||
foreach (var pair in queueItem.ProviderIds)
|
||||
{
|
||||
item.ProviderIds[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
// Console.WriteLine(JsonSerializer.Serialize(item));
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
_logger.LogInformation("更新epid到元数据完成。item数:{0}", queue.Count);
|
||||
}
|
||||
|
||||
private async Task DownloadDanmu(AbstractScraper scraper, BaseItem item, string commentId)
|
||||
{
|
||||
// 下载弹幕xml文件
|
||||
try
|
||||
{
|
||||
// 弹幕一分钟内更新过,忽略处理(有时Update事件会重复执行)
|
||||
if (IsRepeatAction(item))
|
||||
{
|
||||
_logger.LogInformation("[{0}]最近1分钟已更新过弹幕xml,忽略处理:{1}", scraper.Name, item.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var danmaku = await scraper.GetDanmuContent(commentId);
|
||||
if (danmaku != null)
|
||||
{
|
||||
await this.DownloadDanmuInternal(item, danmaku.ToXml());
|
||||
this._logger.LogInformation("[{0}]弹幕下载成功:name={1}", scraper.Name, item.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled download danmu file");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRepeatAction(BaseItem item)
|
||||
{
|
||||
var danmuPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".xml");
|
||||
if (!this._fileSystem.Exists(danmuPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lastWriteTime = this._fileSystem.GetLastWriteTime(danmuPath);
|
||||
var diff = DateTime.Now - lastWriteTime;
|
||||
return diff.TotalSeconds < 60;
|
||||
}
|
||||
|
||||
private async Task DownloadDanmuInternal(BaseItem item, byte[] bytes)
|
||||
{
|
||||
// 下载弹幕xml文件
|
||||
var danmuPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".xml");
|
||||
await this._fileSystem.WriteAllBytesAsync(danmuPath, bytes, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
var config = Plugin.Instance.Configuration;
|
||||
if (config.ToAss && bytes.Length > 0)
|
||||
{
|
||||
var assConfig = new Danmaku2Ass.Config();
|
||||
assConfig.Title = item.Name;
|
||||
if (!string.IsNullOrEmpty(config.AssFont.Trim()))
|
||||
{
|
||||
assConfig.FontName = config.AssFont;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(config.AssFontSize.Trim()))
|
||||
{
|
||||
assConfig.BaseFontSize = config.AssFontSize.Trim().ToInt();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(config.AssTextOpacity.Trim()))
|
||||
{
|
||||
assConfig.TextOpacity = config.AssTextOpacity.Trim().ToFloat();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(config.AssLineCount.Trim()))
|
||||
{
|
||||
assConfig.LineCount = config.AssLineCount.Trim().ToInt();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(config.AssSpeed.Trim()))
|
||||
{
|
||||
assConfig.TuneDuration = config.AssSpeed.Trim().ToInt() - 8;
|
||||
}
|
||||
|
||||
var assPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".danmu.ass");
|
||||
Danmaku2Ass.Bilibili.GetInstance().Create(bytes, assConfig, assPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_queueTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.Danmu.Configuration;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
@@ -14,22 +17,12 @@ namespace Jellyfin.Plugin.Danmu;
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider name.
|
||||
/// </summary>
|
||||
public const string ProviderName = "Bilibili";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provider id.
|
||||
/// </summary>
|
||||
public const string ProviderId = "BilibiliID";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
public Plugin(IApplicationPaths applicationPaths, IApplicationHost applicationHost, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
|
||||
@@ -17,7 +17,6 @@ using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using Jellyfin.Plugin.Danmu.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu
|
||||
{
|
||||
|
||||
@@ -1,890 +0,0 @@
|
||||
using System.Net.Http;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Danmaku2Ass;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Jellyfin.Plugin.Danmu.Api.Entity;
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using Jellyfin.Plugin.Danmu.Model;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Providers;
|
||||
|
||||
public class LibraryManagerEventsHelper : IDisposable
|
||||
{
|
||||
private readonly List<LibraryEvent> _queuedEvents;
|
||||
private readonly IMemoryCache _pendingAddEventCache;
|
||||
private readonly MemoryCacheEntryOptions _expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<LibraryManagerEventsHelper> _logger;
|
||||
private readonly BilibiliApi _api;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private Timer _queueTimer;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LibraryManagerEventsHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
|
||||
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
|
||||
/// <param name="api">The <see cref="BilibiliApi"/>.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
public LibraryManagerEventsHelper(ILibraryManager libraryManager, ILoggerFactory loggerFactory, BilibiliApi api, IFileSystem fileSystem)
|
||||
{
|
||||
_queuedEvents = new List<LibraryEvent>();
|
||||
_pendingAddEventCache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_libraryManager = libraryManager;
|
||||
_logger = loggerFactory.CreateLogger<LibraryManagerEventsHelper>();
|
||||
_api = api;
|
||||
_fileSystem = fileSystem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues an item to be added to trakt.
|
||||
/// </summary>
|
||||
/// <param name="item"> The <see cref="BaseItem"/>.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
public void QueueItem(BaseItem item, EventType eventType)
|
||||
{
|
||||
lock (_queuedEvents)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(item));
|
||||
}
|
||||
|
||||
if (_queueTimer == null)
|
||||
{
|
||||
_queueTimer = new Timer(
|
||||
OnQueueTimerCallback,
|
||||
null,
|
||||
TimeSpan.FromMilliseconds(10000),
|
||||
Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
else
|
||||
{
|
||||
_queueTimer.Change(TimeSpan.FromMilliseconds(10000), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
_queuedEvents.Add(new LibraryEvent { Item = item, EventType = eventType });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer callback to be completed.
|
||||
/// </summary>
|
||||
private async void OnQueueTimerCallback(object state)
|
||||
{
|
||||
try
|
||||
{
|
||||
await OnQueueTimerCallbackInternal().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in OnQueueTimerCallbackInternal");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for timer to be completed.
|
||||
/// </summary>
|
||||
private async Task OnQueueTimerCallbackInternal()
|
||||
{
|
||||
// _logger.LogInformation("Timer elapsed - processing queued items");
|
||||
List<LibraryEvent> queue;
|
||||
|
||||
lock (_queuedEvents)
|
||||
{
|
||||
if (!_queuedEvents.Any())
|
||||
{
|
||||
_logger.LogInformation("No events... stopping queue timer");
|
||||
return;
|
||||
}
|
||||
|
||||
queue = _queuedEvents.ToList();
|
||||
_queuedEvents.Clear();
|
||||
}
|
||||
|
||||
var queuedMovieAdds = new List<LibraryEvent>();
|
||||
var queuedMovieUpdates = new List<LibraryEvent>();
|
||||
var queuedEpisodeAdds = new List<LibraryEvent>();
|
||||
var queuedEpisodeUpdates = new List<LibraryEvent>(); ;
|
||||
var queuedShowAdds = new List<LibraryEvent>();
|
||||
var queuedShowUpdates = new List<LibraryEvent>();
|
||||
var queuedSeasonAdds = new List<LibraryEvent>();
|
||||
var queuedSeasonUpdates = new List<LibraryEvent>();
|
||||
|
||||
// add事件可能会在获取元数据完之前执行,导致可能会中断元数据获取,通过pending集合把add事件延缓到获取元数据后再执行(获取完元数据后,一般会多推送一个update事件)
|
||||
foreach (var ev in queue)
|
||||
{
|
||||
switch (ev.Item)
|
||||
{
|
||||
case Movie when ev.EventType is EventType.Add:
|
||||
_logger.LogInformation("Movie add: {0}", ev.Item.Name);
|
||||
_pendingAddEventCache.Set<LibraryEvent>(ev.Item.Id, ev, _expiredOption);
|
||||
break;
|
||||
case Movie when ev.EventType is EventType.Update:
|
||||
_logger.LogInformation("Movie update: {0}", ev.Item.Name);
|
||||
if (_pendingAddEventCache.TryGetValue<LibraryEvent>(ev.Item.Id, out LibraryEvent addMovieEv))
|
||||
{
|
||||
queuedMovieAdds.Add(addMovieEv);
|
||||
_pendingAddEventCache.Remove(ev.Item.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
queuedMovieUpdates.Add(ev);
|
||||
}
|
||||
break;
|
||||
case Series when ev.EventType is EventType.Update:
|
||||
_logger.LogInformation("Series update: {0}", ev.Item.Name);
|
||||
queuedShowUpdates.Add(ev);
|
||||
break;
|
||||
case Season when ev.EventType is EventType.Add:
|
||||
_logger.LogInformation("Season add: {0}", ev.Item.Name);
|
||||
_pendingAddEventCache.Set<LibraryEvent>(ev.Item.Id, ev, _expiredOption);
|
||||
break;
|
||||
case Season when ev.EventType is EventType.Update:
|
||||
_logger.LogInformation("Season update: {0}", ev.Item.Name);
|
||||
if (_pendingAddEventCache.TryGetValue<LibraryEvent>(ev.Item.Id, out LibraryEvent addSeasonEv))
|
||||
{
|
||||
queuedSeasonAdds.Add(addSeasonEv);
|
||||
_pendingAddEventCache.Remove(ev.Item.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
queuedSeasonUpdates.Add(ev);
|
||||
}
|
||||
break;
|
||||
case Episode when ev.EventType is EventType.Update:
|
||||
_logger.LogInformation("Episode update: {0}", ev.Item.Name);
|
||||
queuedEpisodeUpdates.Add(ev);
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 对于剧集,处理顺序也很重要(Add事件后,会刷新元数据,导致会同时推送Update事件)
|
||||
await ProcessQueuedMovieEvents(queuedMovieAdds, EventType.Add).ConfigureAwait(false);
|
||||
await ProcessQueuedMovieEvents(queuedMovieUpdates, EventType.Update).ConfigureAwait(false);
|
||||
|
||||
await ProcessQueuedShowEvents(queuedShowAdds, EventType.Add).ConfigureAwait(false);
|
||||
await ProcessQueuedSeasonEvents(queuedSeasonAdds, EventType.Add).ConfigureAwait(false);
|
||||
await ProcessQueuedEpisodeEvents(queuedEpisodeAdds, EventType.Add).ConfigureAwait(false);
|
||||
|
||||
await ProcessQueuedShowEvents(queuedShowUpdates, EventType.Update).ConfigureAwait(false);
|
||||
await ProcessQueuedSeasonEvents(queuedSeasonUpdates, EventType.Update).ConfigureAwait(false);
|
||||
await ProcessQueuedEpisodeEvents(queuedEpisodeUpdates, EventType.Update).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes queued movie events.
|
||||
/// </summary>
|
||||
/// <param name="events">The <see cref="LibraryEvent"/> enumerable.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ProcessQueuedMovieEvents(IReadOnlyCollection<LibraryEvent> events, EventType eventType)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} movies with event type {EventType}", events.Count, eventType);
|
||||
|
||||
var movies = events.Select(lev => (Movie)lev.Item)
|
||||
.Where(lev => !string.IsNullOrEmpty(lev.Name))
|
||||
.ToHashSet();
|
||||
|
||||
try
|
||||
{
|
||||
// 新增事件也会触发update,不需要处理Add
|
||||
// 更新,判断是否有bvid,有的话刷新弹幕文件
|
||||
if (eventType == EventType.Add)
|
||||
{
|
||||
var queueUpdateMeta = new List<BaseItem>();
|
||||
foreach (var item in movies)
|
||||
{
|
||||
var providerVal = item.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
// 视频也支持指定的BV号
|
||||
if (providerVal.StartsWith("BV", StringComparison.CurrentCulture))
|
||||
{
|
||||
var bvid = providerVal;
|
||||
|
||||
// 下载弹幕xml文件
|
||||
var bytes = await _api.GetDanmuContentAsync(bvid, CancellationToken.None).ConfigureAwait(false);
|
||||
var danmuPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".xml");
|
||||
await File.WriteAllBytesAsync(danmuPath, bytes, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var epId = providerVal.ToLong();
|
||||
if (epId <= 0)
|
||||
{
|
||||
// 搜索查找匹配的视频
|
||||
var searchName = this.NormalizeSearchName(item.Name);
|
||||
var seasonId = await GetMatchBiliSeasonId(item, searchName).ConfigureAwait(false);
|
||||
var season = await _api.GetSeasonAsync(seasonId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (season == null)
|
||||
{
|
||||
_logger.LogInformation("b站没有找到对应视频信息:name={0}", item.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (season.Episodes.Length > 0)
|
||||
{
|
||||
epId = season.Episodes[0].Id;
|
||||
|
||||
// 更新epid元数据
|
||||
item.SetProviderId(Plugin.ProviderId, $"{epId}");
|
||||
queueUpdateMeta.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (epId <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 下载弹幕xml文件
|
||||
var bytes = await _api.GetDanmuContentAsync(epId, CancellationToken.None).ConfigureAwait(false);
|
||||
var danmuPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".xml");
|
||||
await File.WriteAllBytesAsync(danmuPath, bytes, CancellationToken.None).ConfigureAwait(false);
|
||||
_logger.LogInformation("匹配成功:name={0} b站: {1}", item.Name, epId);
|
||||
}
|
||||
}
|
||||
|
||||
await ProcessQueuedUpdateMeta(queueUpdateMeta).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
// 更新,判断是否有bvid,有的话刷新弹幕文件
|
||||
if (eventType == EventType.Update)
|
||||
{
|
||||
foreach (var item in movies)
|
||||
{
|
||||
var providerVal = item.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
// 视频也支持指定的BV号
|
||||
if (providerVal.StartsWith("BV", StringComparison.CurrentCulture))
|
||||
{
|
||||
var bvid = providerVal;
|
||||
|
||||
// 下载弹幕xml文件
|
||||
await this.DownloadDanmu(item, bvid).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var epId = providerVal.ToLong();
|
||||
|
||||
if (epId <= 0)
|
||||
{
|
||||
// 用户删除了epid,存在旧弹幕的话,尝试删除
|
||||
// this.DeleteOldDanmu(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 下载弹幕xml文件
|
||||
await this.DownloadDanmu(item, epId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// 延迟200毫秒,避免请求太频繁
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
catch (FrequentlyRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "api接口触发风控,中止执行,请稍候再试.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued movie events");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes queued show events.
|
||||
/// </summary>
|
||||
/// <param name="events">The <see cref="LibraryEvent"/> enumerable.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ProcessQueuedShowEvents(IReadOnlyCollection<LibraryEvent> events, EventType eventType)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} shows with event type {EventType}", events.Count, eventType);
|
||||
|
||||
var series = events.Select(lev => (Series)lev.Item)
|
||||
.Where(lev => !string.IsNullOrEmpty(lev.Name))
|
||||
.ToHashSet();
|
||||
|
||||
try
|
||||
{
|
||||
if (eventType == EventType.Update)
|
||||
{
|
||||
foreach (var item in series)
|
||||
{
|
||||
var seasons = item.GetSeasons(null, new DtoOptions(false));
|
||||
foreach (var season in seasons)
|
||||
{
|
||||
// 发现season保存元数据,不会推送update事件,这里通过series的update事件推送刷新
|
||||
QueueItem(season, eventType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued show events");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes queued season events.
|
||||
/// </summary>
|
||||
/// <param name="events">The <see cref="LibraryEvent"/> enumerable.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ProcessQueuedSeasonEvents(IReadOnlyCollection<LibraryEvent> events, EventType eventType)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} seasons with event type {EventType}", events.Count, eventType);
|
||||
|
||||
var seasons = events.Select(lev => (Season)lev.Item)
|
||||
.Where(lev => !string.IsNullOrEmpty(lev.Name))
|
||||
.ToHashSet();
|
||||
|
||||
try
|
||||
{
|
||||
if (eventType == EventType.Add)
|
||||
{
|
||||
var queueUpdateMeta = new List<BaseItem>();
|
||||
foreach (var season in seasons)
|
||||
{
|
||||
if (season.IndexNumber.HasValue && season.IndexNumber == 0)
|
||||
{
|
||||
_logger.LogInformation("特典不处理:name={0} number={1}", season.Name, season.IndexNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
var series = season.GetParent();
|
||||
var providerVal = season.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
// 支持视频分片BV号
|
||||
if (providerVal.StartsWith("BV", StringComparison.CurrentCulture))
|
||||
{
|
||||
await ProcessSplitVideo(season, eventType).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var seasonId = providerVal.ToLong();
|
||||
|
||||
// 根据名称搜索剧集对应的视频
|
||||
if (seasonId <= 0)
|
||||
{
|
||||
var searchName = this.NormalizeSearchName(series.Name);
|
||||
seasonId = await GetMatchBiliSeasonId(season, searchName).ConfigureAwait(false);
|
||||
if (seasonId <= 0)
|
||||
{
|
||||
_logger.LogInformation("b站没有找到对应视频信息:name={0} season_number={1}", searchName, season.IndexNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新seasonId元数据
|
||||
season.SetProviderId(Plugin.ProviderId, $"{seasonId}");
|
||||
queueUpdateMeta.Add(season);
|
||||
_logger.LogInformation("匹配成功:name={0} season_number={1} b站: {2}", searchName, season.IndexNumber, seasonId);
|
||||
}
|
||||
|
||||
|
||||
//// Add事件后,会自动触发season的Update事件来处理,不需要主动处理
|
||||
// if (seasonId > 0)
|
||||
// {
|
||||
// await ProcessSeasonEpisodes(season, eventType).ConfigureAwait(false);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// 保存元数据
|
||||
await ProcessQueuedUpdateMeta(queueUpdateMeta).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (eventType == EventType.Update)
|
||||
{
|
||||
foreach (var season in seasons)
|
||||
{
|
||||
var providerVal = season.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
// 支持视频分片BV号
|
||||
if (providerVal.StartsWith("BV", StringComparison.CurrentCulture))
|
||||
{
|
||||
await ProcessSplitVideo(season, eventType).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// season存在epid,尝试搜索刷新episode的
|
||||
var seasonId = providerVal.ToLong();
|
||||
if (seasonId > 0)
|
||||
{
|
||||
await ProcessSeasonEpisodes(season, eventType).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (FrequentlyRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "api接口触发风控,中止执行,请稍候再试.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued show events");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes queued episode events.
|
||||
/// </summary>
|
||||
/// <param name="events">The <see cref="LibraryEvent"/> enumerable.</param>
|
||||
/// <param name="eventType">The <see cref="EventType"/>.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task ProcessQueuedEpisodeEvents(IReadOnlyCollection<LibraryEvent> events, EventType eventType)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} episodes with event type {EventType}", events.Count, eventType);
|
||||
|
||||
var episodes = events.Select(lev => (Episode)lev.Item)
|
||||
.Where(lev => !string.IsNullOrEmpty(lev.Name))
|
||||
.ToHashSet();
|
||||
|
||||
try
|
||||
{
|
||||
// 判断epid,有的话刷新弹幕文件
|
||||
if (eventType == EventType.Update)
|
||||
{
|
||||
foreach (var item in episodes)
|
||||
{
|
||||
var providerVal = item.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
var epId = providerVal.ToLong();
|
||||
|
||||
// 新影片,判断是否设置epId,没的话,尝试搜索填充
|
||||
if (epId <= 0)
|
||||
{
|
||||
// 用户删除了epid,存在旧弹幕的话,尝试删除
|
||||
// this.DeleteOldDanmu(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 下载弹幕xml文件
|
||||
await this.DownloadDanmu(item, epId).ConfigureAwait(false);
|
||||
|
||||
// 延迟200毫秒,避免请求太频繁
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 删除弹幕文件(jellyfin自己会删除)
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled processing queued episode events");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 分片视频处理
|
||||
private async Task ProcessSplitVideo(Season season, EventType eventType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bvid = season.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(bvid) || !bvid.StartsWith("BV", StringComparison.CurrentCulture))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// season手动设置了bv号情况
|
||||
// 判断剧集数目是否一致,根据集号下载对应的弹幕
|
||||
if (eventType == EventType.Update || eventType == EventType.Add)
|
||||
{
|
||||
var episodes = season.GetEpisodes(null, new DtoOptions(false));
|
||||
var video = await this._api.GetVideoByBvidAsync(bvid, CancellationToken.None).ConfigureAwait(false);
|
||||
if (video == null)
|
||||
{
|
||||
_logger.LogInformation("获取不到b站视频信息:bvid={0}", bvid);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (episode, idx) in episodes.WithIndex())
|
||||
{
|
||||
// 分片的集数不规范,采用大于jellyfin集数方式判断.
|
||||
if (video.Pages.Length >= episodes.Count)
|
||||
{
|
||||
var cid = video.Pages[idx].Cid;
|
||||
_logger.LogInformation("视频分片成功匹配. {0} -> index: {1}", episode.Name, idx);
|
||||
|
||||
// 下载弹幕xml文件
|
||||
await this.DownloadDanmuByCid(episode, cid).ConfigureAwait(false);
|
||||
|
||||
// 延迟200毫秒,避免请求太频繁
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("刷新弹幕失败, 和b站集数不一致。video: {0} 弹幕数:{1} 集数:{2}", episode.Name, video.Pages.Length, episodes.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled ProcessSplitVideo");
|
||||
}
|
||||
}
|
||||
|
||||
// 每季剧集处理
|
||||
private async Task ProcessSeasonEpisodes(Season season, EventType eventType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerVal = season.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
var seasonId = providerVal.ToLong();
|
||||
if (seasonId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var queueUpdateMeta = new List<BaseItem>();
|
||||
var episodes = season.GetEpisodes(null, new DtoOptions(false));
|
||||
foreach (var (episode, idx) in episodes.WithIndex())
|
||||
{
|
||||
var episodeProviderVal = episode.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
var epId = episodeProviderVal.ToLong();
|
||||
if (epId > 0)
|
||||
{
|
||||
QueueItem(episode, EventType.Update);
|
||||
}
|
||||
else
|
||||
{
|
||||
// seasonId存在,但episode没有epid时,重新匹配获取
|
||||
var seasonData = await _api.GetSeasonAsync(seasonId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (seasonData == null)
|
||||
{
|
||||
_logger.LogInformation("获取不到b站视频信息:seasonId={0}", seasonId);
|
||||
return;
|
||||
}
|
||||
|
||||
var indexNumber = episode.IndexNumber ?? 0;
|
||||
if (indexNumber <= 0)
|
||||
{
|
||||
// TODO: 通过Anitomy检测名称中的集号
|
||||
_logger.LogInformation("匹配失败,缺少集号. [{0}]{1}", season.Name, episode.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indexNumber > seasonData.Episodes.Length)
|
||||
{
|
||||
_logger.LogInformation("匹配失败,集号超过b站集数,可能集号错误. [{0}]{1} indexNumber: {2}", season.Name, episode.Name, indexNumber);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seasonData.Episodes.Length == episodes.Count)
|
||||
{
|
||||
epId = seasonData.Episodes[idx].Id;
|
||||
_logger.LogInformation("成功匹配. [{0}]{1} -> episode id: {2}", season.Name, episode.Name, epId);
|
||||
|
||||
// 推送更新epid元数据,(更新元数据后,会触发episode的Update事件从而下载xml)
|
||||
episode.SetProviderId(Plugin.ProviderId, $"{epId}");
|
||||
queueUpdateMeta.Add(episode);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("刷新弹幕失败, 和b站集数不一致。video: {0} 弹幕数:{1} 集数:{2}", season.Name, seasonData.Episodes.Length, episodes.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ProcessQueuedUpdateMeta(queueUpdateMeta).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled ProcessSplitVideo");
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteOldDanmu(BaseItem item)
|
||||
{
|
||||
// 存在旧弹幕xml文件
|
||||
var oldDanmuPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".xml");
|
||||
var fileMeta = _fileSystem.GetFileInfo(oldDanmuPath);
|
||||
if (fileMeta.Exists)
|
||||
{
|
||||
_fileSystem.DeleteFile(oldDanmuPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据名称搜索对应的seasonId
|
||||
private async Task<long> GetMatchBiliSeasonId(BaseItem item, string searchName)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 读取最新数据,要不然取不到年份信息
|
||||
var currentItem = _libraryManager.GetItemById(item.Id);
|
||||
|
||||
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;
|
||||
|
||||
// 检测标题是否相似(越大越相似)
|
||||
var score = searchName.Distance(title);
|
||||
if (score < 0.7)
|
||||
{
|
||||
_logger.LogInformation("[{0}] 标题差异太大,忽略处理. 搜索词:{1}, score: {2}", title, searchName, score);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检测年份是否一致
|
||||
var itemPubYear = currentItem.ProductionYear ?? 0;
|
||||
if (itemPubYear > 0 && pubYear > 0 && itemPubYear != pubYear)
|
||||
{
|
||||
_logger.LogInformation("[{0}] 发行年份不一致,忽略处理. b站:{1} jellyfin: {2}", title, pubYear, itemPubYear);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("匹配成功. [{0}] seasonId: {1} score: {2}", title, seasonId, score);
|
||||
return seasonId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
if (ex.StatusCode == System.Net.HttpStatusCode.PreconditionFailed)
|
||||
{
|
||||
throw new FrequentlyRequestException(ex);
|
||||
}
|
||||
_logger.LogError(ex, "Exception handled GetMatchSeasonId. {0}", searchName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled GetMatchSeasonId. {0}", searchName);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 调用UpdateToRepositoryAsync后,但未完成时,会导致GetEpisodes返回缺少正在处理的集数,所以采用统一最后处理
|
||||
private async Task ProcessQueuedUpdateMeta(List<BaseItem> queue)
|
||||
{
|
||||
if (queue.Count <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var queueItem in queue)
|
||||
{
|
||||
// 获取最新的item数据
|
||||
var item = _libraryManager.GetItemById(queueItem.Id);
|
||||
// 合并新添加的provider id
|
||||
foreach (var pair in queueItem.ProviderIds)
|
||||
{
|
||||
item.ProviderIds[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
// Console.WriteLine(JsonSerializer.Serialize(item));
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
_logger.LogInformation("更新b站epid到元数据完成。item数:{0}", queue.Count);
|
||||
}
|
||||
|
||||
private async Task DownloadDanmu(BaseItem item, long epId)
|
||||
{
|
||||
// 下载弹幕xml文件
|
||||
try
|
||||
{
|
||||
// 弹幕一分钟内更新过,忽略处理(有时Update事件会重复执行)
|
||||
if (IsRepeatAction(item))
|
||||
{
|
||||
_logger.LogInformation("最近1分钟已更新过弹幕xml,忽略处理:{0}", item.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = await this._api.GetDanmuContentAsync(epId, CancellationToken.None).ConfigureAwait(false);
|
||||
await this.DownloadDanmuInternal(item, bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled download danmu file");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadDanmu(BaseItem item, string bvid)
|
||||
{
|
||||
// 下载弹幕xml文件
|
||||
try
|
||||
{
|
||||
// 弹幕一分钟内更新过,忽略处理(有时Update事件会重复执行)
|
||||
if (IsRepeatAction(item))
|
||||
{
|
||||
_logger.LogInformation("最近1分钟已更新过弹幕xml,忽略处理:{0}", item.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = await this._api.GetDanmuContentAsync(bvid, CancellationToken.None).ConfigureAwait(false);
|
||||
await this.DownloadDanmuInternal(item, bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled download danmu file");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadDanmuByCid(BaseItem item, long cid)
|
||||
{
|
||||
// 下载弹幕xml文件
|
||||
try
|
||||
{
|
||||
// 弹幕一分钟内更新过,忽略处理(有时Update事件会重复执行)
|
||||
if (IsRepeatAction(item))
|
||||
{
|
||||
_logger.LogInformation("最近1分钟已更新过弹幕xml,忽略处理:{0}", item.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = await this._api.GetDanmuContentByCidAsync(cid, CancellationToken.None).ConfigureAwait(false);
|
||||
await this.DownloadDanmuInternal(item, bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled download danmu file");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRepeatAction(BaseItem item)
|
||||
{
|
||||
var danmuPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".xml");
|
||||
if (!File.Exists(danmuPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lastWriteTime = File.GetLastWriteTime(danmuPath);
|
||||
var diff = DateTime.Now - lastWriteTime;
|
||||
return diff.TotalSeconds < 60;
|
||||
}
|
||||
|
||||
private async Task DownloadDanmuInternal(BaseItem item, byte[] bytes)
|
||||
{
|
||||
// 下载弹幕xml文件
|
||||
try
|
||||
{
|
||||
var danmuPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".xml");
|
||||
await File.WriteAllBytesAsync(danmuPath, bytes, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
var config = Plugin.Instance.Configuration;
|
||||
if (config.ToAss && bytes.Length > 0)
|
||||
{
|
||||
var assConfig = new Danmaku2Ass.Config();
|
||||
assConfig.Title = item.Name;
|
||||
if (!string.IsNullOrEmpty(config.AssFont.Trim()))
|
||||
{
|
||||
assConfig.FontName = config.AssFont;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(config.AssFontSize.Trim()))
|
||||
{
|
||||
assConfig.BaseFontSize = config.AssFontSize.Trim().ToInt();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(config.AssTextOpacity.Trim()))
|
||||
{
|
||||
assConfig.TextOpacity = config.AssTextOpacity.Trim().ToFloat();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(config.AssLineCount.Trim()))
|
||||
{
|
||||
assConfig.LineCount = config.AssLineCount.Trim().ToInt();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(config.AssSpeed.Trim()))
|
||||
{
|
||||
assConfig.TuneDuration = config.AssSpeed.Trim().ToInt() - 8;
|
||||
}
|
||||
|
||||
var assPath = Path.Combine(item.ContainingFolderPath, item.FileNameWithoutExtension + ".danmu.ass");
|
||||
Bilibili.GetInstance().Create(Encoding.UTF8.GetString(bytes), assConfig, assPath);
|
||||
}
|
||||
|
||||
this._logger.LogInformation("弹幕下载成功:name={0}", item.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Exception handled download danmu file");
|
||||
}
|
||||
}
|
||||
|
||||
private string NormalizeSearchName(string name)
|
||||
{
|
||||
// 去掉可能存在的季名称
|
||||
return Regex.Replace(name, @"\s*第.季", "");
|
||||
}
|
||||
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_queueTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Providers
|
||||
{
|
||||
public class MovieProvider// : IRemoteMetadataProvider<Movie, MovieInfo>
|
||||
{
|
||||
private ILogger<MovieProvider> _logger;
|
||||
private BilibiliApi _api;
|
||||
|
||||
public MovieProvider(ILoggerFactory loggerFactory, BilibiliApi api)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<MovieProvider>();
|
||||
_api = api;
|
||||
}
|
||||
|
||||
public string Name => Plugin.ProviderName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new MetadataResult<Movie>();
|
||||
// 检查b站元数据是否为空,是的话,搜索查找匹配的epid
|
||||
Console.WriteLine("###################");
|
||||
if (string.IsNullOrEmpty(info.Name))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var searchResult = await _api.SearchAsync(info.Name, CancellationToken.None).ConfigureAwait(false);
|
||||
if (searchResult.Result != null)
|
||||
{
|
||||
foreach (var media in searchResult.Result)
|
||||
{
|
||||
if ((media.ResultType == "media_ft" || media.ResultType == "media_bangumi") && media.Data.Length > 0)
|
||||
{
|
||||
var seasonId = media.Data[0].SeasonId;
|
||||
var season = await _api.GetSeasonAsync(seasonId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (season != null && season.Episodes.Length > 0)
|
||||
{
|
||||
var epId = season.Episodes[0].Id;
|
||||
Console.WriteLine($"###################epId={epId}");
|
||||
|
||||
// 更新epid元数据
|
||||
result.Item = new Movie
|
||||
{
|
||||
ProviderIds = new Dictionary<string, string> { { Plugin.ProviderId, $"{epId}" } }
|
||||
};
|
||||
result.HasMetadata = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception handled GetMatchSeasonId");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = new List<RemoteSearchResult>();
|
||||
|
||||
if (string.IsNullOrEmpty(searchInfo.Name))
|
||||
{
|
||||
return list;
|
||||
}
|
||||
|
||||
//try
|
||||
//{
|
||||
// var searchResult = await _api.SearchAsync(searchInfo.Name, CancellationToken.None).ConfigureAwait(false);
|
||||
// if (searchResult.Result != null)
|
||||
// {
|
||||
// foreach (var media in searchResult.Result)
|
||||
// {
|
||||
// if ((media.ResultType == "media_ft" || media.ResultType == "media_bangumi") && media.Data.Length > 0)
|
||||
// {
|
||||
// foreach (var item in media.Data)
|
||||
// {
|
||||
// list.Add(new RemoteSearchResult
|
||||
// {
|
||||
// ProviderIds = new Dictionary<string, string> { { Plugin.ProviderId, $"{item.SeasonId}" } },
|
||||
// ImageUrl = item.Cover,
|
||||
// ProductionYear = Utils.UnixTimeStampToDateTime(item.PublishTime).Year,
|
||||
// Name = item.Title
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//catch (Exception ex)
|
||||
//{
|
||||
// _logger.LogError(ex, "Exception handled GetMatchSeasonId");
|
||||
//}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using Jellyfin.Plugin.Danmu.Model;
|
||||
using Jellyfin.Plugin.Danmu.Providers;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
@@ -18,13 +17,16 @@ using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Jellyfin.Plugin.Danmu.Core.Extensions;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
{
|
||||
public class RefreshDanmuTask : IScheduledTask
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly BilibiliApi _api;
|
||||
private readonly ScraperFactory _scraperFactory;
|
||||
private readonly ILogger _logger;
|
||||
private readonly LibraryManagerEventsHelper _libraryManagerEventsHelper;
|
||||
|
||||
@@ -33,7 +35,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
|
||||
public string Name => "更新弹幕文件";
|
||||
|
||||
public string Description => $"根据视频b站元数据下载最新的弹幕文件。";
|
||||
public string Description => $"根据视频元数据下载最新的弹幕文件。";
|
||||
|
||||
public string Category => Plugin.Instance.Name;
|
||||
|
||||
@@ -43,11 +45,11 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
|
||||
/// <param name="api">Instance of the <see cref="BilibiliApi"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public RefreshDanmuTask(ILoggerFactory loggerFactory, BilibiliApi api, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper)
|
||||
public RefreshDanmuTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperFactory scraperFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<RefreshDanmuTask>();
|
||||
_libraryManager = libraryManager;
|
||||
_api = api;
|
||||
_scraperFactory = scraperFactory;
|
||||
_libraryManagerEventsHelper = libraryManagerEventsHelper;
|
||||
}
|
||||
|
||||
@@ -67,10 +69,11 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
|
||||
progress?.Report(0);
|
||||
|
||||
var scrapers = this._scraperFactory.All();
|
||||
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
// MediaTypes = new[] { MediaType.Video },
|
||||
HasAnyProviderId = new Dictionary<string, string> { { Plugin.ProviderId, string.Empty } },
|
||||
HasAnyProviderId = GetScraperFilter(scrapers),
|
||||
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Season }
|
||||
}).ToList();
|
||||
|
||||
@@ -85,9 +88,8 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
|
||||
try
|
||||
{
|
||||
// 没epid的不处理
|
||||
var providerVal = item.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(providerVal))
|
||||
// 没epid元数据的不处理
|
||||
if (!this.HasAnyScraperProviderId(scrapers, item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -115,5 +117,30 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
progress?.Report(100);
|
||||
_logger.LogInformation("Exectue task completed. success: {0} fail: {1}", successCount, failCount);
|
||||
}
|
||||
|
||||
private bool HasAnyScraperProviderId(ReadOnlyCollection<AbstractScraper> scrapers, BaseItem item)
|
||||
{
|
||||
foreach (var scraper in scrapers)
|
||||
{
|
||||
var providerVal = item.GetProviderId(scraper.ProviderId);
|
||||
if (!string.IsNullOrEmpty(providerVal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private Dictionary<string, string> GetScraperFilter(ReadOnlyCollection<AbstractScraper> scrapers)
|
||||
{
|
||||
var filter = new Dictionary<string, string>();
|
||||
foreach (var scraper in scrapers)
|
||||
{
|
||||
filter.Add(scraper.ProviderId, string.Empty);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
@@ -8,8 +9,9 @@ using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using Jellyfin.Plugin.Danmu.Core.Extensions;
|
||||
using Jellyfin.Plugin.Danmu.Model;
|
||||
using Jellyfin.Plugin.Danmu.Providers;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
@@ -23,7 +25,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
public class ScanLibraryTask : IScheduledTask
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly BilibiliApi _api;
|
||||
private readonly ScraperFactory _scraperFactory;
|
||||
private readonly ILogger _logger;
|
||||
private readonly LibraryManagerEventsHelper _libraryManagerEventsHelper;
|
||||
|
||||
@@ -32,7 +34,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
|
||||
public string Name => "扫描媒体库匹配弹幕";
|
||||
|
||||
public string Description => $"扫描缺少弹幕的视频,匹配b站元数据后,下载对应弹幕文件。";
|
||||
public string Description => $"扫描缺少弹幕的视频,搜索匹配后,再下载对应弹幕文件。";
|
||||
|
||||
public string Category => Plugin.Instance.Name;
|
||||
|
||||
@@ -42,11 +44,11 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
|
||||
/// <param name="api">Instance of the <see cref="BilibiliApi"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public ScanLibraryTask(ILoggerFactory loggerFactory, BilibiliApi api, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper)
|
||||
public ScanLibraryTask(ILoggerFactory loggerFactory, ILibraryManager libraryManager, LibraryManagerEventsHelper libraryManagerEventsHelper, ScraperFactory scraperFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<RefreshDanmuTask>();
|
||||
_logger = loggerFactory.CreateLogger<ScanLibraryTask>();
|
||||
_libraryManager = libraryManager;
|
||||
_api = api;
|
||||
_scraperFactory = scraperFactory;
|
||||
_libraryManagerEventsHelper = libraryManagerEventsHelper;
|
||||
}
|
||||
|
||||
@@ -61,16 +63,19 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
|
||||
progress?.Report(0);
|
||||
|
||||
var scrapers = this._scraperFactory.All();
|
||||
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
// MediaTypes = new[] { MediaType.Video },
|
||||
ExcludeProviderIds = new Dictionary<string, string> { { Plugin.ProviderId, string.Empty } },
|
||||
ExcludeProviderIds = this.GetScraperFilter(scrapers),
|
||||
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Season }
|
||||
}).ToList();
|
||||
|
||||
_logger.LogInformation("Scan danmu for {0} videos.", items.Count);
|
||||
|
||||
|
||||
var successCount = 0;
|
||||
var failCount = 0;
|
||||
foreach (var (item, idx) in items.WithIndex())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -79,8 +84,7 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
try
|
||||
{
|
||||
// 有epid的忽略处理(不需要再匹配)
|
||||
var providerVal = item.GetProviderId(Plugin.ProviderId) ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(providerVal))
|
||||
if (this.HasAnyScraperProviderId(scrapers, item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -105,17 +109,43 @@ namespace Jellyfin.Plugin.Danmu.ScheduledTasks
|
||||
// await _libraryManagerEventsHelper.ProcessQueuedEpisodeEvents(new List<LibraryEvent>() { new LibraryEvent { Item = item, EventType = EventType.Add } }, EventType.Add).ConfigureAwait(false);
|
||||
// break;
|
||||
}
|
||||
successCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Scan danmu failed for video {0}: {1}", item.Name, ex.Message);
|
||||
failCount++;
|
||||
}
|
||||
|
||||
// 延迟200毫秒,避免搜索请求太频繁
|
||||
Thread.Sleep(200);
|
||||
}
|
||||
|
||||
progress?.Report(100);
|
||||
_logger.LogInformation("Exectue task completed. success: {0} fail: {1}", successCount, failCount);
|
||||
}
|
||||
|
||||
private bool HasAnyScraperProviderId(ReadOnlyCollection<AbstractScraper> scrapers, BaseItem item)
|
||||
{
|
||||
foreach (var scraper in scrapers)
|
||||
{
|
||||
var providerVal = item.GetProviderId(scraper.ProviderId);
|
||||
if (!string.IsNullOrEmpty(providerVal))
|
||||
{
|
||||
_logger.LogInformation(scraper.Name + " -> " + providerVal);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private Dictionary<string, string> GetScraperFilter(ReadOnlyCollection<AbstractScraper> scrapers)
|
||||
{
|
||||
var filter = new Dictionary<string, string>();
|
||||
foreach (var scraper in scrapers)
|
||||
{
|
||||
filter.Add(scraper.ProviderId, string.Empty);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs
Normal file
38
Jellyfin.Plugin.Danmu/Scrapers/AbstractScraper.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Entity;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers;
|
||||
|
||||
public abstract class AbstractScraper
|
||||
{
|
||||
protected ILogger log;
|
||||
|
||||
public abstract string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provider name.
|
||||
/// </summary>
|
||||
public abstract string ProviderName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provider id.
|
||||
/// </summary>
|
||||
public abstract string ProviderId { get; }
|
||||
|
||||
|
||||
public AbstractScraper(ILogger log)
|
||||
{
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
public abstract Task<string?> GetMatchMediaId(BaseItem item);
|
||||
|
||||
public abstract Task<ScraperMedia?> GetMedia(string id);
|
||||
|
||||
public abstract Task<ScraperEpisode?> GetMediaEpisode(string id);
|
||||
|
||||
|
||||
public abstract Task<ScraperDanmaku?> GetDanmuContent(string commentId);
|
||||
}
|
||||
267
Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs
Normal file
267
Jellyfin.Plugin.Danmu/Scrapers/Bilibili/Bilibili.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Entity;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml;
|
||||
using Jellyfin.Plugin.Danmu.Core.Extensions;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili;
|
||||
|
||||
public class Bilibili : AbstractScraper
|
||||
{
|
||||
public const string ScraperProviderName = "Bilibili";
|
||||
public const string ScraperProviderId = "BilibiliID";
|
||||
|
||||
private readonly BilibiliApi _api;
|
||||
|
||||
public Bilibili(ILoggerFactory logManager, BilibiliApi api)
|
||||
: base(logManager.CreateLogger<Bilibili>())
|
||||
{
|
||||
_api = api;
|
||||
}
|
||||
|
||||
public override string Name => "bilibili";
|
||||
|
||||
public override string ProviderName => ScraperProviderName;
|
||||
|
||||
public override string ProviderId => ScraperProviderId;
|
||||
|
||||
public override async Task<string?> GetMatchMediaId(BaseItem item)
|
||||
{
|
||||
var searchName = this.NormalizeSearchName(item.Name);
|
||||
var seasonId = await GetMatchBiliSeasonId(item, searchName).ConfigureAwait(false);
|
||||
if (seasonId > 0)
|
||||
{
|
||||
return $"{seasonId}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public override async Task<ScraperMedia?> GetMedia(string id)
|
||||
{
|
||||
var media = new ScraperMedia();
|
||||
if (id.StartsWith("BV", StringComparison.CurrentCulture))
|
||||
{
|
||||
var video = await _api.GetVideoByBvidAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
if (video == null)
|
||||
{
|
||||
log.LogInformation("获取不到b站视频信息:bvid={0}", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
media.Id = id;
|
||||
media.Name = video.Title;
|
||||
foreach (var (page, idx) in video.Pages.WithIndex())
|
||||
{
|
||||
media.Episodes.Add(new ScraperEpisode() { Id = "", CommentId = $"{page.Cid}" });
|
||||
}
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
var seasonId = id.ToLong();
|
||||
if (seasonId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var season = await _api.GetSeasonAsync(seasonId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (season == null)
|
||||
{
|
||||
log.LogInformation("获取不到b站视频信息:seasonId={0}", seasonId);
|
||||
return null;
|
||||
}
|
||||
|
||||
media.Id = id;
|
||||
media.Name = season.Title;
|
||||
foreach (var item in season.Episodes)
|
||||
{
|
||||
media.Episodes.Add(new ScraperEpisode() { Id = $"{item.Id}", CommentId = $"{item.CId}" });
|
||||
}
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
public override async Task<ScraperEpisode?> GetMediaEpisode(string id)
|
||||
{
|
||||
var episode = new ScraperEpisode();
|
||||
if (id.StartsWith("BV", StringComparison.CurrentCulture))
|
||||
{
|
||||
var video = await _api.GetVideoByBvidAsync(id, CancellationToken.None).ConfigureAwait(false);
|
||||
if (video == null)
|
||||
{
|
||||
log.LogInformation("获取不到b站视频信息:bvid={0}", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (video.Pages.Length > 0)
|
||||
{
|
||||
return new ScraperEpisode() { Id = "", CommentId = $"{video.Pages[0].Cid}" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var epId = id.ToLong();
|
||||
if (epId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var season = await _api.GetEpisodeAsync(epId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (season == null)
|
||||
{
|
||||
log.LogInformation("获取不到b站视频信息:EpisodeId={0}", epId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (season.Episodes.Length > 0)
|
||||
{
|
||||
return new ScraperEpisode() { Id = $"{season.Episodes[0].Id}", CommentId = $"{season.Episodes[0].CId}" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override async Task<ScraperDanmaku?> GetDanmuContent(string commentId)
|
||||
{
|
||||
var cid = commentId.ToLong();
|
||||
if (cid > 0)
|
||||
{
|
||||
var bytes = await _api.GetDanmuContentByCidAsync(cid, CancellationToken.None).ConfigureAwait(false);
|
||||
var danmaku = ParseXml(System.Text.Encoding.UTF8.GetString(bytes));
|
||||
danmaku.ChatId = cid;
|
||||
return danmaku;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private ScraperDanmaku ParseXml(string xml)
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
var calFontSizeDict = new Dictionary<int, int>();
|
||||
var biliDanmakus = new ScraperDanmaku();
|
||||
var nodes = doc.GetElementsByTagName("d");
|
||||
foreach (XmlNode node in nodes)
|
||||
{
|
||||
// bilibili弹幕格式:
|
||||
// <d p="944.95400,5,25,16707842,1657598634,0,ece5c9d1,1094775706690331648,11">今天的风儿甚是喧嚣</d>
|
||||
// time, mode, size, color, create, pool, sender, id, weight(屏蔽等级)
|
||||
var p = node.Attributes["p"];
|
||||
if (p == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var danmaku = new ScraperDanmakuText();
|
||||
var arr = p.Value.Split(",");
|
||||
danmaku.Progress = (int)(Convert.ToDouble(arr[0]) * 1000);
|
||||
danmaku.Mode = Convert.ToInt32(arr[1]);
|
||||
danmaku.Fontsize = Convert.ToInt32(arr[2]);
|
||||
danmaku.Color = Convert.ToUInt32(arr[3]);
|
||||
danmaku.Ctime = Convert.ToInt64(arr[4]);
|
||||
danmaku.Pool = Convert.ToInt32(arr[5]);
|
||||
danmaku.MidHash = arr[6];
|
||||
danmaku.Id = Convert.ToInt64(arr[7]);
|
||||
danmaku.Weight = Convert.ToInt32(arr[8]);
|
||||
danmaku.Content = node.InnerText;
|
||||
|
||||
biliDanmakus.Items.Add(danmaku);
|
||||
|
||||
if (calFontSizeDict.ContainsKey(danmaku.Fontsize))
|
||||
{
|
||||
calFontSizeDict[danmaku.Fontsize]++;
|
||||
}
|
||||
else
|
||||
{
|
||||
calFontSizeDict[danmaku.Fontsize] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 按弹幕出现顺序排序
|
||||
biliDanmakus.Items.Sort((x, y) => { return x.Progress.CompareTo(y.Progress); });
|
||||
|
||||
return biliDanmakus;
|
||||
}
|
||||
|
||||
private string NormalizeSearchName(string name)
|
||||
{
|
||||
// 去掉可能存在的季名称
|
||||
return Regex.Replace(name, @"\s*第.季", "");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 根据名称搜索对应的seasonId
|
||||
private async Task<long> GetMatchBiliSeasonId(BaseItem item, string searchName)
|
||||
{
|
||||
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;
|
||||
|
||||
// 检测标题是否相似(越大越相似)
|
||||
var score = searchName.Distance(title);
|
||||
if (score < 0.7)
|
||||
{
|
||||
log.LogInformation("[{0}] 标题差异太大,忽略处理. 搜索词:{1}, score: {2}", title, searchName, score);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检测年份是否一致
|
||||
var itemPubYear = item.ProductionYear ?? 0;
|
||||
if (itemPubYear > 0 && pubYear > 0 && itemPubYear != pubYear)
|
||||
{
|
||||
log.LogInformation("[{0}] 发行年份不一致,忽略处理. b站:{1} jellyfin: {2}", title, pubYear, itemPubYear);
|
||||
continue;
|
||||
}
|
||||
|
||||
log.LogInformation("匹配成功. [{0}] seasonId: {1} score: {2}", title, seasonId, score);
|
||||
return seasonId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
if (ex.StatusCode == System.Net.HttpStatusCode.PreconditionFailed)
|
||||
{
|
||||
throw new FrequentlyRequestException(ex);
|
||||
}
|
||||
log.LogError(ex, "Exception handled GetMatchSeasonId. {0}", searchName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogError(ex, "Exception handled GetMatchSeasonId. {0}", searchName);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,16 @@ using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Providers.ExternalId
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.ExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public class EpisodeExternalId : IExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => Plugin.ProviderName;
|
||||
public string ProviderName => Bilibili.ScraperProviderName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => Plugin.ProviderId;
|
||||
public string Key => Bilibili.ScraperProviderId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ExternalIdMediaType? Type => ExternalIdMediaType.Episode;
|
||||
@@ -8,16 +8,16 @@ using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Providers.ExternalId
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.ExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public class MovieExternalId : IExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => Plugin.ProviderName;
|
||||
public string ProviderName => Bilibili.ScraperProviderName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => Plugin.ProviderId;
|
||||
public string Key => Bilibili.ScraperProviderId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ExternalIdMediaType? Type => ExternalIdMediaType.Movie;
|
||||
@@ -8,17 +8,17 @@ using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Providers.ExternalId
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.Bilibili.ExternalId
|
||||
{
|
||||
|
||||
/// <inheritdoc />
|
||||
public class SeasonExternalId : IExternalId
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => Plugin.ProviderName;
|
||||
public string ProviderName => Bilibili.ScraperProviderName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => Plugin.ProviderId;
|
||||
public string Key => Bilibili.ScraperProviderId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ExternalIdMediaType? Type => ExternalIdMediaType.Season;
|
||||
117
Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperDanmaku.cs
Normal file
117
Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperDanmaku.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.Text;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Xml.Serialization;
|
||||
using System.IO;
|
||||
using System.Xml.Schema;
|
||||
using System.Xml;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.Entity;
|
||||
|
||||
[XmlRoot("i")]
|
||||
public class ScraperDanmaku
|
||||
{
|
||||
[XmlElement("chatid")]
|
||||
public long ChatId { get; set; } = 0;
|
||||
[XmlElement("chatserver")]
|
||||
public string ChatServer { get; set; } = "chat.bilibili.com";
|
||||
|
||||
[XmlElement("mission")]
|
||||
public long Mission { get; set; } = 0;
|
||||
|
||||
[XmlElement("maxlimit")]
|
||||
public long MaxLimit { get; set; } = 3000;
|
||||
[XmlElement("state")]
|
||||
public int State { get; set; } = 0;
|
||||
[XmlElement("real_name")]
|
||||
public int RealName { get; set; } = 0;
|
||||
[XmlElement("source")]
|
||||
public string Source { get; set; } = "k-v";
|
||||
|
||||
[XmlElement("d")]
|
||||
public List<ScraperDanmakuText> Items { get; set; } = new List<ScraperDanmakuText>();
|
||||
|
||||
public byte[] ToXml()
|
||||
{
|
||||
var enc = new UTF8Encoding(); // Remove utf-8 BOM
|
||||
|
||||
using (MemoryStream ms = new MemoryStream())
|
||||
{
|
||||
var xmlWriterSettings = new System.Xml.XmlWriterSettings()
|
||||
{
|
||||
// If set to true XmlWriter would close MemoryStream automatically and using would then do double dispose
|
||||
// Code analysis does not understand that. That's why there is a suppress message.
|
||||
CloseOutput = false,
|
||||
Encoding = enc,
|
||||
OmitXmlDeclaration = false,
|
||||
Indent = false
|
||||
};
|
||||
using (var xw = System.Xml.XmlWriter.Create(ms, xmlWriterSettings))
|
||||
{
|
||||
var xmlSerializer = new XmlSerializer(typeof(ScraperDanmaku));
|
||||
var ns = new XmlSerializerNamespaces();
|
||||
ns.Add("", "");
|
||||
xmlSerializer.Serialize(xw, this, ns);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
// var serializer = new XmlSerializer(typeof(ScraperDanmaku));
|
||||
|
||||
// //将对象序列化输出到控制台
|
||||
// serializer.Serialize(Console.Out, cc);
|
||||
|
||||
// var sb = new StringBuilder();
|
||||
// sb.Append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||
// sb.Append("<i>");
|
||||
// sb.AppendFormat("<chatserver>chat.bilibili.com</chatserver><chatid>{0}</chatid><mission>0</mission><maxlimit>3000</maxlimit><state>0</state><real_name>0</real_name><source>k-v</source>", id);
|
||||
// foreach (var item in Items)
|
||||
// {
|
||||
// // bilibili弹幕格式:
|
||||
// // <d p="944.95400,5,25,16707842,1657598634,0,ece5c9d1,1094775706690331648,11">今天的风儿甚是喧嚣</d>
|
||||
// // time, mode, size, color, create, pool, sender, id, weight(屏蔽等级)
|
||||
// var time = (Convert.ToDouble(item.Progress) / 1000).ToString("F05");
|
||||
// sb.AppendFormat("<d p=\"{0},{1},{2},{3},{4},{5},{6},{7},{8}\">{9}</d>", time, item.Mode, item.Fontsize, item.Color, item.Ctime, item.Pool, item.MidHash, item.Id, item.Weight, item.Content);
|
||||
// }
|
||||
// sb.Append("</i>");
|
||||
|
||||
// return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public class ScraperDanmakuText : IXmlSerializable
|
||||
{
|
||||
public long Id { get; set; } //弹幕dmID
|
||||
public int Progress { get; set; } //出现时间(单位ms)
|
||||
public int Mode { get; set; } //弹幕类型 1 2 3:普通弹幕 4:底部弹幕 5:顶部弹幕 6:逆向弹幕 7:高级弹幕 8:代码弹幕 9:BAS弹幕(pool必须为2)
|
||||
public int Fontsize { get; set; } //文字大小
|
||||
public uint Color { get; set; } //弹幕颜色
|
||||
public string MidHash { get; set; } //发送者UID的HASH
|
||||
public string Content { get; set; } //弹幕内容
|
||||
public long Ctime { get; set; } //发送时间
|
||||
public int Weight { get; set; } //权重
|
||||
//public string Action { get; set; } //动作?
|
||||
public int Pool { get; set; } //弹幕池
|
||||
|
||||
public XmlSchema? GetSchema()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void ReadXml(XmlReader reader)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public void WriteXml(XmlWriter writer)
|
||||
{
|
||||
// bilibili弹幕格式:
|
||||
// <d p="944.95400,5,25,16707842,1657598634,0,ece5c9d1,1094775706690331648,11">今天的风儿甚是喧嚣</d>
|
||||
// time, mode, size, color, create, pool, sender, id, weight(屏蔽等级)
|
||||
var time = (Convert.ToDouble(Progress) / 1000).ToString("F05");
|
||||
var attr = string.Format("{0},{1},{2},{3},{4},{5},{6},{7},{8}", time, Mode, Fontsize, Color, Ctime, Pool, MidHash, Id, Weight);
|
||||
writer.WriteAttributeString("p", attr);
|
||||
writer.WriteString(Content);
|
||||
}
|
||||
}
|
||||
19
Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperMedia.cs
Normal file
19
Jellyfin.Plugin.Danmu/Scrapers/Entity/ScraperMedia.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers.Entity;
|
||||
|
||||
public class ScraperMedia
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int? Year { get; set; }
|
||||
|
||||
public List<ScraperEpisode> Episodes { get; set; } = new List<ScraperEpisode>();
|
||||
|
||||
}
|
||||
|
||||
public class ScraperEpisode
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string CommentId { get; set; }
|
||||
}
|
||||
23
Jellyfin.Plugin.Danmu/Scrapers/ScraperFactory.cs
Normal file
23
Jellyfin.Plugin.Danmu/Scrapers/ScraperFactory.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Scrapers;
|
||||
|
||||
public class ScraperFactory
|
||||
{
|
||||
private List<AbstractScraper> scrapers { get; }
|
||||
|
||||
public ScraperFactory(ILoggerFactory logManager, BilibiliApi api)
|
||||
{
|
||||
scrapers = new List<AbstractScraper>() {
|
||||
new Jellyfin.Plugin.Danmu.Scrapers.Bilibili.Bilibili(logManager, api)
|
||||
};
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<AbstractScraper> All()
|
||||
{
|
||||
return new ReadOnlyCollection<AbstractScraper>(scrapers);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Api;
|
||||
using Jellyfin.Plugin.Danmu.Providers;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -12,6 +11,7 @@ using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu
|
||||
{
|
||||
@@ -21,14 +21,23 @@ namespace Jellyfin.Plugin.Danmu
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection)
|
||||
{
|
||||
serviceCollection.AddSingleton<Jellyfin.Plugin.Danmu.Core.IFileSystem>((ctx) =>
|
||||
{
|
||||
return new Jellyfin.Plugin.Danmu.Core.FileSystem();
|
||||
});
|
||||
serviceCollection.AddSingleton<BilibiliApi>((ctx) =>
|
||||
{
|
||||
return new BilibiliApi(ctx.GetRequiredService<ILoggerFactory>());
|
||||
});
|
||||
serviceCollection.AddSingleton<ScraperFactory>((ctx) =>
|
||||
{
|
||||
return new ScraperFactory(ctx.GetRequiredService<ILoggerFactory>(), ctx.GetRequiredService<BilibiliApi>());
|
||||
});
|
||||
serviceCollection.AddSingleton<LibraryManagerEventsHelper>((ctx) =>
|
||||
{
|
||||
return new LibraryManagerEventsHelper(ctx.GetRequiredService<ILibraryManager>(), ctx.GetRequiredService<ILoggerFactory>(), ctx.GetRequiredService<BilibiliApi>(), ctx.GetRequiredService<IFileSystem>());
|
||||
return new LibraryManagerEventsHelper(ctx.GetRequiredService<ILibraryManager>(), ctx.GetRequiredService<ILoggerFactory>(), ctx.GetRequiredService<Jellyfin.Plugin.Danmu.Core.IFileSystem>(), ctx.GetRequiredService<ScraperFactory>());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user