feat(torrents): resolve magnet metadata before enqueue

This commit is contained in:
NimBold
2026-07-31 00:06:07 +03:30
parent 2c1bd9cdf1
commit 186e189277
7 changed files with 494 additions and 29 deletions
+86 -13
View File
@@ -160,11 +160,47 @@ export const AddDownloadsModal = () => {
const [urls, setUrls] = useState('');
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
const parsedItemsRef = useRef<AddDownloadDraftRow[]>([]);
const addModalOpenRef = useRef(isAddModalOpen);
const metadataRequestsRef = useRef(new Set<string>());
const playlistRequestsRef = useRef(new Set<string>());
const latestPlaylistRequestRef = useRef(new Map<string, string>());
const cachedTorrentDraftIdsRef = useRef(new Set<string>());
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
parsedItemsRef.current = parsedItems;
addModalOpenRef.current = isAddModalOpen;
const cleanupDraftTorrentCache = useCallback((ids?: Iterable<string>) => {
const idsToRemove = ids
? Array.from(ids)
: Array.from(cachedTorrentDraftIdsRef.current);
idsToRemove.forEach(id => cachedTorrentDraftIdsRef.current.delete(id));
idsToRemove.forEach(id => {
void invoke('remove_torrent_metadata', { id }).catch(error => {
console.warn('Failed to remove temporary torrent metadata:', error);
});
});
}, []);
useEffect(() => {
if (!isAddModalOpen) cleanupDraftTorrentCache();
}, [cleanupDraftTorrentCache, isAddModalOpen]);
useEffect(() => {
const activeDraftIds = new Set<string>();
for (const row of parsedItems) {
if (!row.isTorrent) continue;
activeDraftIds.add(row.torrentCacheId || row.id);
activeDraftIds.add(`${row.id}-${row.generation}`);
}
const staleDraftIds = Array.from(cachedTorrentDraftIdsRef.current)
.filter(id => !activeDraftIds.has(id));
if (staleDraftIds.length > 0) cleanupDraftTorrentCache(staleDraftIds);
}, [cleanupDraftTorrentCache, parsedItems]);
useEffect(() => cleanupDraftTorrentCache, [cleanupDraftTorrentCache]);
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false);
const modalRef = useModalFocus(isAddModalOpen);
@@ -519,11 +555,30 @@ export const AddDownloadsModal = () => {
const contextUrl = requestContextUrlForRow(row);
const requestContext = requestContextForUrl(contextUrl);
if (row.isTorrent) {
const torrentCacheId = row.torrentCacheId || `${row.id}-${row.generation}`;
const proxy = row.sourceUrl.trim().toLowerCase().startsWith('magnet:')
? await getProxyArgs(settingsStore)
: undefined;
const torrentData = await invoke('inspect_torrent', {
source: row.sourceUrl,
id: row.id,
cache: false
id: torrentCacheId,
cache: true,
proxy: proxy ?? undefined
});
const isCurrentTorrentDraft = addModalOpenRef.current
&& parsedItemsRef.current.some(currentRow =>
currentRow.id === row.id
&& currentRow.sourceUrl === row.sourceUrl
&& currentRow.generation === row.generation
&& (currentRow.torrentCacheId || `${currentRow.id}-${currentRow.generation}`) === torrentCacheId
);
if (torrentData.torrentPath && isCurrentTorrentDraft) {
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);
});
}
const totalBytes = torrentData.totalBytes || undefined;
setParsedItems(current => updateRowIfCurrent(
current,
@@ -541,6 +596,7 @@ export const AddDownloadsModal = () => {
status: 'ready',
isTorrent: true,
torrentPath: torrentData.torrentPath,
torrentCacheId,
torrentInfoHash: torrentData.infoHash,
torrentFiles: torrentData.files,
selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices
@@ -1280,20 +1336,32 @@ export const AddDownloadsModal = () => {
for (const [itemIndex, item] of itemsToAdd.entries()) {
if (!item) continue;
let allocatedId: string | null = null;
try {
const id = crypto.randomUUID();
allocatedId = id;
let torrentPath = item.torrentPath;
if (item.isTorrent && !item.sourceUrl.trim().toLowerCase().startsWith('magnet:')) {
// Cached torrent metadata is deliberately keyed by the download
// identity. The metadata row ID is temporary, so re-key the
// cache after the final download ID is allocated (including
// replacement flows).
const torrentData = await invoke('inspect_torrent', {
source: item.sourceUrl,
id,
cache: true
});
torrentPath = torrentData.torrentPath;
if (item.isTorrent) {
if (item.torrentPath) {
torrentPath = await invoke('rekey_torrent_metadata', {
sourceId: item.torrentCacheId || item.id,
targetId: id
});
cachedTorrentDraftIdsRef.current.delete(item.torrentCacheId || item.id);
} else {
// Keep a safe fallback for rows restored from an older draft
// shape that did not retain the preview cache identity.
const proxy = item.sourceUrl.trim().toLowerCase().startsWith('magnet:')
? await getProxyArgs(useSettingsStore.getState())
: undefined;
const torrentData = await invoke('inspect_torrent', {
source: item.sourceUrl,
id,
cache: true,
proxy: proxy ?? undefined
});
torrentPath = torrentData.torrentPath;
}
}
let finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
@@ -1342,6 +1410,11 @@ export const AddDownloadsModal = () => {
}
addedCount += 1;
} catch (e) {
if (item.isTorrent && allocatedId) {
await invoke('remove_torrent_metadata', { id: allocatedId }).catch(error => {
console.warn('Failed to remove cached torrent metadata after add failure:', error);
});
}
console.error("Invalid URL or failed to add:", e);
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
}
+9 -1
View File
@@ -34,9 +34,17 @@ type CommandMap = {
result: MediaPlaylistMetadata;
};
inspect_torrent: {
args: { source: string; id: string; cache?: boolean };
args: { source: string; id: string; cache?: boolean; proxy?: string };
result: TorrentMetadata;
};
rekey_torrent_metadata: {
args: { sourceId: string; targetId: string };
result: string;
};
remove_torrent_metadata: {
args: { id: string };
result: void;
};
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
+34
View File
@@ -102,6 +102,40 @@ describe('add download metadata workflow', () => {
sourceUrl: 'file:///tmp/Example.torrent',
status: 'loading'
});
expect(rows[0].torrentCacheId).toBe(`${rows[0].id}-1`);
expect(rows[1].torrentCacheId).toBe(`${rows[1].id}-1`);
});
it('gives refreshed torrent metadata a new cache identity', () => {
const existing = row({
id: 'torrent-row',
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
downloadUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
isTorrent: true,
torrentCacheId: 'torrent-row-1',
torrentPath: '/managed/torrent-row-1.torrent',
torrentInfoHash: '0123456789abcdef0123456789abcdef01234567',
generation: 1,
requestContextVersion: 1
});
const refreshed = reconcileDownloadRows(
existing.sourceUrl,
[existing],
undefined,
new Set(),
undefined,
{},
{ [existing.sourceUrl]: 2 }
);
expect(refreshed[0]).toMatchObject({
generation: 2,
torrentCacheId: 'torrent-row-2',
torrentPath: undefined,
torrentInfoHash: undefined,
torrentFiles: undefined
});
});
it('keeps a playlist as one loading row until discovery succeeds', () => {
+13 -7
View File
@@ -55,6 +55,7 @@ export interface AddDownloadDraftRow {
selected?: boolean;
isTorrent?: boolean;
torrentPath?: string;
torrentCacheId?: string;
torrentInfoHash?: string;
torrentFiles?: TorrentFile[];
selectedTorrentFileIndices?: number[];
@@ -237,6 +238,7 @@ export const reconcileDownloadRows = (
|| preserved.playlistCount !== input.playlistCount
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
const nextGeneration = preserved.generation + 1;
const requestedFilename = input.playlistSourceUrl
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
: requestFilenames[input.sourceUrl];
@@ -246,7 +248,7 @@ export const reconcileDownloadRows = (
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
: preserved.file,
status: 'loading',
generation: preserved.generation + 1,
generation: nextGeneration,
requestContextVersion,
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
isTorrent: input.isTorrent,
@@ -267,10 +269,11 @@ export const reconcileDownloadRows = (
playlistEntryTitle: input.playlistEntryTitle,
playlistError: undefined,
metadataBlockedReason: undefined,
torrentPath: input.isTorrent ? preserved.torrentPath : undefined,
torrentInfoHash: input.isTorrent ? preserved.torrentInfoHash : undefined,
torrentFiles: input.isTorrent ? preserved.torrentFiles : undefined,
selectedTorrentFileIndices: input.isTorrent ? preserved.selectedTorrentFileIndices : undefined
torrentPath: undefined,
torrentCacheId: input.isTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
torrentInfoHash: undefined,
torrentFiles: undefined,
selectedTorrentFileIndices: undefined
};
}
return preserved;
@@ -284,13 +287,15 @@ export const reconcileDownloadRows = (
requestedFilename || fileNameFromUrl(input.sourceUrl)
);
const id = createId();
const generation = input.valid ? 1 : 0;
return {
id: createId(),
id,
sourceUrl: input.sourceUrl,
downloadUrl: input.sourceUrl,
file: fallback,
status: input.valid ? 'loading' : 'invalid',
generation: input.valid ? 1 : 0,
generation,
requestContextVersion: input.requestContextVersion,
isMedia: input.valid && (
Boolean(input.isPlaylist)
@@ -306,6 +311,7 @@ export const reconcileDownloadRows = (
playlistCount: input.playlistCount,
playlistEntryTitle: input.playlistEntryTitle,
metadataBlockedReason: undefined,
torrentCacheId: input.valid && input.isTorrent ? `${id}-${generation}` : undefined,
selected: input.selected !== false
};
});