diff --git a/app/controllers/queue_controller.ts b/app/controllers/queue_controller.ts index 68b72a6..594d93a 100644 --- a/app/controllers/queue_controller.ts +++ b/app/controllers/queue_controller.ts @@ -116,17 +116,35 @@ export default class QueueController { * Grab a release */ async grab({ request, response }: HttpContext) { - const { title, downloadUrl, size, albumId, releaseId, indexerId, indexerName, guid } = - request.only([ - 'title', - 'downloadUrl', - 'size', - 'albumId', - 'releaseId', - 'indexerId', - 'indexerName', - 'guid', - ]) + const { + title, + downloadUrl, + size, + albumId, + movieId, + tvShowId, + episodeId, + bookId, + releaseId, + indexerId, + indexerName, + guid, + downloadClientId, + } = request.only([ + 'title', + 'downloadUrl', + 'size', + 'albumId', + 'movieId', + 'tvShowId', + 'episodeId', + 'bookId', + 'releaseId', + 'indexerId', + 'indexerName', + 'guid', + 'downloadClientId', + ]) if (!title || !downloadUrl) { return response.badRequest({ error: 'Title and download URL are required' }) @@ -138,10 +156,15 @@ export default class QueueController { downloadUrl, size, albumId, + movieId, + tvShowId, + episodeId, + bookId, releaseId, indexerId, indexerName, guid, + downloadClientId: downloadClientId ? Number(downloadClientId) : undefined, }) return response.created({ diff --git a/app/services/download_clients/download_manager.ts b/app/services/download_clients/download_manager.ts index d9e14cf..18ef117 100644 --- a/app/services/download_clients/download_manager.ts +++ b/app/services/download_clients/download_manager.ts @@ -36,6 +36,7 @@ export interface DownloadRequest { indexerId?: string indexerName?: string guid?: string + downloadClientId?: number } export interface QueueItem { @@ -528,11 +529,22 @@ export class DownloadManager { throw new Error('File already exists in library') } - // Get enabled download client - const client = await DownloadClient.query() - .where('enabled', true) - .orderBy('priority', 'asc') - .first() + // Get download client — use override if specified, otherwise pick by priority + let client: DownloadClient | null = null + if (request.downloadClientId) { + client = await DownloadClient.query() + .where('id', request.downloadClientId) + .where('enabled', true) + .first() + if (!client) { + throw new Error('Selected download client not found or is disabled') + } + } else { + client = await DownloadClient.query() + .where('enabled', true) + .orderBy('priority', 'asc') + .first() + } if (!client) { throw new Error('No enabled download client configured') diff --git a/inertia/components/library/download-client-indicator.tsx b/inertia/components/library/download-client-indicator.tsx new file mode 100644 index 0000000..a142b39 --- /dev/null +++ b/inertia/components/library/download-client-indicator.tsx @@ -0,0 +1,75 @@ +import { Badge } from '@/components/ui/badge' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowDown01Icon } from '@hugeicons/core-free-icons' +import type { DownloadClientInfo } from '@/hooks/use_download_clients' + +interface DownloadClientIndicatorProps { + clients: DownloadClientInfo[] + selectedClientId: number | null + onClientChange: (clientId: number | null) => void +} + +const clientTypeLabels: Record = { + sabnzbd: 'SABnzbd', + nzbget: 'NZBGet', + qbittorrent: 'qBittorrent', + transmission: 'Transmission', + deluge: 'Deluge', +} + +export function DownloadClientIndicator({ + clients, + selectedClientId, + onClientChange, +}: DownloadClientIndicatorProps) { + if (clients.length === 0) return null + + const activeClient = selectedClientId + ? clients.find((c) => c.id === selectedClientId) ?? clients[0] + : clients[0] + + const label = activeClient.name || clientTypeLabels[activeClient.type] || activeClient.type + + if (clients.length === 1) { + return ( + + {label} + + ) + } + + return ( + + + + {label} + + + + + {clients.map((client) => ( + onClientChange(client.id === clients[0].id ? null : client.id)} + className={client.id === activeClient.id ? 'font-medium' : ''} + > + {client.name || clientTypeLabels[client.type] || client.type} + {client.id === clients[0].id && ( + (default) + )} + + ))} + + + ) +} diff --git a/inertia/hooks/use_download_clients.ts b/inertia/hooks/use_download_clients.ts new file mode 100644 index 0000000..4c55a02 --- /dev/null +++ b/inertia/hooks/use_download_clients.ts @@ -0,0 +1,46 @@ +import { useState, useEffect } from 'react' + +export interface DownloadClientInfo { + id: number + name: string + type: string + enabled: boolean + priority: number +} + +export function useDownloadClients() { + const [clients, setClients] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + + async function fetchClients() { + try { + const response = await fetch('/api/v1/downloadclients') + if (response.ok) { + const data = await response.json() + if (!cancelled) { + const enabled = data + .filter((c: DownloadClientInfo) => c.enabled) + .sort((a: DownloadClientInfo, b: DownloadClientInfo) => a.priority - b.priority) + setClients(enabled) + } + } + } catch { + // Silently fail — indicator just won't show + } finally { + if (!cancelled) setLoading(false) + } + } + + fetchClients() + return () => { + cancelled = true + } + }, []) + + const activeClient = clients[0] ?? null + + return { clients, activeClient, loading, hasMultiple: clients.length > 1 } +} diff --git a/inertia/pages/library/album/[id].tsx b/inertia/pages/library/album/[id].tsx index 1ab2e38..c794d64 100644 --- a/inertia/pages/library/album/[id].tsx +++ b/inertia/pages/library/album/[id].tsx @@ -45,6 +45,8 @@ import { DownloadProgressCard } from '@/components/library/download-progress-car import { useActiveDownloads } from '@/hooks/use_active_downloads' import { useShowMore } from '@/hooks/use_show_more' import { DeleteMediaDialog } from '@/components/library/delete-media-dialog' +import { DownloadClientIndicator } from '@/components/library/download-client-indicator' +import { useDownloadClients } from '@/hooks/use_download_clients' import { MediaStatusBadge } from '@/components/library/media-status-badge' interface Track { @@ -116,6 +118,8 @@ export default function AlbumDetail() { const { getForAlbum } = useActiveDownloads() const albumDownloads = albumId ? getForAlbum(albumId) : [] const tracksPage = useShowMore(album?.tracks ?? []) + const [selectedClientId, setSelectedClientId] = useState(null) + const { clients: downloadClients } = useDownloadClients() const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteFileDialogOpen, setDeleteFileDialogOpen] = useState(false) @@ -270,6 +274,7 @@ export default function AlbumDetail() { indexerId: result.indexerId, indexerName: result.indexer, guid: result.id, + ...(selectedClientId && { downloadClientId: selectedClientId }), }), }) if (response.ok) { @@ -555,12 +560,17 @@ export default function AlbumDetail() { ))} - {/* Quality and folder info */} - {(album.qualityProfile || album.rootFolder) && ( + {/* Quality, download client, and folder info */} + {(album.qualityProfile || album.rootFolder || downloadClients.length > 0) && (
{album.qualityProfile && ( {album.qualityProfile.name} )} + {album.rootFolder && {album.rootFolder.path}}
)} diff --git a/inertia/pages/library/movie/[id].tsx b/inertia/pages/library/movie/[id].tsx index 1c7a297..74220f4 100644 --- a/inertia/pages/library/movie/[id].tsx +++ b/inertia/pages/library/movie/[id].tsx @@ -59,6 +59,8 @@ import { DownloadProgressCard } from '@/components/library/download-progress-car import { useActiveDownloads } from '@/hooks/use_active_downloads' import { useAudioPlayer } from '@/contexts/audio_player_context' import { DeleteMediaDialog } from '@/components/library/delete-media-dialog' +import { DownloadClientIndicator } from '@/components/library/download-client-indicator' +import { useDownloadClients } from '@/hooks/use_download_clients' import { VideoPlayer } from '@/components/player/video_player' interface QualityProfile { @@ -151,9 +153,11 @@ export default function MovieDetail() { const [grabbing, setGrabbing] = useState(null) const [videoPlayerOpen, setVideoPlayerOpen] = useState(false) const [releasePickerOpen, setReleasePickerOpen] = useState(false) + const [selectedClientId, setSelectedClientId] = useState(null) const audioPlayer = useAudioPlayer() const { getForMovie } = useActiveDownloads() const activeDownload = movieId ? getForMovie(movieId) : null + const { clients: downloadClients } = useDownloadClients() useEffect(() => { fetchMovie() @@ -330,6 +334,7 @@ export default function MovieDetail() { indexerId: result.indexerId, indexerName: result.indexer, guid: result.id, + ...(selectedClientId && { downloadClientId: selectedClientId }), }), }) if (response.ok) { @@ -375,6 +380,54 @@ export default function MovieDetail() { } } + const searchReleases = async () => { + setSearchResults([]) + setSearching(true) + try { + const response = await fetch(`/api/v1/movies/${movieId}/releases`) + if (response.ok) { + const data = await response.json() + setSearchResults(data) + } + } catch (error) { + console.error('Failed to search releases:', error) + toast.error('Failed to search releases') + } finally { + setSearching(false) + } + } + + const grabRelease = async (result: SearchResult) => { + setGrabbing(result.id) + try { + const response = await fetch('/api/v1/queue/grab', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: result.title, + downloadUrl: result.downloadUrl, + size: result.size, + movieId: movie?.id, + indexerId: result.indexerId, + indexerName: result.indexer, + guid: result.id, + ...(selectedClientId && { downloadClientId: selectedClientId }), + }), + }) + if (response.ok) { + toast.success('Download started') + } else { + const error = await response.json() + toast.error(error.error || 'Failed to grab release') + } + } catch (error) { + console.error('Failed to grab release:', error) + toast.error('Failed to grab release') + } finally { + setGrabbing(null) + } + } + const formatSize = (bytes: number) => { if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB` if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(0)} MB` @@ -604,9 +657,14 @@ export default function MovieDetail() { )} - {/* Quality and folder info */} + {/* Quality, download client, and folder info */}
{movie.qualityProfile && {movie.qualityProfile.name}} + {movie.rootFolder && {movie.rootFolder.path}}
diff --git a/inertia/pages/library/tvshow/[id].tsx b/inertia/pages/library/tvshow/[id].tsx index ca46418..d50a739 100644 --- a/inertia/pages/library/tvshow/[id].tsx +++ b/inertia/pages/library/tvshow/[id].tsx @@ -57,6 +57,8 @@ import { useActiveDownloads, type ActiveDownloadInfo } from '@/hooks/use_active_ import { useAudioPlayer } from '@/contexts/audio_player_context' import { VideoPlayer } from '@/components/player/video_player' import { DeleteMediaDialog } from '@/components/library/delete-media-dialog' +import { DownloadClientIndicator } from '@/components/library/download-client-indicator' +import { useDownloadClients } from '@/hooks/use_download_clients' interface QualityProfile { id: number @@ -185,6 +187,11 @@ export default function TvShowDetail() { const { runBulk } = useOperationTrackerContext() const [enriching, setEnriching] = useState(false) const [refreshing, setRefreshing] = useState(false) + const [episodeSearchResults, setEpisodeSearchResults] = useState>({}) + const [searchingEpisode, setSearchingEpisode] = useState(null) + const [grabbingRelease, setGrabbingRelease] = useState(null) + const [selectedClientId, setSelectedClientId] = useState(null) + const { clients: downloadClients } = useDownloadClients() const [videoPlayerOpen, setVideoPlayerOpen] = useState(false) const [searchResults, setSearchResults] = useState([]) const [searching, setSearching] = useState(false) @@ -720,6 +727,7 @@ export default function TvShowDetail() { indexerId: result.indexerId, indexerName: result.indexer, guid: result.id, + ...(selectedClientId && { downloadClientId: selectedClientId }), }), }) if (response.ok) { @@ -970,9 +978,14 @@ export default function TvShowDetail() { )} - {/* Quality and folder info */} + {/* Quality, download client, and folder info */}
{show.qualityProfile && {show.qualityProfile.name}} + {show.rootFolder && {show.rootFolder.path}}