diff --git a/extension/agent-writes.js b/extension/agent-writes.js index f4e5b0b1..16d09735 100644 --- a/extension/agent-writes.js +++ b/extension/agent-writes.js @@ -325,13 +325,14 @@ return bvids.map((b) => by.get(b)); } - // One run of B站 requests over items, a chunk per request, until the call's time or B站's limit ends it: send(chunk) + // One run of B站 requests over items, a chunk of size per request (20 for a folder's batch endpoints, TRIAGE_DEAL_MAX + // where B站 adds to a folder or takes out of 稍后再看, 1 for 稍后再看's add), until the call's time or B站's limit ends + // it: send(chunk) // makes the request (triage-bg.js's handler, as the 分拣台 sends it), then landed(chunk, reply) keeps the local // records. Resolves { done: the items written, failed: those B站 said no to (skip, one at a time), stopped?: // "TIMEOUT" | "THROTTLED" }; another error is thrown with .done, the items written before it. - async function run(items, one, deadline, send, landed, { skip = false } = {}) { - const size = one ? 1 : 20; - const gap = one ? 300 : 1000; + async function run(items, size, deadline, send, landed, { skip = false } = {}) { + const gap = size === 1 ? 300 : 1000; // triage-bg.js spaces its batch adds itself (triageDealPost) const done = []; const failed = []; for (let i = 0; i < items.length; i += size) { @@ -345,7 +346,7 @@ if (e.code === MdgErrors.CODE.THROTTLED) return { done, failed, stopped: "THROTTLED" }; // B站 said no to this one video (deleted, say): an undo goes on with the others. A request that failed (HTTP // 500, no JSON) is not that, and stops the run as any error. - if (skip && one && e.refused) { + if (skip && size === 1 && e.refused) { failed.push(...chunk); continue; } @@ -363,7 +364,7 @@ // 移动 / 复制 from → to, records kept as the 分拣台's transfer keeps them. Resolves run's result. async function transfer(from, to, items, move, deadline) { const [fromName, toName] = [await titleOf(from), await titleOf(to)]; - return run(items, from === TOVIEW, deadline, + return run(items, from === TOVIEW ? TRIAGE_DEAL_MAX : 20, deadline, (chunk) => TRIAGE_HANDLERS["triage-transfer"]({ from, to, aids: chunk.map(res), move }), (chunk, reply) => R.lock(async () => { const chosen = await chosenIds(); @@ -383,7 +384,7 @@ // Out of a folder with no 取消收藏 record (a 复制 undone): the cached list just loses them. const remove = (folder, items, deadline) => - run(items, folder === TOVIEW, deadline, + run(items, folder === TOVIEW ? TRIAGE_DEAL_MAX : 20, deadline, (chunk) => TRIAGE_HANDLERS["triage-unfav"]({ mediaId: folder, aids: chunk.map(res) }), (chunk) => R.lock(() => R.patchSnapshotNow(folder, { drop: chunk.map((it) => it.bvid) }))); @@ -394,14 +395,15 @@ const others = chosen.filter((id) => id !== folder && id !== TOVIEW); const got = await chrome.storage.local.get(others.map(K.triageSnapshot)); const elsewhere = new Set(others.flatMap((id) => got[K.triageSnapshot(id)]?.bvids || [])); - return run(items, folder === TOVIEW, deadline, + return run(items, folder === TOVIEW ? TRIAGE_DEAL_MAX : 20, deadline, (chunk) => TRIAGE_HANDLERS["triage-unfav"]({ mediaId: folder, aids: chunk.map(res) }), (chunk) => chosen.includes(folder) && R.lock(() => R.patchDecisionsNow(folder, Object.fromEntries(chunk.map((it) => [it.bvid, R.unfavRecord(it, Date.now(), batch, elsewhere.has(it.bvid))]))))); } - // Back into the folder, one per request (B站 has no batch for it); its 取消收藏 record goes. + // Back into the folder, one per request, so a video B站 refuses (deleted, say) is skipped and the others go on; its + // 取消收藏 record goes. const refav = (folder, items, deadline) => - run(items, true, deadline, + run(items, 1, deadline, ([it]) => TRIAGE_HANDLERS["triage-refav"]({ mediaId: folder, aid: res(it) }), ([it]) => R.lock(async () => { await R.patchDecisionsNow(folder, { [it.bvid]: null }); @@ -567,7 +569,7 @@ const old = op === "move" ? items.filter((it) => had.has(it.bvid)) : []; // 稍后再看 takes no 移动: each goes back into it, then out of the target. if (op === "move" && from === TOVIEW) { - const r = await run(items, true, deadline, + const r = await run(items, 1, deadline, async ([it]) => { await TRIAGE_HANDLERS["triage-refav"]({ mediaId: TOVIEW, aid: res(it) }); if (!had.has(it.bvid)) await TRIAGE_HANDLERS["triage-unfav"]({ mediaId: to, aids: [res(it)] }); diff --git a/extension/messages.js b/extension/messages.js index c07a298d..ae4a6ac0 100644 --- a/extension/messages.js +++ b/extension/messages.js @@ -87,7 +87,7 @@ "triage-zones": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["items"], response: data }, "triage-folder-create": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["title", "privacy"], response: data }, "triage-unfav": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["mediaId", "aids"], response: data }, - "triage-refav": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["mediaId", "aid"], response: data }, + "triage-refav": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["mediaId", "aids"], response: data }, "triage-transfer": { from: ["triage/fav-writes.js"], to: TRIAGE_BG, request: ["from", "to", "aids", "move"], response: data }, "triage-title-get": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["bvids"], response: data }, "triage-analysis-get": { from: ["triage/fav-folders.js"], to: TRIAGE_BG, request: ["bvids"], response: data }, diff --git a/extension/triage/dev/mock-chrome.js b/extension/triage/dev/mock-chrome.js index 03c41e0d..623c077f 100644 --- a/extension/triage/dev/mock-chrome.js +++ b/extension/triage/dev/mock-chrome.js @@ -311,12 +311,15 @@ } return { ok: true, data: { done: aids.length } }; }, - "triage-refav": ({ mediaId, aid }) => { - const r = removed.get(`${mediaId}:${aid}`); - if (!r) return { ok: false, error: "找不到要恢复的视频" }; - r.folder.items.splice(Math.min(r.index, r.folder.items.length), 0, r.item); - removed.delete(`${mediaId}:${aid}`); - return { ok: true, data: { done: 1 } }; + "triage-refav": ({ mediaId, aid, aids }) => { + const list = aids || [aid]; + const found = list.map((a) => removed.get(`${mediaId}:${a}`)); + if (found.some((r) => !r)) return { ok: false, error: "找不到要恢复的视频" }; + for (const [k, r] of found.entries()) { + r.folder.items.splice(Math.min(r.index, r.folder.items.length), 0, r.item); + removed.delete(`${mediaId}:${list[k]}`); + } + return { ok: true, data: { done: list.length } }; }, "triage-settings-get": () => ({ ok: true, diff --git a/extension/triage/fav-writes.js b/extension/triage/fav-writes.js index 7f8ebd09..fce025c1 100644 --- a/extension/triage/fav-writes.js +++ b/extension/triage/fav-writes.js @@ -124,19 +124,22 @@ const W = MdgWrites.create({ line: favLine, settleMs: globalThis.__TRIAGE_SETTLE_MS // the selftest's; undefined leaves writes.js's 3 s }); -// Bilibili takes 稍后再看 (and 收藏到 from 已出分拣范围) one video per request: one per chunk keeps the count exact on a -// failure. Other folders take 20, a second apart. -const chunkSize = (one) => (one ? 1 : 20); -const chunkGap = (one) => (one ? 300 : 1000); -function chunksOf(items, one, make) { +// Chunks a second apart. Bilibili takes 10 videos a request where it adds them to a folder (收藏 / 恢复收藏, and 移动 / +// 复制 out of 稍后再看: resource/batch-deal) or takes them out of 稍后再看, and the worker spaces those requests 3 s +// apart (triage-bg.js TRIAGE_DEAL_MAX, triageDealPost); its other batch endpoints take 20. Adding to 稍后再看 is one video +// a request. Each chunk is one request (or one add and one removal), all or nothing, so the count stays exact on a +// failure. +const DEAL_MAX = 10; +function chunksOf(items, size, make) { const out = []; - for (let i = 0; i < items.length; i += chunkSize(one)) out.push({ ...make(items.slice(i, i + chunkSize(one))), gap: chunkGap(one) }); + for (let i = 0; i < items.length; i += size) out.push({ ...make(items.slice(i, i + size)), gap: 1000 }); return out; } -const refavChunk = (f, it) => ({ msg: { type: "triage-refav", mediaId: f, aid: favRes(it) }, keys: [it.bvid], folders: [f], item: favRef(it), gap: chunkGap(true) }); -async function refavDone({ folders: [f], keys: [bvid] }) { - bumpCount(f, 1); - await patchDecisions(f, { [bvid]: null }); +const refavChunks = (f, items) => + chunksOf(items, f === TOVIEW ? 1 : DEAL_MAX, (its) => ({ msg: { type: "triage-refav", mediaId: f, aids: its.map(favRes) }, keys: its.map((it) => it.bvid), folders: [f], items: its.map(favRef) })); +async function refavDone({ folders: [f], keys }) { + bumpCount(f, keys.length); + await patchDecisions(f, Object.fromEntries(keys.map((b) => [b, null]))); renderStatus(); } // A B站 batch write that failed midway: what went through stays. @@ -148,7 +151,7 @@ const busyError = (r) => r.error || UI.busyText(r.busy); // Re-favorites items ([{ bvid, aid, ogv }]) into mediaId, one per request; the first failure stops it. It changes Bilibili, // so it runs to the end even after another folder opens. Returns { n, rest, error }. async function refavInto(mediaId, items) { - const r = await W.run("refav", REFAV, { verb: REFAV, chunks: items.map((it) => refavChunk(mediaId, it)), folders: [mediaId], meta: { mediaId, verb: REFAV }, onChunk: refavDone }); + const r = await W.run("refav", REFAV, { verb: REFAV, chunks: refavChunks(mediaId, items), folders: [mediaId], meta: { mediaId, verb: REFAV }, onChunk: refavDone }); render(); const went = new Set(r.keys || []); return { n: r.done, rest: items.filter((it) => !went.has(it.bvid)), error: busyError(r), stopped: r.stopped }; @@ -156,25 +159,29 @@ async function refavInto(mediaId, items) { // 所有收藏夹's batch 取消收藏 undone: each video back in every folder it was taken out of, as one run. async function refavGone(gone) { - const chunks = Object.entries(gone).flatMap(([f, items]) => items.map((it) => refavChunk(f, it))); + const chunks = Object.entries(gone).flatMap(([f, items]) => refavChunks(f, items)); + const went = new Set(); // the chunks that went through: a video taken out of two folders has a chunk in each const r = await W.run("refav", REFAV, { verb: REFAV, chunks, folders: [String(S.mediaId)], meta: { mediaId: String(S.mediaId), verb: REFAV }, async onChunk(c) { + went.add(c); await refavDone(c); const [f] = c.folders; - const it = S.itemMap.get(c.keys[0]); - if (!it?.folders) return; - it.folders = [...new Set([...it.folders, f])]; - it.left = (it.left || []).filter((x) => x !== f); - if (S.decisions[it.bvid]?.action === "unfav") unfavGone(it.bvid); + for (const bvid of c.keys) { + const it = S.itemMap.get(bvid); + if (!it?.folders) continue; + it.folders = [...new Set([...it.folders, f])]; + it.left = (it.left || []).filter((x) => x !== f); + if (S.decisions[it.bvid]?.action === "unfav") unfavGone(it.bvid); + } } }); render(); const rest = {}; - for (const c of chunks.slice(r.done)) (rest[c.folders[0]] ||= []).push(c.item); + for (const c of chunks) if (!went.has(c)) (rest[c.folders[0]] ||= []).push(...c.items); return { n: r.done, rest, error: busyError(r), stopped: r.stopped }; } @@ -305,7 +312,7 @@ const fav = { const gone = {}; // folder → the videos taken out of it const elsewhere = all ? null : await favedElsewhere(mediaId); const chunks = folders.flatMap((f) => - chunksOf(all ? list.filter((it) => it.folders.includes(f)) : list, f === TOVIEW, (items) => ({ msg: { type: "triage-unfav", mediaId: f, aids: items.map(favRes) }, keys: items.map((it) => it.bvid), folders: [f], items })) + chunksOf(all ? list.filter((it) => it.folders.includes(f)) : list, f === TOVIEW ? DEAL_MAX : 20, (items) => ({ msg: { type: "triage-unfav", mediaId: f, aids: items.map(favRes) }, keys: items.map((it) => it.bvid), folders: [f], items })) ); const r = await W.run("unfav", UNFAV_MANY, { verb: UNFAV, @@ -385,7 +392,7 @@ const fav = { S.included = await MdgLimits.update(K.included, (stored) => [...new Set([...(stored || []).map(String), meta.to])]); } const { to, toName } = meta; - const chunks = chunksOf(list, add || from === TOVIEW, (items) => ({ msg: { type: "triage-transfer", from: add ? "" : from, to, aids: items.map(favRes), move }, keys: items.map((it) => it.bvid), folders: [from, to], items })); + const chunks = chunksOf(list, add || from === TOVIEW ? DEAL_MAX : 20, (items) => ({ msg: { type: "triage-transfer", from: add ? "" : from, to, aids: items.map(favRes), move }, keys: items.map((it) => it.bvid), folders: [from, to], items })); return { verb, chunks, diff --git a/extension/triage/triage-bg.js b/extension/triage/triage-bg.js index d88e4ded..b2f53141 100644 --- a/extension/triage/triage-bg.js +++ b/extension/triage/triage-bg.js @@ -300,8 +300,9 @@ const triageNotLoggedIn = () => triageError(MdgErrors.TEXT.biliLogin, "NOT_LOGGE function triageCodeError(json, what = "B站返回 ", keep = false) { if (json.code === -101) return triageNotLoggedIn(); if (BILI_RISK_CODES.has(json.code)) return triageError(MdgErrors.TEXT.biliThrottled, MdgErrors.CODE.THROTTLED); - // refused: B站 answered and said no to this request (a deleted video, a full list), unlike a request that failed. - return Object.assign(triageError(`${what}${json.code}:${json.message || ""}`, keep ? json.code : undefined), { refused: true }); + // refused: B站 answered and said no to this request (a deleted video, a full list), unlike a request that failed; + // biliCode is B站's code either way, for a caller that tells one refusal from another. + return Object.assign(triageError(`${what}${json.code}:${json.message || ""}`, keep ? json.code : undefined), { refused: true, biliCode: json.code }); } function triageBiliData(json) { if (json.code !== 0) throw triageCodeError(json); @@ -946,6 +947,43 @@ const triageRes = (v) => { return { rid, type: Number(type), resource: `${rid}:${type}` }; }; const triageAids = (v) => (Array.isArray(v) && v.every(triageResOk) ? v : []); +// Bilibili adds at most this many videos to a folder in one request (resource/batch-deal: 11 add the first 10 and fail +// with -702, 2026-11-12), and 稍后再看's removal takes as many (toview/del with aids joined by commas). Two batch adds +// right after each other are refused with the same -702 and add nothing; 3 s apart both go through. So every batch write +// here, whoever sends it (a page's run, another run started meanwhile, the MCP), waits until TRIAGE_DEAL_GAP_MS after +// the last one (triageDealPost). A -702 that still comes is asked again once after the gap; a second one is reported as +// B站 said it. Adding a video already there, or with an aid B站 does not know among the others, still answers 0 +// (2026-11-12), so asking again is safe. +const TRIAGE_DEAL_MAX = 10; +const TRIAGE_DEAL_GAP_MS = 3000; +const TRIAGE_DEAL_BUSY = -702; +const triageDealParts = (list) => Array.from({ length: Math.ceil(list.length / TRIAGE_DEAL_MAX) }, (_, i) => list.slice(i * TRIAGE_DEAL_MAX, (i + 1) * TRIAGE_DEAL_MAX)); +let triageDealAt = 0; // when the last batch write went out; the worker is the only sender +async function triageDealPost(path, fields) { + for (let again = true; ; again = false) { + // ponytail: a worker-wide stamp; two writes waiting at once both leave after the same stamp, then the next waits + const wait = triageDealAt + TRIAGE_DEAL_GAP_MS - Date.now(); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + triageDealAt = Date.now(); + try { + return await triageBiliPost(path, fields); + } catch (e) { + if (e.biliCode !== TRIAGE_DEAL_BUSY || !again) throw e; + } finally { + triageDealAt = Date.now(); + } + } +} +// One video goes through resource/deal, as before batching: its refusal (稿件不存在…) is that video's, which the MCP's +// undo, adding one at a time, goes on past. +const triageDeal = (aids, mediaId) => { + if (aids.length === 1) { + const { rid, type } = triageRes(aids[0]); + return triageBiliPost("/x/v3/fav/resource/deal", { rid, type, add_media_ids: mediaId, del_media_ids: "" }); + } + return triageDealPost("/x/v3/fav/resource/batch-deal", { resources: aids.map((a) => triageRes(a).resource).join(","), add_media_ids: mediaId, del_media_ids: "", platform: "web" }); +}; +const triageToviewDel = (aids) => triageDealPost("/x/v2/history/toview/del", { aid: aids.map((a) => triageRes(a).rid).join(",") }); // The inverse of sites.js biliAvToBv; "" for anything that is not a BV id. A type-24 favorite's id is its episode id, // so its archive aid comes from the list's (correct) bvid. function triageBvToAv(bvid) { @@ -1110,7 +1148,7 @@ const TRIAGE_HANDLERS = { const list = triageAids(aids); if (!triageFolderId(mediaId) || !list.length) throw triageError("缺少 mediaId 或 aids"); if (mediaId === TRIAGE_TOVIEW) { - for (const a of list) await triageBiliPost("/x/v2/history/toview/del", { aid: triageRes(a).rid }); + for (const part of triageDealParts(list)) await triageToviewDel(part); return { done: list.length }; } await triageBiliPost("/x/v3/fav/resource/batch-del", { @@ -1121,35 +1159,36 @@ const TRIAGE_HANDLERS = { return { done: list.length }; }, - "triage-refav": async ({ mediaId, aid }) => { - if (!triageFolderId(mediaId) || !triageResOk(aid)) throw triageError("缺少 mediaId 或 aid"); + // aids (or one aid) back into mediaId: a folder takes TRIAGE_DEAL_MAX a request, 稍后再看 one. + "triage-refav": async ({ mediaId, aid, aids }) => { + const list = triageAids(aids ?? [aid]); + if (!triageFolderId(mediaId) || !list.length) throw triageError("缺少 mediaId 或 aid"); if (mediaId === TRIAGE_TOVIEW) { - await triageBiliPost("/x/v2/history/toview/add", { aid: triageRes(aid).rid }); + for (const a of list) await triageBiliPost("/x/v2/history/toview/add", { aid: triageRes(a).rid }); return {}; } - const { rid, type } = triageRes(aid); - await triageBiliPost("/x/v3/fav/resource/deal", { rid, type, add_media_ids: mediaId, del_media_ids: "" }); + for (const part of triageDealParts(list)) await triageDeal(part, mediaId); return {}; }, // 移动 / 复制 to another folder (never 稍后再看); without from, just 收藏 into it (videos from 已出分拣范围). - // From 稍后再看 or nowhere there is no batch endpoint: add one by one, then remove. The page sends one video per - // request from 稍后再看. A removal that B站 limits is thrown as it is: the run waits and sends the same video again (the - // add is idempotent). Any other failed removal is not undone and not a failure: the video is in the target and still - // in 稍后再看, answered as stayed (its aids) with the reason, so the page shows it copied rather than moved. + // From 稍后再看 or nowhere there is no move endpoint: add (TRIAGE_DEAL_MAX a request), then take them out of 稍后再看 + // in one request; the page sends that many per chunk. A removal that B站 limits is thrown as it is: the run waits and + // sends the same chunk again (the add is idempotent). Any other failed removal is not undone and not a failure: the + // videos are in the target and still in 稍后再看, answered as stayed (their aids) with the reason, so the page shows + // them copied rather than moved. "triage-transfer": async ({ from, to, aids, move }) => { const list = triageAids(aids); if (!triageNumId(to) || (from && !triageFolderId(from)) || String(from) === String(to) || !list.length) throw triageError("缺少目标或 aids"); if (!from || from === TRIAGE_TOVIEW) { const stayed = []; let stayedError = ""; - for (const aid of list) { - const { rid, type } = triageRes(aid); - await triageBiliPost("/x/v3/fav/resource/deal", { rid, type, add_media_ids: to, del_media_ids: "" }); + for (const part of triageDealParts(list)) { + await triageDeal(part, to); if (move && from) { - await triageBiliPost("/x/v2/history/toview/del", { aid: rid }).catch((e) => { + await triageToviewDel(part).catch((e) => { if (e.code === MdgErrors.CODE.THROTTLED) throw e; - stayed.push(aid); + stayed.push(...part); stayedError = e.message; }); } diff --git a/extension/triage/triage-bg.selftest.js b/extension/triage/triage-bg.selftest.js index 74bd1c8d..81b20d15 100644 --- a/extension/triage/triage-bg.selftest.js +++ b/extension/triage/triage-bg.selftest.js @@ -817,7 +817,29 @@ assert.throws(() => t.triageParseCommand('{"new_tags":[', cmdItems, cmdTags, {}) t.fetch = realFetch; calls.length = 0; await H["triage-transfer"]({ from: "", to: "2", aids: [10, 11], move: false }); - assert.deepStrictEqual(calls.splice(0).map((c) => c[0]), ["/x/v3/fav/resource/deal", "/x/v3/fav/resource/deal"]); + assert.deepStrictEqual(calls.splice(0), [["/x/v3/fav/resource/batch-deal", { resources: "10:2,11:2", add_media_ids: "2", del_media_ids: "", platform: "web", csrf: "csrf" }]], "several videos added in one request"); + // B站 adds at most 10 a request (11 add the first 10 and fail): 12 go as 10, then the last 2; 稍后再看's removal + // follows each add with the same videos. + const twelve = Array.from({ length: 12 }, (_, i) => 100 + i); + await H["triage-transfer"]({ from: "toview", to: "2", aids: twelve, move: true }); + assert.deepStrictEqual(calls.splice(0).map(([p, f]) => [p, (f.resources || f.aid).split(",").length]), [ + ["/x/v3/fav/resource/batch-deal", 10], ["/x/v2/history/toview/del", 10], ["/x/v3/fav/resource/batch-deal", 2], ["/x/v2/history/toview/del", 2] + ]); + await H["triage-unfav"]({ mediaId: "toview", aids: twelve.slice(0, 11) }); + await H["triage-refav"]({ mediaId: "1", aids: twelve.slice(0, 3) }); + assert.deepStrictEqual(calls.splice(0).map(([p, f]) => [p, (f.resources || f.aid).split(",").length]), [ + ["/x/v2/history/toview/del", 10], ["/x/v2/history/toview/del", 1], ["/x/v3/fav/resource/batch-deal", 3] + ]); + // -702 to a batch add (two adds too close together, which add nothing) is asked again once after the gap; a second + // one is reported as B站 said it, not retried for ever. + const plainFetch = t.fetch; + let busy = 1; + t.fetch = async (url, opts) => (/batch-deal/.test(url) && busy-- > 0 ? { ok: true, status: 200, json: async () => ({ code: -702, message: "请求频率过高,请稍后再试" }) } : plainFetch(url, opts)); + await H["triage-refav"]({ mediaId: "1", aids: [10, 11] }); + assert.deepStrictEqual(calls.splice(0).map((c) => c[0]), ["/x/v3/fav/resource/batch-deal"], "the second ask went through"); + busy = 2; + await assert.rejects(H["triage-refav"]({ mediaId: "1", aids: [10, 11] }), /-702/); + t.fetch = plainFetch; await assert.rejects(H["triage-transfer"]({ from: "1", to: "toview", aids: [10], move: true }), /缺少目标/); await assert.rejects(H["triage-transfer"]({ from: "1", to: "1", aids: [10], move: true }), /缺少目标/); // C528: an id that is not digits never reaches B站's form fields (an aid "10:2,99" would add a resource). @@ -868,8 +890,7 @@ assert.throws(() => t.triageParseCommand('{"new_tags":[', cmdItems, cmdTags, {}) ["/x/v3/fav/resource/deal", "714816118:42"], ["/x/v3/fav/folder/created/list-all", "714816118:42"], ["/x/v3/fav/folder/created/list-all", "10:2"], - ["/x/v2/history/toview/del", "714816118"], - ["/x/v2/history/toview/del", "10"], + ["/x/v2/history/toview/del", "714816118,10"], ["/x/v2/history/toview/add", "714816118"], ["/x/v3/fav/resource/deal", "714816118:42"], ["/x/v2/history/toview/del", "714816118"] diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index dc633a26..9fd70691 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -256,7 +256,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = openRecent({ A: { ...structuredClone(savedA), BVsolo: { action: "unfav", at: 1, aid: 7, title: "单个" } } }); assert.ok(t.recentUnfavHtml().includes(`data-refav-batch="${savedA.BV0.batch}" data-folder="A"`) && t.recentUnfavHtml().includes("批量取消收藏 45 个")); const refavAids = []; - handlers["triage-refav"] = (m) => (refavAids.push([m.mediaId, m.aid]), { ok: true }); + handlers["triage-refav"] = (m) => (refavAids.push(...m.aids.map((a) => [m.mediaId, a])), { ok: true }); await t.fav.refavBatch(savedA.BV0.batch, "A"); assert.strictEqual(refavAids.length, 45, "the batch, not the single one"); assert.ok(refavAids.every(([f]) => f === "A"), "back into the folder it left"); @@ -308,7 +308,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = }; await settled(); await t.fav.refav("BV7", "A"); - assert.deepStrictEqual(plain(sent.at(-1)), { type: "triage-refav", mediaId: "A", aid: 1007 }); + assert.deepStrictEqual(plain(sent.at(-1)), { type: "triage-refav", mediaId: "A", aids: [1007] }); assert.ok(!("BV7" in store[t.K.decisions("A")]) && !t.recentUnfavHtml().includes('data-refav="BV7"'), "re-favorited video leaves the recent list"); // X10: the folder counts as written, so the read after it waits for B站 to catch up. assert.ok(t.fav.state().writing("A"), "the read after a 重新收藏 waits for B站 to catch up"); @@ -318,7 +318,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.strictEqual(t.recentUnfavs().length, 50, "the recent list is capped"); assert.strictEqual(t.recentUnfavs()[0][0], "BVx59", "newest first"); - // B9: a refav failure partway through a batch undo keeps the rest undoable. + // B9: a refav failure in a batch undo keeps the chunk it was in undoable (10 a request: here all three). const three = [item(1), item(2), item(3)]; const unfavThree = async (mediaId) => { openFake(mediaId, three); @@ -327,14 +327,14 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(Object.keys(store[t.K.decisions(mediaId)]), ["BV1", "BV2", "BV3"]); }; await unfavThree("D"); - handlers["triage-refav"] = ({ aid }) => (aid === 1002 ? { ok: false, error: "网络错误" } : { ok: true }); + handlers["triage-refav"] = ({ aids }) => (aids.includes(1002) ? { ok: false, error: "网络错误" } : { ok: true }); await t.undo(); - assert.deepStrictEqual(Object.keys(store[t.K.decisions("D")]), ["BV2", "BV3"]); - assert.strictEqual(toasts.at(-1), "撤销中断(已重新收藏 1 个,剩余 2 个可再按 U 重试):网络错误"); + assert.deepStrictEqual(Object.keys(store[t.K.decisions("D")]), ["BV1", "BV2", "BV3"]); + assert.strictEqual(toasts.at(-1), "撤销中断(已重新收藏 0 个,剩余 3 个可再按 U 重试):网络错误"); const retried = []; - handlers["triage-refav"] = ({ aid }) => (retried.push(aid), { ok: true }); + handlers["triage-refav"] = ({ aids }) => (retried.push(aids), { ok: true }); await t.undo(); - assert.deepStrictEqual(retried, [1002, 1003], "U again re-favorites the ones left"); + assert.deepStrictEqual(plain(retried), [[1001, 1002, 1003]], "U again re-favorites the ones left, in one request"); assert.deepStrictEqual(store[t.K.decisions("D")], {}); assert.strictEqual(t.S.undo.length, 0); @@ -352,7 +352,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = openRecent({ R: { BV60: { action: "unfav", at: 1, aid: 1060, title: "视频60" } } }); handlers["triage-refav"] = limitedOnce(refavs); await t.fav.refav("BV60", "R"); - assert.deepStrictEqual(plain(refavs), [1060, 1060]); + assert.deepStrictEqual(plain(refavs), [[1060], [1060]]); assert.ok(!("BV60" in store[t.K.decisions("R")]) && toasts.at(-1) === "已重新收藏《视频60》到「R」", toasts.at(-1)); const moves = []; t.S.allFolders = t.S.folders = [{ id: "R", title: "源" }, { id: "T", title: "目标" }]; @@ -373,33 +373,34 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = await settled(); } - // G2: 稍后再看 goes one video per request; a B站 limit waits and retries the same one; a failure keeps the ones done - // (recorded, undoable) and says how many. + // G2: 稍后再看 goes 10 videos a request; a B站 limit waits and retries the same chunk; a failure keeps the chunks done + // (recorded, undoable) and says how many. Back into 稍后再看 is one video a request. { - const five = [1, 2, 3, 4, 5].map((n) => item(70 + n)); - openFake("toview", five); + const twelve = Array.from({ length: 12 }, (_, i) => item(71 + i)); + openFake("toview", twelve); const tv = []; let limited = false; handlers["triage-unfav"] = (m) => { - tv.push(m.aids); - if (m.aids[0] === 1072 && !limited) return (limited = true), { ok: false, code: "THROTTLED", error: "412" }; - return m.aids[0] === 1074 ? { ok: false, error: "网络错误" } : { ok: true }; + tv.push(m.aids.length); + if (!limited) return (limited = true), { ok: false, code: "THROTTLED", error: "412" }; + return m.aids.includes(1081) ? { ok: false, error: "网络错误" } : { ok: true }; }; - await t.fav.unfavMany(five); - assert.deepStrictEqual(plain(tv), [[1071], [1072], [1072], [1073], [1074]]); - assert.deepStrictEqual(Object.keys(store[t.K.decisions("toview")]), ["BV71", "BV72", "BV73"]); - assert.strictEqual(toasts.at(-1), "批量取消收藏中断(已完成 3 个):网络错误 · U 撤销已完成的"); + await t.fav.unfavMany(twelve); + assert.deepStrictEqual(tv, [10, 10, 2]); + assert.strictEqual(Object.keys(store[t.K.decisions("toview")]).length, 10); + assert.strictEqual(toasts.at(-1), "批量取消收藏中断(已完成 10 个):网络错误 · U 撤销已完成的"); const back = []; - handlers["triage-refav"] = (m) => (back.push(m.aid), { ok: true }); + handlers["triage-refav"] = (m) => (back.push(m.aids), { ok: true }); await t.undo(); - assert.deepStrictEqual(back, [1071, 1072, 1073], "U re-favorites the ones done"); + assert.deepStrictEqual(plain(back), twelve.slice(0, 10).map((it) => [it.aid]), "U re-favorites the ones done, one a request"); } // A batch undo also runs to the end after another folder opens. await unfavThree("D"); let refavs = 0; - handlers["triage-refav"] = () => { - if (++refavs === 1) openFake("E", []); + handlers["triage-refav"] = (m) => { + if (!refavs) openFake("E", []); + refavs += m.aids.length; return { ok: true }; }; await t.undo(); @@ -631,12 +632,13 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual([els("W1", "BV60"), els("W1", "BV61"), els("W2", "BV61"), els("W2", "BV62")], [undefined, true, undefined, true]); const refav = []; const pills = []; - handlers["triage-refav"] = (m) => (refav.push([m.mediaId, m.aid]), pills.push([t.activityState().text, t.fav.state().busy, t.fav.state().writing(m.mediaId)]), { ok: true }); + handlers["triage-refav"] = (m) => (refav.push(...m.aids.map((a) => [m.mediaId, a])), pills.push([t.activityState().text, t.fav.state().busy, t.fav.state().writing(m.mediaId)]), { ok: true }); await t.undo(); assert.deepStrictEqual([asked[0], asked[2]], ["在 B站重新收藏这 3 个视频?", "重新收藏 3 个"], "U asks first"); assert.deepStrictEqual(refav, [["W1", 1060], ["W1", 1061], ["W2", 1061], ["W2", 1062]]); - // G7: the undo shows in the pill, folder by folder, and counts as a batch write on the folder it writes to. - assert.deepStrictEqual(pills, [["重新收藏中「夹1」 0/4", "重新收藏", true], ["重新收藏中「夹1」 1/4", "重新收藏", true], ["重新收藏中「夹2」 2/4", "重新收藏", true], ["重新收藏中「夹2」 3/4", "重新收藏", true]]); + // G7: the undo shows in the pill, folder by folder (one request each), and counts as a batch write on the folder it + // writes to. + assert.deepStrictEqual(pills, [["重新收藏中「夹1」 0/4", "重新收藏", true], ["重新收藏中「夹2」 2/4", "重新收藏", true]]); assert.strictEqual(t.fav.state().busy, ""); assert.deepStrictEqual(plain(vids.map((it) => [...it.folders].sort())), [["W1"], ["W1", "W2"], ["W2", "W3"]]); assert.deepStrictEqual([t.S.decisions, store[t.K.decisions("W1")], store[t.K.decisions("W2")], t.S.undo.length], [{}, {}, {}, 0]); @@ -662,7 +664,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = openFake("all", again); handlers["triage-unfav"] = () => ({ ok: true }); await t.fav.unfavMany(again); - handlers["triage-refav"] = (m) => (m.aid === 1064 ? { ok: false, error: "网络错误" } : (away && openFake("E", []), { ok: true })); + handlers["triage-refav"] = (m) => (m.aids.includes(1064) ? { ok: false, error: "网络错误" } : (away && openFake("E", []), { ok: true })); await t.undo(); const want = away ? ["「所有收藏夹」撤销中断(已重新收藏 1 个,剩余 1 个在「已出分拣范围」的「最近取消收藏」里):网络错误", 0] : ["撤销中断(已重新收藏 1 个,剩余 1 个可再按 U 重试):网络错误", 1]; assert.deepStrictEqual([toasts.at(-1), t.S.undo.length], want, away ? "elsewhere: told only" : "here: U again"); @@ -1998,7 +2000,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.ok(!store[t.K.decisions("A")] && !t.S.decisions.BV7, "still in 甲, so not processed"); assert.ok(!store[t.K.decisions("all")], "no decisions key for the merged view"); await t.undo(); - assert.deepStrictEqual(plain(sent.at(-1)), { type: "triage-refav", mediaId: "B", aid: 1007 }); + assert.deepStrictEqual(plain(sent.at(-1)), { type: "triage-refav", mediaId: "B", aids: [1007] }); assert.deepStrictEqual(plain(shared.folders), ["A", "B"]); assert.ok(!("BV7" in store[t.K.decisions("B")])); assert.deepStrictEqual(plain(shared.left), [], "undo clears the left folder"); @@ -2030,7 +2032,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(plain(sent.slice(from).map((m) => [m.mediaId, m.aids])), [["toview", ["714816118:42"]], ["A", ["714816118:42"]], ["B", ["714816118:42"]]]); assert.strictEqual(store[t.K.decisions("A")].BVep.ogv, true); await t.undo(); - assert.deepStrictEqual(plain(sent.slice(from + 3).map((m) => [m.type, m.mediaId, m.aid])), ["toview", "A", "B"].map((f) => ["triage-refav", f, "714816118:42"])); + assert.deepStrictEqual(plain(sent.slice(from + 3).map((m) => [m.type, m.mediaId, m.aids])), ["toview", "A", "B"].map((f) => ["triage-refav", f, ["714816118:42"]])); // A video's are unchanged: its bare aid everywhere. assert.strictEqual(vm.runInContext("favRes", ctx)(item(7)), 1007); } @@ -3349,10 +3351,10 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = handlers["triage-transfer"] = (m) => (busy.push(t.fav.state().busy), adds.push([m.from, m.to, m.move, m.aids.length]), { ok: true }); t.askTransfer = async () => ({ how: "add", target: { id: "2" } }); await t.fav.transfer(t.S.items.slice()); - assert.deepStrictEqual(adds, [["", "2", false, 1], ["", "2", false, 1]], "one video per request"); + assert.deepStrictEqual(adds, [["", "2", false, 2]], "up to 10 videos a request"); assert.strictEqual(busy[0], "收藏", "C625: others wait on 「收藏还在跑」, not 移动/复制"); assert.deepStrictEqual(plain(store[t.K.removed]), {}); - assert.deepStrictEqual(plain(store[t.K.snapshot("2")].items.slice(0, 2)), [item(961), item(960)]); + assert.deepStrictEqual(plain(store[t.K.snapshot("2")].items.slice(0, 2)), [item(960), item(961)], "one chunk, kept in the order sent (B站 lists a batch's videos, all added the same second, in an order of its own; the id check compares sets)"); assert.strictEqual(toasts.at(-1), "已收藏 2 个到「目标」"); } @@ -3981,7 +3983,9 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = // 停止 on a 收藏夹 batch write's pill (as 关注's): pressed, it says 停止中 (disabled, with why) until the request in // flight lands; what went through stays, recorded and undoable. A batch undo stopped the same way keeps the rest for U. { - const vids = [1, 2, 3, 4].map((n) => item(90 + n)); + // 12 videos: 稍后再看 goes 10 a request, so the stop lands between two chunks. + const vids = Array.from({ length: 12 }, (_, i) => item(91 + i)); + const bvids = (from, to) => vids.slice(from, to).map((it) => it.bvid); const stopAt = (type, aid) => { const h = { inFlight: false, release: null }; handlers[type] = (m) => ((m.aid ?? m.aids[0]) === aid ? ((h.inFlight = true), new Promise((r) => (h.release = () => r({ ok: true })))) : { ok: true }); @@ -3998,28 +4002,29 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = h.release(); }; openFake("toview", vids); - const unfavs = stopAt("triage-unfav", 1092); + const unfavs = stopAt("triage-unfav", 1091); const running = t.fav.unfavMany(vids); await pressStop(unfavs); await running; - assert.deepStrictEqual([Object.keys(store[t.K.decisions("toview")]), toasts.at(-1)], [["BV91", "BV92"], "已停止批量取消收藏:完成 2 个,未完成 2 个 · U 撤销已完成的"]); + assert.deepStrictEqual([Object.keys(store[t.K.decisions("toview")]), toasts.at(-1)], [bvids(0, 10), "已停止批量取消收藏:完成 10 个,未完成 2 个 · U 撤销已完成的"]); + // Back into 稍后再看 is one video a request. const refavs = stopAt("triage-refav", 1091); const undoing = t.undo(); await pressStop(refavs); await undoing; - assert.deepStrictEqual([Object.keys(store[t.K.decisions("toview")]), toasts.at(-1), t.S.undo.length], [["BV92"], "已停止撤销:完成 1 个,未完成 1 个,可再按 U 撤销", 1]); + assert.deepStrictEqual([Object.keys(store[t.K.decisions("toview")]), toasts.at(-1), t.S.undo.length], [bvids(1, 10), "已停止撤销:完成 1 个,未完成 9 个,可再按 U 撤销", 1]); await t.undo(); - assert.deepStrictEqual([store[t.K.decisions("toview")], toasts.at(-1)], [{}, "已重新收藏 1 个"]); + assert.deepStrictEqual([store[t.K.decisions("toview")], toasts.at(-1)], [{}, "已重新收藏 9 个"]); // 移动 / 复制 stopped: the ones copied stay, and it says how far it got. const saved = { allFolders: t.S.allFolders, folders: t.S.folders, included: t.S.included, ask: t.askTransfer }; t.S.allFolders = t.S.folders = [{ id: "toview", title: "稍后再看" }, { id: "T", title: "目标" }]; t.S.included = ["toview", "T"]; t.askTransfer = async () => ({ how: "copy", target: { id: "T" } }); - const copies = stopAt("triage-transfer", 1093); + const copies = stopAt("triage-transfer", 1091); const copying = t.fav.transfer(vids); await pressStop(copies); await copying; - assert.strictEqual(toasts.at(-1), "已停止复制到「目标」:完成 3 个,未完成 1 个"); + assert.strictEqual(toasts.at(-1), "已停止复制到「目标」:完成 10 个,未完成 2 个"); Object.assign(t.S, { allFolders: saved.allFolders, folders: saved.folders, included: saved.included }); t.askTransfer = saved.ask; handlers["triage-unfav"] = handlers["triage-refav"] = handlers["triage-transfer"] = () => ({ ok: true });