mirror of
https://github.com/cxfksword/jellyfin-plugin-danmu.git
synced 2026-02-02 17:59:58 +08:00
fix: fix cyclic update event
This commit is contained in:
148
.github/copilot-instructions.md
vendored
Normal file
148
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
# Jellyfin Plugin Danmu - AI 编码助手指南
|
||||
|
||||
## 项目概述
|
||||
一个为 Jellyfin 开发的弹幕自动下载插件,支持从多个视频平台(B站、优酷、爱奇艺、腾讯视频、芒果TV)自动下载和管理中文弹幕。支持 XML 和 ASS 字幕格式。
|
||||
|
||||
**核心技术栈**: C# .NET 9.0, Jellyfin Plugin API, ILRepack(用于依赖合并)
|
||||
|
||||
## 架构:多弹幕源设计模式
|
||||
|
||||
### 弹幕源系统 (`Jellyfin.Plugin.Danmu/Scrapers/`)
|
||||
核心架构是一个**可插拔的弹幕源系统**,每个视频平台都实现 `AbstractScraper`:
|
||||
|
||||
```csharp
|
||||
// 每个平台(Bilibili/、Youku/、Iqiyi/ 等)需要实现:
|
||||
public abstract class AbstractScraper {
|
||||
Task<List<ScraperSearchInfo>> Search(BaseItem item);
|
||||
Task<string?> SearchMediaId(BaseItem item);
|
||||
Task<ScraperMedia?> GetMedia(BaseItem item, string id);
|
||||
Task<ScraperEpisode?> GetMediaEpisode(BaseItem item, string id);
|
||||
Task<ScraperDanmaku?> GetDanmuContent(BaseItem item, string commentId);
|
||||
}
|
||||
```
|
||||
|
||||
**重要说明**:
|
||||
- 弹幕源通过 `Plugin.cs` 中的 `IApplicationHost.GetExports<AbstractScraper>()` 自动发现
|
||||
- 顺序由 `DefaultOrder` 属性和用户配置控制
|
||||
- 每个弹幕源有唯一的 `ProviderId`(如 `BilibiliID`),用于 Jellyfin 元数据存储
|
||||
- 参考 `Scrapers/Bilibili/Bilibili.cs` 的实现示例
|
||||
|
||||
### 事件驱动的弹幕处理
|
||||
插件使用 Jellyfin 的媒体库事件和**防抖队列处理**机制:
|
||||
|
||||
1. `PluginStartup.cs` 订阅 `ILibraryManager.ItemAdded/ItemUpdated` 事件
|
||||
2. 事件在 `LibraryManagerEventsHelper` 中排队,使用 10 秒防抖定时器
|
||||
3. 批量处理:匹配媒体 → 搜索弹幕源 → 下载弹幕
|
||||
4. 结果保存为 `.xml` 文件,与视频文件同目录(如 `movie.mp4` → `movie.xml`)
|
||||
|
||||
**关键类**:
|
||||
- `LibraryManagerEventsHelper.QueueItem()` - 添加项目到处理队列
|
||||
- `LibraryManagerEventsHelper.ProcessQueuedMovieEvents()` - 批量处理电影
|
||||
- `LibraryManagerEventsHelper.ProcessQueuedSeasonEvents()` - 处理电视剧季
|
||||
|
||||
## 开发工作流
|
||||
|
||||
### 构建项目
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet publish --configuration=Release Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj
|
||||
```
|
||||
|
||||
输出位置: `Jellyfin.Plugin.Danmu/bin/Release/net9.0/Jellyfin.Plugin.Danmu.dll`
|
||||
|
||||
**ILRepack 集成**: Release 构建会通过 `ILRepack.targets` 自动将依赖(RateLimiter、ComposableAsync、Google.Protobuf、SharpZipLib)合并到单个 DLL。这对 Jellyfin 插件部署至关重要。
|
||||
|
||||
### 运行测试
|
||||
```bash
|
||||
dotnet test --no-restore --verbosity normal
|
||||
```
|
||||
|
||||
测试使用 MSTest 框架和 Moq 进行模拟:
|
||||
- `*ApiTest.cs` - API 响应解析测试
|
||||
- `*Test.cs` - 端到端弹幕源测试
|
||||
- 模拟 `ILibraryManager`、`IFileSystem` 以实现隔离测试
|
||||
|
||||
### 开发环境安装
|
||||
1. 构建 Release 配置
|
||||
2. 创建 `danmu/` 文件夹并复制 `Jellyfin.Plugin.Danmu.dll` 到其中
|
||||
3. 将文件夹移动到 Jellyfin 的 `data/plugins/` 目录
|
||||
4. 重启 Jellyfin
|
||||
|
||||
## 关键模式与约定
|
||||
|
||||
### Provider ID 映射
|
||||
每个弹幕源使用其 `ProviderId` 在 Jellyfin 元数据中存储匹配结果:
|
||||
```csharp
|
||||
item.ProviderIds["BilibiliID"] = "123456"; // 季/媒体 ID
|
||||
// 对于剧集,使用格式:"seasonId_episodeId"
|
||||
```
|
||||
|
||||
**特殊处理**: B站支持 `BV` 视频 ID(用户上传内容)和 `av` ID(UGC视频),以及番剧的 season ID。
|
||||
|
||||
### 弹幕 → ASS 转换 (`Core/Danmaku2Ass/`)
|
||||
`Creater` 类将 XML 弹幕转换为 ASS 字幕,具有:
|
||||
- 碰撞检测(`Collision.cs`)- 使用基于行的跟踪防止重叠
|
||||
- 显示模式(`Display.cs`)- 滚动、顶部锚定、底部锚定
|
||||
- 可配置的字体、速度、透明度、行数
|
||||
|
||||
**重要**: ASS 生成是可选的,通过插件配置 `ToAss = true` 启用。
|
||||
|
||||
### API 端点 (`Controllers/DanmuController.cs`)
|
||||
为外部播放器提供的公共 REST API:
|
||||
- `GET /api/danmu/{id}` - 返回弹幕 URL 元数据
|
||||
- `GET /api/danmu/{id}/raw` - 下载 XML 弹幕文件
|
||||
- `GET /api/danmu/search?keyword=` - 跨所有弹幕源搜索
|
||||
- `GET /api/{site}/danmu/{id}/episodes` - 获取剧集列表
|
||||
- `GET /api/{site}/danmu/{cid}/download` - 通过评论 ID 下载弹幕
|
||||
|
||||
### 配置系统
|
||||
`PluginConfiguration.cs` 使用 XML 序列化,特殊的 `Scrapers` 属性会:
|
||||
1. 合并用户配置和新发现的弹幕源
|
||||
2. 删除已废弃的弹幕源
|
||||
3. 尊重用户定义的顺序和启用/禁用状态
|
||||
|
||||
### 异常处理
|
||||
- `CanIgnoreException` - 表示预期的失败(如未找到弹幕),不应记录错误日志
|
||||
- `FrequentlyRequestException` - 视频平台的速率限制异常
|
||||
|
||||
## 外部依赖与集成
|
||||
|
||||
### Jellyfin Plugin API
|
||||
- 实现 `ISubtitleProvider` 用于字幕搜索 UI
|
||||
- `IPluginServiceRegistrator` 用于依赖注入注册
|
||||
- `BasePlugin<PluginConfiguration>` 用于配置管理
|
||||
- `IScheduledTask` 用于定期媒体库扫描(`ScheduledTasks/ScanLibraryTask.cs`)
|
||||
|
||||
### .NET 包
|
||||
- `RateLimiter` - API 请求节流
|
||||
- `Google.Protobuf` - B站 API protobuf 响应解析
|
||||
- `SharpZipLib` - 弹幕数据解压缩
|
||||
- `ComposableAsync.Core` - 异步工具库
|
||||
|
||||
### 平台专用 API
|
||||
每个弹幕源都有一个 `*Api.cs`(如 `BilibiliApi.cs`)处理:
|
||||
- HTTP 客户端和重试逻辑
|
||||
- 响应反序列化(JSON/Protobuf)
|
||||
- 速率限制和错误处理
|
||||
|
||||
## 命名约定
|
||||
- 弹幕源:小写名称(如 `bilibili`、`youku`)用于用户界面显示
|
||||
- Provider ID:驼峰式 + `ID` 后缀(如 `BilibiliID`)
|
||||
- 事件类型:`EventType.Add`、`EventType.Update`、`EventType.Force`
|
||||
- 文件扩展名:`.xml` 用于弹幕,`.ass` 用于字幕
|
||||
|
||||
## 测试视频平台 API
|
||||
使用测试类如 `BilibiliApiTest.cs` 在集成前验证 API 响应。模拟 `ILibraryManager` 以避免单元测试中的 Jellyfin 依赖。
|
||||
|
||||
## 发布流程
|
||||
1. 打标签:`git tag -a v1.2.3 -m "Release notes"`
|
||||
2. GitHub Actions 自动构建和发布
|
||||
3. `scripts/generate_manifest.py` 更新插件清单并生成校验和
|
||||
4. 通过 `CN_DOMAIN` 环境变量支持国内镜像
|
||||
|
||||
## 常见陷阱
|
||||
- **不要在字幕搜索中直接修改 `item.ProviderIds`** - 使用临时 item 副本以避免持久化错误的元数据
|
||||
- **弹幕源必须在 `ServiceRegistrator.cs` 中注册**才能被发现
|
||||
- **季匹配需要正确的 `IndexNumber`** 用于多季系列
|
||||
- **ILRepack 仅在 Release 构建中运行** - Debug 构建有独立的 DLL
|
||||
- **Jellyfin 的 `LocationType.Virtual`** 项目(没有季文件夹的系列)需要特殊处理
|
||||
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -27,7 +28,7 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly MemoryCacheEntryOptions _pendingAddExpiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
|
||||
private readonly MemoryCacheEntryOptions _danmuUpdatedExpiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(24*60) };
|
||||
|
||||
private readonly IItemRepository _itemRepository;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<LibraryManagerEventsHelper> _logger;
|
||||
private readonly Jellyfin.Plugin.Danmu.Core.IFileSystem _fileSystem;
|
||||
@@ -50,11 +51,12 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
/// <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, ScraperManager scraperManager)
|
||||
public LibraryManagerEventsHelper(IItemRepository itemRepository, ILibraryManager libraryManager, ILoggerFactory loggerFactory, Jellyfin.Plugin.Danmu.Core.IFileSystem fileSystem, ScraperManager scraperManager)
|
||||
{
|
||||
_queuedEvents = new List<LibraryEvent>();
|
||||
_memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_itemRepository = itemRepository;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = loggerFactory.CreateLogger<LibraryManagerEventsHelper>();
|
||||
_fileSystem = fileSystem;
|
||||
@@ -869,7 +871,7 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
item.ProviderIds[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
_itemRepository.SaveItems(new[] { item }, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("更新epid到元数据完成。item数:{0}", queue.Count);
|
||||
@@ -983,7 +985,7 @@ public class LibraryManagerEventsHelper : IDisposable
|
||||
// 保存指定弹幕元数据
|
||||
item.ProviderIds[providerId] = providerVal;
|
||||
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
_itemRepository.SaveItems(new[] { item }, CancellationToken.None);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Jellyfin.Plugin.Danmu.Scrapers;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Subtitles;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
|
||||
namespace Jellyfin.Plugin.Danmu
|
||||
{
|
||||
@@ -27,7 +28,7 @@ namespace Jellyfin.Plugin.Danmu
|
||||
});
|
||||
serviceCollection.AddSingleton((ctx) =>
|
||||
{
|
||||
return new LibraryManagerEventsHelper(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<Jellyfin.Plugin.Danmu.Core.IFileSystem>(), ctx.GetRequiredService<ScraperManager>());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user