Reconcile verified account IDs and make language repairs observable
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import './latest-activity.css'
|
||||
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||
|
||||
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string }
|
||||
type Operation = { id: string; label: string; status: string; events: Event[] }
|
||||
@@ -11,21 +12,21 @@ export default function LatestActivity({ operation, besideDownload, onDismiss }:
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [open, setOpen] = useState(true)
|
||||
useEffect(() => { setOpen(true) }, [operation.id])
|
||||
const latest = [...operation.events].sort((a, b) =>
|
||||
(a.finished_at ?? a.started_at ?? '').localeCompare(b.finished_at ?? b.started_at ?? '')
|
||||
).at(-1)
|
||||
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : 'Needs attention'
|
||||
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : operation.status === 'searching' ? 'Search still running' : 'Needs attention'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const element = dialog.current
|
||||
element?.showModal()
|
||||
const previous = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
const unlock = lockBodyScroll()
|
||||
return () => {
|
||||
element?.close()
|
||||
document.body.style.overflow = previous
|
||||
unlock()
|
||||
trigger.current?.focus()
|
||||
}
|
||||
}, [open])
|
||||
@@ -37,13 +38,14 @@ export default function LatestActivity({ operation, besideDownload, onDismiss }:
|
||||
<span className="latest-activity-message" role="status">{latest?.message || 'Getting ready to check your request…'}</span>
|
||||
<span className="latest-activity-more">View all activity ({operation.events.length}) <span aria-hidden="true">↗</span></span>
|
||||
</button>
|
||||
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)} onClick={(event) => { if (event.target === event.currentTarget) setOpen(false) }}>
|
||||
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)}>
|
||||
<div className="activity-dialog-content">
|
||||
<header>
|
||||
<div><span className="request-overview-label">Activity details</span><h2 id="activity-dialog-title">{operation.label}</h2><small>{status} · {operation.events.length} updates</small></div>
|
||||
<button type="button" onClick={() => setOpen(false)} autoFocus>Close</button>
|
||||
<button type="button" onClick={() => setOpen(false)}>Close</button>
|
||||
</header>
|
||||
<ol className="activity-dialog-events" aria-label="All activity, oldest first">
|
||||
{operation.status === 'running' && <p role="status" className="activity-working">Working on your request. This can take a minute while the media services search.</p>}
|
||||
<ol aria-live="polite" className="activity-dialog-events" aria-label="All activity, oldest first">
|
||||
{operation.events.map((event) => <li key={event.id} className={`is-${event.state}`}>
|
||||
<span className="activity-event-state">{event.state === 'active' ? 'Working' : event.state === 'error' ? 'Needs attention' : 'Done'}</span>
|
||||
<div><strong>{event.service}</strong><p>{event.message}</p></div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
|
||||
type AudioChoice = { language: { code: string } | null; originalEnabled?: boolean; canChange?: boolean; profileLanguage?: string }
|
||||
|
||||
export default function RequestLanguage({ requestId, disabled, onApply }: {
|
||||
requestId: string; disabled: boolean; onApply: (code: string) => Promise<void>
|
||||
}) {
|
||||
const [choice, setChoice] = useState<AudioChoice | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [revision, setRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void authFetch(`${getApiBase()}/requests/${requestId}/language`, { signal: controller.signal })
|
||||
.then(async response => { if (!response.ok) throw new Error('Could not check the audio settings. Reload the request to try again.'); return response.json() })
|
||||
.then(data => { if (!controller.signal.aborted) setChoice(data) })
|
||||
.catch(e => { if (!controller.signal.aborted) setError(e.message) })
|
||||
return () => controller.abort()
|
||||
}, [requestId, revision])
|
||||
if (error && !choice) return <p role="alert">{error}</p>
|
||||
if (!choice?.language) return null
|
||||
const code = choice.language.code
|
||||
const name = new Intl.DisplayNames(['en'], { type: 'language' }).of(code) || code
|
||||
return <section className="request-language-notice" aria-label="Audio language">
|
||||
<h2>{name} audio {choice.originalEnabled ? 'enabled' : 'may need your approval'}</h2>
|
||||
<p>This movie was originally made in {name}. An English dub may not exist. {choice.originalEnabled ? 'Radarr is set to accept its original audio.' : `The current audio requirement is ${choice.profileLanguage || 'set by the library'}. This can leave the request waiting even when an original-language release exists.`}</p>
|
||||
<p>Accepting original audio keeps the quality requirements. Subtitles and individual release audio tracks are not guaranteed by title metadata.</p>
|
||||
{choice.canChange && !choice.originalEnabled && <button type="button" disabled={disabled} onClick={async () => {
|
||||
setError(null)
|
||||
try { await onApply(code); setRevision(v => v + 1) } catch (e) { setError(e instanceof Error ? e.message : 'The audio choice could not be saved.') }
|
||||
}}>Use {name} audio & search</button>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</section>
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../../ui/PageHeading'
|
||||
import RequestLanguage from './RequestLanguage'
|
||||
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||
import LatestActivity from './LatestActivity'
|
||||
|
||||
import Image from 'next/image'
|
||||
@@ -339,14 +341,13 @@ export default function RequestTimelinePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!releasePickerOpen) return
|
||||
const previousOverflow = document.body.style.overflow
|
||||
const unlock = lockBodyScroll()
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') closeReleasePicker()
|
||||
}
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
unlock()
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [releasePickerOpen, busyAction])
|
||||
@@ -588,14 +589,16 @@ export default function RequestTimelinePage() {
|
||||
const timer = window.setInterval(() => void refreshProgress(), 650)
|
||||
try {
|
||||
const response = await request
|
||||
const finalProgress = await refreshProgress()
|
||||
if (!finalProgress || finalProgress.status === 'running') {
|
||||
setOperationProgress((current) => current?.id === operationId ? {
|
||||
...current, status: response.ok ? 'complete' : 'error',
|
||||
events: [...current.events, { id: 'result', service: 'Magent', state: response.ok ? 'complete' : 'error',
|
||||
message: response.ok ? 'This action has finished. Check the request status for what happens next.' : 'This action could not be completed. Check the message beside the request controls.' }],
|
||||
} : current)
|
||||
}
|
||||
await refreshProgress()
|
||||
let result: any = null
|
||||
try { result = await response.clone().json() } catch { /* Non-JSON error is handled below. */ }
|
||||
const needsAttention = !response.ok || result?.status === 'attention' || result?.outcome === 'attention'
|
||||
const finalState = needsAttention ? 'error' : result?.status === 'searching' ? 'searching' : 'complete'
|
||||
setOperationProgress((current) => current?.id === operationId ? {
|
||||
...current, status: finalState,
|
||||
events: [...current.events.map(event => event.state === 'active' ? { ...event, state: 'complete' as const } : event), { id: 'result', service: 'Magent', state: needsAttention ? 'error' : 'complete',
|
||||
message: result?.message || (typeof result?.detail === 'string' ? result.detail : response.ok ? 'Action completed. The pipeline will update as the media services report progress.' : 'The action failed. Recheck the request before trying again.') }],
|
||||
} : current)
|
||||
return response
|
||||
} catch (error) {
|
||||
setOperationProgress((current) => current?.id === operationId ? {
|
||||
@@ -660,7 +663,7 @@ export default function RequestTimelinePage() {
|
||||
setReleaseOptions([])
|
||||
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
|
||||
setReleaseSearchMessage(null)
|
||||
setReleasePickerOpen(true)
|
||||
setReleasePickerOpen(false)
|
||||
}
|
||||
setBusyAction(action.id)
|
||||
setActionError(null)
|
||||
@@ -680,6 +683,7 @@ export default function RequestTimelinePage() {
|
||||
if (action.id === 'search_releases') {
|
||||
const releases = Array.isArray(data.releases) ? data.releases : []
|
||||
setReleaseOptions(releases)
|
||||
setReleasePickerOpen(true)
|
||||
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
|
||||
setReleaseSearchMessage(
|
||||
data?.message ??
|
||||
@@ -744,6 +748,16 @@ export default function RequestTimelinePage() {
|
||||
leading={resolvedPoster && <Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={60} height={90} sizes="60px" unoptimized />}
|
||||
/>
|
||||
|
||||
<RequestLanguage requestId={snapshot.request_id} disabled={Boolean(busyAction)} onApply={async (code) => {
|
||||
setBusyAction('language')
|
||||
try {
|
||||
const response = await trackedPost('Use original audio and search', `${getApiBase()}/requests/${snapshot.request_id}/actions/language`, {
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ acceptOriginalLanguage: true, languageCode: code }),
|
||||
})
|
||||
if (!response.ok) throw new Error(await readApiError(response, 'The audio choice could not be saved.'))
|
||||
} finally { setBusyAction(null) }
|
||||
}} />
|
||||
|
||||
<section className="request-overview" aria-labelledby="request-status-heading">
|
||||
<div className="request-overview-block request-overview-status">
|
||||
<span className="request-overview-label" id="request-status-heading">Status</span>
|
||||
|
||||
Reference in New Issue
Block a user