From 0f02cdc036a57f67a649d445cb7dbf104e0ecc1e Mon Sep 17 00:00:00 2001 From: huangxd- <649924757@qq.com> Date: Sun, 13 Sep 2026 13:09:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(local-danmu):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=BC=B9=E5=B9=95=20(#479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 文档与批量预览样式 --- README.md | 6 +- danmu_api/apis/local-danmu-api.js | 87 +++++++- danmu_api/configs/globals.js | 2 +- danmu_api/server.js | 2 +- danmu_api/ui/README.md | 7 +- danmu_api/ui/css/base.css.js | 1 - danmu_api/ui/css/components.css.js | 66 +++++- danmu_api/ui/css/forms.css.js | 9 + danmu_api/ui/css/themes.css.js | 10 +- danmu_api/ui/js/localdanmu.js | 259 +++++++++++++++++++-- danmu_api/ui/template.js | 39 +++- danmu_api/worker.js | 10 +- danmu_api/worker.test.js | 348 ++++++++++++++++++++++++++++- 13 files changed, 800 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 8bb5534..e69e5c3 100644 --- a/README.md +++ b/README.md @@ -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`(支持多个服务器并发查询)。 diff --git a/danmu_api/apis/local-danmu-api.js b/danmu_api/apis/local-danmu-api.js index eef0bdb..894c470 100644 --- a/danmu_api/apis/local-danmu-api.js +++ b/danmu_api/apis/local-danmu-api.js @@ -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); } diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index ba9cc60..bbca7be 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.21.0', + VERSION: '1.21.1', MAX_LOGS: 1000, // 日志存储,最多保存 1000 行 MAX_RECORDS: 100, // 请求记录最大数量 diff --git a/danmu_api/server.js b/danmu_api/server.js index 209f704..bd4a2d5 100644 --- a/danmu_api/server.js +++ b/danmu_api/server.js @@ -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); } diff --git a/danmu_api/ui/README.md b/danmu_api/ui/README.md index b06b60e..037a51e 100644 --- a/danmu_api/ui/README.md +++ b/danmu_api/ui/README.md @@ -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` 也可以执行。 diff --git a/danmu_api/ui/css/base.css.js b/danmu_api/ui/css/base.css.js index b114770..dfface1 100644 --- a/danmu_api/ui/css/base.css.js +++ b/danmu_api/ui/css/base.css.js @@ -19,7 +19,6 @@ html { overflow-y: scroll; scrollbar-gutter: stable; background: var(--theme-page-bg); - color-scheme: light !important; } html.modal-open { diff --git a/danmu_api/ui/css/components.css.js b/danmu_api/ui/css/components.css.js index 3ed383a..95540ac 100644 --- a/danmu_api/ui/css/components.css.js +++ b/danmu_api/ui/css/components.css.js @@ -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, diff --git a/danmu_api/ui/css/forms.css.js b/danmu_api/ui/css/forms.css.js index cc6a7ff..60bc716 100644 --- a/danmu_api/ui/css/forms.css.js +++ b/danmu_api/ui/css/forms.css.js @@ -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 { diff --git a/danmu_api/ui/css/themes.css.js b/danmu_api/ui/css/themes.css.js index 95d5e8e..05c0808 100644 --- a/danmu_api/ui/css/themes.css.js +++ b/danmu_api/ui/css/themes.css.js @@ -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)); } } -`; \ No newline at end of file +`; diff --git a/danmu_api/ui/js/localdanmu.js b/danmu_api/ui/js/localdanmu.js index fbe6fee..a72d0c1 100644 --- a/danmu_api/ui/js/localdanmu.js +++ b/danmu_api/ui/js/localdanmu.js @@ -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(); diff --git a/danmu_api/ui/template.js b/danmu_api/ui/template.js index bb1faa8..546e87b 100644 --- a/danmu_api/ui/template.js +++ b/danmu_api/ui/template.js @@ -83,8 +83,8 @@ export const HTML_TEMPLATE = /* html */ ` - + @@ -307,10 +307,10 @@ export const HTML_TEMPLATE = /* html */ `
- -

支持 XML、JSON、ASS、SSA、CSV、TXT,单个文件不超过 10 MB

+ +

支持 XML、JSON、ASS、SSA、CSV、TXT,可多选同一部剧的弹幕文件,每个文件不超过 10 MB

-
+
@@ -327,12 +327,20 @@ export const HTML_TEMPLATE = /* html */ `
-
+

tv 默认第 1 季第 1 集,movie 的季和集可留空。标题、年份、类型和季相同的文件会归为一个剧集,展开后可查看各集。同一季的同一集重新上传会替换原文件。

+
+
@@ -492,6 +500,27 @@ export const HTML_TEMPLATE = /* html */ ` + + +