mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-10 11:37:21 +00:00
feat(torrents): add live peer controls
This commit is contained in:
@@ -461,6 +461,91 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().downloads[0].torrentUploadLimit).toBe('512K');
|
||||
});
|
||||
|
||||
it('updates active Torrent peer options and clears them to Aria2 defaults', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'live-torrent-peers',
|
||||
status: 'seeding',
|
||||
isMedia: false,
|
||||
isTorrent: true,
|
||||
torrentMaxPeers: 120,
|
||||
torrentPeerSpeedLimit: '512K'
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
await useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers', '240', '2M');
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_peer_options', {
|
||||
id: 'live-torrent-peers',
|
||||
max_peers: 240,
|
||||
peer_speed_limit: '2M'
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
torrentMaxPeers: 240,
|
||||
torrentPeerSpeedLimit: '2M'
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers', null, null);
|
||||
const peerOptionCalls = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'set_torrent_peer_options');
|
||||
expect(peerOptionCalls[peerOptionCalls.length - 1]).toEqual(['set_torrent_peer_options', {
|
||||
id: 'live-torrent-peers',
|
||||
max_peers: null,
|
||||
peer_speed_limit: null
|
||||
}]);
|
||||
expect(useDownloadStore.getState().downloads[0].torrentMaxPeers).toBeUndefined();
|
||||
expect(useDownloadStore.getState().downloads[0].torrentPeerSpeedLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects invalid or inactive live Torrent peer options', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'ordinary-peers', status: 'downloading', isMedia: false, isTorrent: false },
|
||||
{ id: 'paused-peers', status: 'paused', isMedia: false, isTorrent: true }
|
||||
] as any[]
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('ordinary-peers', '100', '2M'))
|
||||
.rejects.toThrow('only for Torrent');
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('paused-peers', '100', '2M'))
|
||||
.rejects.toThrow('active Torrent');
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_peer_options', expect.anything());
|
||||
|
||||
useDownloadStore.setState({
|
||||
downloads: [{ id: 'invalid-peers', status: 'downloading', isMedia: false, isTorrent: true }] as any[]
|
||||
});
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('invalid-peers', '1001', '2M'))
|
||||
.rejects.toThrow('between 0 and 1000');
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('invalid-peers', '100', 'not-a-rate'))
|
||||
.rejects.toThrow('valid Torrent peer speed');
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_peer_options', expect.anything());
|
||||
});
|
||||
|
||||
it('keeps prior Torrent peer options when the backend rejects the update', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'live-torrent-peers-failure',
|
||||
status: 'downloading',
|
||||
isMedia: false,
|
||||
isTorrent: true,
|
||||
torrentMaxPeers: 120,
|
||||
torrentPeerSpeedLimit: '512K'
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||
if (command === 'set_torrent_peer_options') throw new Error('aria2 unavailable');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers-failure', '240', '2M'))
|
||||
.rejects.toThrow('aria2 unavailable');
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
torrentMaxPeers: 120,
|
||||
torrentPeerSpeedLimit: '512K'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects live speed changes for media and inactive downloads', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
@@ -610,6 +695,33 @@ describe('useDownloadStore', () => {
|
||||
.toEqual(['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001']);
|
||||
});
|
||||
|
||||
it('skips malformed persisted download records without blocking startup', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [
|
||||
'{not-json',
|
||||
JSON.stringify(null),
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({
|
||||
id: 'valid-after-corruption',
|
||||
url: 'https://example.com/valid.bin',
|
||||
fileName: 'valid.bin',
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
})
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
|
||||
expect(useDownloadStore.getState().downloads.map(download => download.id))
|
||||
.toEqual(['valid-after-corruption']);
|
||||
});
|
||||
|
||||
it('moves persisted paused rows behind runnable rows and assigns contiguous positions', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') {
|
||||
@@ -730,6 +842,23 @@ describe('useDownloadStore', () => {
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('clears malformed persisted Torrent peer options', () => {
|
||||
const normalized = normalizePersistedDownloadProgress({
|
||||
id: 'malformed-torrent-options',
|
||||
url: 'magnet:?xt=urn:btih:bad',
|
||||
fileName: 'payload',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
isTorrent: true,
|
||||
torrentMaxPeers: 'not-a-number' as unknown as number,
|
||||
torrentPeerSpeedLimit: 0 as unknown as string
|
||||
});
|
||||
|
||||
expect(normalized.torrentMaxPeers).toBeUndefined();
|
||||
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes proxy settings for download dispatch', async () => {
|
||||
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
|
||||
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
|
||||
|
||||
@@ -348,6 +348,8 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
torrent_seed_time: item.torrentSeedTime,
|
||||
torrent_seed_ratio: item.torrentSeedRatio,
|
||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
lifecycle_generation: lifecycleGeneration.toString(),
|
||||
};
|
||||
|
||||
@@ -612,10 +614,30 @@ export const hasStaleTemporaryMediaEstimate = (
|
||||
return hasImpossibleNumericEstimate || hasImpossibleVisibleEstimate;
|
||||
};
|
||||
|
||||
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem =>
|
||||
hasStaleTemporaryMediaEstimate(download)
|
||||
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => {
|
||||
const rawMaxPeers = download.torrentMaxPeers as unknown;
|
||||
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
|
||||
Number.isInteger(rawMaxPeers) &&
|
||||
rawMaxPeers >= 0 &&
|
||||
rawMaxPeers <= 1000
|
||||
? rawMaxPeers
|
||||
: undefined;
|
||||
const rawPeerSpeedLimit = download.torrentPeerSpeedLimit as unknown;
|
||||
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
|
||||
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
|
||||
: undefined;
|
||||
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
|
||||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit
|
||||
? {
|
||||
...download,
|
||||
torrentMaxPeers: normalizedMaxPeers,
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit
|
||||
}
|
||||
: download;
|
||||
|
||||
return hasStaleTemporaryMediaEstimate(normalizedOptions)
|
||||
? {
|
||||
...normalizedOptions,
|
||||
// The old lifecycle could persist yt-dlp's temporary HLS estimate as
|
||||
// both the numeric denominator and the visible size. Neither value is
|
||||
// recoverable after the fact, so remove the false claim on startup.
|
||||
@@ -623,7 +645,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
totalBytes: undefined,
|
||||
totalIsEstimate: undefined
|
||||
}
|
||||
: download;
|
||||
: normalizedOptions;
|
||||
};
|
||||
|
||||
export type { DownloadStatus };
|
||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
@@ -801,6 +824,11 @@ interface DownloadState {
|
||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
|
||||
setTorrentUploadLimit: (id: string, limit: string | null) => Promise<void>;
|
||||
setTorrentPeerOptions: (
|
||||
id: string,
|
||||
maxPeers: string | null,
|
||||
peerSpeedLimit: string | null
|
||||
) => Promise<void>;
|
||||
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
|
||||
addQueue: (name: string) => boolean;
|
||||
renameQueue: (id: string, name: string) => boolean;
|
||||
@@ -1920,6 +1948,50 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
true,
|
||||
preemptDispatch
|
||||
),
|
||||
setTorrentPeerOptions: (id, maxPeers, peerSpeedLimit) => runDownloadLifecycleOperation(
|
||||
id,
|
||||
'torrent-peer-options',
|
||||
async () => {
|
||||
await waitForPendingStartupResume();
|
||||
const item = get().downloads.find(download => download.id === id);
|
||||
if (!item) throw new Error('Download no longer exists.');
|
||||
if (!item.isTorrent) {
|
||||
throw new Error('Live peer control is available only for Torrent downloads.');
|
||||
}
|
||||
if (!['downloading', 'seeding', 'retrying'].includes(item.status)) {
|
||||
throw new Error('Live peer control requires an active Torrent.');
|
||||
}
|
||||
|
||||
const trimmedMaxPeers = maxPeers?.trim() || '';
|
||||
const parsedMaxPeers = trimmedMaxPeers ? Number(trimmedMaxPeers) : null;
|
||||
if (
|
||||
parsedMaxPeers !== null
|
||||
&& (!Number.isInteger(parsedMaxPeers) || parsedMaxPeers < 0 || parsedMaxPeers > 1000)
|
||||
) {
|
||||
throw new Error('Torrent maximum peers must be an integer between 0 and 1000.');
|
||||
}
|
||||
const normalizedPeerSpeedLimit = peerSpeedLimit?.trim()
|
||||
? normalizeSpeedLimitForBackend(peerSpeedLimit)
|
||||
: null;
|
||||
if (peerSpeedLimit?.trim() && normalizedPeerSpeedLimit === null) {
|
||||
throw new Error('Enter a valid Torrent peer speed limit.');
|
||||
}
|
||||
|
||||
await invoke('set_torrent_peer_options', {
|
||||
id,
|
||||
max_peers: parsedMaxPeers,
|
||||
peer_speed_limit: normalizedPeerSpeedLimit
|
||||
});
|
||||
if (get().downloads.some(download => download.id === id)) {
|
||||
get().updateDownload(id, {
|
||||
torrentMaxPeers: parsedMaxPeers === null ? undefined : parsedMaxPeers,
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit ?? undefined
|
||||
});
|
||||
}
|
||||
},
|
||||
true,
|
||||
preemptDispatch
|
||||
),
|
||||
setQueueConcurrency: (id, maxConcurrent) => {
|
||||
const operation = queueConfigurationQueue.then(async () => {
|
||||
if (
|
||||
@@ -2088,6 +2160,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
torrent_seed_time: item.torrentSeedTime,
|
||||
torrent_seed_ratio: item.torrentSeedRatio,
|
||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||
});
|
||||
}
|
||||
@@ -2212,9 +2286,18 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const normalizedQueueState = normalizePersistedQueueState(persistedQueues);
|
||||
const queues = normalizedQueueState.queues;
|
||||
const knownQueueIds = new Set(queues.map(queue => queue.id));
|
||||
const downloads = (await invoke('db_get_all_downloads')).map(
|
||||
value => JSON.parse(value) as DownloadItem
|
||||
).map(download => {
|
||||
const downloads = (await invoke('db_get_all_downloads')).flatMap(value => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('persisted download is not an object');
|
||||
}
|
||||
return [parsed as DownloadItem];
|
||||
} catch {
|
||||
console.warn('Skipping malformed persisted download record during startup');
|
||||
return [];
|
||||
}
|
||||
}).map(download => {
|
||||
const persistedQueueId = download.queueId || MAIN_QUEUE_ID;
|
||||
const queueId = normalizedQueueState.queueIdRemap.get(persistedQueueId)
|
||||
|| (knownQueueIds.has(persistedQueueId) ? persistedQueueId : MAIN_QUEUE_ID);
|
||||
|
||||
Reference in New Issue
Block a user