feat(local-danmu): 优化本地弹幕 (#479)
* fix(ui): 让原生 select 下拉菜单跟随明暗主题配色 - 移除 html 上强制的 `color-scheme: light !important`,改为根据 body 的 data-color-scheme 对 select 单独设置明暗 color-scheme - 为 select 的 option/optgroup 弹出菜单指定主题容器背景色与文字色,修复暗色模式下下拉弹窗仍为白底的问题 - 调整导航按钮顺序,将“本地弹幕”移至“请求记录”之前 * feat(local-danmu): 新增标题搜索过滤并默认折叠分组卡片 - 在本地弹幕列表上方新增搜索输入框,支持按标题关键词即时过滤已上传资源 - 缓存完整分组数据(localDanmuGroups),搜索时无需重新请求接口,并保留原有展开状态 - 渲染函数支持自定义空列表提示,搜索无结果显示“未找到匹配标题”,其余场景仍显示“暂无资源” - 分组卡片默认改为折叠状态,避免资源较多时列表过长影响浏览 - 更新 worker 测试以适配渲染函数新的空文案参数及搜索相关交互 * feat(ui): 本地弹幕管理支持标题搜索、重新上传与整组删除 - 管理列表新增按标题关键词搜索,便于快速定位已上传的剧集 - 每集新增“重新上传”按钮,可直接替换原有弹幕文件 - 剧集分组新增“删除整个剧集”按钮,支持一次性删除整组资源 - 新增 local-danmu-group-remove、local-danmu-episode-actions 等样式类 - 同步更新根目录与 UI 模块 README 中本地弹幕功能的使用说明 * feat(local-danmu): 新增 PATCH 接口支持编辑本地弹幕元数据 - 新增 PATCH /api/v2/local-danmu/:resourceKey 接口:scope=resource 可修改集数与显示文件名,scope=group 可修改当前季的标题、年份、类型与季数 - 抽取 buildLocalDanmuMatchKeys 函数,复用上传与编辑时的匹配键构建逻辑 - 新增 validateEditFields 校验编辑字段的标题、年份与类型合法性 - 编辑仅更新元数据,不重新解析弹幕内容;目标资源已存在时返回冲突错误,避免覆盖原文件 - 同步更新 README 中本地弹幕接口及管理列表编辑功能的说明 * feat(ui): 支持电视剧本地弹幕文件批量导入 管理页面新增电视剧弹幕批量导入功能,提升多集上传效率: - 同一部电视剧可多选文件一次导入,共用标题、年份和季 - 按文件名自动识别集数(如 S01E02、EP02、第02集、02.xml), 支持逐个修改;未识别或重复的集数需先修正 - 页面逐个调用现有单文件上传接口,单个文件失败后继续处理 其余文件,并显示各文件结果及成功、失败统计 - 电影仍逐个导入;同步更新 README 文档与批量预览样式
This commit is contained in:
1 parent
96602d6a7e
commit
0f02cdc036
13 files changed
+800
-46
No files matched your search
@@ -67,9 +67,10 @@ LogVar 弹幕 API 服务器
|
||||
- `POST /api/v2/favorite/refresh`:使用 `{ "keyword": "火影忍者" }` 强制重新搜索并更新收藏缓存。
|
||||
- `POST /api/v2/favorite/schedule`:设置或关闭收藏的定时刷新(仅 Node/Docker 部署可用)。设置使用 `{ "keyword": "火影忍者", "schedule": { "frequency": "daily", "time": "03:00" } }`;每周模式需额外传 `"weekday": 1-7`(周一至周日),例如 `{ "frequency": "weekly", "time": "03:00", "weekday": 1 }`。关闭使用 `{ "keyword": "火影忍者", "schedule": null }`。固定按北京时间(`Asia/Shanghai`)执行,serverless 平台返回 `501`。
|
||||
- `POST /api/v2/favorite/remove`:使用 `{ "keyword": "火影忍者" }` 删除收藏及对应搜索缓存。
|
||||
- `POST /api/v2/local-danmu/upload`:上传并解析本地弹幕文件(`multipart/form-data`,字段包括 `file`、`title`、`year`、`type`,电视剧可指定 `season`、`episode`)。单个文件最大 10 MB。
|
||||
- `POST /api/v2/local-danmu/upload`:上传并解析本地弹幕文件(`multipart/form-data`,字段包括 `file`、`title`、`year`、`type`,电视剧可指定 `season`、`episode`)。每次接收一个文件,最大 10 MB;管理页面的批量导入会依次调用此接口。
|
||||
- `GET /api/v2/local-danmu/list`:获取已上传的本地弹幕资源及按标题、年份、类型、季分组的列表。
|
||||
- `GET /api/v2/local-danmu/:resourceKey`:获取指定本地弹幕资源的元数据;`DELETE /api/v2/local-danmu/:resourceKey`:删除资源。
|
||||
- `PATCH /api/v2/local-danmu/:resourceKey`:编辑本地弹幕元数据;`scope=resource` 修改集数和显示文件名,`scope=group` 修改当前季的标题、年份、类型和季数。目标资源已存在时返回冲突错误,不会覆盖原文件。
|
||||
- 本地弹幕接口需要 `TOKEN` 或 `ADMIN_TOKEN`。默认仅管理员可上传和删除;设置 `LOCAL_DANMU_NOT_REQUIRE_ADMIN=true` 后,普通 `TOKEN` 也可执行上传和删除。Node/Docker 将资源保存到 `.cache/local-danmu`,云端部署使用 Redis 持久化。
|
||||
- **弹幕格式输出**:支持 JSON 和 XML 及 [@dan-uni/dan-any](https://github.com/ani-uni/dan-any)支持的全部输出格式 输出,通过以下方式配置:
|
||||
- 环境变量:`DANMU_OUTPUT_FORMAT=json|xml|artplayer.json|baha.json|bili.xml|danuni.json|danuni.binpb|ddplay.json|dplayer.json|vod.json`(默认:json)
|
||||
@@ -776,7 +777,8 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
- cloudflare貌似有单次请求数量限制,会导致后半部分没有弹幕。
|
||||
- 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。
|
||||
- 如果想使用自定义弹幕源,请添加环境变量`CUSTOM_SOURCE_API_URL`,并在`SOURCE_ORDER`环境变量中添加`custom`源。
|
||||
- 本地弹幕上传的标题、年份和类型为必填项。年份从今年向下排列至 `1900` 年,默认值和最大值均为打开页面时的当前年份;类型仅可选 `tv` 或 `movie`。`tv` 的季和集均默认 `1`,`movie` 的季和集可留空。管理列表按标题、年份、类型、季归为一个剧集,展开后缩进显示各集,可单独查看和删除;同一季同一集重新上传会替换原文件,不同季独立保存。旧资源继续兼容,未填写季数的资源沿用第 1 季处理。在 `SOURCE_ORDER` 中添加 `local` 后即可搜索已上传的剧集。
|
||||
- 本地弹幕上传的标题、年份和类型为必填项。年份从今年向下排列至 `1900` 年,默认值和最大值均为打开页面时的当前年份;类型仅可选 `tv` 或 `movie`。`tv` 的季和集均默认 `1`,`movie` 的季和集可留空。管理列表按标题、年份、类型、季归为一个剧集,支持按标题关键词搜索,展开后缩进显示各集,可编辑剧集或单集信息、单独删除文件、重新上传文件或一次删除整个剧集;编辑不会重新解析弹幕内容,同一季同一集重新上传会替换原文件,不同季独立保存。编辑后若目标资源已存在会拒绝保存。旧资源继续兼容,未填写季数的资源沿用第 1 季处理。在 `SOURCE_ORDER` 中添加 `local` 后即可搜索已上传的剧集。
|
||||
- 同一部电视剧支持多选弹幕文件批量导入,共用标题、年份和季。页面从 `S01E02`、`EP02`、`第02集`、`02.xml` 等文件名识别集数,并支持逐个修改;未识别或重复的集数需要先修正。文件逐个上传,每个文件不超过 10 MB,失败后继续处理其余文件,并显示各文件结果及成功、失败数量。电影仍逐个导入。
|
||||
- Node/Docker 部署的本地弹幕文件保存在 `.cache/local-danmu`,使用 Docker 时请挂载 `.cache` 目录以持久化;Vercel、Netlify、Cloudflare、EdgeOne、Hugging Face 等云端部署需要可用的 Redis 才能保存本地弹幕资源。
|
||||
- 如果想搜索bilibili港澳台番剧,请开启`Bangumi Data`匹配或添加环境变量`PROXY_URL`并填写`bilibili@`字段的解析/反代服务地址,示例:`bilibili@https://233.233.233`,支持部分[公共解析服务器](https://github.com/yujincheng08/BiliRoaming/wiki/%E5%85%AC%E5%85%B1%E8%A7%A3%E6%9E%90%E6%9C%8D%E5%8A%A1%E5%99%A8),另外港澳台区域搜索最好在`BILIBILI_COOKIE`环境变量中加入包含`bili_jct`或`access_key`字段的cookie使用App接口,如果没有会使用不稳定的web接口进行搜索。(如果你填写的服务器遇到了App接口报错说明不支持App接口,Web接口报错-500、502正常,风控严重,但只要一直搜索总会成功)
|
||||
- 如果想更换vod站点,请添加环境变量`VOD_SERVERS`,示例`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`(支持多个服务器并发查询)。
|
||||
|
||||
@@ -8,6 +8,14 @@ function resourceMetadata({ comments, ...meta }) {
|
||||
return { ...meta, season: normalizeLocalSeason(meta.season) };
|
||||
}
|
||||
|
||||
function buildLocalDanmuMatchKeys(resource) {
|
||||
return [...new Set([
|
||||
resource.title,
|
||||
`${resource.title}|${resource.year}|${resource.type}`,
|
||||
`${resource.title}|${resource.year}|${resource.type}|${resource.season}|${resource.episode ?? 'movie'}`
|
||||
].map(normalizeLocalKey).filter(Boolean))];
|
||||
}
|
||||
|
||||
function invalidateLocalDanmuCache(resourceKey) {
|
||||
globals.searchCache?.clear();
|
||||
globals.commentCache?.delete(`local:${resourceKey}`);
|
||||
@@ -40,7 +48,7 @@ export async function handleLocalDanmuUpload(req) {
|
||||
const parsed = parseLocalDanmu(Buffer.from(await file.arrayBuffer()), filename);
|
||||
// videoId 作为内部资源标识,不要求用户填写;未提供时自动生成 UUID。
|
||||
const videoId = String(form.get('videoId') || '').trim() || crypto.randomUUID();
|
||||
const matchKeys = [...new Set([title, `${title}|${year}|${type}`, `${title}|${year}|${type}|${season}|${episode ?? 'movie'}`].map(normalizeLocalKey).filter(Boolean))];
|
||||
const matchKeys = buildLocalDanmuMatchKeys({ title, year, type, season, episode });
|
||||
const resource = { resourceKey, videoId, title, year, type, season, episode, filename, size: file.size, format: parsed.format, status: 'ready', count: parsed.comments.length, matchKeys, comments: parsed.comments, updatedAt: new Date().toISOString() };
|
||||
await saveLocalDanmu(resource);
|
||||
invalidateLocalDanmuCache(resourceKey);
|
||||
@@ -57,4 +65,81 @@ export async function handleLocalDanmuDelete(key) {
|
||||
invalidateLocalDanmuCache(key);
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
function validateEditFields(fields, current) {
|
||||
const title = String(fields.title ?? current.title ?? '').trim();
|
||||
if (!title) throw new Error('标题为必填项');
|
||||
const yearValue = String(fields.year ?? current.year ?? '').trim();
|
||||
const year = Number(yearValue);
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (!/^[0-9]{4}$/.test(yearValue) || year < 1900 || year > currentYear) throw new Error(`年份必须在 1900–${currentYear} 年之间`);
|
||||
const type = normalizeLocalType(fields.type ?? current.type);
|
||||
if (type !== 'tv' && type !== 'movie') throw new Error('类型只能选择 tv 或 movie');
|
||||
const season = normalizeLocalSeason(fields.season ?? current.season);
|
||||
if (season === null) throw new Error('季数必须是大于 0 的整数');
|
||||
return { title, year, type, season };
|
||||
}
|
||||
|
||||
export async function handleLocalDanmuUpdate(req, key) {
|
||||
try {
|
||||
const current = await getLocalDanmu(key);
|
||||
if (!current) return jsonResponse({ success: false, errorMessage: '资源不存在' }, 404);
|
||||
const body = await req.json();
|
||||
const all = await listLocalDanmu();
|
||||
const scope = body?.scope === 'group' ? 'group' : 'resource';
|
||||
const targets = scope === 'group'
|
||||
? all.filter(resource => resource.title === current.title && Number(resource.year) === Number(current.year) && normalizeLocalType(resource.type) === normalizeLocalType(current.type) && normalizeLocalSeason(resource.season) === normalizeLocalSeason(current.season))
|
||||
: [current];
|
||||
if (!targets.length) return jsonResponse({ success: false, errorMessage: '资源不存在' }, 404);
|
||||
const common = scope === 'group' ? validateEditFields(body, current) : {
|
||||
title: current.title,
|
||||
year: current.year,
|
||||
type: normalizeLocalType(current.type),
|
||||
season: normalizeLocalSeason(current.season)
|
||||
};
|
||||
const updates = targets.map(resource => {
|
||||
const episodeValue = scope === 'group' || !Object.prototype.hasOwnProperty.call(body || {}, 'episode') ? resource.episode : body.episode;
|
||||
const episode = normalizeLocalEpisode(episodeValue);
|
||||
if (episode !== null && (!Number.isSafeInteger(episode) || episode < 1)) throw new Error('集数必须是大于 0 的整数');
|
||||
if (scope === 'resource' && common.type === 'tv' && episode === null) throw new Error('电视剧集数不能为空');
|
||||
const filename = scope === 'group' ? resource.filename : String(body?.filename ?? resource.filename ?? '').trim().slice(0, 240);
|
||||
if (!filename) throw new Error('文件名不能为空');
|
||||
const next = { ...resource, ...common, episode, filename };
|
||||
next.resourceKey = buildLocalDanmuResourceKey(next);
|
||||
next.matchKeys = buildLocalDanmuMatchKeys(next);
|
||||
next.updatedAt = new Date().toISOString();
|
||||
return next;
|
||||
});
|
||||
const oldKeys = new Set(targets.map(resource => resource.resourceKey));
|
||||
const existingKeys = new Set(all.map(resource => resource.resourceKey));
|
||||
if (updates.some(resource => existingKeys.has(resource.resourceKey) && !oldKeys.has(resource.resourceKey))) {
|
||||
return jsonResponse({ success: false, errorMessage: '目标资源已存在,无法覆盖' }, 409);
|
||||
}
|
||||
const savedKeys = [];
|
||||
try {
|
||||
for (const resource of updates) {
|
||||
await saveLocalDanmu(resource);
|
||||
savedKeys.push(resource.resourceKey);
|
||||
}
|
||||
const nextKeys = new Set(updates.map(resource => resource.resourceKey));
|
||||
for (const resource of targets) {
|
||||
if (!nextKeys.has(resource.resourceKey)) await removeLocalDanmu(resource.resourceKey);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const resource of targets) {
|
||||
try { await saveLocalDanmu(resource); } catch {}
|
||||
}
|
||||
for (const savedKey of savedKeys) {
|
||||
if (!oldKeys.has(savedKey)) {
|
||||
try { await removeLocalDanmu(savedKey); } catch {}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const resource of updates) invalidateLocalDanmuCache(resource.resourceKey);
|
||||
for (const resource of targets) invalidateLocalDanmuCache(resource.resourceKey);
|
||||
return jsonResponse({ success: true, scope, resources: updates.map(resourceMetadata), resource: resourceMetadata(updates[0]) });
|
||||
} catch (e) {
|
||||
return jsonResponse({ success: false, errorMessage: e.message || '更新失败' }, 400);
|
||||
}
|
||||
}
|
||||
export async function handleLocalDanmuComment(key, format, formatResponse) { const r = await getLocalDanmu(key); if (!r) return null; return formatResponse({ count: r.count, comments: r.comments }, format); }
|
||||
@@ -13,7 +13,7 @@ export const Globals = {
|
||||
accessedEnvVars: {},
|
||||
|
||||
// 静态常量
|
||||
VERSION: '1.21.0',
|
||||
VERSION: '1.21.1',
|
||||
MAX_LOGS: 1000, // 日志存储,最多保存 1000 行
|
||||
MAX_RECORDS: 100, // 请求记录最大数量
|
||||
|
||||
|
||||
+1
-1
@@ -346,7 +346,7 @@ function createServer() {
|
||||
|
||||
// 异步读取 POST/PUT 请求的请求体
|
||||
let body;
|
||||
if (req.method === 'POST' || req.method === 'PUT') {
|
||||
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
|
||||
body = await readRequestBody(req);
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,11 @@ UI 系统需要通过在 URL 中添加 TOKEN 来访问,以确保安全性:
|
||||
|
||||
本地弹幕页面用于将本地弹幕文件导入服务,并在搜索和自动匹配中使用。页面支持 XML、JSON、ASS、SSA、CSV、TXT 格式,单个文件大小上限为 10 MB。
|
||||
|
||||
上传时需要填写标题、年份和类型(`tv` 或 `movie`)。电视剧默认第 1 季第 1 集,也可以指定季数和集数;电影的季数和集数可以留空。标题、年份、类型和季相同的资源会归为一组,列表中可展开查看每一集并单独删除。重新上传同一季同一集会替换原文件。
|
||||
上传时需要填写标题、年份和类型(`tv` 或 `movie`)。电视剧默认第 1 季第 1 集,也可以指定季数和集数;电影的季数和集数可以留空。标题、年份、类型和季相同的资源会归为一组,列表支持按标题关键词搜索,可编辑剧集信息(标题、年份、类型、季)或单集信息(集数、显示文件名),编辑只更新元数据,不重新解析弹幕;也可展开查看每一集并单独删除文件、重新上传文件,或一次删除整个剧集。编辑目标与已有资源冲突时会拒绝保存。重新上传同一季同一集会替换原文件。
|
||||
|
||||
同一部电视剧的多集可以一次多选导入,共用标题、年份和季。选择多个文件后,页面会按文件名排序并显示预览,自动识别 `S01E02`、`EP02`、`第02集`、`02.xml` 等文件名中的集数;每个文件的集数都可以修改,未识别或重复的集数需要先修正才能上传。批量导入仅用于 `tv`,电影仍逐个导入。
|
||||
|
||||
点击“批量上传并解析”后,页面逐个上传文件,显示每个文件的结果和整批统计。单个文件超过 10 MB 或上传失败时会继续处理其余文件,成功后统一刷新资源列表。批量导入由页面依次调用现有上传接口完成,接口每次仍只接收一个 `file`。
|
||||
|
||||
本地弹幕页面和接口需要有效的 `TOKEN` 或 `ADMIN_TOKEN`。默认只有 `ADMIN_TOKEN` 可以上传和删除,普通 `TOKEN` 只能查看列表;设置 `LOCAL_DANMU_NOT_REQUIRE_ADMIN=true` 后,普通 `TOKEN` 也可以上传和删除。资源保存方式取决于部署环境:Node/Docker 保存到 `.cache/local-danmu`,云端部署使用已配置的 Redis。
|
||||
|
||||
@@ -357,6 +361,7 @@ A: 大多数平台的 Token 只显示一次,如果忘记复制需要删除后重
|
||||
- `GET /api/v2/local-danmu/list`:获取资源列表和分组信息。
|
||||
- `GET /api/v2/local-danmu/:resourceKey`:获取单个资源元数据。
|
||||
- `DELETE /api/v2/local-danmu/:resourceKey`:删除单个资源。
|
||||
- `PATCH /api/v2/local-danmu/:resourceKey`:编辑资源元数据;`scope=resource` 修改集数和显示文件名,`scope=group` 修改当前季的标题、年份、类型和季数。编辑不会重新解析弹幕内容,目标键冲突时返回 409。
|
||||
|
||||
这些接口也支持去掉 `/v2` 的 `/api/local-danmu/...` 路径。上传和删除默认需要 `ADMIN_TOKEN`;设置 `LOCAL_DANMU_NOT_REQUIRE_ADMIN=true` 后,普通 `TOKEN` 也可以执行。
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ html {
|
||||
overflow-y: scroll;
|
||||
scrollbar-gutter: stable;
|
||||
background: var(--theme-page-bg);
|
||||
color-scheme: light !important;
|
||||
}
|
||||
|
||||
html.modal-open {
|
||||
|
||||
@@ -324,6 +324,10 @@ body[data-theme] .favorite-schedule-btn:disabled:hover {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.local-danmu-fields[data-batch="true"] {
|
||||
grid-template-columns: minmax(190px, 2fr) repeat(3, minmax(100px, 1fr)) auto;
|
||||
}
|
||||
|
||||
.local-danmu-fields .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -331,6 +335,10 @@ body[data-theme] .favorite-schedule-btn:disabled:hover {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.local-danmu-fields .form-group[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.local-danmu-fields .form-group label {
|
||||
min-height: 18px;
|
||||
margin-bottom: 6px;
|
||||
@@ -360,6 +368,51 @@ body[data-theme] .favorite-schedule-btn:disabled:hover {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.local-danmu-batch-preview {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#local-danmu-upload-status {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.local-danmu-batch-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 90px minmax(120px, 0.65fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.local-danmu-batch-file {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.local-danmu-batch-episode {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.local-danmu-batch-status {
|
||||
color: var(--theme-muted);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.local-danmu-batch-row {
|
||||
grid-template-columns: minmax(0, 1fr) 90px;
|
||||
}
|
||||
.local-danmu-batch-status {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.local-danmu-search {
|
||||
max-width: 480px;
|
||||
margin: 18px 0 12px;
|
||||
}
|
||||
|
||||
.local-danmu-group {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--theme-border);
|
||||
@@ -375,6 +428,12 @@ body[data-theme] .favorite-schedule-btn:disabled:hover {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.local-danmu-group-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 16px 8px 34px;
|
||||
}
|
||||
|
||||
.local-danmu-group > summary::marker {
|
||||
color: var(--theme-accent);
|
||||
}
|
||||
@@ -434,12 +493,15 @@ body[data-theme] .favorite-schedule-btn:disabled:hover {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.local-danmu-episode > button {
|
||||
.local-danmu-episode-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.local-danmu-fields {
|
||||
.local-danmu-fields,
|
||||
.local-danmu-fields[data-batch="true"] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.local-danmu-name-field,
|
||||
|
||||
@@ -29,6 +29,15 @@ export const formsCssContent = /* css */ `
|
||||
transition: border-color 0.22s var(--app-ease-smooth), box-shadow 0.22s var(--app-ease-smooth);
|
||||
}
|
||||
|
||||
/* Native select popups use these colors as well as the closed control. */
|
||||
.form-group select option,
|
||||
.form-group select optgroup,
|
||||
select option,
|
||||
select optgroup {
|
||||
background-color: var(--theme-container-bg);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
|
||||
@@ -117,6 +117,14 @@ body[data-color-scheme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Keep native select controls and their popup menus in sync with the theme. */
|
||||
body[data-color-scheme="dark"] select {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body:not([data-color-scheme="dark"]) select {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* 暗色模式下各强调色饱和度降低 */
|
||||
body[data-color-scheme="dark"][data-theme="shinyo"] {
|
||||
--app-primary: #7da67d; --app-primary-hover: #6f966f; --app-primary-soft: rgba(125,166,125,0.12);
|
||||
@@ -239,4 +247,4 @@ body[data-color-scheme="dark"] #bg-danmaku-layer span { opacity: 0.08 !important
|
||||
.theme-options { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 480px) { .theme-options { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
`;
|
||||
`;
|
||||
+236
-23
@@ -2,6 +2,9 @@
|
||||
export const localDanmuJsContent = /* javascript */ `
|
||||
let localDanmuStorageReady = globals.localDanmuRedisValid;
|
||||
let localDanmuIsCloud = globals.localDanmuIsCloud;
|
||||
let localDanmuGroups = [];
|
||||
let localDanmuUploadItems = [];
|
||||
let localDanmuUploading = false;
|
||||
function localDanmuUrl(path, admin = false) { return buildApiUrl(path, admin); }
|
||||
function localDanmuRedisUnavailable() {
|
||||
return localDanmuIsCloud && !localDanmuStorageReady;
|
||||
@@ -68,8 +71,11 @@ function initializeLocalDanmuForm() {
|
||||
year.append(option);
|
||||
}
|
||||
year.value = String(currentYear);
|
||||
document.getElementById('local-danmu-file').addEventListener('change', updateLocalDanmuUploadFiles);
|
||||
document.getElementById('local-danmu-type').addEventListener('change', updateLocalDanmuTypeFields);
|
||||
document.getElementById('local-danmu-search')?.addEventListener('input', filterLocalDanmuGroups);
|
||||
updateLocalDanmuTypeFields();
|
||||
updateLocalDanmuUploadFiles();
|
||||
}
|
||||
function localDanmuElement(tag, className, text) {
|
||||
const element = document.createElement(tag);
|
||||
@@ -83,15 +89,67 @@ function localDanmuFileSize(size) {
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
function renderLocalDanmuGroups(box, groups) {
|
||||
function localDanmuEpisodeFromFilename(filename) {
|
||||
const name = String(filename || '').normalize('NFKC').replace(/\\.(xml|json|ass|ssa|csv|txt)$/i, '').trim();
|
||||
const match = name.match(/S[0-9]+[ ._-]*EP?([0-9]+)/i)
|
||||
|| name.match(/第\\s*([0-9]+)\\s*[集话話回]/)
|
||||
|| name.match(/(?:^|[^A-Za-z0-9])(?:EP?|Episode)[ ._-]*([0-9]+)/i)
|
||||
|| name.match(/^([0-9]+)$/)
|
||||
|| name.match(/(?:^|[\\s._-])([0-9]{1,3})(?=$|[\\s._-]*[[(【])/);
|
||||
const episode = match ? Number(match[1]) : null;
|
||||
return Number.isSafeInteger(episode) && episode > 0 ? episode : null;
|
||||
}
|
||||
function localDanmuSelectedFiles() {
|
||||
return Array.from(document.getElementById('local-danmu-file').files || [])
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' }));
|
||||
}
|
||||
function updateLocalDanmuUploadFiles() {
|
||||
if (localDanmuUploading) return;
|
||||
const files = localDanmuSelectedFiles();
|
||||
const isBatch = files.length > 1;
|
||||
const list = document.getElementById('local-danmu-batch-list');
|
||||
localDanmuUploadItems = [];
|
||||
list.replaceChildren();
|
||||
document.getElementById('local-danmu-batch-preview').hidden = !isBatch;
|
||||
document.getElementById('local-danmu-episode-field').hidden = isBatch;
|
||||
document.getElementById('local-danmu-fields').dataset.batch = String(isBatch);
|
||||
document.getElementById('local-danmu-upload-button').textContent = isBatch ? '批量上传并解析' : '上传并解析';
|
||||
document.getElementById('local-danmu-upload-status').textContent = '';
|
||||
if (!isBatch) return;
|
||||
for (const [index, file] of files.entries()) {
|
||||
const row = localDanmuElement('div', 'local-danmu-batch-row');
|
||||
const info = localDanmuElement('div', 'local-danmu-batch-file');
|
||||
info.append(localDanmuElement('div', 'local-danmu-filename', file.name), localDanmuElement('div', 'local-danmu-file-hint', localDanmuFileSize(file.size)));
|
||||
const field = localDanmuElement('div', 'form-group local-danmu-batch-episode');
|
||||
const input = localDanmuElement('input');
|
||||
input.id = 'local-danmu-batch-episode-' + index;
|
||||
input.type = 'number';
|
||||
input.min = '1';
|
||||
input.step = '1';
|
||||
input.required = true;
|
||||
const episode = localDanmuEpisodeFromFilename(file.name);
|
||||
input.value = episode === null ? '' : String(episode);
|
||||
input.placeholder = '请填写';
|
||||
input.setAttribute('aria-label', file.name + ' 的集数');
|
||||
const label = localDanmuElement('label', '', '集数');
|
||||
label.htmlFor = input.id;
|
||||
field.append(label, input);
|
||||
const status = localDanmuElement('div', 'local-danmu-batch-status', episode === null ? '未识别,请填写集数' : '待上传');
|
||||
input.addEventListener('input', () => { status.textContent = input.value ? '待上传' : '请填写集数'; });
|
||||
row.append(info, field, status);
|
||||
list.append(row);
|
||||
localDanmuUploadItems.push({ file, input, status });
|
||||
}
|
||||
}
|
||||
function renderLocalDanmuGroups(box, groups, emptyText = '暂无资源') {
|
||||
const openStates = new Map(Array.from(box.querySelectorAll('.local-danmu-group'), element => [element.dataset.groupKey, element.open]));
|
||||
box.replaceChildren();
|
||||
if (!groups.length) { box.append(localDanmuElement('p', 'text-gray', '暂无资源')); return; }
|
||||
if (!groups.length) { box.append(localDanmuElement('p', 'text-gray', emptyText)); return; }
|
||||
const typeNames = { tv: '电视剧', movie: '电影', ova: 'OVA', special: '特别篇' };
|
||||
for (const group of groups) {
|
||||
const card = localDanmuElement('details', 'local-danmu-group');
|
||||
card.dataset.groupKey = group.groupKey;
|
||||
card.open = openStates.get(group.groupKey) ?? true;
|
||||
card.open = openStates.get(group.groupKey) ?? false;
|
||||
const summary = localDanmuElement('summary');
|
||||
const seasonText = group.type === 'movie' && group.season === 1 ? '' : ' · 第' + group.season + '季';
|
||||
summary.append(
|
||||
@@ -99,6 +157,12 @@ function renderLocalDanmuGroups(box, groups) {
|
||||
localDanmuElement('span', 'local-danmu-group-meta', (group.year || '年份未填写') + ' · ' + (typeNames[group.type] || group.type || '类型未填写') + seasonText),
|
||||
localDanmuElement('span', 'local-danmu-group-count', '已上传 ' + group.episodeCount + (group.type === 'movie' ? ' 个文件 · ' : ' 集 · ') + Number(group.count || 0).toLocaleString() + ' 条弹幕')
|
||||
);
|
||||
const editGroup = localDanmuElement('button', 'btn btn-secondary', '编辑剧集');
|
||||
editGroup.type = 'button';
|
||||
editGroup.addEventListener('click', () => openLocalDanmuEdit('group', group));
|
||||
const removeGroup = localDanmuElement('button', 'btn btn-danger', '删除整个剧集');
|
||||
removeGroup.type = 'button';
|
||||
removeGroup.addEventListener('click', () => deleteLocalDanmuGroup(group));
|
||||
const episodes = localDanmuElement('div', 'local-danmu-episodes');
|
||||
for (const resource of group.episodes) {
|
||||
const row = localDanmuElement('div', 'local-danmu-episode');
|
||||
@@ -110,33 +174,113 @@ function renderLocalDanmuGroups(box, groups) {
|
||||
localDanmuElement('div', 'local-danmu-filename', resource.filename || '弹幕文件'),
|
||||
localDanmuElement('div', 'local-danmu-episode-meta', Number(resource.count || 0).toLocaleString() + ' 条弹幕 · ' + localDanmuFileSize(resource.size) + ' · ' + state)
|
||||
);
|
||||
const reupload = localDanmuElement('button', 'btn btn-secondary', '重新上传');
|
||||
reupload.type = 'button';
|
||||
reupload.addEventListener('click', () => prepareLocalDanmuReupload(resource));
|
||||
const remove = localDanmuElement('button', 'btn btn-danger', resource.episode == null ? '删除文件' : '删除本集');
|
||||
remove.type = 'button';
|
||||
remove.addEventListener('click', () => deleteLocalDanmu(resource.resourceKey));
|
||||
row.append(info, remove);
|
||||
const actions = localDanmuElement('div', 'local-danmu-episode-actions');
|
||||
const edit = localDanmuElement('button', 'btn btn-secondary', '编辑');
|
||||
edit.type = 'button';
|
||||
edit.addEventListener('click', () => openLocalDanmuEdit('resource', resource));
|
||||
actions.append(remove, reupload, edit);
|
||||
row.append(info, actions);
|
||||
episodes.append(row);
|
||||
}
|
||||
card.append(summary, episodes);
|
||||
const groupActions = localDanmuElement('div', 'local-danmu-group-actions');
|
||||
groupActions.append(editGroup, removeGroup);
|
||||
card.append(summary, episodes, groupActions);
|
||||
box.append(card);
|
||||
}
|
||||
}
|
||||
let localDanmuEditTarget = null;
|
||||
function openLocalDanmuEdit(scope, target) {
|
||||
if (!checkLocalDanmuWritePermission('编辑')) return;
|
||||
localDanmuEditTarget = { scope, target };
|
||||
const modal = document.getElementById('local-danmu-edit-modal');
|
||||
document.getElementById('local-danmu-edit-group-fields').style.display = scope === 'group' ? '' : 'none';
|
||||
document.getElementById('local-danmu-edit-resource-fields').style.display = scope === 'resource' ? '' : 'none';
|
||||
document.getElementById('local-danmu-edit-name').value = target.title || '';
|
||||
document.getElementById('local-danmu-edit-year').value = target.year == null ? '' : String(target.year);
|
||||
document.getElementById('local-danmu-edit-type').value = target.type || 'tv';
|
||||
document.getElementById('local-danmu-edit-season').value = target.season == null ? '' : String(target.season);
|
||||
document.getElementById('local-danmu-edit-episode').value = target.episode == null ? '' : String(target.episode);
|
||||
document.getElementById('local-danmu-edit-filename').value = target.filename || '';
|
||||
document.getElementById('local-danmu-edit-status').textContent = '';
|
||||
modal.classList.add('active');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
function closeLocalDanmuEdit() {
|
||||
const modal = document.getElementById('local-danmu-edit-modal');
|
||||
if (modal) { modal.classList.remove('active'); modal.setAttribute('aria-hidden', 'true'); }
|
||||
localDanmuEditTarget = null;
|
||||
}
|
||||
async function submitLocalDanmuEdit() {
|
||||
if (!localDanmuEditTarget || !checkLocalDanmuWritePermission('编辑')) return;
|
||||
const { scope, target } = localDanmuEditTarget;
|
||||
const resourceKey = target.resourceKey || target.episodes?.[0]?.resourceKey;
|
||||
const status = document.getElementById('local-danmu-edit-status');
|
||||
const body = { scope };
|
||||
if (scope === 'group') Object.assign(body, { title: document.getElementById('local-danmu-edit-name').value.trim(), year: document.getElementById('local-danmu-edit-year').value.trim(), type: document.getElementById('local-danmu-edit-type').value, season: document.getElementById('local-danmu-edit-season').value.trim() });
|
||||
else Object.assign(body, { episode: document.getElementById('local-danmu-edit-episode').value.trim(), filename: document.getElementById('local-danmu-edit-filename').value.trim() });
|
||||
try {
|
||||
const response = await fetch(localDanmuUrl('/api/local-danmu/' + encodeURIComponent(resourceKey)), { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) { status.textContent = data.errorMessage || '更新失败'; return; }
|
||||
closeLocalDanmuEdit();
|
||||
await loadLocalDanmuList();
|
||||
} catch { status.textContent = '更新失败,请稍后重试'; }
|
||||
}
|
||||
function prepareLocalDanmuReupload(resource) {
|
||||
if (localDanmuUploading) return;
|
||||
if (!checkLocalDanmuWritePermission('重新上传')) return;
|
||||
document.getElementById('local-danmu-title').value = resource.title || '';
|
||||
document.getElementById('local-danmu-year').value = resource.year == null ? '' : String(resource.year);
|
||||
document.getElementById('local-danmu-type').value = resource.type || 'tv';
|
||||
document.getElementById('local-danmu-season').value = resource.season == null ? '' : String(resource.season);
|
||||
document.getElementById('local-danmu-episode').value = resource.episode == null ? '' : String(resource.episode);
|
||||
updateLocalDanmuTypeFields();
|
||||
const file = document.getElementById('local-danmu-file');
|
||||
file.value = '';
|
||||
updateLocalDanmuUploadFiles();
|
||||
document.getElementById('local-danmu-upload-status').textContent = '已填充“' + (resource.title || '') + '”的信息,请选择新文件后上传。';
|
||||
file.click?.();
|
||||
}
|
||||
function filterLocalDanmuGroups() {
|
||||
const box = document.getElementById('local-danmu-list');
|
||||
const search = document.getElementById('local-danmu-search');
|
||||
if (!box || !search) return;
|
||||
const keyword = search.value.trim().toLocaleLowerCase();
|
||||
const groups = keyword
|
||||
? localDanmuGroups.filter(group => String(group.title || '').toLocaleLowerCase().includes(keyword))
|
||||
: localDanmuGroups;
|
||||
renderLocalDanmuGroups(box, groups, keyword ? '未找到匹配标题' : '暂无资源');
|
||||
}
|
||||
async function loadLocalDanmuList() {
|
||||
const box = document.getElementById('local-danmu-list'); if (!box) return;
|
||||
try {
|
||||
const r = await fetch(localDanmuUrl('/api/local-danmu/list'));
|
||||
if (!r.ok) { box.replaceChildren(localDanmuElement('p', 'text-gray', r.status === 401 || r.status === 403 ? '请使用有效 TOKEN 查看资源列表' : '资源列表加载失败')); return; }
|
||||
const d = await r.json();
|
||||
renderLocalDanmuGroups(box, d.groups || []);
|
||||
localDanmuGroups = d.groups || [];
|
||||
filterLocalDanmuGroups();
|
||||
} catch { box.replaceChildren(localDanmuElement('p', 'text-gray', '资源列表加载失败,请稍后重试')); }
|
||||
}
|
||||
async function uploadLocalDanmu() {
|
||||
if (localDanmuUploading) return;
|
||||
if (localDanmuRedisUnavailable()) {
|
||||
showLocalDanmuRedisRequired();
|
||||
return;
|
||||
}
|
||||
if (!checkLocalDanmuWritePermission('上传')) return;
|
||||
const f = document.getElementById('local-danmu-file').files[0]; const s = document.getElementById('local-danmu-upload-status');
|
||||
if (!f) { s.textContent = '请选择文件'; return; }
|
||||
const files = localDanmuSelectedFiles();
|
||||
const s = document.getElementById('local-danmu-upload-status');
|
||||
if (!files.length) { s.textContent = '请选择文件'; return; }
|
||||
const isBatch = files.length > 1;
|
||||
if (isBatch ? files.length !== localDanmuUploadItems.length || files.some((file, index) => file !== localDanmuUploadItems[index].file) : localDanmuUploadItems.length > 0) {
|
||||
updateLocalDanmuUploadFiles();
|
||||
}
|
||||
const title = document.getElementById('local-danmu-title').value.trim();
|
||||
if (!title) { s.textContent = '请填写标题'; return; }
|
||||
const year = document.getElementById('local-danmu-year').value.trim();
|
||||
@@ -145,31 +289,78 @@ async function uploadLocalDanmu() {
|
||||
if (!/^[0-9]{4}$/.test(year) || Number(year) < 1900 || Number(year) > currentYear) { s.textContent = '年份必须在 1900–' + currentYear + ' 年之间'; return; }
|
||||
const type = document.getElementById('local-danmu-type').value;
|
||||
if (type !== 'tv' && type !== 'movie') { s.textContent = '请选择类型(tv 或 movie)'; return; }
|
||||
if (isBatch && type !== 'tv') { s.textContent = '批量导入用于同一部电视剧,请选择 tv 类型;电影请逐个上传。'; return; }
|
||||
const seasonInput = document.getElementById('local-danmu-season');
|
||||
const episodeInput = document.getElementById('local-danmu-episode');
|
||||
const seasonValue = seasonInput.value.trim();
|
||||
const episodeValue = episodeInput.value.trim();
|
||||
const season = seasonValue ? Number(seasonValue) : (type === 'tv' ? 1 : null);
|
||||
const episode = episodeValue ? Number(episodeValue) : (type === 'tv' ? 1 : null);
|
||||
const episode = isBatch ? null : episodeValue ? Number(episodeValue) : (type === 'tv' ? 1 : null);
|
||||
if (seasonInput.validity?.badInput || (season !== null && (!Number.isSafeInteger(season) || season < 1))) { s.textContent = '季数必须是大于 0 的整数'; return; }
|
||||
if (episodeInput.validity?.badInput || (episode !== null && (!Number.isSafeInteger(episode) || episode < 1))) { s.textContent = '集数必须是大于 0 的整数'; return; }
|
||||
if (!isBatch && (episodeInput.validity?.badInput || (episode !== null && (!Number.isSafeInteger(episode) || episode < 1)))) { s.textContent = '集数必须是大于 0 的整数'; return; }
|
||||
if (type === 'tv' && !seasonValue) seasonInput.value = '1';
|
||||
if (type === 'tv' && !episodeValue) episodeInput.value = '1';
|
||||
const fd = new FormData(); fd.append('file', f); fd.append('title', title); fd.append('year', year); fd.append('type', type);
|
||||
if (season !== null) fd.append('season', String(season));
|
||||
if (episode !== null) fd.append('episode', String(episode));
|
||||
const button = document.getElementById('local-danmu-upload-button');
|
||||
button.disabled = true;
|
||||
s.textContent = '正在上传并解析…';
|
||||
if (!isBatch && type === 'tv' && !episodeValue) episodeInput.value = '1';
|
||||
const uploads = isBatch
|
||||
? localDanmuUploadItems.map(item => ({ ...item, episode: Number(item.input.value.trim()) }))
|
||||
: [{ file: files[0], episode }];
|
||||
if (isBatch) {
|
||||
const episodes = new Map();
|
||||
let invalid = false;
|
||||
for (const item of uploads) {
|
||||
item.status.textContent = '待上传';
|
||||
if (item.input.validity?.badInput || !Number.isSafeInteger(item.episode) || item.episode < 1) {
|
||||
item.status.textContent = '请填写有效集数';
|
||||
invalid = true;
|
||||
} else if (episodes.has(item.episode)) {
|
||||
item.status.textContent = '集数重复,请修改';
|
||||
episodes.get(item.episode).status.textContent = '集数重复,请修改';
|
||||
invalid = true;
|
||||
} else episodes.set(item.episode, item);
|
||||
}
|
||||
if (invalid) { s.textContent = '请先填写有效且不重复的集数,再批量上传。'; return; }
|
||||
}
|
||||
const controls = ['file', 'title', 'year', 'type', 'season', 'episode', 'upload-button']
|
||||
.map(name => document.getElementById('local-danmu-' + name)).concat(isBatch ? uploads.map(item => item.input) : []);
|
||||
const disabledStates = controls.map(control => control.disabled === true);
|
||||
localDanmuUploading = true;
|
||||
controls.forEach(control => { control.disabled = true; });
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let totalComments = 0;
|
||||
try {
|
||||
const r = await fetch(localDanmuUrl('/api/local-danmu/upload'), { method: 'POST', body: fd });
|
||||
const d = await r.json();
|
||||
s.textContent = d.success ? (type === 'movie' ? '电影上传成功' : '第' + d.resource.season + '季上传成功') + ',共 ' + d.resource.count + ' 条弹幕' : (d.errorMessage || '上传失败');
|
||||
if (d.success) await loadLocalDanmuList();
|
||||
} catch { s.textContent = '上传失败,请稍后重试'; }
|
||||
finally { button.disabled = false; }
|
||||
// 逐个上传,避免并发写入 Redis 索引时相互覆盖。
|
||||
for (const [index, item] of uploads.entries()) {
|
||||
s.textContent = isBatch ? '正在上传 ' + (index + 1) + ' / ' + uploads.length + ':' + item.file.name : '正在上传并解析…';
|
||||
if (item.status) item.status.textContent = '正在上传…';
|
||||
let errorMessage = item.file.size > 10 * 1024 * 1024 ? '文件大小不能超过 10 MB' : '';
|
||||
let resource;
|
||||
if (!errorMessage) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', item.file); fd.append('title', title); fd.append('year', year); fd.append('type', type);
|
||||
if (season !== null) fd.append('season', String(season));
|
||||
if (item.episode !== null) fd.append('episode', String(item.episode));
|
||||
const response = await fetch(localDanmuUrl('/api/local-danmu/upload'), { method: 'POST', body: fd });
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) errorMessage = data.errorMessage || '上传失败';
|
||||
else resource = data.resource;
|
||||
} catch { errorMessage = '上传失败,请稍后重试'; }
|
||||
}
|
||||
const count = Number(resource?.count) || 0;
|
||||
if (errorMessage) failed++;
|
||||
else { succeeded++; totalComments += count; }
|
||||
if (item.status) item.status.textContent = errorMessage ? '失败:' + errorMessage : '成功 · ' + count + ' 条弹幕';
|
||||
if (!isBatch) s.textContent = errorMessage || (type === 'movie' ? '电影上传成功' : '第' + (resource?.season || season || 1) + '季上传成功') + ',共 ' + count + ' 条弹幕';
|
||||
}
|
||||
if (isBatch) s.textContent = '批量导入完成:成功 ' + succeeded + ' 个,失败 ' + failed + ' 个,共 ' + totalComments + ' 条弹幕';
|
||||
if (succeeded) await loadLocalDanmuList();
|
||||
} finally {
|
||||
controls.forEach((control, index) => { control.disabled = disabledStates[index]; });
|
||||
localDanmuUploading = false;
|
||||
}
|
||||
}
|
||||
async function deleteLocalDanmu(key) {
|
||||
if (localDanmuEditTarget) return;
|
||||
if (localDanmuRedisUnavailable()) {
|
||||
showLocalDanmuRedisRequired();
|
||||
return;
|
||||
@@ -185,6 +376,28 @@ async function deleteLocalDanmu(key) {
|
||||
await loadLocalDanmuList();
|
||||
} catch { status.textContent = '删除失败,请稍后重试'; }
|
||||
}
|
||||
async function deleteLocalDanmuGroup(group) {
|
||||
if (localDanmuEditTarget) return;
|
||||
if (localDanmuRedisUnavailable()) {
|
||||
showLocalDanmuRedisRequired();
|
||||
return;
|
||||
}
|
||||
if (!checkLocalDanmuWritePermission('删除')) return;
|
||||
const relatedGroups = localDanmuGroups.filter(item => item.title === group.title && item.year === group.year && item.type === group.type);
|
||||
const resources = [...new Map(relatedGroups.flatMap(item => item.episodes).map(resource => [resource.resourceKey, resource])).values()];
|
||||
if (!confirm('确认删除“' + group.title + '”的全部本地弹幕文件?')) return;
|
||||
const status = document.getElementById('local-danmu-upload-status');
|
||||
try {
|
||||
const responses = await Promise.all(resources.map(resource =>
|
||||
fetch(localDanmuUrl('/api/local-danmu/' + encodeURIComponent(resource.resourceKey)), { method: 'DELETE' })
|
||||
.then(async response => ({ response, data: await response.json() }))
|
||||
));
|
||||
const failed = responses.find(({ response, data }) => !response.ok || !data.success);
|
||||
if (failed) { status.textContent = failed.data.errorMessage || '删除失败'; return; }
|
||||
status.textContent = '已删除剧集“' + group.title + '”的全部弹幕文件';
|
||||
await loadLocalDanmuList();
|
||||
} catch { status.textContent = '删除失败,请稍后重试'; }
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeLocalDanmuForm();
|
||||
updateLocalDanmuPermission();
|
||||
|
||||
@@ -83,8 +83,8 @@ export const HTML_TEMPLATE = /* html */ `
|
||||
<button class="nav-btn" onclick="switchSection('logs', event)">日志查看</button>
|
||||
<button class="nav-btn" onclick="switchSection('api', event)">接口调试</button>
|
||||
<button class="nav-btn" onclick="switchSection('push', event)">推送弹幕</button>
|
||||
<button class="nav-btn" onclick="switchSection('request-records', event)">请求记录</button>
|
||||
<button class="nav-btn" onclick="switchSection('local-danmu', event)">本地弹幕</button>
|
||||
<button class="nav-btn" onclick="switchSection('request-records', event)">请求记录</button>
|
||||
<button class="nav-btn" onclick="switchSection('env', event)" id="env-nav-btn">系统配置</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,10 +307,10 @@ export const HTML_TEMPLATE = /* html */ `
|
||||
<div id="local-danmu-upload-panel">
|
||||
<div class="form-group local-danmu-file-field">
|
||||
<label for="local-danmu-file">弹幕文件</label>
|
||||
<input type="file" id="local-danmu-file" accept=".xml,.json,.ass,.ssa,.csv,.txt" aria-describedby="local-danmu-file-hint" data-can-upload="globals.localDanmuCanUpload" onclick="return checkLocalDanmuWritePermission('上传', event)">
|
||||
<p id="local-danmu-file-hint" class="local-danmu-file-hint">支持 XML、JSON、ASS、SSA、CSV、TXT,单个文件不超过 10 MB</p>
|
||||
<input type="file" id="local-danmu-file" accept=".xml,.json,.ass,.ssa,.csv,.txt" multiple aria-describedby="local-danmu-file-hint" data-can-upload="globals.localDanmuCanUpload" onclick="return checkLocalDanmuWritePermission('上传', event)">
|
||||
<p id="local-danmu-file-hint" class="local-danmu-file-hint">支持 XML、JSON、ASS、SSA、CSV、TXT,可多选同一部剧的弹幕文件,每个文件不超过 10 MB</p>
|
||||
</div>
|
||||
<div class="local-danmu-fields">
|
||||
<div class="local-danmu-fields" id="local-danmu-fields">
|
||||
<div class="form-group local-danmu-name-field"><label for="local-danmu-title">标题(必填)</label><input id="local-danmu-title" placeholder="电视剧或影片标题"></div>
|
||||
<div class="form-group">
|
||||
<label id="local-danmu-year-label" for="local-danmu-year">年份(必填)</label>
|
||||
@@ -327,12 +327,20 @@ export const HTML_TEMPLATE = /* html */ `
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label id="local-danmu-season-label" for="local-danmu-season">季</label><input id="local-danmu-season" type="number" min="1" step="1" value="1"></div>
|
||||
<div class="form-group"><label id="local-danmu-episode-label" for="local-danmu-episode">集</label><input id="local-danmu-episode" type="number" min="1" step="1" value="1"></div>
|
||||
<div class="form-group" id="local-danmu-episode-field"><label id="local-danmu-episode-label" for="local-danmu-episode">集</label><input id="local-danmu-episode" type="number" min="1" step="1" value="1"></div>
|
||||
<button id="local-danmu-upload-button" type="button" class="btn btn-success" onclick="uploadLocalDanmu()">上传并解析</button>
|
||||
</div>
|
||||
<p class="preview-description">tv 默认第 1 季第 1 集,movie 的季和集可留空。标题、年份、类型和季相同的文件会归为一个剧集,展开后可查看各集。同一季的同一集重新上传会替换原文件。</p>
|
||||
<div id="local-danmu-batch-preview" class="local-danmu-batch-preview" hidden>
|
||||
<p class="local-danmu-file-hint">批量导入用于同一部电视剧(tv),标题、年份和季沿用上方设置。集数从文件名识别,可逐个修改;未识别的请手动填写,同一批次不能重复。</p>
|
||||
<div id="local-danmu-batch-list"></div>
|
||||
</div>
|
||||
<div id="local-danmu-upload-status" class="preview-status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="form-group local-danmu-search">
|
||||
<label for="local-danmu-search">搜索已上传标题</label>
|
||||
<input type="search" id="local-danmu-search" placeholder="输入标题关键词" autocomplete="off">
|
||||
</div>
|
||||
<div id="local-danmu-list" class="favorite-list"></div>
|
||||
</div>
|
||||
|
||||
@@ -492,6 +500,27 @@ export const HTML_TEMPLATE = /* html */ `
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地弹幕编辑弹窗放在页面顶层,避免命中 section 子元素的错峰动画延迟 -->
|
||||
<div class="modal" id="local-danmu-edit-modal" aria-hidden="true">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h3 id="local-danmu-edit-title">编辑本地弹幕</h3><button type="button" class="close-btn" onclick="closeLocalDanmuEdit()">×</button></div>
|
||||
<div class="modal-body">
|
||||
<div id="local-danmu-edit-group-fields">
|
||||
<div class="form-group"><label for="local-danmu-edit-name">标题</label><input id="local-danmu-edit-name"></div>
|
||||
<div class="form-group"><label for="local-danmu-edit-year">年份</label><input id="local-danmu-edit-year" type="number" min="1900" step="1"></div>
|
||||
<div class="form-group"><label for="local-danmu-edit-type">类型</label><select id="local-danmu-edit-type"><option value="tv">tv</option><option value="movie">movie</option></select></div>
|
||||
<div class="form-group"><label for="local-danmu-edit-season">季</label><input id="local-danmu-edit-season" type="number" min="1" step="1"></div>
|
||||
</div>
|
||||
<div id="local-danmu-edit-resource-fields">
|
||||
<div class="form-group"><label for="local-danmu-edit-episode">集</label><input id="local-danmu-edit-episode" type="number" min="1" step="1"></div>
|
||||
<div class="form-group"><label for="local-danmu-edit-filename">显示文件名</label><input id="local-danmu-edit-filename"></div>
|
||||
</div>
|
||||
<p id="local-danmu-edit-status" class="preview-status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="modal-footer"><button type="button" class="btn btn-secondary" onclick="closeLocalDanmuEdit()">取消</button><button type="button" class="btn btn-primary" onclick="submitLocalDanmuEdit()">保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑模态框 -->
|
||||
<div class="modal" id="env-modal">
|
||||
<div class="modal-content">
|
||||
|
||||
+6
-4
@@ -11,7 +11,7 @@ import { getFongmiDanmaku } from "./apis/clients/fongmi-api.js";
|
||||
import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache, handleReqRecords, handleCacheAnimes } from "./apis/system-api.js";
|
||||
import { handleForwardTrace } from "./apis/forward-trace-api.js";
|
||||
import { handleSetEnv, handleAddEnv, handleDelEnv, handleAiVerify } from "./apis/env-api.js";
|
||||
import { handleLocalDanmuUpload, handleLocalDanmuList, handleLocalDanmuGet, handleLocalDanmuDelete } from "./apis/local-danmu-api.js";
|
||||
import { handleLocalDanmuUpload, handleLocalDanmuList, handleLocalDanmuGet, handleLocalDanmuDelete, handleLocalDanmuUpdate } from "./apis/local-danmu-api.js";
|
||||
import { extendBangumiDownloadLifecycle } from "./utils/bangumi-data-util.js";
|
||||
import { Segment } from "./models/dandan-model.js"
|
||||
import {
|
||||
@@ -278,17 +278,19 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
|
||||
const isLocalDanmuUpload = (path === '/api/local-danmu/upload' || path === '/api/v2/local-danmu/upload') && method === 'POST';
|
||||
const isLocalDanmuList = (path === '/api/local-danmu/list' || path === '/api/v2/local-danmu/list') && method === 'GET';
|
||||
const localResourceMatch = path.match(/^\/api(?:\/v2)?\/local-danmu\/([^/]+)$/);
|
||||
if (isLocalDanmuUpload || isLocalDanmuList || (localResourceMatch && (method === 'GET' || method === 'DELETE'))) {
|
||||
if (isLocalDanmuUpload || isLocalDanmuList || (localResourceMatch && (method === 'GET' || method === 'DELETE' || method === 'PATCH'))) {
|
||||
const isAdmin = !!globals.adminToken && globals.currentToken === globals.adminToken;
|
||||
const isUser = !!globals.token && globals.currentToken === globals.token;
|
||||
if (!isAdmin && !isUser) return jsonResponse({ errorCode: 401, success: false, errorMessage: 'Unauthorized' }, 401);
|
||||
if ((isLocalDanmuUpload || method === 'DELETE') && !isAdmin && !globals.localDanmuNotRequireAdmin) {
|
||||
if ((isLocalDanmuUpload || method === 'DELETE' || method === 'PATCH') && !isAdmin && !globals.localDanmuNotRequireAdmin) {
|
||||
return jsonResponse({ errorCode: 403, success: false, errorMessage: 'Local danmu upload and deletion require ADMIN_TOKEN or LOCAL_DANMU_NOT_REQUIRE_ADMIN=true' }, 403);
|
||||
}
|
||||
if (isLocalDanmuUpload) return handleLocalDanmuUpload(req);
|
||||
if (isLocalDanmuList) return handleLocalDanmuList();
|
||||
const key = decodeURIComponent(localResourceMatch[1]);
|
||||
return method === 'GET' ? handleLocalDanmuGet(key) : handleLocalDanmuDelete(key);
|
||||
if (method === 'GET') return handleLocalDanmuGet(key);
|
||||
if (method === 'PATCH') return handleLocalDanmuUpdate(req, key);
|
||||
return handleLocalDanmuDelete(key);
|
||||
}
|
||||
|
||||
// GET /api/reqrecords - 获取请求记录 (需要 token)
|
||||
|
||||
+344
-4
@@ -48,7 +48,7 @@ import { generateNipaplaySignature, parseNipaplayRelatedLinks, resolveNipaplayLi
|
||||
import { extractFongmiSeasonNumber, scoreFongmiEpisodeMatch } from "./apis/clients/fongmi-api.js";
|
||||
import { localDanmuJsContent } from './ui/js/localdanmu.js';
|
||||
import { buildLocalDanmuResourceKey, groupLocalDanmuResources, parseLocalDanmu, normalizeLocalSeason } from './utils/local-danmu-parser.js';
|
||||
import { handleLocalDanmuUpload, handleLocalDanmuList, handleLocalDanmuDelete, handleLocalDanmuGet } from './apis/local-danmu-api.js';
|
||||
import { handleLocalDanmuUpload, handleLocalDanmuList, handleLocalDanmuDelete, handleLocalDanmuGet, handleLocalDanmuUpdate } from './apis/local-danmu-api.js';
|
||||
import { saveLocalDanmu, getLocalDanmu, findLocalDanmu } from './utils/local-danmu-store.js';
|
||||
import { handleConfig } from './apis/system-api.js';
|
||||
|
||||
@@ -3550,6 +3550,24 @@ test('local source configuration and search', async t => {
|
||||
assert.ok(groups.every(group => group.episodes.every(resource => !('comments' in resource))));
|
||||
});
|
||||
|
||||
await t.test('local metadata edits migrate resource keys and reject conflicts', async () => {
|
||||
resetState();
|
||||
const first = await uploadResource({ title: '编辑剧集', year: 2026, type: 'tv', season: 1, episode: 1 }, 'edit first');
|
||||
await uploadResource({ title: '编辑剧集', year: 2026, type: 'tv', season: 1, episode: 2 }, 'edit second');
|
||||
const firstResource = (await first.json()).resource;
|
||||
const groupEdit = await handleLocalDanmuUpdate(new Request('http://localhost/api/local-danmu/' + encodeURIComponent(firstResource.resourceKey), { method: 'PATCH', body: JSON.stringify({ scope: 'group', title: '编辑后的剧集', year: '2025', type: 'tv', season: '3' }), headers: { 'content-type': 'application/json' } }), firstResource.resourceKey);
|
||||
assert.equal(groupEdit.status, 200);
|
||||
assert.equal((await getLocalDanmu(firstResource.resourceKey)), null);
|
||||
const movedList = await (await handleLocalDanmuList()).json();
|
||||
assert.deepEqual(movedList.resources.filter(resource => resource.title === '编辑后的剧集').map(resource => resource.season), [3, 3]);
|
||||
const conflictSource = await uploadResource({ title: '冲突剧集', year: 2026, type: 'tv', season: 1, episode: 1 }, 'conflict');
|
||||
const conflictResource = (await conflictSource.json()).resource;
|
||||
await uploadResource({ title: '冲突剧集', year: 2026, type: 'tv', season: 1, episode: 2 }, 'conflict target');
|
||||
const conflict = await handleLocalDanmuUpdate(new Request('http://localhost/api/local-danmu/' + encodeURIComponent(conflictResource.resourceKey), { method: 'PATCH', body: JSON.stringify({ scope: 'resource', episode: 2, filename: '冲突文件.txt' }), headers: { 'content-type': 'application/json' } }), conflictResource.resourceKey);
|
||||
assert.equal(conflict.status, 409);
|
||||
assert.equal((await getLocalDanmu(conflictResource.resourceKey)).filename, 'danmu.json');
|
||||
});
|
||||
|
||||
await t.test('search, details and matching isolate each season', async () => {
|
||||
resetState();
|
||||
const all = await (await searchAnime(searchUrl(seasonFields.title))).json();
|
||||
@@ -3671,6 +3689,14 @@ class TestElement {
|
||||
this.value = '';
|
||||
this.required = false;
|
||||
this.validity = { badInput: false };
|
||||
this.style = {};
|
||||
this.attributes = {};
|
||||
const classes = () => this.className.split(/\s+/).filter(Boolean);
|
||||
this.classList = {
|
||||
add: (...tokens) => { this.className = [...new Set([...classes(), ...tokens])].join(' '); },
|
||||
remove: (...tokens) => { this.className = classes().filter(token => !tokens.includes(token)).join(' '); },
|
||||
contains: token => classes().includes(token),
|
||||
};
|
||||
this._text = '';
|
||||
}
|
||||
set textContent(value) { this._text = String(value); this.children = []; }
|
||||
@@ -3679,6 +3705,8 @@ class TestElement {
|
||||
append(...children) { this.children.push(...children); }
|
||||
replaceChildren(...children) { this._text = ''; this.children = children; }
|
||||
addEventListener(type, callback) { this.listeners.set(type, callback); }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
click() { this.clicked = true; }
|
||||
querySelectorAll(selector) {
|
||||
const matches = element => selector.startsWith('.')
|
||||
? element.className.split(' ').includes(selector.slice(1)) : element.tagName === selector;
|
||||
@@ -3689,7 +3717,7 @@ class TestElement {
|
||||
function makePage(fetch, sandboxGlobals = {}, html) {
|
||||
const elements = new Map();
|
||||
const documentListeners = new Map();
|
||||
for (const name of ['file', 'title', 'year', 'type', 'season', 'episode', 'season-label', 'episode-label', 'permission', 'upload-button', 'upload-status', 'list']) {
|
||||
for (const name of ['file', 'title', 'year', 'type', 'season', 'episode', 'season-label', 'episode-label', 'fields', 'episode-field', 'batch-preview', 'batch-list', 'permission', 'upload-button', 'upload-status', 'search', 'list', 'edit-modal', 'edit-group-fields', 'edit-resource-fields', 'edit-name', 'edit-year', 'edit-type', 'edit-season', 'edit-episode', 'edit-filename', 'edit-status']) {
|
||||
elements.set(`local-danmu-${name}`, new TestElement());
|
||||
}
|
||||
const fileInput = (html || HTML_TEMPLATE).match(/<input\b[^>]*\bid="local-danmu-file"[^>]*>/)[0];
|
||||
@@ -3915,21 +3943,333 @@ test('group cards preserve collapse state and delete only the selected season ep
|
||||
await context.loadLocalDanmuList();
|
||||
let cards = box.querySelectorAll('.local-danmu-group');
|
||||
assert.equal(cards.length, 2);
|
||||
assert.equal(cards[0].open, false);
|
||||
assert.equal(cards[1].open, false);
|
||||
assert.equal(cards[0].querySelectorAll('.local-danmu-episode').length, 2);
|
||||
assert.deepEqual(cards[0].querySelectorAll('.local-danmu-episode-title').map(element => element.textContent), ['第5集', '第10集']);
|
||||
assert.ok(cards[1].querySelectorAll('summary')[0].textContent.includes('2026 · 电视剧 · 第2季'));
|
||||
assert.ok(box.textContent.includes('<img src=x> 分季剧'));
|
||||
assert.equal(box.querySelectorAll('img').length, 0);
|
||||
cards[0].open = false;
|
||||
cards[0].open = true;
|
||||
await context.loadLocalDanmuList();
|
||||
cards = box.querySelectorAll('.local-danmu-group');
|
||||
assert.equal(cards[0].open, false);
|
||||
assert.equal(cards[0].open, true);
|
||||
await cards[1].querySelectorAll('button')[0].listeners.get('click')();
|
||||
assert.equal(requests.find(request => request.method === 'DELETE').url, '/api/local-danmu/' + encodeURIComponent(rows[2].resourceKey));
|
||||
assert.equal(box.querySelectorAll('.local-danmu-group').length, 1);
|
||||
assert.equal(box.querySelectorAll('.local-danmu-episode').length, 2);
|
||||
});
|
||||
|
||||
test('local danmu list filters uploaded groups by title', async () => {
|
||||
const groups = groupLocalDanmuResources([resource(1, 1), { ...resource(1, 2), title: '另一部作品', resourceKey: buildLocalDanmuResourceKey({ ...resource(1, 2), title: '另一部作品' }) }]);
|
||||
const { context, box, elements } = makePage(async () => ({ ok: true, json: async () => ({ success: true, groups }) }));
|
||||
context.initializeLocalDanmuForm();
|
||||
await context.loadLocalDanmuList();
|
||||
assert.equal(box.querySelectorAll('.local-danmu-group').length, 2);
|
||||
elements.get('local-danmu-search').value = '分季';
|
||||
elements.get('local-danmu-search').listeners.get('input')();
|
||||
assert.equal(box.querySelectorAll('.local-danmu-group').length, 1);
|
||||
assert.equal(box.querySelectorAll('.local-danmu-group-title')[0].textContent, '<img src=x> 分季剧');
|
||||
elements.get('local-danmu-search').value = '不存在';
|
||||
elements.get('local-danmu-search').listeners.get('input')();
|
||||
assert.match(box.textContent, /未找到匹配标题/);
|
||||
elements.get('local-danmu-search').value = '';
|
||||
elements.get('local-danmu-search').listeners.get('input')();
|
||||
assert.equal(box.querySelectorAll('.local-danmu-group').length, 2);
|
||||
});
|
||||
|
||||
test('local danmu can delete an entire series in one action', async () => {
|
||||
const rows = [resource(1, 1), resource(1, 2)];
|
||||
let groups = groupLocalDanmuResources(rows);
|
||||
const requests = [];
|
||||
let confirmations = 0;
|
||||
const { context, box } = makePage(async (url, options = {}) => {
|
||||
requests.push({ url, method: options.method || 'GET' });
|
||||
if (options.method === 'DELETE') {
|
||||
groups = [];
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({ success: true, groups }) };
|
||||
}, { confirm: () => { confirmations++; return true; } });
|
||||
await context.loadLocalDanmuList();
|
||||
const card = box.querySelectorAll('.local-danmu-group')[0];
|
||||
const removeGroup = card.querySelectorAll('button').at(-1);
|
||||
await removeGroup.listeners.get('click')({ preventDefault() {}, stopPropagation() {} });
|
||||
assert.equal(confirmations, 1);
|
||||
assert.equal(requests.filter(request => request.method === 'DELETE').length, 2);
|
||||
assert.equal(box.querySelectorAll('.local-danmu-group').length, 0, JSON.stringify(requests));
|
||||
});
|
||||
|
||||
test('local danmu re-upload fills the original resource metadata', async () => {
|
||||
const row = resource(2, 7);
|
||||
const { context, elements, box } = makePage(async () => ({ ok: true, json: async () => ({ success: true, groups: [] }) }));
|
||||
context.renderLocalDanmuGroups(box, groupLocalDanmuResources([row]));
|
||||
const reupload = box.querySelectorAll('button')[1];
|
||||
await reupload.listeners.get('click')();
|
||||
assert.equal(elements.get('local-danmu-title').value, row.title);
|
||||
assert.equal(elements.get('local-danmu-year').value, String(row.year));
|
||||
assert.equal(elements.get('local-danmu-type').value, row.type);
|
||||
assert.equal(elements.get('local-danmu-season').value, String(row.season));
|
||||
assert.equal(elements.get('local-danmu-episode').value, String(row.episode));
|
||||
assert.match(elements.get('local-danmu-upload-status').textContent, /请选择新文件/);
|
||||
});
|
||||
|
||||
test('local danmu edit dialogs save metadata, close and refresh the list', async () => {
|
||||
const row = resource(2, 7);
|
||||
const groups = groupLocalDanmuResources([row]);
|
||||
const requests = [];
|
||||
const { context, elements, box } = makePage(async (url, options = {}) => {
|
||||
requests.push({ url, method: options.method || 'GET', body: options.body && JSON.parse(options.body) });
|
||||
return Response.json({ success: true, groups });
|
||||
});
|
||||
context.renderLocalDanmuGroups(box, groups);
|
||||
const modal = elements.get('local-danmu-edit-modal');
|
||||
const scenarios = [
|
||||
{
|
||||
button: '编辑剧集', fields: { name: '修改标题', year: '2025', type: 'tv', season: '3' },
|
||||
body: { scope: 'group', title: '修改标题', year: '2025', type: 'tv', season: '3' },
|
||||
},
|
||||
{
|
||||
button: '编辑', fields: { episode: '8', filename: '新文件名.xml' },
|
||||
body: { scope: 'resource', episode: '8', filename: '新文件名.xml' },
|
||||
},
|
||||
];
|
||||
for (const scenario of scenarios) {
|
||||
requests.length = 0;
|
||||
const edit = box.querySelectorAll('button').find(button => button.textContent === scenario.button);
|
||||
edit.listeners.get('click')();
|
||||
assert.equal(modal.classList.contains('active'), true);
|
||||
assert.equal(modal.attributes['aria-hidden'], 'false');
|
||||
for (const [field, value] of Object.entries(scenario.fields)) elements.get('local-danmu-edit-' + field).value = value;
|
||||
await context.submitLocalDanmuEdit();
|
||||
assert.deepEqual(requests, [
|
||||
{ url: '/api/local-danmu/' + encodeURIComponent(row.resourceKey), method: 'PATCH', body: scenario.body },
|
||||
{ url: '/api/local-danmu/list', method: 'GET', body: undefined },
|
||||
]);
|
||||
assert.equal(modal.classList.contains('active'), false);
|
||||
assert.equal(modal.attributes['aria-hidden'], 'true');
|
||||
assert.equal(elements.get('local-danmu-edit-status').textContent, '');
|
||||
}
|
||||
});
|
||||
|
||||
test('local danmu edit failures keep the dialog and input without refreshing', async t => {
|
||||
const conflict = '目标资源已存在,无法覆盖';
|
||||
const genericError = '更新失败,请稍后重试';
|
||||
for (const scenario of [
|
||||
{ name: 'conflict', respond: () => Response.json({ success: false, errorMessage: conflict }, { status: 409 }), message: conflict },
|
||||
{ name: 'unsuccessful result', respond: () => Response.json({ success: false }), message: '更新失败' },
|
||||
{ name: 'non-JSON response', respond: () => new Response('<html>Bad gateway</html>', { status: 502 }), message: genericError },
|
||||
{ name: 'network failure', respond: () => { throw new Error('offline'); }, message: genericError },
|
||||
]) {
|
||||
await t.test(scenario.name, async () => {
|
||||
const requests = [];
|
||||
const { context, elements } = makePage(async (url, options = {}) => {
|
||||
requests.push({ url, method: options.method || 'GET' });
|
||||
return scenario.respond();
|
||||
});
|
||||
const row = resource(2, 7);
|
||||
context.openLocalDanmuEdit('resource', row);
|
||||
elements.get('local-danmu-edit-filename').value = '未保存.xml';
|
||||
await context.submitLocalDanmuEdit();
|
||||
assert.deepEqual(requests, [{ url: '/api/local-danmu/' + encodeURIComponent(row.resourceKey), method: 'PATCH' }]);
|
||||
assert.equal(elements.get('local-danmu-edit-modal').classList.contains('active'), true);
|
||||
assert.equal(elements.get('local-danmu-edit-modal').attributes['aria-hidden'], 'false');
|
||||
assert.equal(elements.get('local-danmu-edit-filename').value, '未保存.xml');
|
||||
assert.equal(elements.get('local-danmu-edit-status').textContent, scenario.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('local danmu edits block episode and group deletion until cancelled', async () => {
|
||||
const row = resource(2, 7);
|
||||
const groups = groupLocalDanmuResources([row]);
|
||||
const requests = [];
|
||||
let confirmations = 0;
|
||||
const { context, elements } = makePage(async (url, options = {}) => {
|
||||
requests.push({ url, method: options.method || 'GET' });
|
||||
return Response.json({ success: true, groups });
|
||||
}, { confirm: () => { confirmations++; return true; } });
|
||||
await context.loadLocalDanmuList();
|
||||
for (const scope of ['resource', 'group']) {
|
||||
requests.length = 0;
|
||||
confirmations = 0;
|
||||
context.openLocalDanmuEdit(scope, scope === 'group' ? groups[0] : row);
|
||||
await context.deleteLocalDanmu(row.resourceKey);
|
||||
await context.deleteLocalDanmuGroup(groups[0]);
|
||||
assert.equal(confirmations, 0);
|
||||
assert.deepEqual(requests, []);
|
||||
context.closeLocalDanmuEdit();
|
||||
assert.equal(elements.get('local-danmu-edit-modal').classList.contains('active'), false);
|
||||
assert.equal(elements.get('local-danmu-edit-modal').attributes['aria-hidden'], 'true');
|
||||
if (scope === 'group') await context.deleteLocalDanmuGroup(groups[0]);
|
||||
else await context.deleteLocalDanmu(row.resourceKey);
|
||||
assert.equal(confirmations, 1);
|
||||
assert.deepEqual(requests, [
|
||||
{ url: '/api/local-danmu/' + encodeURIComponent(row.resourceKey), method: 'DELETE' },
|
||||
{ url: '/api/local-danmu/list', method: 'GET' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('batch local danmu recognizes explicit and numbered filenames without guessing release years', () => {
|
||||
const { context } = makePage(async () => { throw new Error('Unexpected request'); });
|
||||
for (const [filename, episode] of [
|
||||
['Show.S02E08.1080p.xml', 8], ['Show.S02EP09.json', 9], ['Show.EP010.ass', 10],
|
||||
['Show.E11.ssa', 11], ['剧名 第 12 集.csv', 12], ['剧名第13話.txt', 13],
|
||||
['014.xml', 14], ['剧名 - 15 (1080p).xml', 15], ['剧名_16.json', 16],
|
||||
['剧名.EP17.xml', 17], ['Show.Episode 18.xml', 18],
|
||||
['Show.2026.1080p.xml', null], ['Show.2026.xml', null], ['unknown.xml', null],
|
||||
['Show.E00.xml', null], ['Show.E9007199254740992.xml', null],
|
||||
]) assert.equal(context.localDanmuEpisodeFromFilename(filename), episode, filename);
|
||||
assert.match(HTML_TEMPLATE, /<input\b[^>]*id="local-danmu-file"[^>]*\bmultiple\b/);
|
||||
});
|
||||
|
||||
test('batch local danmu previews editable episodes and uploads serially with shared metadata', async () => {
|
||||
const posts = [];
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let listLoads = 0;
|
||||
let releaseFirst;
|
||||
const firstResponse = new Promise(resolve => { releaseFirst = resolve; });
|
||||
const { context, elements } = makePage(async (url, options = {}) => {
|
||||
if (options.method !== 'POST') {
|
||||
assert.equal(url, '/api/local-danmu/list');
|
||||
listLoads++;
|
||||
return Response.json({ success: true, groups: [] });
|
||||
}
|
||||
assert.equal(url, '/api/local-danmu/upload');
|
||||
posts.push(options.body);
|
||||
maxActive = Math.max(maxActive, ++active);
|
||||
if (posts.length === 1) await firstResponse;
|
||||
active--;
|
||||
return Response.json({ success: true, resource: { season: 2, count: 2 } });
|
||||
});
|
||||
context.initializeLocalDanmuForm();
|
||||
fillUploadForm(elements, { title: '批量剧集', season: '2' });
|
||||
const file = elements.get('local-danmu-file');
|
||||
file.files = ['Series.S02E10.xml', 'Series.S02E02.xml', 'Series.unknown.xml'].map(name => new File(['<i/>'], name));
|
||||
file.listeners.get('change')();
|
||||
const inputs = elements.get('local-danmu-batch-list').querySelectorAll('input');
|
||||
assert.deepEqual(inputs.map(input => input.value), ['2', '10', '']);
|
||||
assert.equal(elements.get('local-danmu-batch-preview').hidden, false);
|
||||
assert.equal(elements.get('local-danmu-episode-field').hidden, true);
|
||||
await context.uploadLocalDanmu();
|
||||
assert.equal(posts.length, 0);
|
||||
assert.match(elements.get('local-danmu-upload-status').textContent, /有效且不重复/);
|
||||
inputs[2].value = '12';
|
||||
inputs[2].listeners.get('input')();
|
||||
const pending = context.uploadLocalDanmu();
|
||||
try {
|
||||
assert.equal(posts.length, 1);
|
||||
assert.equal(file.disabled, true);
|
||||
assert.equal(elements.get('local-danmu-title').disabled, true);
|
||||
assert.equal(elements.get('local-danmu-upload-button').disabled, true);
|
||||
assert.ok(inputs.every(input => input.disabled));
|
||||
await context.uploadLocalDanmu();
|
||||
context.prepareLocalDanmuReupload(resource(3, 4));
|
||||
assert.equal(posts.length, 1);
|
||||
assert.equal(elements.get('local-danmu-title').value, '批量剧集');
|
||||
} finally { releaseFirst(); }
|
||||
await pending;
|
||||
assert.equal(maxActive, 1);
|
||||
assert.equal(listLoads, 1);
|
||||
assert.deepEqual(posts.map(body => body.get('episode')), ['2', '10', '12']);
|
||||
for (const body of posts) {
|
||||
assert.equal(body.get('title'), '批量剧集');
|
||||
assert.equal(body.get('year'), '2026');
|
||||
assert.equal(body.get('type'), 'tv');
|
||||
assert.equal(body.get('season'), '2');
|
||||
assert.equal(body.getAll('file').length, 1);
|
||||
}
|
||||
assert.equal(file.disabled, false);
|
||||
assert.equal(elements.get('local-danmu-upload-button').disabled, false);
|
||||
assert.ok(inputs.every(input => !input.disabled));
|
||||
assert.match(elements.get('local-danmu-upload-status').textContent, /成功 3 个,失败 0 个,共 6 条弹幕/);
|
||||
});
|
||||
|
||||
test('batch local danmu rejects invalid or duplicate episodes before uploading any files', async t => {
|
||||
for (const scenario of [
|
||||
{ name: 'duplicate filenames', names: ['A.E01.xml', 'B.E01.json'] },
|
||||
{ name: 'duplicate manual episode', value: '1' },
|
||||
{ name: 'empty episode', value: '' },
|
||||
{ name: 'zero episode', value: '0' },
|
||||
{ name: 'fractional episode', value: '1.5' },
|
||||
{ name: 'invalid numeric input', badInput: true },
|
||||
{ name: 'movie batch', type: 'movie' },
|
||||
]) {
|
||||
await t.test(scenario.name, async () => {
|
||||
let requests = 0;
|
||||
const { context, elements } = makePage(async () => { requests++; throw new Error('Unexpected request'); });
|
||||
fillUploadForm(elements, { type: scenario.type || 'tv' });
|
||||
elements.get('local-danmu-file').files = (scenario.names || ['E01.xml', 'E02.xml']).map(name => new File(['<i/>'], name));
|
||||
context.updateLocalDanmuUploadFiles();
|
||||
const input = elements.get('local-danmu-batch-list').querySelectorAll('input')[1];
|
||||
if (scenario.value !== undefined) input.value = scenario.value;
|
||||
if (scenario.badInput) input.validity.badInput = true;
|
||||
await context.uploadLocalDanmu();
|
||||
assert.equal(requests, 0);
|
||||
assert.match(elements.get('local-danmu-upload-status').textContent, scenario.type ? /tv/ : /有效且不重复/);
|
||||
assert.notEqual(elements.get('local-danmu-upload-button').disabled, true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('batch local danmu continues after failed or oversized files and reports each result', async () => {
|
||||
const uploads = [];
|
||||
let listLoads = 0;
|
||||
const { context, elements } = makePage(async (_url, options = {}) => {
|
||||
if (options.method !== 'POST') {
|
||||
listLoads++;
|
||||
return Response.json({ success: true, groups: [] });
|
||||
}
|
||||
const episode = Number(options.body.get('episode'));
|
||||
uploads.push(episode);
|
||||
if (episode === 2) return Response.json({ success: false, errorMessage: '文件中没有有效弹幕' }, { status: 400 });
|
||||
if (episode === 3) return new Response('<html>Bad gateway</html>', { status: 502 });
|
||||
if (episode === 4) throw new Error('offline');
|
||||
return Response.json({ success: true, resource: { count: 3 } });
|
||||
});
|
||||
fillUploadForm(elements);
|
||||
elements.get('local-danmu-file').files = [
|
||||
...[1, 2, 3, 4].map(episode => new File(['<i/>'], 'E0' + episode + '.xml')),
|
||||
{ name: 'E05.xml', size: 10 * 1024 * 1024 + 1 },
|
||||
new File(['<i/>'], 'E06.xml'),
|
||||
];
|
||||
await context.uploadLocalDanmu();
|
||||
assert.deepEqual(uploads, [1, 2, 3, 4, 6]);
|
||||
assert.equal(listLoads, 1);
|
||||
const statuses = elements.get('local-danmu-batch-list').querySelectorAll('.local-danmu-batch-status').map(element => element.textContent);
|
||||
assert.match(statuses[0], /成功/);
|
||||
assert.match(statuses[1], /文件中没有有效弹幕/);
|
||||
assert.match(statuses[2], /失败/);
|
||||
assert.match(statuses[3], /失败/);
|
||||
assert.match(statuses[4], /10 MB/);
|
||||
assert.match(statuses[5], /成功/);
|
||||
assert.match(elements.get('local-danmu-upload-status').textContent, /成功 2 个,失败 4 个,共 6 条弹幕/);
|
||||
assert.equal(elements.get('local-danmu-file').disabled, false);
|
||||
});
|
||||
|
||||
test('switching back to one local danmu file restores the manual episode field', async () => {
|
||||
let uploaded;
|
||||
const { context, elements } = makePage(async (_url, options = {}) => {
|
||||
if (options.method === 'POST') uploaded = options.body;
|
||||
return Response.json({ success: true, resource: { season: 1, count: 1 }, groups: [] });
|
||||
});
|
||||
context.initializeLocalDanmuForm();
|
||||
fillUploadForm(elements, { episode: '9' });
|
||||
const file = elements.get('local-danmu-file');
|
||||
file.files = ['E01.xml', 'E02.xml'].map(name => new File(['<i/>'], name));
|
||||
file.listeners.get('change')();
|
||||
file.files = [file.files[0]];
|
||||
file.listeners.get('change')();
|
||||
assert.equal(elements.get('local-danmu-batch-preview').hidden, true);
|
||||
assert.equal(elements.get('local-danmu-episode-field').hidden, false);
|
||||
assert.equal(elements.get('local-danmu-batch-list').children.length, 0);
|
||||
assert.equal(elements.get('local-danmu-upload-button').textContent, '上传并解析');
|
||||
await context.uploadLocalDanmu();
|
||||
assert.equal(uploaded.get('episode'), '9');
|
||||
});
|
||||
|
||||
test('upload sends the selected season and retains series fields for the next episode', async () => {
|
||||
let uploaded = null;
|
||||
const { context, elements } = makePage(async (_url, options = {}) => {
|
||||
|
||||
Reference in new issue
Block a user