feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+482
View File
@@ -0,0 +1,482 @@
/**
* 自动排期算法
* 在候选池中选取总时长最接近目标的歌曲组合
*
* 策略:主路径按降序贪心 + 'under' 二次升序回填;辅路径随机采样;取 absDiff 更小的结果
*
* @param direction 'under' 总时长不超过目标,'over' 总时长不低于目标,'middle' 相差最小(中间放)
* @param targetMinutes 目标总时长(分钟)
* @param candidates 候选歌曲列表(需含 id 和 durationSeconds)
* @param preSelected 用户已固定的歌曲(总是保留,算法仅对剩余候选补齐剩余时长)
* @param plansCount 返回方案数,按 absDiff 升序排列(默认 1,仅返回最优)
* @param targetSongCount 期望的最终歌曲数量(可选;启用后优先匹配歌曲数量,再比较时长)
* @param targetRequesterCount 期望的不同投稿人数量(可选;启用后优先覆盖不同投稿人,再比较时长)
*/
export type AutoScheduleDirection = 'under' | 'over' | 'middle'
export interface AutoScheduleCandidate {
id: number
songId: number
title: string
artist: string
durationSeconds?: number | null
replayRequestId?: number | null
musicId?: string | null
musicPlatform?: string | null
requester?: string | null
cover?: string | null
createdAt?: string | null
// UI 标记:是否为固定歌曲
isFixed?: boolean
}
export interface AutoScheduleResult {
songs: AutoScheduleCandidate[]
totalDuration: number
diff: number
absDiff: number
}
function normalizeTargetSongCount(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
return Math.floor(value)
}
/**
* 按投稿人分散排序:每个投稿人的首首歌排前,其余排后(组内保持原时长顺序)
* 用于贪心选曲时优先覆盖不同投稿人
*/
function diversifyOrder(
list: AutoScheduleCandidate[],
targetRequesterCount: number | null
): AutoScheduleCandidate[] {
if (targetRequesterCount === null || targetRequesterCount <= 0) return list
const seen = new Set<string>()
const firstOfRequester: AutoScheduleCandidate[] = []
const rest: AutoScheduleCandidate[] = []
for (const song of list) {
const key = song.requester || ''
if (key && !seen.has(key)) {
seen.add(key)
firstOfRequester.push(song)
} else {
rest.push(song)
}
}
return [...firstOfRequester, ...rest]
}
interface ResultCompareContext {
targetSongCount: number | null
fixedCount: number
targetRequesterCount: number | null
fixedRequesterSet: Set<string>
}
function distinctRequesterCount(
songs: AutoScheduleCandidate[],
ctx: ResultCompareContext
): number {
const set = new Set(ctx.fixedRequesterSet)
for (const s of songs) {
if (s.requester) set.add(s.requester)
}
return set.size
}
function compareResults(a: AutoScheduleResult, b: AutoScheduleResult, ctx: ResultCompareContext): number {
if (ctx.targetSongCount !== null) {
const aCountDiff = Math.abs(a.songs.length + ctx.fixedCount - ctx.targetSongCount)
const bCountDiff = Math.abs(b.songs.length + ctx.fixedCount - ctx.targetSongCount)
if (aCountDiff !== bCountDiff) return aCountDiff - bCountDiff
}
if (ctx.targetRequesterCount !== null) {
const aReqDiff = Math.abs(distinctRequesterCount(a.songs, ctx) - ctx.targetRequesterCount)
const bReqDiff = Math.abs(distinctRequesterCount(b.songs, ctx) - ctx.targetRequesterCount)
if (aReqDiff !== bReqDiff) return aReqDiff - bReqDiff
}
return a.absDiff - b.absDiff || b.songs.length - a.songs.length
}
/**
* 公共前置处理:计算目标秒数、排除固定歌曲、准备候选池
* 若无可用的候选歌曲,直接返回 earlyReturn 供调用方提前结束
*/
function prepareScheduleInput(
targetMinutes: number,
candidates: AutoScheduleCandidate[],
preSelected: AutoScheduleCandidate[],
plansCount: number
): {
targetSeconds: number
preSelectedSeconds: number
availableCandidates: AutoScheduleCandidate[]
emptyResult: AutoScheduleResult
earlyReturn?: AutoScheduleResult | AutoScheduleResult[]
} {
const targetSeconds = Math.floor(targetMinutes * 60)
const preSelectedIds = new Set(preSelected.map((s) => s.id))
const preSelectedSeconds = preSelected.reduce(
(sum, s) => sum + (typeof s.durationSeconds === 'number' ? s.durationSeconds : 0),
0
)
const availableCandidates = candidates.filter(
(s) => !preSelectedIds.has(s.id) && typeof s.durationSeconds === 'number' && s.durationSeconds > 0
)
const emptyResult: AutoScheduleResult = { songs: [], totalDuration: 0, diff: 0, absDiff: 0 }
if (availableCandidates.length === 0 && preSelected.length === 0) {
return { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult, earlyReturn: plansCount === 1 ? emptyResult : [emptyResult] }
}
if (availableCandidates.length === 0) {
const fixedSongs = preSelected.map((s) => ({ ...s, isFixed: true }))
const total = preSelectedSeconds
const diff = total - targetSeconds
const result: AutoScheduleResult = { songs: fixedSongs, totalDuration: total, diff, absDiff: Math.abs(diff) }
return { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult, earlyReturn: plansCount === 1 ? result : [result] }
}
return { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult }
}
export function autoSchedule(
direction: AutoScheduleDirection,
targetMinutes: number,
candidates: AutoScheduleCandidate[],
preSelected: AutoScheduleCandidate[] = [],
plansCount: number = 1,
targetSongCount?: number | null,
targetRequesterCount?: number | null
): AutoScheduleResult | AutoScheduleResult[] {
const input = prepareScheduleInput(targetMinutes, candidates, preSelected, plansCount)
if (input.earlyReturn !== undefined) return input.earlyReturn
const { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult } = input
const normalizedTargetSongCount = normalizeTargetSongCount(targetSongCount)
const normalizedTargetRequesterCount = normalizeTargetSongCount(targetRequesterCount)
const compareCtx: ResultCompareContext = {
targetSongCount: normalizedTargetSongCount,
fixedCount: preSelected.length,
targetRequesterCount: normalizedTargetRequesterCount,
fixedRequesterSet: new Set(preSelected.map((s) => s.requester).filter(Boolean))
}
const targetAdditionalCount = normalizedTargetSongCount === null
? null
: Math.max(0, normalizedTargetSongCount - preSelected.length)
const remainingTarget = Math.max(0, targetSeconds - preSelectedSeconds)
const songMap = new Map(availableCandidates.map((s) => [s.id, s]))
const runGreedy = (sorted: AutoScheduleCandidate[], dir: 'under' | 'over') => {
const result: AutoScheduleCandidate[] = []
let total = 0
for (const song of sorted) {
if (targetAdditionalCount !== null && result.length >= targetAdditionalCount) break
const newTotal = total + song.durationSeconds!
if (dir === 'under') {
if (newTotal <= remainingTarget) {
result.push(song)
total = newTotal
}
} else {
if (remainingTarget <= 0) {
// 已选歌曲时长已超过目标,无需再追加
} else {
result.push(song)
total = newTotal
if (
total >= remainingTarget &&
(targetAdditionalCount === null || result.length >= targetAdditionalCount)
) break
}
}
}
if (dir === 'under') {
const ids = new Set(result.map((s) => s.id))
for (const song of diversifyOrder(
[...availableCandidates].sort((a, b) => a.durationSeconds! - b.durationSeconds!),
normalizedTargetRequesterCount
)) {
if (targetAdditionalCount !== null && result.length >= targetAdditionalCount) break
if (ids.has(song.id)) continue
if (total + song.durationSeconds! <= remainingTarget) {
result.push(song)
total += song.durationSeconds!
ids.add(song.id)
}
}
} else {
const selectedIds = new Set(result.map((s) => s.id))
let remaining = availableCandidates.filter((s) => !selectedIds.has(s.id))
.sort((a, b) => a.durationSeconds! - b.durationSeconds!)
let ri = result.length - 1
search: while (ri >= 0) {
const currentSong = result[ri]
const currentDuration = currentSong.durationSeconds!
const totalWithoutThis = total - currentDuration
// 移除当前歌后仍满足约束,直接移除(更优解)
if (
totalWithoutThis >= remainingTarget &&
(targetAdditionalCount === null || result.length > targetAdditionalCount)
) {
result.splice(ri, 1)
total = totalWithoutThis
selectedIds.delete(currentSong.id)
remaining = availableCandidates.filter((s) => !selectedIds.has(s.id))
.sort((a, b) => a.durationSeconds! - b.durationSeconds!)
ri = result.length - 1
continue
}
const minReplacement = remainingTarget - totalWithoutThis
if (minReplacement >= currentDuration) {
ri--
continue
}
for (let ii = 0; ii < remaining.length; ii++) {
const candidate = remaining[ii]
if (
candidate.durationSeconds! >= minReplacement &&
candidate.durationSeconds! < currentDuration
) {
result[ri] = candidate
total = totalWithoutThis + candidate.durationSeconds!
selectedIds.delete(currentSong.id)
selectedIds.add(candidate.id)
remaining.splice(ii, 1)
break search
}
}
ri--
}
}
const diff = (total + preSelectedSeconds) - targetSeconds
return { songs: result, totalDuration: total + preSelectedSeconds, diff, absDiff: Math.abs(diff) }
}
const sortedDescending = [...availableCandidates].sort(
(a, b) => b.durationSeconds! - a.durationSeconds!
)
const sortedAscending = [...availableCandidates].sort(
(a, b) => a.durationSeconds! - b.durationSeconds!
)
// 设置投稿人目标时分散排序,优先覆盖不同投稿人
const diversifiedDescending = diversifyOrder(sortedDescending, normalizedTargetRequesterCount)
const diversifiedAscending = diversifyOrder(sortedAscending, normalizedTargetRequesterCount)
const sortedByDuration = normalizedTargetSongCount !== null && direction === 'under'
? diversifiedAscending
: diversifiedDescending
// 生成方案
const plans: AutoScheduleResult[] = []
const seenSets = new Set<string>()
const addUnique = (result: AutoScheduleResult) => {
const key = result.songs.map((s) => s.id).sort().join(',')
if (!seenSets.has(key)) {
seenSets.add(key)
plans.push(result)
}
}
// 确定性路径
if (direction === 'middle') {
addUnique(runGreedy(normalizedTargetSongCount !== null ? diversifiedAscending : diversifiedDescending, 'under'))
addUnique(runGreedy(diversifiedDescending, 'over'))
} else {
addUnique(runGreedy(sortedByDuration, direction))
}
// 随机路径:多次 shuffle 生成不同方案
// 生成比 plansCount 多一些以保留筛选空间
const maxIter = Math.max(50, plansCount * 5)
for (let i = 0; i < maxIter && plans.length < plansCount * 3; i++) {
const shuffled = availableCandidates.map((s) => s.id)
for (let j = shuffled.length - 1; j > 0; j--) {
const k = Math.floor(Math.random() * (j + 1))
;[shuffled[j], shuffled[k]] = [shuffled[k], shuffled[j]]
}
const shuffledSorted = shuffled.map((id) => songMap.get(id)).filter(Boolean)
const dir = direction === 'middle' ? (i % 2 === 0 ? 'under' : 'over') : direction
addUnique(runGreedy(diversifyOrder(shuffledSorted, normalizedTargetRequesterCount), dir))
}
// 按质量排序:设置期望首数时先比较首数偏差,再比较时长偏差
plans.sort((a, b) => compareResults(a, b, compareCtx))
// 合并固定歌曲并截取
const fixedSongs = preSelected.map((s) => ({ ...s, isFixed: true }))
const finalPlans = plans
.slice(0, plansCount)
.map((p) => ({ ...p, songs: [...fixedSongs, ...p.songs] }))
if (plansCount === 1) {
return finalPlans[0] || emptyResult
}
return finalPlans
}
/**
* 穷举排期算法(DFS + 剪枝)
* 系统遍历候选歌曲的子集组合,按 absDiff 收集最优方案
*
* 复杂度:最坏 O(2^n),通过剪枝大幅缩减实际搜索空间
* - 方向约束剪枝:'under' 超过目标立即终止;'over' 无法达到目标终止
* - 最优解剪枝:当前分支最差情况已超过已有最优解则终止
* - 节点上限:限制总访问节点数,防止超大候选池卡死
*/
export function autoScheduleExhaustive(
direction: AutoScheduleDirection,
targetMinutes: number,
candidates: AutoScheduleCandidate[],
preSelected: AutoScheduleCandidate[] = [],
plansCount: number = 1,
targetSongCount?: number | null,
targetRequesterCount?: number | null
): AutoScheduleResult | AutoScheduleResult[] {
const input = prepareScheduleInput(targetMinutes, candidates, preSelected, plansCount)
if (input.earlyReturn !== undefined) return input.earlyReturn
const { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult } = input
const normalizedTargetSongCount = normalizeTargetSongCount(targetSongCount)
const normalizedTargetRequesterCount = normalizeTargetSongCount(targetRequesterCount)
const compareCtx: ResultCompareContext = {
targetSongCount: normalizedTargetSongCount,
fixedCount: preSelected.length,
targetRequesterCount: normalizedTargetRequesterCount,
fixedRequesterSet: new Set(preSelected.map((s) => s.requester).filter(Boolean))
}
// 将时长收窄为 number 类型,消除后续的非空断言
type DurationCandidate = Omit<AutoScheduleCandidate, 'durationSeconds'> & { durationSeconds: number }
const remainingTarget = Math.max(0, targetSeconds - preSelectedSeconds)
// 按时长降序排列,大歌曲优先,利于剪枝;设置投稿人目标时分散排序
const sorted = diversifyOrder(
[...(availableCandidates as DurationCandidate[])].sort((a, b) => b.durationSeconds - a.durationSeconds),
normalizedTargetRequesterCount
)
const n = sorted.length
// 后缀和:suffixSum[i] = sorted[i..n-1] 的时长之和,用于判定能否达到目标
const suffixSum = new Array(n + 1).fill(0)
for (let i = n - 1; i >= 0; i--) {
suffixSum[i] = suffixSum[i + 1] + sorted[i].durationSeconds
}
const solutions: AutoScheduleResult[] = []
const seenKeys = new Set<string>()
let nodeCount = 0
const maxNodes = 100000
const maxSolutions = plansCount * 3
// worstAbs 记录当前已收录方案中最差的 absDiff,作为剪枝阈值
// 初始值 Infinity 确保首个方案能被正确记录并更新该值(0 会导致找到完美解后后续方案被全部过滤)
let worstAbs = Infinity
const currentSongs: AutoScheduleCandidate[] = []
const record = (currentTotal: number) => {
const canUseFixedOnly = normalizedTargetSongCount !== null && preSelected.length >= normalizedTargetSongCount
if (currentSongs.length === 0 && !canUseFixedOnly) return
const total = currentTotal + preSelectedSeconds
const diff = total - targetSeconds
if (direction === 'over' && total < targetSeconds) return
if (direction === 'under' && total > targetSeconds) return
const absDiff = Math.abs(diff)
const key = currentSongs.map((s) => s.id).sort().join(',')
if (seenKeys.has(key)) return
seenKeys.add(key)
solutions.push({ songs: [...currentSongs], totalDuration: total, diff, absDiff })
solutions.sort((a, b) => compareResults(a, b, compareCtx))
if (solutions.length > maxSolutions) {
solutions.splice(maxSolutions)
worstAbs = solutions[solutions.length - 1].absDiff
}
}
const canPrune = (idx: number, currentTotal: number): boolean => {
if (nodeCount > maxNodes) return true
if (direction === 'under') {
// 已超过目标,无效
if (currentTotal > remainingTarget) return true
// 即使加上所有剩余歌曲也到不了目标,且当前差距已大于已有最优
if (normalizedTargetSongCount === null && currentTotal + suffixSum[idx] < remainingTarget && remainingTarget - currentTotal > worstAbs) {
return true
}
} else if (direction === 'over') {
// 即使加上所有剩余歌曲也达不到目标
if (currentTotal + suffixSum[idx] < remainingTarget) return true
// 当前已远超目标且超出量已大于已有最优
if (normalizedTargetSongCount === null && currentTotal >= remainingTarget && currentTotal - remainingTarget > worstAbs) {
return true
}
} else {
// middle:判断从当前状态能达到的最好 absDiff
const minDiff = currentTotal - remainingTarget
const maxDiff = currentTotal + suffixSum[idx] - remainingTarget
if (normalizedTargetSongCount === null) {
if (minDiff >= 0 && minDiff > worstAbs) return true
if (maxDiff <= 0 && -maxDiff > worstAbs) return true
}
}
return false
}
const dfs = (idx: number, currentTotal: number) => {
nodeCount++
if (nodeCount > maxNodes) return
record(currentTotal)
if (idx >= n) return
if (canPrune(idx, currentTotal)) return
// 尝试包含 sorted[idx]
if (direction === 'under') {
if (currentTotal + sorted[idx].durationSeconds <= remainingTarget) {
currentSongs.push(sorted[idx])
dfs(idx + 1, currentTotal + sorted[idx].durationSeconds)
currentSongs.pop()
}
} else {
currentSongs.push(sorted[idx])
dfs(idx + 1, currentTotal + sorted[idx].durationSeconds)
currentSongs.pop()
}
// 尝试不包含 sorted[idx]
dfs(idx + 1, currentTotal)
}
dfs(0, 0)
solutions.sort((a, b) => compareResults(a, b, compareCtx))
const fixedSongs = preSelected.map((s) => ({ ...s, isFixed: true }))
const finalPlans = solutions.slice(0, plansCount).map((p) => ({
...p,
songs: [...fixedSongs, ...p.songs]
}))
return plansCount === 1 ? (finalPlans[0] || emptyResult) : finalPlans
}
/**
* 将备选池条目规范化为统一的候选歌曲对象
*/
export function poolCandidateFromItem(item: any): AutoScheduleCandidate {
return {
id: item.songId,
songId: item.songId,
title: item.title,
artist: item.artist,
durationSeconds: item.durationSeconds,
replayRequestId: item.replayRequestId || null,
musicId: item.musicId,
musicPlatform: item.musicPlatform,
requester: item.requester,
cover: item.cover,
createdAt: item.createdAt
}
}