mirror of
https://github.com/cxfksword/jellyfin-plugin-danmu.git
synced 2026-02-02 17:59:58 +08:00
refactor: optimize api cache
This commit is contained in:
@@ -5,6 +5,8 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Danmu.Controllers;
|
||||
using Jellyfin.Plugin.Danmu.Controllers.Entity;
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Bilibili;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Dandan;
|
||||
@@ -12,10 +14,10 @@ using Jellyfin.Plugin.Danmu.Scrapers.Iqiyi;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Tencent;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Mgtv;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers.Youku;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using System.IO;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Test
|
||||
{
|
||||
@@ -39,26 +41,19 @@ namespace Jellyfin.Plugin.Danmu.Test
|
||||
// _scraperManager.Register(new Tencent(loggerFactory));
|
||||
// _scraperManager.Register(new Mgtv(loggerFactory));
|
||||
|
||||
// Mock 依赖
|
||||
var fileSystemMock = new Mock<IFileSystem>();
|
||||
var libraryManagerMock = new Mock<ILibraryManager>();
|
||||
var itemRepositoryMock = new Mock<MediaBrowser.Controller.Persistence.IItemRepository>();
|
||||
var fileSystemStub = new Mock<Jellyfin.Plugin.Danmu.Core.IFileSystem>();
|
||||
|
||||
var libraryManagerEventsHelper = new LibraryManagerEventsHelper(
|
||||
itemRepositoryMock.Object,
|
||||
libraryManagerMock.Object,
|
||||
loggerFactory,
|
||||
fileSystemStub.Object,
|
||||
_scraperManager);
|
||||
// 配置插件存储路径,确保文件缓存可用
|
||||
var pluginConfigPath = Path.Combine(Path.GetTempPath(), "danmu-plugin-tests");
|
||||
Directory.CreateDirectory(pluginConfigPath);
|
||||
var applicationPathsMock = new Mock<IApplicationPaths>();
|
||||
applicationPathsMock.SetupGet(p => p.PluginConfigurationsPath).Returns(pluginConfigPath);
|
||||
|
||||
var fileCache = new FileCache<AnimeCacheItem>(applicationPathsMock.Object, loggerFactory, TimeSpan.FromDays(31), TimeSpan.FromSeconds(60));
|
||||
|
||||
// 创建 ApiController 实例
|
||||
_apiController = new ApiController(
|
||||
fileSystemMock.Object,
|
||||
loggerFactory,
|
||||
libraryManagerEventsHelper,
|
||||
libraryManagerMock.Object,
|
||||
_scraperManager);
|
||||
_scraperManager,
|
||||
fileCache);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -26,11 +27,8 @@ namespace Jellyfin.Plugin.Danmu.Controllers
|
||||
[AllowAnonymous]
|
||||
public class ApiController : ControllerBase
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly LibraryManagerEventsHelper _libraryManagerEventsHelper;
|
||||
private readonly MediaBrowser.Model.IO.IFileSystem _fileSystem;
|
||||
private readonly ScraperManager _scraperManager;
|
||||
private static TimeSpan _cacheExpiration = TimeSpan.FromDays(31);
|
||||
private static TimeSpan _cacheExpiration = TimeSpan.FromDays(1);
|
||||
private static readonly IMemoryCache _animeIdCache = new MemoryCache(new MemoryCacheOptions
|
||||
{
|
||||
// 设置缓存大小限制(可选)
|
||||
@@ -42,65 +40,26 @@ namespace Jellyfin.Plugin.Danmu.Controllers
|
||||
SizeLimit = 10000
|
||||
});
|
||||
|
||||
private readonly FileCache<AnimeCacheItem> _animeFileCache;
|
||||
|
||||
private readonly ILogger<ApiController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ApiController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="fileSystem">Instance of the <see cref="MediaBrowser.Model.IO.IFileSystem"/> interface.</param>
|
||||
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
|
||||
/// <param name="libraryManagerEventsHelper">Instance of the <see cref="LibraryManagerEventsHelper"/> class.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="scraperManager">Instance of the <see cref="ScraperManager"/> class.</param>
|
||||
/// <param name="animeFileCache">File-backed cache injected via dependency injection.</param>
|
||||
public ApiController(
|
||||
MediaBrowser.Model.IO.IFileSystem fileSystem,
|
||||
ILoggerFactory loggerFactory,
|
||||
LibraryManagerEventsHelper libraryManagerEventsHelper,
|
||||
ILibraryManager libraryManager,
|
||||
ScraperManager scraperManager)
|
||||
ScraperManager scraperManager,
|
||||
FileCache<AnimeCacheItem> animeFileCache)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
_logger = loggerFactory.CreateLogger<ApiController>();
|
||||
_libraryManager = libraryManager;
|
||||
_libraryManagerEventsHelper = libraryManagerEventsHelper;
|
||||
_scraperManager = scraperManager;
|
||||
_animeFileCache = animeFileCache ?? throw new ArgumentNullException(nameof(animeFileCache));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 scraper ID 转换为带有 HashPrefix 的 11 位 long 类型 AnimeId
|
||||
/// 格式:前 2 位是 HashPrefix,后 9 位是 ID 的哈希值
|
||||
/// 例如:HashPrefix=10, Id="abc123" => 10xxxxxxxxx (后9位是哈希值)
|
||||
/// </summary>
|
||||
/// <param name="scraper">弹幕源</param>
|
||||
/// <param name="id">原始 ID 字符串(可以是任意字母数字组合)</param>
|
||||
/// <param name="animeData">完整的 Anime 数据(可选)</param>
|
||||
/// <returns>11 位 long 类型的 AnimeId,失败时返回 0</returns>
|
||||
private long ConvertToHashId(AbstractScraper scraper, string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long animeId;
|
||||
|
||||
// 如果是纯数字且不超过 9 位,直接使用
|
||||
if (long.TryParse(id, out var numericId) && numericId <= 999999999)
|
||||
{
|
||||
animeId = ((long)scraper.HashPrefix * 1000000000) + numericId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 对于非纯数字或超长的 ID,使用哈希算法转换为 9 位数字
|
||||
// 使用 GetHashCode 并取绝对值,然后模 999999999 确保在 9 位范围内
|
||||
var hashCode = id.GetHashCode();
|
||||
var hashValue = Math.Abs(hashCode) % 1000000000; // 确保在 0-999999999 范围内
|
||||
|
||||
animeId = ((long)scraper.HashPrefix * 1000000000) + hashValue;
|
||||
}
|
||||
|
||||
return animeId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找弹幕
|
||||
@@ -282,14 +241,30 @@ namespace Jellyfin.Plugin.Danmu.Controllers
|
||||
// 从缓存中获取Anime数据
|
||||
if (!_animeIdCache.TryGetValue(animeId, out AnimeCacheItem? animeCacheItem) || animeCacheItem == null)
|
||||
{
|
||||
return new ApiResult<Anime>()
|
||||
if (this._animeFileCache.TryGetValue(animeId.ToString(CultureInfo.InvariantCulture), out animeCacheItem) && animeCacheItem != null)
|
||||
{
|
||||
ErrorCode = 404,
|
||||
Success = false,
|
||||
ErrorMessage = "Anime not found",
|
||||
};
|
||||
// 将数据重新放入内存缓存
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
Size = 1,
|
||||
AbsoluteExpirationRelativeToNow = _cacheExpiration,
|
||||
};
|
||||
_animeIdCache.Set(animeId, animeCacheItem, cacheOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new ApiResult<Anime>()
|
||||
{
|
||||
ErrorCode = 404,
|
||||
Success = false,
|
||||
ErrorMessage = "Anime not found",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 延长文件缓存时间或写入文件缓存
|
||||
this._animeFileCache.Set(animeId.ToString(CultureInfo.InvariantCulture), animeCacheItem);
|
||||
|
||||
var animeData = animeCacheItem.AnimeData;
|
||||
if (animeData == null)
|
||||
{
|
||||
@@ -396,6 +371,14 @@ namespace Jellyfin.Plugin.Danmu.Controllers
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
// 延长缓存时间
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
Size = 1,
|
||||
AbsoluteExpirationRelativeToNow = _cacheExpiration,
|
||||
};
|
||||
_episodeIdCache.Set(episodeId, commentCacheItem, cacheOptions);
|
||||
|
||||
var scraper = this._scraperManager.All().FirstOrDefault(s => s.ProviderId == commentCacheItem.ScraperProviderId);
|
||||
if (scraper == null)
|
||||
@@ -422,7 +405,6 @@ namespace Jellyfin.Plugin.Danmu.Controllers
|
||||
Cid = item.Id,
|
||||
P = $"{item.Progress / 1000.0:F2},{item.Mode},{item.Color},{item.MidHash}",
|
||||
Text = item.Content,
|
||||
Time = (uint)(item.Progress / 1000),
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
@@ -433,5 +415,42 @@ namespace Jellyfin.Plugin.Danmu.Controllers
|
||||
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 将 scraper ID 转换为带有 HashPrefix 的 11 位 long 类型 AnimeId
|
||||
/// 格式:前 2 位是 HashPrefix,后 9 位是 ID 的哈希值
|
||||
/// 例如:HashPrefix=10, Id="abc123" => 10xxxxxxxxx (后9位是哈希值)
|
||||
/// </summary>
|
||||
/// <param name="scraper">弹幕源</param>
|
||||
/// <param name="id">原始 ID 字符串(可以是任意字母数字组合)</param>
|
||||
/// <param name="animeData">完整的 Anime 数据(可选)</param>
|
||||
/// <returns>11 位 long 类型的 AnimeId,失败时返回 0</returns>
|
||||
private long ConvertToHashId(AbstractScraper scraper, string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long animeId;
|
||||
|
||||
// 如果是纯数字且不超过 9 位,直接使用
|
||||
if (long.TryParse(id, out var numericId) && numericId <= 999999999)
|
||||
{
|
||||
animeId = ((long)scraper.HashPrefix * 1000000000) + numericId;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 对于非纯数字或超长的 ID,使用哈希算法转换为 9 位数字
|
||||
// 使用 GetHashCode 并取绝对值,然后模 999999999 确保在 9 位范围内
|
||||
var hashCode = id.GetHashCode();
|
||||
var hashValue = Math.Abs(hashCode) % 1000000000; // 确保在 0-999999999 范围内
|
||||
|
||||
animeId = ((long)scraper.HashPrefix * 1000000000) + hashValue;
|
||||
}
|
||||
|
||||
return animeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
namespace Jellyfin.Plugin.Danmu.Controllers.Entity
|
||||
{
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 表示由弹幕源抓取并缓存的番剧条目。
|
||||
/// </summary>
|
||||
public class AnimeCacheItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AnimeCacheItem"/> class,用于封装弹幕源缓存的番剧条目.
|
||||
/// </summary>
|
||||
/// <param name="scraperProviderId">弹幕源提供的唯一 ProviderId。</param>
|
||||
/// <param name="id">对应弹幕源的番剧标识。</param>
|
||||
/// <param name="animeData">弹幕源返回的番剧原始数据。</param>
|
||||
[JsonConstructor]
|
||||
public AnimeCacheItem(string scraperProviderId, string id, Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.Anime? animeData)
|
||||
{
|
||||
this.ScraperProviderId = scraperProviderId;
|
||||
this.Id = id;
|
||||
this.AnimeData = animeData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets 弹幕源 ProviderId(例如 BilibiliID).
|
||||
/// </summary>
|
||||
public string ScraperProviderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets 弹幕源内部使用的番剧编号.
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets 弹幕源返回的番剧原始数据对象.
|
||||
/// </summary>
|
||||
public Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.Anime? AnimeData { get; set; }
|
||||
|
||||
public AnimeCacheItem(string providerId, string id, Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity.Anime anime)
|
||||
{
|
||||
ScraperProviderId = providerId;
|
||||
Id = id;
|
||||
AnimeData = anime;
|
||||
}
|
||||
}
|
||||
}
|
||||
268
Jellyfin.Plugin.Danmu/Core/FileCache.cs
Normal file
268
Jellyfin.Plugin.Danmu/Core/FileCache.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Simple thread-safe cache that keeps data in-memory and persists to disk with a delayed flush.
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">Type of cached values.</typeparam>
|
||||
public sealed class FileCache<TValue> : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _entries = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeSpan _defaultTtl;
|
||||
private readonly TimeSpan _saveDelay;
|
||||
private readonly JsonSerializerOptions _serializerOptions;
|
||||
private readonly object _flushLock = new();
|
||||
private readonly string _filePath;
|
||||
private Timer? _flushTimer;
|
||||
private bool _disposed;
|
||||
private readonly ILogger<FileCache<TValue>> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileCache{TValue}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Provides plugin specific storage locations.</param>
|
||||
/// <param name="defaultTtl">Default time-to-live for entries when not specified.</param>
|
||||
/// <param name="saveDelay">Delay before pending changes are flushed to disk.</param>
|
||||
public FileCache(IApplicationPaths applicationPaths, ILoggerFactory loggerFactory, TimeSpan? defaultTtl = null, TimeSpan? saveDelay = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(applicationPaths);
|
||||
_logger = loggerFactory.CreateLogger<FileCache<TValue>>();
|
||||
_defaultTtl = defaultTtl ?? TimeSpan.FromHours(12);
|
||||
_saveDelay = saveDelay ?? TimeSpan.FromSeconds(10);
|
||||
_serializerOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
_filePath = Path.Join(applicationPaths.PluginConfigurationsPath, "Jellyfin.Plugin.Danmu.Cache.dat");
|
||||
LoadFromDisk();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a cached value.
|
||||
/// </summary>
|
||||
/// <param name="key">Cache key.</param>
|
||||
/// <param name="value">Resolved value when found.</param>
|
||||
/// <returns>True when the value exists and did not expire.</returns>
|
||||
public bool TryGetValue(string key, out TValue value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
}
|
||||
|
||||
value = default!;
|
||||
if (!_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.IsExpired)
|
||||
{
|
||||
_entries.TryRemove(key, out _);
|
||||
ScheduleFlush();
|
||||
return false;
|
||||
}
|
||||
|
||||
value = entry.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a cache item.
|
||||
/// </summary>
|
||||
/// <param name="key">Cache key.</param>
|
||||
/// <param name="value">Value to cache.</param>
|
||||
/// <param name="ttl">Optional per-entry expiration override.</param>
|
||||
public void Set(string key, TValue value, TimeSpan? ttl = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
}
|
||||
|
||||
var expiration = DateTimeOffset.UtcNow.Add(ttl ?? _defaultTtl);
|
||||
_entries[key] = new CacheEntry(value, expiration);
|
||||
ScheduleFlush();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a cached entry.
|
||||
/// </summary>
|
||||
/// <param name="key">Cache key.</param>
|
||||
/// <returns>True when an entry was removed.</returns>
|
||||
public bool Remove(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
}
|
||||
|
||||
var removed = _entries.TryRemove(key, out _);
|
||||
if (removed)
|
||||
{
|
||||
ScheduleFlush();
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached entries.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_entries.Clear();
|
||||
ScheduleFlush();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of cached entries currently in memory.
|
||||
/// </summary>
|
||||
public int Count => _entries.Count;
|
||||
|
||||
private void LoadFromDisk()
|
||||
{
|
||||
var path = _filePath;
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
var cache = JsonSerializer.Deserialize<Dictionary<string, CacheEntry>>(stream, _serializerOptions);
|
||||
if (cache == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pair in cache)
|
||||
{
|
||||
if (pair.Value != null && !pair.Value.IsExpired)
|
||||
{
|
||||
_entries.TryAdd(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the exception
|
||||
_logger.LogError(ex, "Failed to load cache from disk");
|
||||
// Ignore corrupted cache file to keep plugin functional.
|
||||
_ = ex;
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistToDisk()
|
||||
{
|
||||
if (_disposed || string.IsNullOrEmpty(_filePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveExpiredEntries();
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var snapshot = _entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
|
||||
using var stream = new FileStream(_filePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
JsonSerializer.Serialize(stream, snapshot, _serializerOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to persist cache to disk");
|
||||
// Ignore IO issues; data remains in memory and will retry on next flush.
|
||||
_ = ex;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveExpiredEntries()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var pair in _entries)
|
||||
{
|
||||
if (pair.Value.Expiration <= now)
|
||||
{
|
||||
_entries.TryRemove(pair.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleFlush()
|
||||
{
|
||||
if (_disposed || string.IsNullOrEmpty(_filePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_flushLock)
|
||||
{
|
||||
_flushTimer ??= new Timer(OnFlushTimer, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_flushTimer.Change(_saveDelay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes pending entries to disk and releases resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
lock (_flushLock)
|
||||
{
|
||||
_flushTimer?.Dispose();
|
||||
_flushTimer = null;
|
||||
}
|
||||
|
||||
PersistToDisk();
|
||||
}
|
||||
|
||||
private void OnFlushTimer(object? state)
|
||||
{
|
||||
PersistToDisk();
|
||||
}
|
||||
|
||||
private sealed class CacheEntry
|
||||
{
|
||||
public CacheEntry(TValue value, DateTimeOffset expiration)
|
||||
{
|
||||
Value = value;
|
||||
Expiration = expiration;
|
||||
}
|
||||
|
||||
public TValue Value { get; }
|
||||
|
||||
public DateTimeOffset Expiration { get; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsExpired => DateTimeOffset.UtcNow >= Expiration;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public abstract class AbstractApi : IDisposable
|
||||
_cookieContainer = handler.CookieContainer;
|
||||
httpClient = new HttpClient(handler, true);
|
||||
httpClient.DefaultRequestHeaders.Add("user-agent", HTTP_USER_AGENT);
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(30);
|
||||
_memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public class BilibiliApi : AbstractApi
|
||||
// 搜索影视
|
||||
var result = new SearchResult();
|
||||
var url = $"https://api.bilibili.com/x/web-interface/search/type?keyword={keyword}&search_type=media_ft";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var ftResult = await response.Content.ReadFromJsonAsync<ApiResult<SearchResult>>(this._jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (ftResult != null && ftResult.Code == 0 && ftResult.Data != null)
|
||||
@@ -69,9 +69,9 @@ public class BilibiliApi : AbstractApi
|
||||
|
||||
// 搜索番剧
|
||||
url = $"https://api.bilibili.com/x/web-interface/search/type?keyword={keyword}&search_type=media_bangumi";
|
||||
response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var bangumiResult = await response.Content.ReadFromJsonAsync<ApiResult<SearchResult>>(this._jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
using var bangumiResponse = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
bangumiResponse.EnsureSuccessStatusCode();
|
||||
var bangumiResult = await bangumiResponse.Content.ReadFromJsonAsync<ApiResult<SearchResult>>(this._jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (bangumiResult != null && bangumiResult.Code == 0 && bangumiResult.Data != null && bangumiResult.Data.Result != null)
|
||||
{
|
||||
if (result.Result == null)
|
||||
@@ -106,7 +106,7 @@ public class BilibiliApi : AbstractApi
|
||||
// https://api.bilibili.com/x/v1/dm/list.so?oid={cid}
|
||||
bvid = bvid.Trim();
|
||||
var pageUrl = $"http://api.bilibili.com/x/player/pagelist?bvid={bvid}";
|
||||
var response = await this.httpClient.GetAsync(pageUrl, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(pageUrl, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult<VideoPart[]>>(this._jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null && result.Code == 0 && result.Data != null)
|
||||
@@ -152,7 +152,7 @@ public class BilibiliApi : AbstractApi
|
||||
}
|
||||
|
||||
var url = $"https://api.bilibili.com/x/v1/dm/list.so?oid={cid}";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new Exception($"Request fail. url={url} status_code={response.StatusCode}");
|
||||
@@ -189,7 +189,7 @@ public class BilibiliApi : AbstractApi
|
||||
|
||||
// var url = $"http://api.bilibili.com/pgc/view/web/season?season_id={seasonId}";
|
||||
var url = $"https://api.bilibili.com/pgc/view/web/ep/list?season_id={seasonId}"; // 接口依赖 referer 过滤正片选集
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult<VideoSeason>>(this._jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null && result.Code == 0 && result.Result != null)
|
||||
@@ -222,7 +222,7 @@ public class BilibiliApi : AbstractApi
|
||||
await this.EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var url = $"https://api.bilibili.com/pgc/view/web/ep/list?ep_id={epId}";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult<VideoSeason>>(this._jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null && result.Code == 0 && result.Result != null && result.Result.Episodes != null)
|
||||
@@ -259,7 +259,7 @@ public class BilibiliApi : AbstractApi
|
||||
await this.EnsureSessionCookie(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var url = $"https://api.bilibili.com/x/web-interface/view?bvid={bvid}";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ApiResult<Video>>(this._jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
@@ -290,7 +290,7 @@ public class BilibiliApi : AbstractApi
|
||||
}
|
||||
|
||||
var url = $"https://www.biliplus.com/video/{avid}/";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -328,7 +328,7 @@ public class BilibiliApi : AbstractApi
|
||||
while (true)
|
||||
{
|
||||
var url = $"https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid={cid}&pid={aid}&segment_index={segmentIndex}";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotModified)
|
||||
{
|
||||
// 已经到最后了
|
||||
@@ -399,7 +399,7 @@ public class BilibiliApi : AbstractApi
|
||||
return;
|
||||
}
|
||||
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ public class DandanApi : AbstractApi
|
||||
: base(loggerFactory.CreateLogger<DandanApi>())
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +91,7 @@ public class DandanApi : AbstractApi
|
||||
|
||||
keyword = HttpUtility.UrlEncode(keyword);
|
||||
var url = $"https://api.dandanplay.net/api/v2/search/anime?keyword={keyword}";
|
||||
var response = await this.Request(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.Request(url, cancellationToken).ConfigureAwait(false);
|
||||
var result = await response.Content.ReadFromJsonAsync<SearchResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null && result.Success)
|
||||
{
|
||||
@@ -131,7 +132,7 @@ public class DandanApi : AbstractApi
|
||||
}
|
||||
|
||||
var url = "https://api.dandanplay.net/api/v2/match";
|
||||
var response = await this.Request(url, HttpMethod.Post, matchRequest, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.Request(url, HttpMethod.Post, matchRequest, cancellationToken).ConfigureAwait(false);
|
||||
var result = await response.Content.ReadFromJsonAsync<MatchResponseV2>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null && result.Success && result.Matches != null)
|
||||
{
|
||||
@@ -189,7 +190,7 @@ public class DandanApi : AbstractApi
|
||||
}
|
||||
|
||||
var url = $"https://api.dandanplay.net/api/v2/bangumi/{animeId}";
|
||||
var response = await this.Request(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.Request(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AnimeResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null && result.Success && result.Bangumi != null)
|
||||
@@ -224,7 +225,7 @@ public class DandanApi : AbstractApi
|
||||
var withRelated = this.Config.WithRelatedDanmu ? "true" : "false";
|
||||
var chConvert = this.Config.ChConvert;
|
||||
var url = $"https://api.dandanplay.net/api/v2/comment/{epId}?withRelated={withRelated}&chConvert={chConvert}";
|
||||
var response = await this.Request(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.Request(url, cancellationToken).ConfigureAwait(false);
|
||||
var result = await response.Content.ReadFromJsonAsync<CommentResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (result != null)
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
|
||||
public List<Episode>? Episodes { get; set; }
|
||||
|
||||
[JsonPropertyName("rating")]
|
||||
public int Rating { get; set; } = 0;
|
||||
public float Rating { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("isFavorited")]
|
||||
public bool IsFavorited { get; set; } = false;
|
||||
|
||||
@@ -18,8 +18,5 @@ namespace Jellyfin.Plugin.Danmu.Scrapers.Dandan.Entity
|
||||
|
||||
[JsonPropertyName("m")]
|
||||
public string Text { get; set; }
|
||||
|
||||
[JsonPropertyName("t")]
|
||||
public uint Time { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public class MgtvApi : AbstractApi
|
||||
|
||||
keyword = HttpUtility.UrlEncode(keyword);
|
||||
var url = $"https://mobileso.bz.mgtv.com/msite/search/v2?q={keyword}&pc=30&pn=1&sort=-99&ty=0&du=0&pt=0&corr=1&abroad=0&_support=10000000000000000";
|
||||
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = new List<MgtvSearchItem>();
|
||||
@@ -100,7 +100,7 @@ public class MgtvApi : AbstractApi
|
||||
do
|
||||
{
|
||||
var url = $"https://pcweb.api.mgtv.com/variety/showlist?allowedRC=1&collection_id={id}&month={month}&page=1&_support=10000000";
|
||||
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<MgtvEpisodeListResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -59,7 +59,7 @@ public class TencentApi : AbstractApi
|
||||
|
||||
var postData = new TencentSearchRequest() { Query = keyword };
|
||||
var url = $"https://pbaccess.video.qq.com/trpc.videosearch.mobile_search.HttpMobileRecall/MbSearchHttp";
|
||||
var response = await httpClient.PostAsJsonAsync<TencentSearchRequest>(url, postData, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await httpClient.PostAsJsonAsync<TencentSearchRequest>(url, postData, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = new List<TencentVideo>();
|
||||
@@ -111,7 +111,7 @@ public class TencentApi : AbstractApi
|
||||
{
|
||||
var postData = new TencentEpisodeListRequest() { PageParams = new TencentPageParams() { Cid = id, PageSize = $"{pageSize}", PageContext = nextPageContext } };
|
||||
var url = "https://pbaccess.video.qq.com/trpc.universal_backend_service.page_server_rpc.PageServer/GetPageData?video_appid=3000010&vplatform=2";
|
||||
var response = await this.httpClient.PostAsJsonAsync<TencentEpisodeListRequest>(url, postData, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.PostAsJsonAsync<TencentEpisodeListRequest>(url, postData, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
nextPageContext = string.Empty;
|
||||
@@ -175,7 +175,7 @@ public class TencentApi : AbstractApi
|
||||
}
|
||||
|
||||
var url = $"https://dm.video.qq.com/barrage/base/{vid}";
|
||||
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<TencentCommentResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
@@ -243,7 +243,7 @@ public class TencentApi : AbstractApi
|
||||
private async Task<List<TencentComment>> GetSegmentDanmuAsync(string vid, string segmentName, CancellationToken cancellationToken)
|
||||
{
|
||||
var segmentUrl = $"https://dm.video.qq.com/barrage/segment/{vid}/{segmentName}";
|
||||
var segmentResponse = await httpClient.GetAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
|
||||
using var segmentResponse = await httpClient.GetAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
|
||||
segmentResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var segmentResult = await segmentResponse.Content.ReadFromJsonAsync<TencentCommentSegmentResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -64,7 +64,7 @@ public class YoukuApi : AbstractApi
|
||||
keyword = HttpUtility.UrlEncode(keyword);
|
||||
var ua = HttpUtility.UrlEncode(AbstractApi.HTTP_USER_AGENT);
|
||||
var url = $"https://search.youku.com/api/search?keyword={keyword}&userAgent={ua}&site=1&categories=0&ftype=0&ob=0&pg=1";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = new List<YoukuVideo>();
|
||||
@@ -170,7 +170,7 @@ public class YoukuApi : AbstractApi
|
||||
// 获取影片信息:https://openapi.youku.com/v2/shows/show.json?client_id=53e6cc67237fc59a&package=com.huawei.hwvplayer.youku&show_id=0b39c5b6569311e5b2ad
|
||||
// 获取影片剧集信息:https://openapi.youku.com/v2/shows/videos.json?client_id=53e6cc67237fc59a&package=com.huawei.hwvplayer.youku&ext=show&show_id=deea7e54c2594c489bfd
|
||||
var url = $"https://openapi.youku.com/v2/shows/videos.json?client_id=53e6cc67237fc59a&package=com.huawei.hwvplayer.youku&ext=show&show_id={id}&page={page}&count={pageSize}";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<YoukuVideo>(this._jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
@@ -216,7 +216,7 @@ public class YoukuApi : AbstractApi
|
||||
|
||||
// 文档:https://cloud.youku.com/docs?id=46
|
||||
var url = $"https://openapi.youku.com/v2/videos/show_basic.json?client_id=53e6cc67237fc59a&package=com.huawei.hwvplayer.youku&video_id={vid}";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<YoukuEpisode>(this._jsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
@@ -397,7 +397,7 @@ public class YoukuApi : AbstractApi
|
||||
if (cookie == null)
|
||||
{
|
||||
var url = "https://log.mmstat.com/eg.js";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// 重新读取最新
|
||||
@@ -416,7 +416,7 @@ public class YoukuApi : AbstractApi
|
||||
if (tokenCookie == null || tokenEncCookie == null)
|
||||
{
|
||||
var url = "https://acs.youku.com/h5/mtop.com.youku.aplatform.weakget/1.0/?jsv=2.5.1&appKey=24679788";
|
||||
var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// 重新读取最新
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
using System;
|
||||
using Jellyfin.Plugin.Danmu.Controllers.Entity;
|
||||
using Jellyfin.Plugin.Danmu.Core;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu
|
||||
{
|
||||
@@ -18,17 +22,19 @@ namespace Jellyfin.Plugin.Danmu
|
||||
serviceCollection.AddHostedService<PluginStartup>();
|
||||
|
||||
serviceCollection.AddSingleton<ISubtitleProvider, DanmuSubtitleProvider>();
|
||||
serviceCollection.AddSingleton<Jellyfin.Plugin.Danmu.Core.IFileSystem>((ctx) =>
|
||||
{
|
||||
return new Jellyfin.Plugin.Danmu.Core.FileSystem();
|
||||
});
|
||||
serviceCollection.AddSingleton<IFileSystem>(_ => new FileSystem());
|
||||
serviceCollection.AddSingleton((ctx) =>
|
||||
{
|
||||
return new ScraperManager(ctx.GetRequiredService<ILoggerFactory>());
|
||||
});
|
||||
serviceCollection.AddSingleton((ctx) =>
|
||||
{
|
||||
return new LibraryManagerEventsHelper(ctx.GetRequiredService<IItemRepository>(), ctx.GetRequiredService<ILibraryManager>(), ctx.GetRequiredService<ILoggerFactory>(), ctx.GetRequiredService<Jellyfin.Plugin.Danmu.Core.IFileSystem>(), ctx.GetRequiredService<ScraperManager>());
|
||||
return new LibraryManagerEventsHelper(ctx.GetRequiredService<IItemRepository>(), ctx.GetRequiredService<ILibraryManager>(), ctx.GetRequiredService<ILoggerFactory>(), ctx.GetRequiredService<IFileSystem>(), ctx.GetRequiredService<ScraperManager>());
|
||||
});
|
||||
serviceCollection.AddSingleton<FileCache<AnimeCacheItem>>((ctx) =>
|
||||
{
|
||||
var applicationPaths = ctx.GetRequiredService<IApplicationPaths>();
|
||||
return new FileCache<AnimeCacheItem>(applicationPaths, ctx.GetRequiredService<ILoggerFactory>(),TimeSpan.FromDays(31), TimeSpan.FromSeconds(60));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user