Files
torrserver/web/src/components/Search/SearchDialog.jsx
T
nkozobrod 616c6b1c62
Release Docker multi arch / docker (push) Has been cancelled
Test Install Script / Test Script Syntax (push) Has been cancelled
Test Install Script / Test on almalinux-10 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-10 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (root) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (default) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (root) (push) Has been cancelled
Test Install Script / Test on debian-11 (default) (push) Has been cancelled
Test Install Script / Test on debian-11 (root) (push) Has been cancelled
Test Install Script / Test on debian-12 (default) (push) Has been cancelled
Test Install Script / Test on debian-12 (root) (push) Has been cancelled
Test Install Script / Test on debian-13 (default) (push) Has been cancelled
Test Install Script / Test on debian-13 (root) (push) Has been cancelled
Test Install Script / Test on fedora-latest (default) (push) Has been cancelled
Test Install Script / Test on fedora-latest (root) (push) Has been cancelled
Test Install Script / Test on rocky-10 (default) (push) Has been cancelled
Test Install Script / Test on rocky-10 (root) (push) Has been cancelled
Test Install Script / Test on rocky-8 (default) (push) Has been cancelled
Test Install Script / Test on rocky-8 (root) (push) Has been cancelled
Test Install Script / Test on rocky-9 (default) (push) Has been cancelled
Test Install Script / Test on rocky-9 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (root) (push) Has been cancelled
Initial commit: docker compose config
2026-05-30 12:07:11 +00:00

348 lines
12 KiB
React

