Align missing-episode searches and permit reviewed profile overrides
Magent CI/CD / deploy-beta (push) Successful in 50s
Magent CI/CD / verify (push) Successful in 2m4s
Magent CI/CD / deploy-prod (push) Skipped

This commit is contained in:
2026-09-11 20:26:57 +12:00
parent de25255ea8
commit 4f7853b17b
13 changed files with 376 additions and 200 deletions
+2 -1
View File
@@ -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')
}
+34 -11
View File
@@ -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&apos;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>