mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 21:48:00 +00:00
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:
@@ -168,9 +168,11 @@ export const AddDownloadsModal = () => {
|
||||
const [urls, setUrls] = useState('');
|
||||
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
||||
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
|
||||
const [metadataSchedulerRevision, setMetadataSchedulerRevision] = useState(0);
|
||||
const parsedItemsRef = useRef<AddDownloadDraftRow[]>([]);
|
||||
const addModalOpenRef = useRef(isAddModalOpen);
|
||||
const metadataRequestsRef = useRef(new Set<string>());
|
||||
const magnetMetadataRequestsRef = useRef(new Set<string>());
|
||||
const playlistRequestsRef = useRef(new Set<string>());
|
||||
const latestPlaylistRequestRef = useRef(new Map<string, string>());
|
||||
const cachedTorrentDraftIdsRef = useRef(new Set<string>());
|
||||
@@ -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,
|
||||
|
||||
@@ -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"><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>
|
||||
{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 && <>
|
||||
<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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -362,7 +362,7 @@ const fa = {
|
||||
torrentSeededDuration: 'مدت سید',
|
||||
torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه میدهد. برای استفاده از پیشفرض خالی بگذارید.',
|
||||
torrentConnectedPeers: 'همتاها',
|
||||
torrentPeersSeeders: 'همتاهای متصل / سیدهای متصل',
|
||||
torrentPeersSeeders: 'همتاها / سیدها',
|
||||
torrentConnectedPeerMetric: '{{peers}} همتای متصل / {{seeders}} سید متصل',
|
||||
torrentPeerDetailsUnavailable: '{{connected}} همتای متصل گزارش شده، اما جزئیات همتاها هنوز در دسترس نیست.',
|
||||
torrentPeerCountDifference: 'وضعیت اتصال: {{connectedPeers}} همتا / {{connectedSeeders}} سید. پاسخ جزئیات همتاها {{listedPeers}} همتا / {{listedSeeders}} سید را فهرست کرده است.',
|
||||
|
||||
@@ -362,7 +362,7 @@ const he = {
|
||||
torrentSeededDuration: 'משך שיתוף',
|
||||
torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.',
|
||||
torrentConnectedPeers: 'עמיתים',
|
||||
torrentPeersSeeders: 'עמיתים מחוברים / משתפים מחוברים',
|
||||
torrentPeersSeeders: 'עמיתים / משתפים',
|
||||
torrentConnectedPeerMetric: '{{peers}} עמיתים מחוברים / {{seeders}} משתפים מחוברים',
|
||||
torrentPeerDetailsUnavailable: 'דווחו {{connected}} עמיתים מחוברים, אך פרטי העמיתים עדיין אינם זמינים.',
|
||||
torrentPeerCountDifference: 'מחוברים: {{connectedPeers}} עמיתים / {{connectedSeeders}} משתפים. תגובת פרטי העמיתים מציגה {{listedPeers}} עמיתים / {{listedSeeders}} משתפים.',
|
||||
|
||||
@@ -362,7 +362,7 @@ const ru = {
|
||||
torrentSeededDuration: 'Время раздачи',
|
||||
torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.',
|
||||
torrentConnectedPeers: 'Пиры',
|
||||
torrentPeersSeeders: 'Подключённые пиры / подключённые сиды',
|
||||
torrentPeersSeeders: 'Пиры / Сиды',
|
||||
torrentConnectedPeerMetric: '{{peers}} подключённых пиров / {{seeders}} подключённых сидов',
|
||||
torrentPeerDetailsUnavailable: 'Подключённых пиров: {{connected}}, но сведения о них пока недоступны.',
|
||||
torrentPeerCountDifference: 'Подключено: {{connectedPeers}} пиров / {{connectedSeeders}} сидов. В ответе со сведениями о пирах указано: {{listedPeers}} пиров / {{listedSeeders}} сидов.',
|
||||
|
||||
@@ -362,7 +362,7 @@ const uk = {
|
||||
torrentSeededDuration: 'Час роздачі',
|
||||
torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.',
|
||||
torrentConnectedPeers: 'Піри',
|
||||
torrentPeersSeeders: 'Підключені піри / підключені сіди',
|
||||
torrentPeersSeeders: 'Піри / Сіди',
|
||||
torrentConnectedPeerMetric: '{{peers}} підключених пірів / {{seeders}} підключених сідів',
|
||||
torrentPeerDetailsUnavailable: 'Підключених пірів: {{connected}}, але відомості про них поки недоступні.',
|
||||
torrentPeerCountDifference: 'Підключено: {{connectedPeers}} пірів / {{connectedSeeders}} сідів. У відповіді з відомостями про піри зазначено: {{listedPeers}} пірів / {{listedSeeders}} сідів.',
|
||||
|
||||
@@ -362,7 +362,7 @@ const zhCN = {
|
||||
torrentSeededDuration: '做种时长',
|
||||
torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。',
|
||||
torrentConnectedPeers: '连接数',
|
||||
torrentPeersSeeders: '已连接节点 / 已连接做种节点',
|
||||
torrentPeersSeeders: '节点 / 做种',
|
||||
torrentConnectedPeerMetric: '{{peers}} 个已连接节点 / {{seeders}} 个已连接做种节点',
|
||||
torrentPeerDetailsUnavailable: '检测到 {{connected}} 个已连接节点,但其详细信息暂时不可用。',
|
||||
torrentPeerCountDifference: '连接状态:{{connectedPeers}} 个节点 / {{connectedSeeders}} 个做种节点。对等节点详情响应列出 {{listedPeers}} 个节点 / {{listedSeeders}} 个做种节点。',
|
||||
|
||||
+1
-14
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
: {})
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user