import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import {
TextField,
Button,
List,
ListItem,
ListItemText,
CircularProgress,
Typography,
Divider,
ListItemSecondaryAction,
IconButton,
Snackbar,
useMediaQuery,
Select,
MenuItem,
FormControl,
InputLabel,
} from '@material-ui/core'
import { CloudDownload as DownloadIcon, ArrowUpward, ArrowDownward } from '@material-ui/icons'
import { torznabSearchHost, torrentsHost, settingsHost, searchHost } from 'utils/Hosts'
import useOnStandaloneAppOutsideClick from 'utils/useOnStandaloneAppOutsideClick'
import { StyledDialog, StyledHeader } from 'style/CustomMaterialUiStyles'
import { parseSizeToBytes, formatSizeToClassicUnits } from 'utils/Utils'
import { getMoviePosters, shortenTitleForPosterSearch } from 'components/Add/helpers'
import { Content } from './style'
export default function SearchDialog({ handleClose }) {
const { t } = useTranslation()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [adding, setAdding] = useState(false)
const [successMsg, setSuccessMsg] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [trackers, setTrackers] = useState([])
const [enableRutor, setEnableRutor] = useState(false)
const [selectedTracker, setSelectedTracker] = useState(-1)
const [sortField, setSortField] = useState('') // '', 'size', 'seeds', 'peers'
const [sortDirection, setSortDirection] = useState('desc') // 'asc' or 'desc'
const fullScreen = useMediaQuery('@media (max-width:930px)')
const isMobile = useMediaQuery('(max-width:600px)')
const ref = useOnStandaloneAppOutsideClick(handleClose)
useEffect(() => {
axios
.post(settingsHost(), { action: 'get' })
.then(({ data }) => {
if (data) {
if (data.TorznabUrls) {
setTrackers(data.TorznabUrls)
}
setEnableRutor(!!data.EnableRutorSearch)
}
})
.catch(() => {})
}, [])
const handleSearch = async () => {
if (!query) return
setLoading(true)
setSearched(true)
setResults([])
try {
let url = torznabSearchHost()
const params = { query }
if (selectedTracker === 'rutor') {
url = searchHost()
} else if (selectedTracker !== -1) {
params.index = selectedTracker
}
const { data } = await axios.get(url, { params })
setResults(data || [])
} catch (error) {
setErrorMsg(t('Torznab.SearchFailed'))
} finally {
setLoading(false)
}
}
const handleKeyDown = e => {
if (e.key === 'Enter') {
handleSearch()
}
}
const handleAdd = async item => {
setAdding(true)
try {
const link = item.Magnet || item.Link
if (!link) {
setErrorMsg(t('Torznab.NoLinkFound'))
return
}
let poster = item.Poster
if (!poster && item.Title) {
const query = shortenTitleForPosterSearch(item.Title)
if (query) {
const urlList = await getMoviePosters(query, 'en')
const [firstPosterUrl] = urlList || []
if (firstPosterUrl) poster = firstPosterUrl
}
}
await axios.post(torrentsHost(), {
action: 'add',
link,
title: item.Title,
save_to_db: true,
poster: poster || '',
})
setSuccessMsg(t('Torznab.TorrentAddedSuccessfully'))
} catch (error) {
setErrorMsg(t('Torznab.FailedToAddTorrent'))
} finally {
setAdding(false)
}
}
const handleAlertClose = (event, reason) => {
if (reason === 'clickaway') {
return
}
setSuccessMsg('')
setErrorMsg('')
}
const toggleSortDirection = () => {
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'))
}
const sortedResults = useMemo(() => {
if (!sortField || results.length === 0) return results
const sorted = [...results].sort((a, b) => {
let aVal
let bVal
switch (sortField) {
case 'size':
aVal = parseSizeToBytes(a.Size || '0')
bVal = parseSizeToBytes(b.Size || '0')
break
case 'seeds':
aVal = a.Seed || 0
bVal = b.Seed || 0
break
case 'peers':
aVal = a.Peer || 0
bVal = b.Peer || 0
break
default:
return 0
}
if (aVal === bVal) return 0
return sortDirection === 'asc' ? (aVal < bVal ? -1 : 1) : aVal > bVal ? -1 : 1
})
return sorted
}, [results, sortField, sortDirection])
return (
<StyledDialog open onClose={handleClose} fullScreen={fullScreen} fullWidth maxWidth='md' ref={ref}>
<StyledHeader>{t('Torznab.SearchTorrents')}</StyledHeader>
<Content>
<div style={{ padding: '20px' }}>
<div
style={{
display: 'flex',
gap: '8px',
marginBottom: '20px',
alignItems: 'flex-start',
flexWrap: fullScreen ? 'wrap' : 'nowrap',
}}
>
<FormControl
variant='outlined'
size='small'
style={{
minWidth: 150,
flex: fullScreen ? '1 1 100%' : '0 0 auto',
}}
>
<InputLabel>{t('Tracker')}</InputLabel>
<Select value={selectedTracker} onChange={e => setSelectedTracker(e.target.value)} label={t('Tracker')}>
<MenuItem value={-1}>{t('AllTrackers')}</MenuItem>
{enableRutor && <MenuItem value='rutor'>{t('Rutor')}</MenuItem>}
{trackers.map((tracker, index) => (
<MenuItem key={`${tracker.Host}-${tracker.Key}`} value={index}>
{tracker.Name || tracker.Host}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label={t('Torznab.SearchTorznab')}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
variant='outlined'
size='small'
fullWidth
placeholder={t('Torznab.SearchMoviesShows')}
autoFocus
/>
<Button
variant='contained'
color='primary'
onClick={handleSearch}
disabled={loading}
style={{
minWidth: fullScreen ? '80px' : '100px',
height: '40px',
}}
>
{loading ? <CircularProgress size={24} color='inherit' /> : t('Search')}
</Button>
</div>
{searched && results.length > 0 && (
<div
style={{
display: 'flex',
gap: isMobile ? '8px' : '4px',
marginBottom: '16px',
alignItems: 'center',
padding: isMobile ? '12px 8px' : '8px 12px',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
borderRadius: '4px',
border: '1px solid rgba(0, 0, 0, 0.08)',
flexWrap: isMobile ? 'wrap' : 'nowrap',
}}
>
<FormControl
variant='outlined'
size='small'
style={{
minWidth: isMobile ? '100%' : 140,
flexShrink: 0,
flex: isMobile ? '1 1 100%' : '0 0 auto',
}}
>
<InputLabel>{t('Torznab.SortBy')}</InputLabel>
<Select value={sortField} onChange={e => setSortField(e.target.value)} label={t('Torznab.SortBy')}>
<MenuItem value=''>{t('Torznab.SortByNone')}</MenuItem>
<MenuItem value='size'>{t('Torznab.SortBySize')}</MenuItem>
<MenuItem value='seeds'>{t('Torznab.SortBySeeds')}</MenuItem>
<MenuItem value='peers'>{t('Torznab.SortByPeers')}</MenuItem>
</Select>
</FormControl>
{sortField && (
<IconButton
size='small'
onClick={toggleSortDirection}
title={sortDirection === 'asc' ? t('Torznab.SortAscending') : t('Torznab.SortDescending')}
style={{
marginLeft: isMobile ? 'auto' : '4px',
padding: '8px',
}}
>
{sortDirection === 'asc' ? <ArrowUpward /> : <ArrowDownward />}
</IconButton>
)}
</div>
)}
<div style={{ overflowY: 'auto', maxHeight: 'calc(100vh - 200px)' }}>
{searched && results.length === 0 && !loading && (
<Typography align='center' variant='body1' color='textSecondary'>
{t('Torznab.NoResultsFound')}
</Typography>
)}
<List>
{sortedResults.map((item, index) => {
const sizeBytes = parseSizeToBytes(item.Size || '0')
const formattedSize = formatSizeToClassicUnits(sizeBytes)
return (
<div key={item.Hash || item.Link || index}>
<ListItem button onClick={() => handleAdd(item)}>
<ListItemText
primary={item.Title}
secondary={
<>
<Typography component='span' variant='body2' color='textPrimary'>
{formattedSize}
</Typography>
{` • S: ${item.Seed || 0} P: ${item.Peer || 0}`}
</>
}
primaryTypographyProps={{
style: {
whiteSpace: isMobile ? 'normal' : 'inherit',
fontSize: isMobile ? '0.9rem' : 'inherit',
},
}}
secondaryTypographyProps={{
style: {
fontSize: isMobile ? '0.75rem' : 'inherit',
},
}}
/>
<ListItemSecondaryAction>
<IconButton
edge='end'
aria-label='add'
onClick={() => handleAdd(item)}
disabled={adding}
size={isMobile ? 'small' : 'medium'}
>
<DownloadIcon color='secondary' />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
<Divider component='li' />
</div>
)
})}
</List>
</div>
</div>
</Content>
<Snackbar open={!!successMsg} autoHideDuration={1500} onClose={handleAlertClose} message={successMsg} />
<Snackbar open={!!errorMsg} autoHideDuration={1500} onClose={handleAlertClose} message={errorMsg} />
<div
style={{
padding: '16px',
display: 'flex',
justifyContent: 'flex-end',
borderTop: '1px solid rgba(0,0,0,0.12)',
}}
>
<Button onClick={handleClose} color='secondary' variant='outlined'>
{t('Close')}
</Button>
</div>
</StyledDialog>
)
}