Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions app/controllers/queue_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand All @@ -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({
Expand Down
22 changes: 17 additions & 5 deletions app/services/download_clients/download_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface DownloadRequest {
indexerId?: string
indexerName?: string
guid?: string
downloadClientId?: number
}

export interface QueueItem {
Expand Down Expand Up @@ -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')
Expand Down
75 changes: 75 additions & 0 deletions inertia/components/library/download-client-indicator.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 (
<Badge variant="secondary" title="Download client">
{label}
</Badge>
)
}

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Badge
variant="secondary"
className="cursor-pointer hover:bg-secondary/80 gap-1"
title="Download client (click to change)"
>
{label}
<HugeiconsIcon icon={ArrowDown01Icon} className="h-3 w-3" />
</Badge>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{clients.map((client) => (
<DropdownMenuItem
key={client.id}
onClick={() => 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 && (
<span className="ml-2 text-xs text-muted-foreground">(default)</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
46 changes: 46 additions & 0 deletions inertia/hooks/use_download_clients.ts
Original file line number Diff line number Diff line change
@@ -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<DownloadClientInfo[]>([])
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 }
}
14 changes: 12 additions & 2 deletions inertia/pages/library/album/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<number | null>(null)
const { clients: downloadClients } = useDownloadClients()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteFileDialogOpen, setDeleteFileDialogOpen] = useState(false)

Expand Down Expand Up @@ -270,6 +274,7 @@ export default function AlbumDetail() {
indexerId: result.indexerId,
indexerName: result.indexer,
guid: result.id,
...(selectedClientId && { downloadClientId: selectedClientId }),
}),
})
if (response.ok) {
Expand Down Expand Up @@ -555,12 +560,17 @@ export default function AlbumDetail() {
))}
</div>

{/* Quality and folder info */}
{(album.qualityProfile || album.rootFolder) && (
{/* Quality, download client, and folder info */}
{(album.qualityProfile || album.rootFolder || downloadClients.length > 0) && (
<div className="flex flex-wrap gap-2 text-sm">
{album.qualityProfile && (
<Badge variant="secondary">{album.qualityProfile.name}</Badge>
)}
<DownloadClientIndicator
clients={downloadClients}
selectedClientId={selectedClientId}
onClientChange={setSelectedClientId}
/>
{album.rootFolder && <Badge variant="secondary">{album.rootFolder.path}</Badge>}
</div>
)}
Expand Down
60 changes: 59 additions & 1 deletion inertia/pages/library/movie/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -151,9 +153,11 @@ export default function MovieDetail() {
const [grabbing, setGrabbing] = useState<string | null>(null)
const [videoPlayerOpen, setVideoPlayerOpen] = useState(false)
const [releasePickerOpen, setReleasePickerOpen] = useState(false)
const [selectedClientId, setSelectedClientId] = useState<number | null>(null)
const audioPlayer = useAudioPlayer()
const { getForMovie } = useActiveDownloads()
const activeDownload = movieId ? getForMovie(movieId) : null
const { clients: downloadClients } = useDownloadClients()

useEffect(() => {
fetchMovie()
Expand Down Expand Up @@ -330,6 +334,7 @@ export default function MovieDetail() {
indexerId: result.indexerId,
indexerName: result.indexer,
guid: result.id,
...(selectedClientId && { downloadClientId: selectedClientId }),
}),
})
if (response.ok) {
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -604,9 +657,14 @@ export default function MovieDetail() {
</div>
)}

{/* Quality and folder info */}
{/* Quality, download client, and folder info */}
<div className="flex flex-wrap gap-2 text-sm">
{movie.qualityProfile && <Badge variant="secondary">{movie.qualityProfile.name}</Badge>}
<DownloadClientIndicator
clients={downloadClients}
selectedClientId={selectedClientId}
onClientChange={setSelectedClientId}
/>
{movie.rootFolder && <Badge variant="secondary">{movie.rootFolder.path}</Badge>}
</div>

Expand Down
Loading
Loading