feat(download): add download interval and random jitter settings

新增 download.interval 与 download.intervalJitter 配置,限制相邻两次下载任务启动之间的最小间隔并叠加随机抖动,覆盖 add/resume/retry 全部入口,默认关闭。
This commit is contained in:
maotoumao
2026-06-25 22:43:01 +08:00
parent c7a42dbaf3
commit 2f09cbb10c
8 changed files with 134 additions and 5 deletions

View File

@@ -14,10 +14,12 @@ The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.
### 新增
- 下载设置新增「下载间隔」与「随机抖动」:可设置两次下载任务启动之间的最小间隔(秒),并在其基础上叠加随机浮动,默认关闭。
- 引入 Issue / PR 治理工作流:长期无活动且未被接受的 Issue 会被自动标记 `stale` 并关闭PR 必须提交到 `dev` 分支并关联一个已被接受的 Issue。
### Added
- Add "Download interval" and "Random jitter" download settings: configure the minimum interval (seconds) between starting two download tasks, with an optional random variation on top. Disabled by default.
- Add issue/PR governance workflows: inactive and unaccepted issues are automatically marked `stale` and closed; PRs must target the `dev` branch and link an accepted issue.
---

View File

@@ -384,7 +384,12 @@
"download_lower": "Download lower quality",
"download_higher": "Download higher quality",
"concurrency_label": "Concurrent downloads",
"concurrency_desc": "Maximum number of simultaneous downloads"
"concurrency_desc": "Maximum number of simultaneous downloads",
"interval_label": "Download interval",
"interval_desc": "Minimum interval between starting two tasks. 0 to disable",
"interval_jitter_label": "Random jitter",
"interval_jitter_desc": "Seconds of random variation added to the download interval",
"seconds_unit": "sec"
},
"lyric": {
"subtitle": "Style, Color & Display Settings",

View File

@@ -384,7 +384,12 @@
"download_lower": "下载更低音质",
"download_higher": "下载更高音质",
"concurrency_label": "同时下载数量",
"concurrency_desc": "最多同时下载的任务数"
"concurrency_desc": "最多同时下载的任务数",
"interval_label": "下载间隔",
"interval_desc": "两首任务启动之间的最小间隔0 表示关闭",
"interval_jitter_label": "随机抖动",
"interval_jitter_desc": "在下载间隔基础上上下随机浮动的秒数",
"seconds_unit": "秒"
},
"lyric": {
"subtitle": "样式、颜色与显示策略",

View File

@@ -384,7 +384,12 @@
"download_lower": "下載更低音質",
"download_higher": "下載更高音質",
"concurrency_label": "同時下載數量",
"concurrency_desc": "最多同時下載的任務數"
"concurrency_desc": "最多同時下載的任務數",
"interval_label": "下載間隔",
"interval_desc": "兩首任務啟動之間的最小間隔0 表示關閉",
"interval_jitter_label": "隨機抖動",
"interval_jitter_desc": "在下載間隔基礎上上下隨機浮動的秒數",
"seconds_unit": "秒"
},
"lyric": {
"subtitle": "樣式、顏色與顯示策略",

View File

@@ -29,6 +29,8 @@ const defaultAppConfig: IAppConfig = {
'shortCut.enableLocal': true,
'shortCut.enableGlobal': false,
'download.concurrency': 5,
'download.interval': 0,
'download.intervalJitter': 0,
'normal.musicListHideColumns': ['duration'],
'backup.resumeBehavior': 'append',
'normal.language': 'zh-CN',

View File

@@ -14,6 +14,7 @@ import path from 'path';
import fsp from 'fs/promises';
import type Database from 'better-sqlite3';
import throttle, { type IThrottledFunction } from '@common/throttle';
import delay from '@common/delay';
import { QUALITY_KEYS, INTERNAL_SLIM_KEY } from '@common/constant';
import sanitizeFileName from '@common/sanitizeFileName';
import { safeParse, safeStringify } from '@common/safeSerialize';
@@ -90,6 +91,8 @@ class DownloadManager {
private taskGeneration = new Map<string, number>();
/** 下载进度缓存(用于节流广播) */
private progressCache = new Map<string, IDownloadProgress>();
/** 下一个任务允许启动的最早时间戳(启动间隔闸门) */
private nextAllowedStart = 0;
/** 预编译 SQL */
private queries!: IQueries;
/** 下载完成时的原子事务 */
@@ -400,6 +403,31 @@ class DownloadManager {
.map((r) => r.value);
}
/**
* 启动间隔闸门:约束相邻两次任务“启动”之间的最小间隔(而非“上一个下完到下一个开始”)。
*
* 同步预约 nextAllowedStart使并发回调各自抢到错开的启动时隙闲置足够久时等待为 0、立即启动。
* 实际间隔 = interval ± random(0..jitter)。覆盖 add/resume/resumeAll/retry 全部入口。
*/
private async acquireLaunchSlot(): Promise<void> {
const intervalSec = this.appConfig.getConfigByKey('download.interval') ?? 0;
if (!(intervalSec > 0)) return; // 间隔关闭
const jitterSec = this.appConfig.getConfigByKey('download.intervalJitter') ?? 0;
const intervalMs = intervalSec * 1000;
const jitterMs = jitterSec > 0 ? jitterSec * 1000 : 0;
const now = Date.now();
// 本任务实际启动时刻:不早于 now也不早于上一个预约的最早启动时间
const startAt = Math.max(now, this.nextAllowedStart);
// 预约下一个任务的最早启动时间(固定区间抖动)
const jitterOffset = jitterMs > 0 ? (Math.random() * 2 - 1) * jitterMs : 0;
this.nextAllowedStart = startAt + Math.max(0, intervalMs + jitterOffset);
const waitMs = startAt - now;
if (waitMs > 0) await delay(waitMs);
}
private enqueueTask(task: IDownloadTask): void {
// 捕获当前世代号,回调执行时比对——若不一致说明中间发生过 pause/remove
const generation = this.taskGeneration.get(task.id) ?? 0;
@@ -412,6 +440,13 @@ class DownloadManager {
const freshRow = this.queries.getTaskById.get(task.id) as IDownloadTaskRow | undefined;
if (!freshRow || freshRow.status !== 'pending') return;
// 启动间隔闸门:在获取媒体源之前等待到允许的启动时隙
await this.acquireLaunchSlot();
// 等待期间可能被 pause/remove/dispose
if ((this.taskGeneration.get(task.id) ?? 0) !== generation) return;
if (this.disposed) return;
// 延迟获取媒体源:在任务真正开始执行时才获取,避免 resumeAll 过早批量请求
let musicItem = safeParse<IMusic.IMusicItem>(task.musicItemRaw ?? '');
if (musicItem && !task.mediaSource) {

View File

@@ -1,9 +1,10 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FolderOpen } from 'lucide-react';
import { SettingsCard } from '../components/SettingsCard';
import { SettingRow } from '../components/SettingRow';
import { Select } from '@renderer/mainWindow/components/ui/Select';
import { Input } from '@renderer/mainWindow/components/ui/Input';
import { Button } from '@renderer/mainWindow/components/ui/Button';
import { useConfigValue } from '@renderer/common/hooks/useConfigValue';
import systemUtil from '@infra/systemUtil/renderer';
@@ -11,7 +12,8 @@ import systemUtil from '@infra/systemUtil/renderer';
/**
* 下载设置
*
* 配置项download.path、defaultQuality、whenQualityMissing、concurrency
* 配置项download.path、defaultQuality、whenQualityMissing、concurrency
* interval启动间隔、intervalJitter随机抖动
*/
export function DownloadSection() {
const { t } = useTranslation();
@@ -21,6 +23,43 @@ export function DownloadSection() {
'download.whenQualityMissing',
);
const [concurrency, setConcurrency] = useConfigValue('download.concurrency');
const [interval, setInterval] = useConfigValue('download.interval');
const [intervalJitter, setIntervalJitter] = useConfigValue('download.intervalJitter');
// 间隔 / 抖动:本地缓冲 + 失焦提交(取整并夹紧到 0~60
const [intervalInput, setIntervalInput] = useState(() => String(interval ?? 0));
const [jitterInput, setJitterInput] = useState(() => String(intervalJitter ?? 0));
useEffect(() => {
setIntervalInput(String(interval ?? 0));
}, [interval]);
useEffect(() => {
setJitterInput(String(intervalJitter ?? 0));
}, [intervalJitter]);
const clampSeconds = useCallback((raw: string): number => {
const n = Math.round(Number(raw));
if (!Number.isFinite(n)) return 0;
return Math.min(60, Math.max(0, n));
}, []);
const commitInterval = useCallback(() => {
const n = clampSeconds(intervalInput);
setIntervalInput(String(n));
if (n !== (interval ?? 0)) setInterval(n);
// 抖动不应超过基准间隔,间隔调小时同步收窄抖动
if ((intervalJitter ?? 0) > n) {
setJitterInput(String(n));
setIntervalJitter(n);
}
}, [clampSeconds, intervalInput, interval, setInterval, intervalJitter, setIntervalJitter]);
const commitJitter = useCallback(() => {
// 抖动夹紧到 [0, interval],避免实际间隔出现负值
const n = Math.min(clampSeconds(jitterInput), interval ?? 0);
setJitterInput(String(n));
if (n !== (intervalJitter ?? 0)) setIntervalJitter(n);
}, [clampSeconds, jitterInput, interval, intervalJitter, setIntervalJitter]);
const handleChoosePath = useCallback(async () => {
const result = await systemUtil.showOpenDialog({
@@ -106,6 +145,38 @@ export function DownloadSection() {
/>
}
/>
<SettingRow
label={t('settings.download.interval_label')}
description={t('settings.download.interval_desc')}
control={
<Input
type="number"
min={0}
max={60}
step={1}
value={intervalInput}
onChange={(e) => setIntervalInput(e.target.value)}
onBlur={commitInterval}
suffix={t('settings.download.seconds_unit')}
/>
}
/>
<SettingRow
label={t('settings.download.interval_jitter_label')}
description={t('settings.download.interval_jitter_desc')}
control={
<Input
type="number"
min={0}
max={60}
step={1}
value={jitterInput}
onChange={(e) => setJitterInput(e.target.value)}
onBlur={commitJitter}
suffix={t('settings.download.seconds_unit')}
/>
}
/>
</SettingsCard>
);
}

View File

@@ -66,6 +66,10 @@ interface _IAppConfig {
'download.whenQualityMissing': 'higher' | 'lower';
/** 最多同时下载 */
'download.concurrency': number;
/** 下载启动间隔两次任务启动之间的最小间隔0 表示关闭 */
'download.interval': number;
/** 下载启动间隔的随机抖动(秒),实际间隔在 interval ± jitter 之间 */
'download.intervalJitter': number;
/** 是否自动升级插件 */
'plugin.autoUpdatePlugin': boolean;