From ba491ccd7da4b8d0c385ed139dafec9330cbca32 Mon Sep 17 00:00:00 2001 From: NimBold Date: Tue, 25 Aug 2026 03:27:49 +0330 Subject: [PATCH] fix(torrent): harden magnet metadata enrichment - Preserve magnet display names before metadata resolution - Bound optional magnet probes separately from required metadata work - Fence stale Add-window probes and temporary Torrent metadata across modal races - Compact responsive Torrent peer metrics across locales --- src/components/AddDownloadsModal.tsx | 71 +++++++++++++++++++++----- src/components/PropertiesWindowApp.tsx | 2 +- src/i18n/catalogs/en.ts | 2 +- src/i18n/catalogs/fa.ts | 2 +- src/i18n/catalogs/he.ts | 2 +- src/i18n/catalogs/ru.ts | 2 +- src/i18n/catalogs/uk.ts | 2 +- src/i18n/catalogs/zh-CN.ts | 2 +- src/index.css | 15 +----- src/utils/addDownloadMetadata.test.ts | 18 ++++--- src/utils/addDownloadMetadata.ts | 15 ++++-- src/utils/downloads.test.ts | 15 ++++++ src/utils/downloads.ts | 6 +++ 13 files changed, 110 insertions(+), 44 deletions(-) diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 77c0731..02e6163 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -168,9 +168,11 @@ export const AddDownloadsModal = () => { const [urls, setUrls] = useState(''); const [selectedItemIndex, setSelectedItemIndex] = useState(null); const [parsedItems, setParsedItems] = useState([]); + const [metadataSchedulerRevision, setMetadataSchedulerRevision] = useState(0); const parsedItemsRef = useRef([]); const addModalOpenRef = useRef(isAddModalOpen); const metadataRequestsRef = useRef(new Set()); + const magnetMetadataRequestsRef = useRef(new Set()); const playlistRequestsRef = useRef(new Set()); const latestPlaylistRequestRef = useRef(new Map()); const cachedTorrentDraftIdsRef = useRef(new Set()); @@ -361,7 +363,6 @@ export const AddDownloadsModal = () => { pendingLastUsedDownloadDirectoryRef.current = null; setUrls(''); setPlaylistExpansions({}); - playlistRequestsRef.current.clear(); latestPlaylistRequestRef.current.clear(); playlistMediaSelectionsRef.current.clear(); setPlaylistMediaTypeSelections({}); @@ -391,8 +392,9 @@ export const AddDownloadsModal = () => { setUrls(initialUrls); setParsedItems([]); setPlaylistExpansions({}); - metadataRequestsRef.current.clear(); - playlistRequestsRef.current.clear(); + // Keep active request ownership across rapid modal close/reopen cycles. + // The promises remove their own keys in finally; clearing these sets here + // would let stale native probes multiply beyond the concurrency bounds. latestPlaylistRequestRef.current.clear(); playlistMediaSelectionsRef.current.clear(); setPlaylistMediaTypeSelections({}); @@ -593,13 +595,28 @@ export const AddDownloadsModal = () => { ]); useEffect(() => { + if (!isAddModalOpen) return; const maxConcurrentMetadataRequests = 4; + // Magnet inspection is optional enrichment. Keep it in a separate lane + // so a stalled public magnet cannot consume all slots needed to validate + // required HTTP, media, or .torrent metadata in the same Add window. + const maxConcurrentMagnetMetadataRequests = 2; for (const row of parsedItems) { - if (row.status !== 'loading' || row.selected === false) continue; + const isDirectMagnetEnrichment = row.isTorrent === true + && isMagnetUrl(row.sourceUrl) + && !row.torrentPath + && row.torrentMetadataStatus !== 'ready' + && row.torrentMetadataStatus !== 'error'; + if ((row.status !== 'loading' && !isDirectMagnetEnrichment) || row.selected === false) continue; + if (row.isPlaylist && playlistExpansions[row.sourceUrl]) continue; const requestKey = `${row.id}:${row.generation}`; - const requestSet = row.isPlaylist ? playlistRequestsRef.current : metadataRequestsRef.current; + const requestSet = isDirectMagnetEnrichment + ? magnetMetadataRequestsRef.current + : row.isPlaylist ? playlistRequestsRef.current : metadataRequestsRef.current; if (requestSet.has(requestKey)) continue; - if (metadataRequestsRef.current.size + playlistRequestsRef.current.size >= maxConcurrentMetadataRequests) { + if (isDirectMagnetEnrichment) { + if (magnetMetadataRequestsRef.current.size >= maxConcurrentMagnetMetadataRequests) continue; + } else if (metadataRequestsRef.current.size + playlistRequestsRef.current.size >= maxConcurrentMetadataRequests) { break; } requestSet.add(requestKey); @@ -612,6 +629,7 @@ export const AddDownloadsModal = () => { } void (async () => { + let shouldWakeMetadataScheduler = true; try { const settingsStore = useSettingsStore.getState(); const login = getSiteLogin(row.sourceUrl, settingsStore); @@ -637,14 +655,22 @@ export const AddDownloadsModal = () => { && currentRow.generation === row.generation && (currentRow.torrentCacheId || `${currentRow.id}-${currentRow.generation}`) === torrentCacheId ); - if (torrentData.torrentPath && isCurrentTorrentDraft) { + if (!isCurrentTorrentDraft) { + cachedTorrentDraftIdsRef.current.delete(torrentCacheId); + if (torrentData.torrentPath) { + void invoke('remove_torrent_metadata', { id: torrentCacheId }).catch(error => { + console.warn('Failed to remove stale torrent metadata:', error); + }); + } + return; + } + if (torrentData.torrentPath) { cachedTorrentDraftIdsRef.current.add(torrentCacheId); - } else if (torrentData.torrentPath) { - void invoke('remove_torrent_metadata', { id: torrentCacheId }).catch(error => { - console.warn('Failed to remove stale torrent metadata:', error); - }); + } else { + cachedTorrentDraftIdsRef.current.delete(torrentCacheId); } const totalBytes = torrentData.totalBytes || undefined; + shouldWakeMetadataScheduler = false; setParsedItems(current => updateRowIfCurrent( current, row.id, @@ -655,15 +681,16 @@ export const AddDownloadsModal = () => { downloadUrl: !row.sourceUrl.trim().toLowerCase().startsWith('magnet:') ? 'torrent:' + torrentData.infoHash : row.sourceUrl, - file: canonicalizeDownloadFileName(torrentData.name), size: totalBytes ? formatBytes(totalBytes) : undefined, sizeBytes: totalBytes, status: 'ready', isTorrent: true, + torrentMetadataStatus: isMagnetUrl(row.sourceUrl) ? 'ready' : currentRow.torrentMetadataStatus, torrentPath: torrentData.torrentPath, torrentCacheId, torrentInfoHash: torrentData.infoHash, torrentFiles: torrentData.files, + file: canonicalizeDownloadFileName(torrentData.name?.trim() || currentRow.file), selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices ?.filter(index => torrentData.files.some(file => file.index === index)) }) @@ -672,6 +699,7 @@ export const AddDownloadsModal = () => { } const proxy = await getProxyArgs(settingsStore); if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) { + shouldWakeMetadataScheduler = false; settingsStore.setShowKeychainModal(true); return; } @@ -701,7 +729,10 @@ export const AddDownloadsModal = () => { }; if (row.isPlaylist) { - if (playlistExpansions[row.sourceUrl]) return; + if (playlistExpansions[row.sourceUrl]) { + shouldWakeMetadataScheduler = false; + return; + } const playlistData = await fetchMediaPlaylistMetadataDeduped({ ...mediaMetadataArgs, url: contextUrl @@ -710,6 +741,7 @@ export const AddDownloadsModal = () => { if (!playlistData.entries.length) { throw new Error(t($ => $.addDownloads.playlistNoEntries)); } + shouldWakeMetadataScheduler = false; setPlaylistExpansions(current => ({ ...current, [row.sourceUrl]: playlistData @@ -758,6 +790,7 @@ export const AddDownloadsModal = () => { ? requestedFormatIndex : fallbackFormatIndex >= 0 ? fallbackFormatIndex : 0; const selectedFormat = mappedFormats[selectedFormatIndex]; + shouldWakeMetadataScheduler = false; setParsedItems(current => updateRowIfCurrent( current, row.id, @@ -806,6 +839,7 @@ export const AddDownloadsModal = () => { // its expiry. The metadata response remains useful for filename, // size, and resumability. const nextDownloadUrl = durableDownloadUrl(row.sourceUrl); + shouldWakeMetadataScheduler = false; setParsedItems(current => updateRowIfCurrent( current, row.id, @@ -839,6 +873,7 @@ export const AddDownloadsModal = () => { ].some(prefix => errorMessage.startsWith(prefix)) ? 'unsafe-url' as const : undefined; + shouldWakeMetadataScheduler = false; setParsedItems(current => updateRowIfCurrent( current, row.id, @@ -849,7 +884,9 @@ export const AddDownloadsModal = () => { downloadUrl: currentRow.sourceUrl, size: undefined, sizeBytes: undefined, - status: 'metadata-error', + status: currentRow.isTorrent && isMagnetUrl(currentRow.sourceUrl) + ? 'ready' + : 'metadata-error', formats: undefined, selectedFormat: undefined, ...(currentRow.isTorrent @@ -858,6 +895,7 @@ export const AddDownloadsModal = () => { torrentCacheId: undefined, torrentInfoHash: undefined, torrentFiles: undefined, + torrentMetadataStatus: isMagnetUrl(currentRow.sourceUrl) ? 'error' : undefined, selectedTorrentFileIndices: undefined } : {}), @@ -869,12 +907,17 @@ export const AddDownloadsModal = () => { )); } finally { requestSet.delete(requestKey); + if (shouldWakeMetadataScheduler) { + setMetadataSchedulerRevision(revision => revision + 1); + } } })(); } }, [ + isAddModalOpen, keychainAccessReady, keychainPromptDismissed, + metadataSchedulerRevision, parsedItems, pendingAddFilename, pendingAddMediaUrls, diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 465d171..9c2a8a3 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -1332,7 +1332,7 @@ export const PropertiesWindowApp = () => {
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
{t($ => $.properties.speed)}{allocationPending ? '—' : snapshot.speed || '—'}
{t($ => $.properties.eta)}{allocationPending ? '—' : snapshot.eta || '—'}
- {connectionPresentation.showHeaderMetric &&
{connectionHeaderLabel}{connectionValue}
} + {connectionPresentation.showHeaderMetric &&
{connectionHeaderLabel}{connectionValue}
} {isTorrent && <>
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 8bd024a..0201ed1 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -362,7 +362,7 @@ const common = { torrentSeededDuration: 'Seeded', torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.', torrentConnectedPeers: 'Peers', - torrentPeersSeeders: 'Connected peers / connected seeders', + torrentPeersSeeders: 'Peers/Seeds', torrentConnectedPeerMetric: '{{peers}} connected peers / {{seeders}} connected seeders', torrentPeerDetailsUnavailable: '{{connected}} connected peers reported, but peer details are not available yet.', torrentPeerCountDifference: 'Connected status: {{connectedPeers}} peers / {{connectedSeeders}} seeders. The peer-details response lists {{listedPeers}} peers / {{listedSeeders}} seeders.', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 7eabbea..24cafba 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -362,7 +362,7 @@ const fa = { torrentSeededDuration: 'مدت سید', torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه می‌دهد. برای استفاده از پیش‌فرض خالی بگذارید.', torrentConnectedPeers: 'همتاها', - torrentPeersSeeders: 'همتاهای متصل / سیدهای متصل', + torrentPeersSeeders: 'همتاها / سیدها', torrentConnectedPeerMetric: '{{peers}} همتای متصل / {{seeders}} سید متصل', torrentPeerDetailsUnavailable: '{{connected}} همتای متصل گزارش شده، اما جزئیات همتاها هنوز در دسترس نیست.', torrentPeerCountDifference: 'وضعیت اتصال: {{connectedPeers}} همتا / {{connectedSeeders}} سید. پاسخ جزئیات همتاها {{listedPeers}} همتا / {{listedSeeders}} سید را فهرست کرده است.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 96bef7b..907a66a 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -362,7 +362,7 @@ const he = { torrentSeededDuration: 'משך שיתוף', torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.', torrentConnectedPeers: 'עמיתים', - torrentPeersSeeders: 'עמיתים מחוברים / משתפים מחוברים', + torrentPeersSeeders: 'עמיתים / משתפים', torrentConnectedPeerMetric: '{{peers}} עמיתים מחוברים / {{seeders}} משתפים מחוברים', torrentPeerDetailsUnavailable: 'דווחו {{connected}} עמיתים מחוברים, אך פרטי העמיתים עדיין אינם זמינים.', torrentPeerCountDifference: 'מחוברים: {{connectedPeers}} עמיתים / {{connectedSeeders}} משתפים. תגובת פרטי העמיתים מציגה {{listedPeers}} עמיתים / {{listedSeeders}} משתפים.', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index eab52b4..e0b96fd 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -362,7 +362,7 @@ const ru = { torrentSeededDuration: 'Время раздачи', torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.', torrentConnectedPeers: 'Пиры', - torrentPeersSeeders: 'Подключённые пиры / подключённые сиды', + torrentPeersSeeders: 'Пиры / Сиды', torrentConnectedPeerMetric: '{{peers}} подключённых пиров / {{seeders}} подключённых сидов', torrentPeerDetailsUnavailable: 'Подключённых пиров: {{connected}}, но сведения о них пока недоступны.', torrentPeerCountDifference: 'Подключено: {{connectedPeers}} пиров / {{connectedSeeders}} сидов. В ответе со сведениями о пирах указано: {{listedPeers}} пиров / {{listedSeeders}} сидов.', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index c5b565c..4512eb1 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -362,7 +362,7 @@ const uk = { torrentSeededDuration: 'Час роздачі', torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.', torrentConnectedPeers: 'Піри', - torrentPeersSeeders: 'Підключені піри / підключені сіди', + torrentPeersSeeders: 'Піри / Сіди', torrentConnectedPeerMetric: '{{peers}} підключених пірів / {{seeders}} підключених сідів', torrentPeerDetailsUnavailable: 'Підключених пірів: {{connected}}, але відомості про них поки недоступні.', torrentPeerCountDifference: 'Підключено: {{connectedPeers}} пірів / {{connectedSeeders}} сідів. У відповіді з відомостями про піри зазначено: {{listedPeers}} пірів / {{listedSeeders}} сідів.', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index aad0d47..f583955 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -362,7 +362,7 @@ const zhCN = { torrentSeededDuration: '做种时长', torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。', torrentConnectedPeers: '连接数', - torrentPeersSeeders: '已连接节点 / 已连接做种节点', + torrentPeersSeeders: '节点 / 做种', torrentConnectedPeerMetric: '{{peers}} 个已连接节点 / {{seeders}} 个已连接做种节点', torrentPeerDetailsUnavailable: '检测到 {{connected}} 个已连接节点,但其详细信息暂时不可用。', torrentPeerCountDifference: '连接状态:{{connectedPeers}} 个节点 / {{connectedSeeders}} 个做种节点。对等节点详情响应列出 {{listedPeers}} 个节点 / {{listedSeeders}} 个做种节点。', diff --git a/src/index.css b/src/index.css index 49da11f..34742cf 100644 --- a/src/index.css +++ b/src/index.css @@ -798,7 +798,7 @@ html[data-list-density="relaxed"] { .properties-window-metrics { display: grid; - grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(145px, 100%), 1fr)); gap: 7px; margin-top: 11px; } @@ -842,16 +842,6 @@ html[data-list-density="relaxed"] { white-space: nowrap; } - .properties-metric-card > .properties-metric-content > .properties-metric-label--wide { - overflow: visible; - font-size: 9px; - letter-spacing: 0.01em; - line-height: 1.15; - min-height: 2.3em; - text-overflow: clip; - white-space: normal; - } - .properties-metric-card > .properties-metric-content > .properties-metric-value { overflow: hidden; color: hsl(var(--text-primary)); @@ -1421,9 +1411,6 @@ html[data-list-density="relaxed"] { display: inline-block; } - .properties-window-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } } @media (max-width: 620px) { diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index 7757e51..79a2cd9 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -97,7 +97,9 @@ describe('add download metadata workflow', () => { expect(rows[0]).toMatchObject({ isTorrent: true, isMedia: false, - status: 'ready' + status: 'ready', + file: 'Example', + torrentMetadataStatus: 'loading' }); expect(rows[1]).toMatchObject({ isTorrent: true, @@ -536,20 +538,22 @@ describe('add download metadata workflow', () => { expect(refreshed[1]).toMatchObject({ status: 'loading', generation: 5 }); }); - it('refreshes an admitted magnet only when metadata is explicitly requested', () => { + it('does not duplicate an in-flight magnet probe and refreshes it after failure', () => { const magnet = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'; const admitted = reconcileDownloadRows(magnet, [])[0]; expect(admitted.status).toBe('ready'); expect(canSubmitMetadataRows([admitted])).toBe(true); + expect(isMetadataRefreshableRow(admitted)).toBe(false); - const refreshed = refreshFailedMetadataRows([admitted])[0]; + const failed = { ...admitted, torrentMetadataStatus: 'error' as const }; + const refreshed = refreshFailedMetadataRows([failed])[0]; expect(refreshed).toMatchObject({ status: 'loading', generation: 2, torrentCacheId: `${admitted.id}-2`, }); - expect(isMetadataRefreshableRow(admitted)).toBe(true); + expect(isMetadataRefreshableRow(failed)).toBe(true); expect(isMetadataRefreshableRow({ ...admitted, status: 'loading' })).toBe(false); expect(isMetadataRefreshableRow(row())).toBe(false); }); @@ -589,7 +593,8 @@ describe('add download metadata workflow', () => { expect(refreshed).toMatchObject({ status: 'loading', generation: 2, - torrentCacheId: 'row-1-2' + torrentCacheId: 'row-1-2', + torrentMetadataStatus: 'loading' }); expect(refreshed.torrentPath).toBeUndefined(); expect(refreshed.torrentInfoHash).toBeUndefined(); @@ -616,7 +621,8 @@ describe('add download metadata workflow', () => { expect(migrated).toMatchObject({ status: 'ready', downloadUrl: magnet, - isTorrent: true + isTorrent: true, + torrentMetadataStatus: 'loading' }); expect(migrated.torrentPath).toBeUndefined(); expect(migrated.torrentCacheId).toBeUndefined(); diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index cd1d64c..2952543 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -61,6 +61,8 @@ export interface AddDownloadDraftRow { torrentCacheId?: string; torrentInfoHash?: string; torrentFiles?: TorrentFile[]; + /** Best-effort metadata enrichment state for directly admitted magnets. */ + torrentMetadataStatus?: 'loading' | 'ready' | 'error'; selectedTorrentFileIndices?: number[]; torrentSeedTime?: number; torrentSeedRatio?: number; @@ -120,7 +122,8 @@ export const isMagnetTorrentRow = ( export const isMetadataRefreshableRow = (row: AddDownloadDraftRow): boolean => row.status !== 'loading' - && (row.status === 'metadata-error' || isMagnetTorrentRow(row)); + && (row.status === 'metadata-error' + || (isMagnetTorrentRow(row) && row.torrentMetadataStatus !== 'loading')); type ParsedInput = { identity: string; @@ -291,6 +294,7 @@ export const reconcileDownloadRows = ( ? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl)) : preserved.file, status: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading', + torrentMetadataStatus: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'loading' : undefined, generation: nextGeneration, requestContextVersion, isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl), @@ -332,6 +336,7 @@ export const reconcileDownloadRows = ( downloadUrl: input.sourceUrl, status: 'ready', isTorrent: true, + torrentMetadataStatus: 'loading', torrentPath: undefined, torrentCacheId: undefined, torrentInfoHash: undefined, @@ -359,8 +364,8 @@ export const reconcileDownloadRows = ( file: fallback, // A magnet already contains the transfer identity. Metadata is useful // for the preview, but it is not required to admit the transfer. Keep - // the optional probe behind Refresh Metadata so the Add window never - // blocks a valid magnet on the bounded native probe timeout. + // the probe best-effort so the Add window never blocks a valid magnet + // on the bounded native probe timeout. status: input.valid ? input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading' : 'invalid', @@ -383,6 +388,9 @@ export const reconcileDownloadRows = ( torrentCacheId: input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl)) ? `${id}-${generation}` : undefined, + torrentMetadataStatus: input.valid && input.isTorrent && isMagnetUrl(input.sourceUrl) + ? 'loading' + : undefined, selected: input.selected !== false }; }); @@ -446,6 +454,7 @@ export const refreshFailedMetadataRows = ( torrentCacheId: `${row.id}-${generation}`, torrentInfoHash: undefined, torrentFiles: undefined, + torrentMetadataStatus: isMagnetTorrentRow(row) ? 'loading' : undefined, selectedTorrentFileIndices: undefined } : {}) diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 80a5cb7..9ba961e 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -3,6 +3,7 @@ import type { DownloadItem } from '../bindings/DownloadItem'; import { downloadFileNamesMatch, downloadFileNameWithSuffix, + fileNameFromUrl, downloadMediaKindsMatch, MAX_DOWNLOAD_FILENAME_BYTES, canonicalizeDownloadFileName, @@ -50,6 +51,20 @@ describe('download category detection', () => { }); }); +describe('download names from URLs', () => { + it('uses a magnet display name before metadata is resolved', () => { + expect(fileNameFromUrl( + 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Pizza+House+Simulator+%28v1.0%29', + )).toBe('Pizza House Simulator (v1.0)'); + }); + + it('keeps the generic fallback for unnamed magnets', () => { + expect(fileNameFromUrl( + 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567', + )).toBe('download'); + }); +}); + describe('download persistence progress snapshots', () => { it('does not write active byte counters on every progress event', () => { const persisted = redactDownloadForPersistence(item('downloading')); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 8a26801..809448d 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -492,6 +492,12 @@ export const categoryForDownload = ( export const fileNameFromUrl = (rawUrl: string): string => { try { const url = new URL(rawUrl); + if (url.protocol === 'magnet:') { + const displayName = url.searchParams.get('dn')?.trim(); + if (displayName) { + return displayName.replace(/[\/\\?%*:|"<>]/g, '-'); + } + } const pathName = url.pathname.split('/').filter(Boolean).pop(); if (pathName) { const decoded = decodeURIComponent(pathName).trim();