feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user