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
This commit is contained in:
NimBold
2026-08-25 03:27:49 +03:30
parent 51db4fc6b7
commit ba491ccd7d
13 changed files with 110 additions and 44 deletions
+55 -12
View File
@@ -168,9 +168,11 @@ export const AddDownloadsModal = () => {
const [urls, setUrls] = useState(''); const [urls, setUrls] = useState('');
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null); const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]); const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
const [metadataSchedulerRevision, setMetadataSchedulerRevision] = useState(0);
const parsedItemsRef = useRef<AddDownloadDraftRow[]>([]); const parsedItemsRef = useRef<AddDownloadDraftRow[]>([]);
const addModalOpenRef = useRef(isAddModalOpen); const addModalOpenRef = useRef(isAddModalOpen);
const metadataRequestsRef = useRef(new Set<string>()); const metadataRequestsRef = useRef(new Set<string>());
const magnetMetadataRequestsRef = useRef(new Set<string>());
const playlistRequestsRef = useRef(new Set<string>()); const playlistRequestsRef = useRef(new Set<string>());
const latestPlaylistRequestRef = useRef(new Map<string, string>()); const latestPlaylistRequestRef = useRef(new Map<string, string>());
const cachedTorrentDraftIdsRef = useRef(new Set<string>()); const cachedTorrentDraftIdsRef = useRef(new Set<string>());
@@ -361,7 +363,6 @@ export const AddDownloadsModal = () => {
pendingLastUsedDownloadDirectoryRef.current = null; pendingLastUsedDownloadDirectoryRef.current = null;
setUrls(''); setUrls('');
setPlaylistExpansions({}); setPlaylistExpansions({});
playlistRequestsRef.current.clear();
latestPlaylistRequestRef.current.clear(); latestPlaylistRequestRef.current.clear();
playlistMediaSelectionsRef.current.clear(); playlistMediaSelectionsRef.current.clear();
setPlaylistMediaTypeSelections({}); setPlaylistMediaTypeSelections({});
@@ -391,8 +392,9 @@ export const AddDownloadsModal = () => {
setUrls(initialUrls); setUrls(initialUrls);
setParsedItems([]); setParsedItems([]);
setPlaylistExpansions({}); setPlaylistExpansions({});
metadataRequestsRef.current.clear(); // Keep active request ownership across rapid modal close/reopen cycles.
playlistRequestsRef.current.clear(); // 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(); latestPlaylistRequestRef.current.clear();
playlistMediaSelectionsRef.current.clear(); playlistMediaSelectionsRef.current.clear();
setPlaylistMediaTypeSelections({}); setPlaylistMediaTypeSelections({});
@@ -593,13 +595,28 @@ export const AddDownloadsModal = () => {
]); ]);
useEffect(() => { useEffect(() => {
if (!isAddModalOpen) return;
const maxConcurrentMetadataRequests = 4; 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) { 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 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 (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; break;
} }
requestSet.add(requestKey); requestSet.add(requestKey);
@@ -612,6 +629,7 @@ export const AddDownloadsModal = () => {
} }
void (async () => { void (async () => {
let shouldWakeMetadataScheduler = true;
try { try {
const settingsStore = useSettingsStore.getState(); const settingsStore = useSettingsStore.getState();
const login = getSiteLogin(row.sourceUrl, settingsStore); const login = getSiteLogin(row.sourceUrl, settingsStore);
@@ -637,14 +655,22 @@ export const AddDownloadsModal = () => {
&& currentRow.generation === row.generation && currentRow.generation === row.generation
&& (currentRow.torrentCacheId || `${currentRow.id}-${currentRow.generation}`) === torrentCacheId && (currentRow.torrentCacheId || `${currentRow.id}-${currentRow.generation}`) === torrentCacheId
); );
if (torrentData.torrentPath && isCurrentTorrentDraft) { if (!isCurrentTorrentDraft) {
cachedTorrentDraftIdsRef.current.add(torrentCacheId); cachedTorrentDraftIdsRef.current.delete(torrentCacheId);
} else if (torrentData.torrentPath) { if (torrentData.torrentPath) {
void invoke('remove_torrent_metadata', { id: torrentCacheId }).catch(error => { void invoke('remove_torrent_metadata', { id: torrentCacheId }).catch(error => {
console.warn('Failed to remove stale torrent metadata:', error); console.warn('Failed to remove stale torrent metadata:', error);
}); });
} }
return;
}
if (torrentData.torrentPath) {
cachedTorrentDraftIdsRef.current.add(torrentCacheId);
} else {
cachedTorrentDraftIdsRef.current.delete(torrentCacheId);
}
const totalBytes = torrentData.totalBytes || undefined; const totalBytes = torrentData.totalBytes || undefined;
shouldWakeMetadataScheduler = false;
setParsedItems(current => updateRowIfCurrent( setParsedItems(current => updateRowIfCurrent(
current, current,
row.id, row.id,
@@ -655,15 +681,16 @@ export const AddDownloadsModal = () => {
downloadUrl: !row.sourceUrl.trim().toLowerCase().startsWith('magnet:') downloadUrl: !row.sourceUrl.trim().toLowerCase().startsWith('magnet:')
? 'torrent:' + torrentData.infoHash ? 'torrent:' + torrentData.infoHash
: row.sourceUrl, : row.sourceUrl,
file: canonicalizeDownloadFileName(torrentData.name),
size: totalBytes ? formatBytes(totalBytes) : undefined, size: totalBytes ? formatBytes(totalBytes) : undefined,
sizeBytes: totalBytes, sizeBytes: totalBytes,
status: 'ready', status: 'ready',
isTorrent: true, isTorrent: true,
torrentMetadataStatus: isMagnetUrl(row.sourceUrl) ? 'ready' : currentRow.torrentMetadataStatus,
torrentPath: torrentData.torrentPath, torrentPath: torrentData.torrentPath,
torrentCacheId, torrentCacheId,
torrentInfoHash: torrentData.infoHash, torrentInfoHash: torrentData.infoHash,
torrentFiles: torrentData.files, torrentFiles: torrentData.files,
file: canonicalizeDownloadFileName(torrentData.name?.trim() || currentRow.file),
selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices
?.filter(index => torrentData.files.some(file => file.index === index)) ?.filter(index => torrentData.files.some(file => file.index === index))
}) })
@@ -672,6 +699,7 @@ export const AddDownloadsModal = () => {
} }
const proxy = await getProxyArgs(settingsStore); const proxy = await getProxyArgs(settingsStore);
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) { if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
shouldWakeMetadataScheduler = false;
settingsStore.setShowKeychainModal(true); settingsStore.setShowKeychainModal(true);
return; return;
} }
@@ -701,7 +729,10 @@ export const AddDownloadsModal = () => {
}; };
if (row.isPlaylist) { if (row.isPlaylist) {
if (playlistExpansions[row.sourceUrl]) return; if (playlistExpansions[row.sourceUrl]) {
shouldWakeMetadataScheduler = false;
return;
}
const playlistData = await fetchMediaPlaylistMetadataDeduped({ const playlistData = await fetchMediaPlaylistMetadataDeduped({
...mediaMetadataArgs, ...mediaMetadataArgs,
url: contextUrl url: contextUrl
@@ -710,6 +741,7 @@ export const AddDownloadsModal = () => {
if (!playlistData.entries.length) { if (!playlistData.entries.length) {
throw new Error(t($ => $.addDownloads.playlistNoEntries)); throw new Error(t($ => $.addDownloads.playlistNoEntries));
} }
shouldWakeMetadataScheduler = false;
setPlaylistExpansions(current => ({ setPlaylistExpansions(current => ({
...current, ...current,
[row.sourceUrl]: playlistData [row.sourceUrl]: playlistData
@@ -758,6 +790,7 @@ export const AddDownloadsModal = () => {
? requestedFormatIndex ? requestedFormatIndex
: fallbackFormatIndex >= 0 ? fallbackFormatIndex : 0; : fallbackFormatIndex >= 0 ? fallbackFormatIndex : 0;
const selectedFormat = mappedFormats[selectedFormatIndex]; const selectedFormat = mappedFormats[selectedFormatIndex];
shouldWakeMetadataScheduler = false;
setParsedItems(current => updateRowIfCurrent( setParsedItems(current => updateRowIfCurrent(
current, current,
row.id, row.id,
@@ -806,6 +839,7 @@ export const AddDownloadsModal = () => {
// its expiry. The metadata response remains useful for filename, // its expiry. The metadata response remains useful for filename,
// size, and resumability. // size, and resumability.
const nextDownloadUrl = durableDownloadUrl(row.sourceUrl); const nextDownloadUrl = durableDownloadUrl(row.sourceUrl);
shouldWakeMetadataScheduler = false;
setParsedItems(current => updateRowIfCurrent( setParsedItems(current => updateRowIfCurrent(
current, current,
row.id, row.id,
@@ -839,6 +873,7 @@ export const AddDownloadsModal = () => {
].some(prefix => errorMessage.startsWith(prefix)) ].some(prefix => errorMessage.startsWith(prefix))
? 'unsafe-url' as const ? 'unsafe-url' as const
: undefined; : undefined;
shouldWakeMetadataScheduler = false;
setParsedItems(current => updateRowIfCurrent( setParsedItems(current => updateRowIfCurrent(
current, current,
row.id, row.id,
@@ -849,7 +884,9 @@ export const AddDownloadsModal = () => {
downloadUrl: currentRow.sourceUrl, downloadUrl: currentRow.sourceUrl,
size: undefined, size: undefined,
sizeBytes: undefined, sizeBytes: undefined,
status: 'metadata-error', status: currentRow.isTorrent && isMagnetUrl(currentRow.sourceUrl)
? 'ready'
: 'metadata-error',
formats: undefined, formats: undefined,
selectedFormat: undefined, selectedFormat: undefined,
...(currentRow.isTorrent ...(currentRow.isTorrent
@@ -858,6 +895,7 @@ export const AddDownloadsModal = () => {
torrentCacheId: undefined, torrentCacheId: undefined,
torrentInfoHash: undefined, torrentInfoHash: undefined,
torrentFiles: undefined, torrentFiles: undefined,
torrentMetadataStatus: isMagnetUrl(currentRow.sourceUrl) ? 'error' : undefined,
selectedTorrentFileIndices: undefined selectedTorrentFileIndices: undefined
} }
: {}), : {}),
@@ -869,12 +907,17 @@ export const AddDownloadsModal = () => {
)); ));
} finally { } finally {
requestSet.delete(requestKey); requestSet.delete(requestKey);
if (shouldWakeMetadataScheduler) {
setMetadataSchedulerRevision(revision => revision + 1);
}
} }
})(); })();
} }
}, [ }, [
isAddModalOpen,
keychainAccessReady, keychainAccessReady,
keychainPromptDismissed, keychainPromptDismissed,
metadataSchedulerRevision,
parsedItems, parsedItems,
pendingAddFilename, pendingAddFilename,
pendingAddMediaUrls, pendingAddMediaUrls,
+1 -1
View File
@@ -1332,7 +1332,7 @@ export const PropertiesWindowApp = () => {
<div className="properties-metric-card"><Download size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.size)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div> <div className="properties-metric-card"><Download size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.size)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
<div className="properties-metric-card"><Gauge size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.speed)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.speed || '—'}</strong></div></div> <div className="properties-metric-card"><Gauge size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.speed)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.speed || '—'}</strong></div></div>
<div className="properties-metric-card"><Timer size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.eta)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.eta || '—'}</strong></div></div> <div className="properties-metric-card"><Timer size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.eta)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.eta || '—'}</strong></div></div>
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div className="properties-metric-content"><span className={`properties-metric-label ${connectionPresentation.labelKey === 'torrentPeersSeeders' ? 'properties-metric-label--wide' : ''}`}>{connectionHeaderLabel}</span>{connectionValue}</div></div>} {connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{connectionHeaderLabel}</span>{connectionValue}</div></div>}
{isTorrent && <> {isTorrent && <>
<div className="properties-metric-card"><Upload size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentUploaded)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div> <div className="properties-metric-card"><Upload size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentUploaded)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div> <div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
+1 -1
View File
@@ -362,7 +362,7 @@ const common = {
torrentSeededDuration: 'Seeded', torrentSeededDuration: 'Seeded',
torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.', torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.',
torrentConnectedPeers: 'Peers', torrentConnectedPeers: 'Peers',
torrentPeersSeeders: 'Connected peers / connected seeders', torrentPeersSeeders: 'Peers/Seeds',
torrentConnectedPeerMetric: '{{peers}} connected peers / {{seeders}} connected seeders', torrentConnectedPeerMetric: '{{peers}} connected peers / {{seeders}} connected seeders',
torrentPeerDetailsUnavailable: '{{connected}} connected peers reported, but peer details are not available yet.', 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.', torrentPeerCountDifference: 'Connected status: {{connectedPeers}} peers / {{connectedSeeders}} seeders. The peer-details response lists {{listedPeers}} peers / {{listedSeeders}} seeders.',
+1 -1
View File
@@ -362,7 +362,7 @@ const fa = {
torrentSeededDuration: 'مدت سید', torrentSeededDuration: 'مدت سید',
torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه می‌دهد. برای استفاده از پیش‌فرض خالی بگذارید.', torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه می‌دهد. برای استفاده از پیش‌فرض خالی بگذارید.',
torrentConnectedPeers: 'همتاها', torrentConnectedPeers: 'همتاها',
torrentPeersSeeders: 'همتاهای متصل / سیدهای متصل', torrentPeersSeeders: 'همتاها / سیدها',
torrentConnectedPeerMetric: '{{peers}} همتای متصل / {{seeders}} سید متصل', torrentConnectedPeerMetric: '{{peers}} همتای متصل / {{seeders}} سید متصل',
torrentPeerDetailsUnavailable: '{{connected}} همتای متصل گزارش شده، اما جزئیات همتاها هنوز در دسترس نیست.', torrentPeerDetailsUnavailable: '{{connected}} همتای متصل گزارش شده، اما جزئیات همتاها هنوز در دسترس نیست.',
torrentPeerCountDifference: 'وضعیت اتصال: {{connectedPeers}} همتا / {{connectedSeeders}} سید. پاسخ جزئیات همتاها {{listedPeers}} همتا / {{listedSeeders}} سید را فهرست کرده است.', torrentPeerCountDifference: 'وضعیت اتصال: {{connectedPeers}} همتا / {{connectedSeeders}} سید. پاسخ جزئیات همتاها {{listedPeers}} همتا / {{listedSeeders}} سید را فهرست کرده است.',
+1 -1
View File
@@ -362,7 +362,7 @@ const he = {
torrentSeededDuration: 'משך שיתוף', torrentSeededDuration: 'משך שיתוף',
torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.', torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.',
torrentConnectedPeers: 'עמיתים', torrentConnectedPeers: 'עמיתים',
torrentPeersSeeders: 'עמיתים מחוברים / משתפים מחוברים', torrentPeersSeeders: 'עמיתים / משתפים',
torrentConnectedPeerMetric: '{{peers}} עמיתים מחוברים / {{seeders}} משתפים מחוברים', torrentConnectedPeerMetric: '{{peers}} עמיתים מחוברים / {{seeders}} משתפים מחוברים',
torrentPeerDetailsUnavailable: 'דווחו {{connected}} עמיתים מחוברים, אך פרטי העמיתים עדיין אינם זמינים.', torrentPeerDetailsUnavailable: 'דווחו {{connected}} עמיתים מחוברים, אך פרטי העמיתים עדיין אינם זמינים.',
torrentPeerCountDifference: 'מחוברים: {{connectedPeers}} עמיתים / {{connectedSeeders}} משתפים. תגובת פרטי העמיתים מציגה {{listedPeers}} עמיתים / {{listedSeeders}} משתפים.', torrentPeerCountDifference: 'מחוברים: {{connectedPeers}} עמיתים / {{connectedSeeders}} משתפים. תגובת פרטי העמיתים מציגה {{listedPeers}} עמיתים / {{listedSeeders}} משתפים.',
+1 -1
View File
@@ -362,7 +362,7 @@ const ru = {
torrentSeededDuration: 'Время раздачи', torrentSeededDuration: 'Время раздачи',
torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.', torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.',
torrentConnectedPeers: 'Пиры', torrentConnectedPeers: 'Пиры',
torrentPeersSeeders: одключённые пиры / подключённые сиды', torrentPeersSeeders: иры / Сиды',
torrentConnectedPeerMetric: '{{peers}} подключённых пиров / {{seeders}} подключённых сидов', torrentConnectedPeerMetric: '{{peers}} подключённых пиров / {{seeders}} подключённых сидов',
torrentPeerDetailsUnavailable: 'Подключённых пиров: {{connected}}, но сведения о них пока недоступны.', torrentPeerDetailsUnavailable: 'Подключённых пиров: {{connected}}, но сведения о них пока недоступны.',
torrentPeerCountDifference: 'Подключено: {{connectedPeers}} пиров / {{connectedSeeders}} сидов. В ответе со сведениями о пирах указано: {{listedPeers}} пиров / {{listedSeeders}} сидов.', torrentPeerCountDifference: 'Подключено: {{connectedPeers}} пиров / {{connectedSeeders}} сидов. В ответе со сведениями о пирах указано: {{listedPeers}} пиров / {{listedSeeders}} сидов.',
+1 -1
View File
@@ -362,7 +362,7 @@ const uk = {
torrentSeededDuration: 'Час роздачі', torrentSeededDuration: 'Час роздачі',
torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.', torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.',
torrentConnectedPeers: 'Піри', torrentConnectedPeers: 'Піри',
torrentPeersSeeders: 'Підключені піри / підключені сіди', torrentPeersSeeders: 'Піри / Сіди',
torrentConnectedPeerMetric: '{{peers}} підключених пірів / {{seeders}} підключених сідів', torrentConnectedPeerMetric: '{{peers}} підключених пірів / {{seeders}} підключених сідів',
torrentPeerDetailsUnavailable: 'Підключених пірів: {{connected}}, але відомості про них поки недоступні.', torrentPeerDetailsUnavailable: 'Підключених пірів: {{connected}}, але відомості про них поки недоступні.',
torrentPeerCountDifference: 'Підключено: {{connectedPeers}} пірів / {{connectedSeeders}} сідів. У відповіді з відомостями про піри зазначено: {{listedPeers}} пірів / {{listedSeeders}} сідів.', torrentPeerCountDifference: 'Підключено: {{connectedPeers}} пірів / {{connectedSeeders}} сідів. У відповіді з відомостями про піри зазначено: {{listedPeers}} пірів / {{listedSeeders}} сідів.',
+1 -1
View File
@@ -362,7 +362,7 @@ const zhCN = {
torrentSeededDuration: '做种时长', torrentSeededDuration: '做种时长',
torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。', torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。',
torrentConnectedPeers: '连接数', torrentConnectedPeers: '连接数',
torrentPeersSeeders: '已连接节点 / 已连接做种节点', torrentPeersSeeders: '节点 / 做种',
torrentConnectedPeerMetric: '{{peers}} 个已连接节点 / {{seeders}} 个已连接做种节点', torrentConnectedPeerMetric: '{{peers}} 个已连接节点 / {{seeders}} 个已连接做种节点',
torrentPeerDetailsUnavailable: '检测到 {{connected}} 个已连接节点,但其详细信息暂时不可用。', torrentPeerDetailsUnavailable: '检测到 {{connected}} 个已连接节点,但其详细信息暂时不可用。',
torrentPeerCountDifference: '连接状态:{{connectedPeers}} 个节点 / {{connectedSeeders}} 个做种节点。对等节点详情响应列出 {{listedPeers}} 个节点 / {{listedSeeders}} 个做种节点。', torrentPeerCountDifference: '连接状态:{{connectedPeers}} 个节点 / {{connectedSeeders}} 个做种节点。对等节点详情响应列出 {{listedPeers}} 个节点 / {{listedSeeders}} 个做种节点。',
+1 -14
View File
@@ -798,7 +798,7 @@ html[data-list-density="relaxed"] {
.properties-window-metrics { .properties-window-metrics {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(145px, 100%), 1fr));
gap: 7px; gap: 7px;
margin-top: 11px; margin-top: 11px;
} }
@@ -842,16 +842,6 @@ html[data-list-density="relaxed"] {
white-space: nowrap; 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 { .properties-metric-card > .properties-metric-content > .properties-metric-value {
overflow: hidden; overflow: hidden;
color: hsl(var(--text-primary)); color: hsl(var(--text-primary));
@@ -1421,9 +1411,6 @@ html[data-list-density="relaxed"] {
display: inline-block; display: inline-block;
} }
.properties-window-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 620px) { @media (max-width: 620px) {
+12 -6
View File
@@ -97,7 +97,9 @@ describe('add download metadata workflow', () => {
expect(rows[0]).toMatchObject({ expect(rows[0]).toMatchObject({
isTorrent: true, isTorrent: true,
isMedia: false, isMedia: false,
status: 'ready' status: 'ready',
file: 'Example',
torrentMetadataStatus: 'loading'
}); });
expect(rows[1]).toMatchObject({ expect(rows[1]).toMatchObject({
isTorrent: true, isTorrent: true,
@@ -536,20 +538,22 @@ describe('add download metadata workflow', () => {
expect(refreshed[1]).toMatchObject({ status: 'loading', generation: 5 }); 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 magnet = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567';
const admitted = reconcileDownloadRows(magnet, [])[0]; const admitted = reconcileDownloadRows(magnet, [])[0];
expect(admitted.status).toBe('ready'); expect(admitted.status).toBe('ready');
expect(canSubmitMetadataRows([admitted])).toBe(true); 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({ expect(refreshed).toMatchObject({
status: 'loading', status: 'loading',
generation: 2, generation: 2,
torrentCacheId: `${admitted.id}-2`, torrentCacheId: `${admitted.id}-2`,
}); });
expect(isMetadataRefreshableRow(admitted)).toBe(true); expect(isMetadataRefreshableRow(failed)).toBe(true);
expect(isMetadataRefreshableRow({ ...admitted, status: 'loading' })).toBe(false); expect(isMetadataRefreshableRow({ ...admitted, status: 'loading' })).toBe(false);
expect(isMetadataRefreshableRow(row())).toBe(false); expect(isMetadataRefreshableRow(row())).toBe(false);
}); });
@@ -589,7 +593,8 @@ describe('add download metadata workflow', () => {
expect(refreshed).toMatchObject({ expect(refreshed).toMatchObject({
status: 'loading', status: 'loading',
generation: 2, generation: 2,
torrentCacheId: 'row-1-2' torrentCacheId: 'row-1-2',
torrentMetadataStatus: 'loading'
}); });
expect(refreshed.torrentPath).toBeUndefined(); expect(refreshed.torrentPath).toBeUndefined();
expect(refreshed.torrentInfoHash).toBeUndefined(); expect(refreshed.torrentInfoHash).toBeUndefined();
@@ -616,7 +621,8 @@ describe('add download metadata workflow', () => {
expect(migrated).toMatchObject({ expect(migrated).toMatchObject({
status: 'ready', status: 'ready',
downloadUrl: magnet, downloadUrl: magnet,
isTorrent: true isTorrent: true,
torrentMetadataStatus: 'loading'
}); });
expect(migrated.torrentPath).toBeUndefined(); expect(migrated.torrentPath).toBeUndefined();
expect(migrated.torrentCacheId).toBeUndefined(); expect(migrated.torrentCacheId).toBeUndefined();
+12 -3
View File
@@ -61,6 +61,8 @@ export interface AddDownloadDraftRow {
torrentCacheId?: string; torrentCacheId?: string;
torrentInfoHash?: string; torrentInfoHash?: string;
torrentFiles?: TorrentFile[]; torrentFiles?: TorrentFile[];
/** Best-effort metadata enrichment state for directly admitted magnets. */
torrentMetadataStatus?: 'loading' | 'ready' | 'error';
selectedTorrentFileIndices?: number[]; selectedTorrentFileIndices?: number[];
torrentSeedTime?: number; torrentSeedTime?: number;
torrentSeedRatio?: number; torrentSeedRatio?: number;
@@ -120,7 +122,8 @@ export const isMagnetTorrentRow = (
export const isMetadataRefreshableRow = (row: AddDownloadDraftRow): boolean => export const isMetadataRefreshableRow = (row: AddDownloadDraftRow): boolean =>
row.status !== 'loading' row.status !== 'loading'
&& (row.status === 'metadata-error' || isMagnetTorrentRow(row)); && (row.status === 'metadata-error'
|| (isMagnetTorrentRow(row) && row.torrentMetadataStatus !== 'loading'));
type ParsedInput = { type ParsedInput = {
identity: string; identity: string;
@@ -291,6 +294,7 @@ export const reconcileDownloadRows = (
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl)) ? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
: preserved.file, : preserved.file,
status: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading', status: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading',
torrentMetadataStatus: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'loading' : undefined,
generation: nextGeneration, generation: nextGeneration,
requestContextVersion, requestContextVersion,
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl), isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
@@ -332,6 +336,7 @@ export const reconcileDownloadRows = (
downloadUrl: input.sourceUrl, downloadUrl: input.sourceUrl,
status: 'ready', status: 'ready',
isTorrent: true, isTorrent: true,
torrentMetadataStatus: 'loading',
torrentPath: undefined, torrentPath: undefined,
torrentCacheId: undefined, torrentCacheId: undefined,
torrentInfoHash: undefined, torrentInfoHash: undefined,
@@ -359,8 +364,8 @@ export const reconcileDownloadRows = (
file: fallback, file: fallback,
// A magnet already contains the transfer identity. Metadata is useful // A magnet already contains the transfer identity. Metadata is useful
// for the preview, but it is not required to admit the transfer. Keep // for the preview, but it is not required to admit the transfer. Keep
// the optional probe behind Refresh Metadata so the Add window never // the probe best-effort so the Add window never blocks a valid magnet
// blocks a valid magnet on the bounded native probe timeout. // on the bounded native probe timeout.
status: input.valid status: input.valid
? input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading' ? input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'ready' : 'loading'
: 'invalid', : 'invalid',
@@ -383,6 +388,9 @@ export const reconcileDownloadRows = (
torrentCacheId: input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl)) torrentCacheId: input.valid && (input.isTorrent || forceTorrentUrls.has(input.sourceUrl))
? `${id}-${generation}` ? `${id}-${generation}`
: undefined, : undefined,
torrentMetadataStatus: input.valid && input.isTorrent && isMagnetUrl(input.sourceUrl)
? 'loading'
: undefined,
selected: input.selected !== false selected: input.selected !== false
}; };
}); });
@@ -446,6 +454,7 @@ export const refreshFailedMetadataRows = (
torrentCacheId: `${row.id}-${generation}`, torrentCacheId: `${row.id}-${generation}`,
torrentInfoHash: undefined, torrentInfoHash: undefined,
torrentFiles: undefined, torrentFiles: undefined,
torrentMetadataStatus: isMagnetTorrentRow(row) ? 'loading' : undefined,
selectedTorrentFileIndices: undefined selectedTorrentFileIndices: undefined
} }
: {}) : {})
+15
View File
@@ -3,6 +3,7 @@ import type { DownloadItem } from '../bindings/DownloadItem';
import { import {
downloadFileNamesMatch, downloadFileNamesMatch,
downloadFileNameWithSuffix, downloadFileNameWithSuffix,
fileNameFromUrl,
downloadMediaKindsMatch, downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES, MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName, 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', () => { describe('download persistence progress snapshots', () => {
it('does not write active byte counters on every progress event', () => { it('does not write active byte counters on every progress event', () => {
const persisted = redactDownloadForPersistence(item('downloading')); const persisted = redactDownloadForPersistence(item('downloading'));
+6
View File
@@ -492,6 +492,12 @@ export const categoryForDownload = (
export const fileNameFromUrl = (rawUrl: string): string => { export const fileNameFromUrl = (rawUrl: string): string => {
try { try {
const url = new URL(rawUrl); 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(); const pathName = url.pathname.split('/').filter(Boolean).pop();
if (pathName) { if (pathName) {
const decoded = decodeURIComponent(pathName).trim(); const decoded = decodeURIComponent(pathName).trim();