From 31cac43b893b7b60021ff1a2faa7de88ab6dcf06 Mon Sep 17 00:00:00 2001 From: himsin Date: Wed, 25 Mar 2026 10:09:38 +0530 Subject: [PATCH 01/11] initial changes for save and download paths --- __tests__/api/qbittorrent.test.ts | 24 +++++ src/api/qbittorrent.ts | 16 ++++ src/components/ContextMenu.tsx | 85 ++++++++++++++---- src/components/TorrentDetailsPanel.tsx | 86 ++++++++++++++++++ src/hooks/useTorrents.ts | 26 ++++++ src/mobile/MobileTorrentDetail.tsx | 116 ++++++++++++++++++++++++- 6 files changed, 336 insertions(+), 17 deletions(-) diff --git a/__tests__/api/qbittorrent.test.ts b/__tests__/api/qbittorrent.test.ts index 33d6bfc..608b7ff 100644 --- a/__tests__/api/qbittorrent.test.ts +++ b/__tests__/api/qbittorrent.test.ts @@ -17,6 +17,8 @@ import { getTorrentTrackers, getTorrentFiles, renameTorrent, + setTorrentLocation, + setTorrentDownloadPath, addTrackers, removeTrackers, getPreferences, @@ -167,6 +169,28 @@ describe('qBittorrent API', () => { const call = mockFetch.mock.calls[0] expect(call[1].body.get('deleteFiles')).toBe('true') }) + + it('changes torrent save path', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({})) + + await setTorrentLocation(instanceId, ['hash1', 'hash2'], '/downloads/new-path') + + const call = mockFetch.mock.calls[0] + expect(call[0]).toBe('/api/instances/1/qbt/v2/torrents/setLocation') + expect(call[1].body.get('hashes')).toBe('hash1|hash2') + expect(call[1].body.get('location')).toBe('/downloads/new-path') + }) + + it('changes torrent download path', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({})) + + await setTorrentDownloadPath(instanceId, ['hash1'], '/downloads/incomplete') + + const call = mockFetch.mock.calls[0] + expect(call[0]).toBe('/api/instances/1/qbt/v2/torrents/setDownloadPath') + expect(call[1].body.get('hashes')).toBe('hash1') + expect(call[1].body.get('downloadPath')).toBe('/downloads/incomplete') + }) }) describe('categories', () => { diff --git a/src/api/qbittorrent.ts b/src/api/qbittorrent.ts index 4b7ba4d..0524600 100644 --- a/src/api/qbittorrent.ts +++ b/src/api/qbittorrent.ts @@ -273,6 +273,22 @@ export async function renameTorrent(instanceId: number, hash: string, name: stri }) } +export async function setTorrentLocation(instanceId: number, hashes: string[], location: string): Promise { + await action(instanceId, '/torrents/setLocation', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ hashes: hashes.join('|'), location }), + }) +} + +export async function setTorrentDownloadPath(instanceId: number, hashes: string[], downloadPath: string): Promise { + await action(instanceId, '/torrents/setDownloadPath', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ hashes: hashes.join('|'), downloadPath }), + }) +} + export async function addTrackers(instanceId: number, hash: string, urls: string[]): Promise { await action(instanceId, '/torrents/addTrackers', { method: 'POST', diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 65e7fdd..fccb6c4 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -12,6 +12,8 @@ import { useAddTags, useRemoveTags, useRenameTorrent, + useSetTorrentDownloadPath, + useSetTorrentLocation, useExportTorrents, } from '../hooks/useTorrents' import type { Torrent } from '../types/qbittorrent' @@ -24,11 +26,12 @@ interface Props { } type Submenu = 'category' | 'addTag' | 'removeTag' | 'delete' | null +type EditorMode = 'rename' | 'savePath' | 'downloadPath' | null export function ContextMenu({ x, y, torrents, onClose }: Props) { const [submenu, setSubmenu] = useState(null) - const [renaming, setRenaming] = useState(false) - const [newName, setNewName] = useState('') + const [editorMode, setEditorMode] = useState(null) + const [inputValue, setInputValue] = useState('') const ref = useRef(null) const inputRef = useRef(null) @@ -42,6 +45,8 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) { const addTagsMutation = useAddTags() const removeTagsMutation = useRemoveTags() const renameMutation = useRenameTorrent() + const setLocationMutation = useSetTorrentLocation() + const setDownloadPathMutation = useSetTorrentDownloadPath() const deleteMutation = useDeleteTorrents() const exportMutation = useExportTorrents() @@ -70,11 +75,11 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) { }, [onClose]) useEffect(() => { - if (renaming && inputRef.current) { + if (editorMode && inputRef.current) { inputRef.current.focus() inputRef.current.select() } - }, [renaming]) + }, [editorMode]) const menuStyle: React.CSSProperties = { position: 'fixed', @@ -119,16 +124,45 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) { onClose() } - function handleRename() { - if (isSingle && newName.trim()) { - renameMutation.mutate({ hash: hashes[0], name: newName.trim() }) + function handleEditorSubmit() { + const value = inputValue.trim() + if (!value) return + + if (editorMode === 'rename' && isSingle) { + renameMutation.mutate({ hash: hashes[0], name: value }) + onClose() + return + } + + if (editorMode === 'savePath') { + setLocationMutation.mutate({ hashes, location: value }) + onClose() + return + } + + if (editorMode === 'downloadPath') { + setDownloadPathMutation.mutate({ hashes, downloadPath: value }) onClose() } } function startRename() { - setNewName(torrents[0].name) - setRenaming(true) + setInputValue(torrents[0].name) + setEditorMode('rename') + setSubmenu(null) + } + + function startSetSavePath() { + const uniquePaths = new Set(torrents.map((torrent) => torrent.save_path).filter(Boolean)) + setInputValue(uniquePaths.size === 1 ? torrents[0].save_path : '') + setEditorMode('savePath') + setSubmenu(null) + } + + function startSetDownloadPath() { + const uniquePaths = new Set(torrents.map((torrent) => torrent.save_path).filter(Boolean)) + setInputValue(uniquePaths.size === 1 ? torrents[0].save_path : '') + setEditorMode('downloadPath') setSubmenu(null) } @@ -142,19 +176,34 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) { onClose() } - if (renaming && isSingle) { + const editorTitle = + editorMode === 'rename' + ? 'Rename torrent' + : editorMode === 'savePath' + ? 'Change save path' + : 'Change download path' + const editorActionLabel = editorMode === 'rename' ? 'Rename' : 'Save' + const editorPlaceholder = + editorMode === 'rename' + ? 'Enter torrent name' + : editorMode === 'savePath' + ? 'Enter save path' + : 'Enter download path' + + if (editorMode && (editorMode !== 'rename' || isSingle)) { return (
- Rename torrent + {editorTitle}
setNewName(e.target.value)} + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + placeholder={editorPlaceholder} onKeyDown={(e) => { - if (e.key === 'Enter') handleRename() + if (e.key === 'Enter') handleEditorSubmit() if (e.key === 'Escape') onClose() }} className="w-full px-3 py-2 rounded-lg border text-sm mb-2" @@ -169,11 +218,12 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) { Cancel
@@ -242,6 +292,8 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) { Rename )} + Change Save Path + Change Download Path
Export setSubmenu(submenu === 'delete' ? null : 'delete')} hasSubmenu> @@ -283,3 +335,4 @@ function MenuItem({ ) } + diff --git a/src/components/TorrentDetailsPanel.tsx b/src/components/TorrentDetailsPanel.tsx index 0fe9e88..b61d70f 100644 --- a/src/components/TorrentDetailsPanel.tsx +++ b/src/components/TorrentDetailsPanel.tsx @@ -23,6 +23,7 @@ import { useAddTrackers, useRemoveTrackers, } from '../hooks/useTorrentDetails' +import { useSetTorrentDownloadPath, useSetTorrentLocation } from '../hooks/useTorrents' import { formatSize, formatSpeed, formatDate, formatDuration, formatEta } from '../utils/format' import type { Tracker, Peer } from '../types/torrentDetails' import { buildFileTree, flattenVisibleNodes, getInitialExpanded } from '../utils/fileTree' @@ -142,7 +143,11 @@ function InfoCell({ } function GeneralTab({ hash, category, tags }: { hash: string; category: string; tags: string }) { + const [editorMode, setEditorMode] = useState<'savePath' | 'downloadPath' | null>(null) + const [inputValue, setInputValue] = useState('') const { data: p, isLoading } = useTorrentProperties(hash) + const setLocationMutation = useSetTorrentLocation() + const setDownloadPathMutation = useSetTorrentDownloadPath() if (isLoading) return if (!p) return @@ -153,6 +158,28 @@ function GeneralTab({ hash, category, tags }: { hash: string; category: string; p.seeding_time > 0 ? `${formatDuration(p.time_elapsed)} (seeded ${formatDuration(p.seeding_time)})` : formatDuration(p.time_elapsed) + const pathMutationPending = setLocationMutation.isPending || setDownloadPathMutation.isPending + + function openEditor(mode: 'savePath' | 'downloadPath') { + setInputValue(p.save_path) + setEditorMode(mode) + } + + function handlePathSave() { + const trimmed = inputValue.trim() + if (!trimmed) return + + if (editorMode === 'savePath') { + setLocationMutation.mutate({ hashes: [hash], location: trimmed }) + setEditorMode(null) + return + } + + if (editorMode === 'downloadPath') { + setDownloadPathMutation.mutate({ hashes: [hash], downloadPath: trimmed }) + setEditorMode(null) + } + } return (
@@ -224,6 +251,64 @@ function GeneralTab({ hash, category, tags }: { hash: string; category: string;
+
+ + +
+ {editorMode && ( +
+
+ {editorMode === 'savePath' ? 'Change Save Path' : 'Change Download Path'} +
+ setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handlePathSave() + if (e.key === 'Escape') setEditorMode(null) + }} + className="w-full px-3 py-2 rounded border text-xs" + style={{ + backgroundColor: 'var(--bg-secondary)', + borderColor: 'var(--border)', + color: 'var(--text-primary)', + }} + autoFocus + /> +
+ + +
+
+ )} {p.comment && (
@@ -797,3 +882,4 @@ export function TorrentDetailsPanel({ hash, name, category, tags, expanded, onTo
) } + diff --git a/src/hooks/useTorrents.ts b/src/hooks/useTorrents.ts index 6205249..6132d54 100644 --- a/src/hooks/useTorrents.ts +++ b/src/hooks/useTorrents.ts @@ -125,6 +125,32 @@ export function useRenameTorrent() { }) } +export function useSetTorrentLocation() { + const instance = useInstance() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ hashes, location }: { hashes: string[]; location: string }) => + api.setTorrentLocation(instance.id, hashes, location), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['torrents', instance.id] }) + queryClient.invalidateQueries({ queryKey: ['torrent-properties', instance.id] }) + }, + }) +} + +export function useSetTorrentDownloadPath() { + const instance = useInstance() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ hashes, downloadPath }: { hashes: string[]; downloadPath: string }) => + api.setTorrentDownloadPath(instance.id, hashes, downloadPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['torrents', instance.id] }) + queryClient.invalidateQueries({ queryKey: ['torrent-properties', instance.id] }) + }, + }) +} + export function useCreateTag() { const instance = useInstance() const queryClient = useQueryClient() diff --git a/src/mobile/MobileTorrentDetail.tsx b/src/mobile/MobileTorrentDetail.tsx index 0e25b84..9c06853 100644 --- a/src/mobile/MobileTorrentDetail.tsx +++ b/src/mobile/MobileTorrentDetail.tsx @@ -1,12 +1,13 @@ import { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Drawer } from 'vaul' -import { Play, Pause, Trash2 } from 'lucide-react' +import { Play, Pause, Trash2, FolderInput, Download } from 'lucide-react' import * as api from '../api/qbittorrent' import type { TorrentState } from '../types/qbittorrent' import { formatSize, formatSpeed, formatDate, formatDuration } from '../utils/format' type Tab = 'general' | 'files' | 'trackers' | 'peers' | 'http' +type PathEditorMode = 'savePath' | 'downloadPath' | null const PAUSED_STATES: TorrentState[] = ['pausedDL', 'pausedUP', 'stoppedDL', 'stoppedUP'] @@ -43,6 +44,8 @@ interface Props { export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props) { const [tab, setTab] = useState('general') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [pathEditorMode, setPathEditorMode] = useState(null) + const [pathValue, setPathValue] = useState('') const [deleteFiles, setDeleteFiles] = useState(false) const queryClient = useQueryClient() @@ -97,6 +100,20 @@ export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props) mutationFn: (deleteFiles: boolean) => api.deleteTorrents(instanceId, [torrentHash], deleteFiles), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }), }) + const setLocationMutation = useMutation({ + mutationFn: (location: string) => api.setTorrentLocation(instanceId, [torrentHash], location), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }) + queryClient.invalidateQueries({ queryKey: ['torrent-properties', instanceId, torrentHash] }) + }, + }) + const setDownloadPathMutation = useMutation({ + mutationFn: (downloadPath: string) => api.setTorrentDownloadPath(instanceId, [torrentHash], downloadPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['torrents', instanceId] }) + queryClient.invalidateQueries({ queryKey: ['torrent-properties', instanceId, torrentHash] }) + }, + }) const isPaused = torrent ? PAUSED_STATES.includes(torrent.state) : false const peers = peersData?.peers ? Object.values(peersData.peers) : [] @@ -114,6 +131,32 @@ export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props) onClose() } + function openPathEditor(mode: Exclude) { + setPathValue(torrent?.save_path ?? '') + setPathEditorMode(mode) + } + + function handlePathSave() { + const trimmed = pathValue.trim() + if (!trimmed) return + + if (pathEditorMode === 'savePath') { + setLocationMutation.mutate(trimmed, { + onSuccess: () => setPathEditorMode(null), + }) + return + } + + if (pathEditorMode === 'downloadPath') { + setDownloadPathMutation.mutate(trimmed, { + onSuccess: () => setPathEditorMode(null), + }) + } + } + + const pathMutationPending = setLocationMutation.isPending || setDownloadPathMutation.isPending + const pathEditorTitle = pathEditorMode === 'savePath' ? 'Change Save Path' : 'Change Download Path' + const tabs: { id: Tab; label: string; count?: number }[] = [ { id: 'general', label: 'General' }, { id: 'files', label: 'Files', count: files?.length }, @@ -209,6 +252,27 @@ export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props)
+
+ + +
+
{tabs.map((t) => ( @@ -394,6 +458,55 @@ export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props) + {pathEditorMode && ( + <> +
!pathMutationPending && setPathEditorMode(null)} + /> +
+

+ {pathEditorTitle} +

+ setPathValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handlePathSave()} + className="w-full px-4 py-3 rounded-xl border text-base" + style={{ + backgroundColor: 'var(--bg-tertiary)', + borderColor: 'var(--border)', + color: 'var(--text-primary)', + }} + autoFocus + /> +
+ + +
+
+ + )} + {showDeleteConfirm && ( <>
) } + From 1d7767fdd1beb02e2193754f1a41b7ee1dace29b Mon Sep 17 00:00:00 2001 From: himsin Date: Wed, 25 Mar 2026 10:32:15 +0530 Subject: [PATCH 02/11] Fix build error. --- src/components/TorrentDetailsPanel.tsx | 66 ++++++++++++++------------ 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/src/components/TorrentDetailsPanel.tsx b/src/components/TorrentDetailsPanel.tsx index b61d70f..8ff4e19 100644 --- a/src/components/TorrentDetailsPanel.tsx +++ b/src/components/TorrentDetailsPanel.tsx @@ -150,18 +150,21 @@ function GeneralTab({ hash, category, tags }: { hash: string; category: string; const setDownloadPathMutation = useSetTorrentDownloadPath() if (isLoading) return if (!p) return + const properties = p const ratio = - p.total_downloaded === 0 && p.pieces_have === p.pieces_num && p.total_size > 0 ? '∞' : p.share_ratio.toFixed(2) + properties.total_downloaded === 0 && properties.pieces_have === properties.pieces_num && properties.total_size > 0 + ? '∞' + : properties.share_ratio.toFixed(2) const timeActive = - p.seeding_time > 0 - ? `${formatDuration(p.time_elapsed)} (seeded ${formatDuration(p.seeding_time)})` - : formatDuration(p.time_elapsed) + properties.seeding_time > 0 + ? `${formatDuration(properties.time_elapsed)} (seeded ${formatDuration(properties.seeding_time)})` + : formatDuration(properties.time_elapsed) const pathMutationPending = setLocationMutation.isPending || setDownloadPathMutation.isPending function openEditor(mode: 'savePath' | 'downloadPath') { - setInputValue(p.save_path) + setInputValue(properties.save_path) setEditorMode(mode) } @@ -192,37 +195,37 @@ function GeneralTab({ hash, category, tags }: { hash: string; category: string;
- - - - - + + + + + - - + + - 0 ? formatDuration(p.reannounce) : '0'} span={3} /> - 0 ? formatDate(p.last_seen) : 'Never'} span={3} /> - + 0 ? formatDuration(properties.reannounce) : '0'} span={3} /> + 0 ? formatDate(properties.last_seen) : 'Never'} span={3} /> +
@@ -234,22 +237,22 @@ function GeneralTab({ hash, category, tags }: { hash: string; category: string; Information
- - - - - 0 ? formatDate(p.completion_date) : '—'} /> - 0 ? formatDate(p.creation_date) : '—'} /> - + + + + + 0 ? formatDate(properties.completion_date) : '—'} /> + 0 ? formatDate(properties.creation_date) : '—'} /> +
- - + +
- +
)} - {p.comment && ( + {properties.comment && (
- +
)} @@ -883,3 +886,4 @@ export function TorrentDetailsPanel({ hash, name, category, tags, expanded, onTo ) } + From b48fc7d22c81dbd20cf1b3a0d2974082fd17f5b3 Mon Sep 17 00:00:00 2001 From: himsin Date: Wed, 25 Mar 2026 10:54:02 +0530 Subject: [PATCH 03/11] Added download path option for prowlarr grabs. --- __tests__/api/integrations.test.ts | 4 ++-- src/api/integrations.ts | 2 +- src/components/SearchPanel.tsx | 28 +++++++++++++++++++++++++++- src/mobile/MobileSearchPanel.tsx | 24 +++++++++++++++++++++++- src/server/routes/integrations.ts | 5 +++++ 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/__tests__/api/integrations.test.ts b/__tests__/api/integrations.test.ts index 88067c6..7833aaa 100644 --- a/__tests__/api/integrations.test.ts +++ b/__tests__/api/integrations.test.ts @@ -206,13 +206,13 @@ describe('integrations API', () => { guid: 'abc123', indexerId: 1, downloadUrl: 'http://example.com/download', - }, 5) + }, 5, { savepath: '/downloads/complete', downloadPath: '/downloads/incomplete' }) expect(mockFetch).toHaveBeenCalledWith('/api/integrations/1/grab', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: expect.stringContaining('"instanceId":5'), + body: expect.stringMatching(/"instanceId":5.*"savepath":"\/downloads\/complete".*"downloadPath":"\/downloads\/incomplete"|"instanceId":5.*"downloadPath":"\/downloads\/incomplete".*"savepath":"\/downloads\/complete"/), }) }) diff --git a/src/api/integrations.ts b/src/api/integrations.ts index e62226b..54e7493 100644 --- a/src/api/integrations.ts +++ b/src/api/integrations.ts @@ -120,7 +120,7 @@ export async function grabRelease( integrationId: number, release: { guid: string; indexerId: number; downloadUrl?: string; magnetUrl?: string }, instanceId: number, - options?: { category?: string; savepath?: string } + options?: { category?: string; savepath?: string; downloadPath?: string } ): Promise { const res = await fetch(`/api/integrations/${integrationId}/grab`, { method: 'POST', diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index c1657f6..eb17fa3 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -54,6 +54,7 @@ export function SearchPanel() { const [grabCategories, setGrabCategories] = useState>({}) const [grabCategory, setGrabCategory] = useState('') const [grabSavepath, setGrabSavepath] = useState('') + const [grabDownloadPath, setGrabDownloadPath] = useState('') const [loadingCategories, setLoadingCategories] = useState(false) const [sortKey, setSortKey] = useState('seeders') const [sortAsc, setSortAsc] = useState(false) @@ -186,6 +187,7 @@ export function SearchPanel() { setGrabModal(result) setGrabCategory('') setGrabSavepath('') + setGrabDownloadPath('') if (instances.length === 1) { setGrabInstance(instances[0].id) } else { @@ -199,6 +201,7 @@ export function SearchPanel() { setGrabCategories({}) setGrabCategory('') setGrabSavepath('') + setGrabDownloadPath('') setInstanceDropdownOpen(false) setCategoryDropdownOpen(false) } @@ -207,9 +210,10 @@ export function SearchPanel() { if (!selectedIntegration || !grabModal || !grabInstance) return setGrabbing(grabModal.guid) setGrabResult(null) - const options: { category?: string; savepath?: string } = {} + const options: { category?: string; savepath?: string; downloadPath?: string } = {} if (grabCategory) options.category = grabCategory if (grabSavepath.trim()) options.savepath = grabSavepath.trim() + if (grabDownloadPath.trim()) options.downloadPath = grabDownloadPath.trim() try { await grabRelease( selectedIntegration.id, @@ -1063,6 +1067,27 @@ export function SearchPanel() { }} />
+
+ + setGrabDownloadPath(e.target.value)} + disabled={!grabInstance} + placeholder="Default" + className="w-full px-3 py-2 rounded-lg border text-sm disabled:opacity-50" + style={{ + backgroundColor: 'var(--bg-tertiary)', + borderColor: 'var(--border)', + color: 'var(--text-primary)', + }} + /> +
+
+
+ Download Path +
+ setGrabDownloadPath(e.target.value)} + disabled={!grabInstance} + placeholder="Default" + className="w-full px-4 py-3 rounded-xl border text-base disabled:opacity-50" + style={{ + backgroundColor: 'var(--bg-secondary)', + borderColor: 'var(--border)', + color: 'var(--text-primary)', + }} + /> +
) } + diff --git a/src/server/routes/integrations.ts b/src/server/routes/integrations.ts index b94596c..c2fad62 100644 --- a/src/server/routes/integrations.ts +++ b/src/server/routes/integrations.ts @@ -212,6 +212,7 @@ integrations.post('/:id/grab', async (c) => { instanceId: number category?: string savepath?: string + downloadPath?: string }>() if (!body.instanceId) { @@ -249,6 +250,9 @@ integrations.post('/:id/grab', async (c) => { if (body.savepath) { formData.append('savepath', body.savepath) } + if (body.downloadPath) { + formData.append('downloadPath', body.downloadPath) + } if (body.magnetUrl) { formData.append('urls', body.magnetUrl) @@ -292,3 +296,4 @@ integrations.post('/:id/grab', async (c) => { }) export default integrations + From 7f583716bc9d799eceeb9e75241ac7a19a70a1ee Mon Sep 17 00:00:00 2001 From: himsin Date: Fri, 27 Mar 2026 09:58:35 +0530 Subject: [PATCH 04/11] Fixes for toolbar and show download path in torrent details. --- src/components/TorrentDetailsPanel.tsx | 5 +++++ src/components/TorrentList.tsx | 23 ++++++++++++--------- src/mobile/MobileTorrentDetail.tsx | 28 ++++++++++++++++++++++---- src/types/qbittorrent.ts | 1 + src/types/torrentDetails.ts | 1 + 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/components/TorrentDetailsPanel.tsx b/src/components/TorrentDetailsPanel.tsx index 8ff4e19..5a4830a 100644 --- a/src/components/TorrentDetailsPanel.tsx +++ b/src/components/TorrentDetailsPanel.tsx @@ -254,6 +254,11 @@ function GeneralTab({ hash, category, tags }: { hash: string; category: string;
+ {properties.download_path && ( +
+ +
+ )}
- +
)} {showDeleteConfirm && ( - <> +
setShowDeleteConfirm(false)} + >
setShowDeleteConfirm(false)} - onTouchStart={(e) => { e.preventDefault(); e.stopPropagation() }} - onTouchMove={(e) => { e.preventDefault(); e.stopPropagation() }} - onTouchEnd={(e) => { - e.preventDefault() - e.stopPropagation() - setShowDeleteConfirm(false) - }} - /> -
e.stopPropagation()} - onTouchMove={(e) => e.stopPropagation()} + className="mx-4 w-full max-w-lg rounded-2xl border p-5" + style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }} + onClick={(e) => e.stopPropagation()} >

Delete Torrent @@ -571,7 +553,7 @@ export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props)

- +
)} ) @@ -604,3 +586,4 @@ function InfoRow({ } + From 93eaf31ff1c23b4d8590cd3982e6d1c5db4760eb Mon Sep 17 00:00:00 2001 From: himsin Date: Fri, 27 Mar 2026 10:49:57 +0530 Subject: [PATCH 06/11] Final fix for mobile touch passthrough issue. --- src/mobile/MobileTorrentDetail.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/mobile/MobileTorrentDetail.tsx b/src/mobile/MobileTorrentDetail.tsx index ce58ed8..01039ab 100644 --- a/src/mobile/MobileTorrentDetail.tsx +++ b/src/mobile/MobileTorrentDetail.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { createPortal } from 'react-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Drawer } from 'vaul' import { Play, Pause, Trash2, FolderInput, Download } from 'lucide-react' @@ -176,8 +177,8 @@ export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props) return ( <> !open && !showDeleteConfirm && onClose()} + open={!showDeleteConfirm && !pathEditorMode} + onOpenChange={(open) => !open && !showDeleteConfirm && !pathEditorMode && onClose()} shouldScaleBackground={false} > @@ -459,9 +460,9 @@ export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props) - {pathEditorMode && ( + {pathEditorMode && createPortal(
!pathMutationPending && setPathEditorMode(null)} > @@ -474,17 +475,19 @@ export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props) {pathEditorTitle} { if (el) setTimeout(() => el.focus(), 50) }} type="text" value={pathValue} onChange={(e) => setPathValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handlePathSave()} + onTouchEnd={(e) => { e.stopPropagation(); (e.target as HTMLInputElement).focus() }} className="w-full px-4 py-3 rounded-xl border text-base" style={{ backgroundColor: 'var(--bg-tertiary)', borderColor: 'var(--border)', color: 'var(--text-primary)', + fontSize: '16px', }} - autoFocus />
- + , + document.body )} - {showDeleteConfirm && ( + {showDeleteConfirm && createPortal(
setShowDeleteConfirm(false)} > @@ -553,7 +557,8 @@ export function MobileTorrentDetail({ torrentHash, instanceId, onClose }: Props)
- + , + document.body )} ) @@ -585,5 +590,3 @@ function InfoRow({ ) } - - From 067065a31eab9d8201cc6065985e8ae2feeabfbe Mon Sep 17 00:00:00 2001 From: himsin Date: Mon, 20 Apr 2026 09:58:10 +0530 Subject: [PATCH 07/11] Experimental changes for path history --- src/components/AddTorrentModal.tsx | 10 +- src/components/ContextMenu.tsx | 49 +++++--- src/components/SearchPanel.tsx | 24 ++-- src/components/TorrentDetailsPanel.tsx | 11 +- src/components/ui/PathInput.tsx | 151 +++++++++++++++++++++++++ src/components/ui/index.ts | 2 + src/hooks/usePathHistory.ts | 39 +++++++ src/mobile/MobileTorrentDetail.tsx | 13 ++- 8 files changed, 268 insertions(+), 31 deletions(-) create mode 100644 src/components/ui/PathInput.tsx create mode 100644 src/hooks/usePathHistory.ts diff --git a/src/components/AddTorrentModal.tsx b/src/components/AddTorrentModal.tsx index df19735..ab19507 100644 --- a/src/components/AddTorrentModal.tsx +++ b/src/components/AddTorrentModal.tsx @@ -1,6 +1,8 @@ import { useState, useRef } from 'react' import { Plus, X, Upload, CheckCircle, Check } from 'lucide-react' import { useAddTorrent, useCategories } from '../hooks/useTorrents' +import { PathInput } from './ui/PathInput' +import { usePathHistory } from '../hooks/usePathHistory' interface Props { open: boolean @@ -22,6 +24,7 @@ export function AddTorrentModal({ open, onClose }: Props) { const { data: categories = {} } = useCategories() const addMutation = useAddTorrent() + const { addPath } = usePathHistory() if (!open) return null @@ -44,6 +47,7 @@ export function AddTorrentModal({ open, onClose }: Props) { }, { onSuccess: () => { + if (savepath.trim()) addPath(savepath.trim()) setUrl('') setFiles([]) setCategory('') @@ -257,10 +261,9 @@ export function AddTorrentModal({ open, onClose }: Props) { - setSavepath(e.target.value)} + onChange={setSavepath} placeholder="Default" className="w-full px-3 py-2.5 rounded-xl border text-sm focus:outline-none transition-colors" style={{ @@ -348,3 +351,4 @@ export function AddTorrentModal({ open, onClose }: Props) { ) } + diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index fccb6c4..2dd33ab 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,5 +1,7 @@ import { useState, useRef, useEffect } from 'react' import { ChevronRight } from 'lucide-react' +import { PathInput } from './ui/PathInput' +import { usePathHistory } from '../hooks/usePathHistory' import { useCategories, useTags, @@ -34,6 +36,7 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) { const [inputValue, setInputValue] = useState('') const ref = useRef(null) const inputRef = useRef(null) + const { addPath } = usePathHistory() const { data: categories = {} } = useCategories() const { data: tags = [] } = useTags() @@ -135,12 +138,14 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) { } if (editorMode === 'savePath') { + addPath(value) setLocationMutation.mutate({ hashes, location: value }) onClose() return } if (editorMode === 'downloadPath') { + addPath(value) setDownloadPathMutation.mutate({ hashes, downloadPath: value }) onClose() } @@ -196,19 +201,36 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) {
{editorTitle}
- setInputValue(e.target.value)} - placeholder={editorPlaceholder} - onKeyDown={(e) => { - if (e.key === 'Enter') handleEditorSubmit() - if (e.key === 'Escape') onClose() - }} - className="w-full px-3 py-2 rounded-lg border text-sm mb-2" - style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }} - /> + {editorMode === 'rename' ? ( + setInputValue(e.target.value)} + placeholder={editorPlaceholder} + onKeyDown={(e) => { + if (e.key === 'Enter') handleEditorSubmit() + if (e.key === 'Escape') onClose() + }} + className="w-full px-3 py-2 rounded-lg border text-sm mb-2" + style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }} + /> + ) : ( +
+ { + if (e.key === 'Enter') handleEditorSubmit() + if (e.key === 'Escape') onClose() + }} + className="w-full px-3 py-2 rounded-lg border text-sm" + style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }} + /> +
+ )}