Align missing-episode searches and permit reviewed profile overrides
This commit is contained in:
@@ -4,6 +4,7 @@ export const FEATURES = [
|
||||
{ key: 'new_requests', label: 'New Requests', description: 'Search for movies and TV shows and submit new requests.' },
|
||||
{ key: 'issues', label: 'Issues', description: 'Report problems, follow up on issues and use available repair tools.' },
|
||||
{ key: 'invites', label: 'Invites', description: 'Create and manage invitations within the existing invite limits.' },
|
||||
{ key: 'ignore_profile_limits', label: 'Ignore profile limits', description: 'Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.' },
|
||||
] as const
|
||||
export type Feature = typeof FEATURES[number]['key']
|
||||
export type FeatureAccess = Record<Feature, boolean>
|
||||
@@ -19,5 +20,5 @@ export function canAccess(user: { role?: string; features?: Partial<FeatureAcces
|
||||
if (!feature) return true
|
||||
if (!user) return false
|
||||
if (user.role === 'admin') return true
|
||||
return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : true)
|
||||
return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : feature !== 'ignore_profile_limits')
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ type ReleaseOption = {
|
||||
quality?: string
|
||||
customFormatScore?: number
|
||||
approved?: boolean
|
||||
selectionToken?: string
|
||||
requiresOverride?: boolean
|
||||
selectable?: boolean
|
||||
rejections?: string[]
|
||||
bestPick?: boolean
|
||||
}
|
||||
|
||||
@@ -311,6 +315,9 @@ export default function RequestTimelinePage() {
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null)
|
||||
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
|
||||
const [releasePickerOpen, setReleasePickerOpen] = useState(false)
|
||||
const [canIgnoreProfileLimits, setCanIgnoreProfileLimits] = useState(false)
|
||||
const [ignoreProfileLimits, setIgnoreProfileLimits] = useState(false)
|
||||
const [nextSearchOffset, setNextSearchOffset] = useState<number | null>(null)
|
||||
const [releaseCollector, setReleaseCollector] = useState<string | null>(null)
|
||||
const [releaseSearchMessage, setReleaseSearchMessage] = useState<string | null>(null)
|
||||
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
||||
@@ -646,7 +653,7 @@ export default function RequestTimelinePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const runAction = async (action: RequestAction) => {
|
||||
const runAction = async (action: RequestAction, searchOffset = 0) => {
|
||||
if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return
|
||||
const actionPaths: Record<string, string> = {
|
||||
search_releases: 'actions/search',
|
||||
@@ -661,6 +668,8 @@ export default function RequestTimelinePage() {
|
||||
}
|
||||
if (action.id === 'search_releases') {
|
||||
setReleaseOptions([])
|
||||
setIgnoreProfileLimits(false)
|
||||
setNextSearchOffset(null)
|
||||
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
|
||||
setReleaseSearchMessage(null)
|
||||
setReleasePickerOpen(false)
|
||||
@@ -671,7 +680,7 @@ export default function RequestTimelinePage() {
|
||||
try {
|
||||
const response = await trackedPost(
|
||||
action.label,
|
||||
`${getApiBase()}/requests/${snapshot.request_id}/${path}`
|
||||
`${getApiBase()}/requests/${snapshot.request_id}/${path}${action.id === 'search_releases' ? `?offset=${searchOffset}` : ''}`
|
||||
)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
@@ -683,6 +692,8 @@ export default function RequestTimelinePage() {
|
||||
if (action.id === 'search_releases') {
|
||||
const releases = Array.isArray(data.releases) ? data.releases : []
|
||||
setReleaseOptions(releases)
|
||||
setCanIgnoreProfileLimits(data.canIgnoreProfileLimits === true)
|
||||
setNextSearchOffset(typeof data.nextOffset === 'number' ? data.nextOffset : null)
|
||||
setReleasePickerOpen(true)
|
||||
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
|
||||
setReleaseSearchMessage(
|
||||
@@ -710,6 +721,8 @@ export default function RequestTimelinePage() {
|
||||
setActionError('This release is missing the details needed to start it.')
|
||||
return
|
||||
}
|
||||
if (release.requiresOverride && (!canIgnoreProfileLimits || !ignoreProfileLimits)) return
|
||||
if (release.requiresOverride && !window.confirm(`Download this release outside the assigned profile?\n\n${release.title}\n${(release.rejections || []).join('\n')}\n\nThe assigned profile will stay unchanged.`)) return
|
||||
const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'
|
||||
setBusyAction(`grab:${release.guid}`)
|
||||
setActionError(null)
|
||||
@@ -718,7 +731,7 @@ export default function RequestTimelinePage() {
|
||||
`Send release through ${collector}`,
|
||||
`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(release),
|
||||
body: JSON.stringify({ ...release, ignoreProfileLimits: release.requiresOverride === true && ignoreProfileLimits }),
|
||||
})
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
@@ -953,9 +966,9 @@ export default function RequestTimelinePage() {
|
||||
>
|
||||
<header className="request-release-modal-header">
|
||||
<div>
|
||||
<span className="section-kicker">Approved by {releaseCollector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')}</span>
|
||||
<span className="section-kicker">Results from {releaseCollector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')}</span>
|
||||
<h2 id="release-picker-title">Choose an available download</h2>
|
||||
<p>Only releases accepted by the title's assigned quality profile are shown.</p>
|
||||
<p>Search results include the collector?s rejection reasons. The assigned profile stays unchanged.</p>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" onClick={closeReleasePicker} disabled={busyAction?.startsWith('grab:')}>Close</button>
|
||||
</header>
|
||||
@@ -973,7 +986,7 @@ export default function RequestTimelinePage() {
|
||||
|
||||
{busyAction !== 'search_releases' && releaseSearchMessage && (
|
||||
<div className="request-release-profile-note">
|
||||
<strong>Quality limits applied</strong>
|
||||
<strong>Search results</strong>
|
||||
<span>{releaseSearchMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -988,19 +1001,26 @@ export default function RequestTimelinePage() {
|
||||
{busyAction !== 'search_releases' && !actionError && releaseOptions.length === 0 && (
|
||||
<div className="request-release-empty">
|
||||
<strong>No suitable downloads are available right now</strong>
|
||||
<span>{releaseCollector ?? 'The collector'} did not approve anything within the assigned quality limits. Nothing outside those limits has been shown.</span>
|
||||
<span>No releases were returned for this search. Check the indexers or try again later.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canIgnoreProfileLimits && <label className="feature-access-row">
|
||||
<input type="checkbox" checked={ignoreProfileLimits} disabled={Boolean(busyAction)} onChange={event => setIgnoreProfileLimits(event.target.checked)} />
|
||||
<span><strong>Ignore profile limits</strong><small>Enable selection outside quality, size, language and custom-format limits for this search. Each download requires confirmation. Other rejection reasons remain blocked.</small></span>
|
||||
</label>}
|
||||
{nextSearchOffset !== null && <button type="button" className="ghost-button" disabled={Boolean(busyAction)} onClick={() => void runAction({ id: 'search_releases', label: 'Search next missing episodes', risk: 'low', requires_confirmation: false }, nextSearchOffset)}>Search next missing episodes</button>}
|
||||
{releaseOptions.length > 0 && (
|
||||
<div className="request-release-list">
|
||||
{releaseOptions.map((release, index) => {
|
||||
const isBestPick = release.bestPick || index === 0
|
||||
{releaseOptions.map((release) => {
|
||||
const isBestPick = release.bestPick === true
|
||||
return (
|
||||
<article className={`request-release ${isBestPick ? 'is-best-pick' : ''}`} key={`${release.indexerId ?? ''}:${release.guid ?? release.title}`}>
|
||||
<div className="request-release-copy">
|
||||
<div className="request-release-badges">
|
||||
{isBestPick && <span className="request-release-best-badge">Best pick</span>}
|
||||
{release.requiresOverride && <span>Outside profile</span>}
|
||||
{release.selectable === false && <span>Unavailable for selection</span>}
|
||||
{release.quality && <span>{release.quality}</span>}
|
||||
{release.fullSeason && <span>Season {release.seasonNumber ?? ''} pack</span>}
|
||||
</div>
|
||||
@@ -1009,16 +1029,19 @@ export default function RequestTimelinePage() {
|
||||
{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}
|
||||
{typeof release.customFormatScore === 'number' ? ` · Score ${release.customFormatScore}` : ''}
|
||||
</span>
|
||||
{!!release.rejections?.length && <small className="release-rejection-reasons">{release.rejections.join(' ? ')}</small>}
|
||||
{isBestPick && <small>This is the highest-ranked release approved by {releaseCollector ?? 'the collector'}.</small>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(busyAction) || !release.guid || !release.indexerId}
|
||||
disabled={Boolean(busyAction) || !release.selectionToken || release.selectable === false || (release.requiresOverride && (!canIgnoreProfileLimits || !ignoreProfileLimits))}
|
||||
onClick={() => void downloadRelease(release)}
|
||||
>
|
||||
{busyAction === `grab:${release.guid}`
|
||||
? 'Sending…'
|
||||
: isBestPick
|
||||
: release.requiresOverride
|
||||
? 'Download outside profile'
|
||||
: isBestPick
|
||||
? 'Download best pick'
|
||||
: 'Download this release'}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user