1078 lines
42 KiB
TypeScript
1078 lines
42 KiB
TypeScript
'use client'
|
|
|
|
import Image from 'next/image'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
|
|
|
type TimelineHop = {
|
|
service: string
|
|
status: string
|
|
details?: Record<string, any>
|
|
}
|
|
|
|
type RequestAction = {
|
|
id: string
|
|
label: string
|
|
risk: string
|
|
description?: string
|
|
requires_confirmation: boolean
|
|
}
|
|
|
|
type PipelineStage = {
|
|
id: string
|
|
label: string
|
|
state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string
|
|
stateLabel?: string
|
|
summary: string
|
|
available?: number
|
|
missing?: number
|
|
total?: number
|
|
seasons?: Array<{ seasonNumber: number; available: number; missing: number; total: number }>
|
|
missingEpisodes?: Record<string, number[]>
|
|
actionIds?: string[]
|
|
visible?: boolean
|
|
torrents?: Array<Record<string, any>>
|
|
link?: string | null
|
|
}
|
|
|
|
type RepairActivityStep = {
|
|
id: string
|
|
label: string
|
|
state: 'complete' | 'active' | 'attention' | 'waiting' | string
|
|
detail: string
|
|
}
|
|
|
|
type RepairActivity = {
|
|
visible?: boolean
|
|
actionId?: string
|
|
state?: 'searching' | 'downloading' | 'importing' | 'indexing' | 'complete' | 'attention' | string
|
|
headline?: string
|
|
message?: string
|
|
service?: string
|
|
updatedAt?: string | null
|
|
steps?: RepairActivityStep[]
|
|
}
|
|
|
|
type Snapshot = {
|
|
request_id: string
|
|
title: string
|
|
year?: number
|
|
request_type: string
|
|
state: string
|
|
state_reason?: string
|
|
timeline: TimelineHop[]
|
|
actions: RequestAction[]
|
|
artwork?: { poster_url?: string; backdrop_url?: string }
|
|
presentation?: {
|
|
status?: { label?: string; meaning?: string }
|
|
download?: {
|
|
visible?: boolean
|
|
state?: string
|
|
summary?: string
|
|
torrents?: Array<Record<string, any>>
|
|
lastSeenAt?: string | null
|
|
}
|
|
nextStep?: { title?: string; description?: string; actionIds?: string[] }
|
|
pipeline?: PipelineStage[]
|
|
repairActivity?: RepairActivity
|
|
}
|
|
raw?: Record<string, any>
|
|
}
|
|
|
|
type ReleaseOption = {
|
|
title?: string
|
|
indexer?: string
|
|
indexerId?: number
|
|
guid?: string
|
|
size?: number
|
|
seeders?: number
|
|
leechers?: number
|
|
protocol?: string
|
|
publishDate?: string
|
|
infoUrl?: string
|
|
downloadUrl?: string
|
|
magnetUrl?: string
|
|
fullSeason?: boolean
|
|
seasonNumber?: number
|
|
quality?: string
|
|
customFormatScore?: number
|
|
approved?: boolean
|
|
bestPick?: boolean
|
|
}
|
|
|
|
type SnapshotHistory = {
|
|
request_id: string
|
|
state: string
|
|
state_reason?: string
|
|
created_at: string
|
|
}
|
|
|
|
type ActionHistory = {
|
|
request_id: string
|
|
action_id: string
|
|
label: string
|
|
status: string
|
|
message?: string
|
|
created_at: string
|
|
}
|
|
|
|
type LiveDownloadProgress = {
|
|
request_id: string
|
|
state: string
|
|
summary: string
|
|
torrents: Array<Record<string, any>>
|
|
updated_at: string
|
|
}
|
|
|
|
type OperationEvent = {
|
|
id: string
|
|
service: string
|
|
state: 'active' | 'complete' | 'error' | string
|
|
message: string
|
|
duration_ms?: number | null
|
|
status_code?: number | null
|
|
}
|
|
|
|
type OperationProgress = {
|
|
id: string
|
|
label: string
|
|
status: 'running' | 'complete' | 'error' | string
|
|
duration_ms?: number | null
|
|
events: OperationEvent[]
|
|
}
|
|
|
|
const readApiError = async (response: Response, fallback: string) => {
|
|
try {
|
|
const contentType = response.headers.get('content-type') ?? ''
|
|
if (contentType.includes('application/json')) {
|
|
const payload = await response.json()
|
|
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail
|
|
if (typeof payload?.message === 'string' && payload.message.trim()) return payload.message
|
|
} else {
|
|
const text = await response.text()
|
|
if (text.trim()) return text.trim()
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
const isSnapshotPayload = (value: unknown): value is Snapshot => {
|
|
if (!value || typeof value !== 'object') return false
|
|
const snapshot = value as Partial<Snapshot>
|
|
return (
|
|
typeof snapshot.request_id === 'string' &&
|
|
typeof snapshot.title === 'string' &&
|
|
typeof snapshot.request_type === 'string' &&
|
|
typeof snapshot.state === 'string' &&
|
|
Array.isArray(snapshot.timeline) &&
|
|
Array.isArray(snapshot.actions)
|
|
)
|
|
}
|
|
|
|
const formatBytes = (value?: number) => {
|
|
if (!value || Number.isNaN(value)) return 'Size unavailable'
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
let size = value
|
|
let index = 0
|
|
while (size >= 1024 && index < units.length - 1) {
|
|
size /= 1024
|
|
index += 1
|
|
}
|
|
return `${size.toFixed(1)} ${units[index]}`
|
|
}
|
|
|
|
const torrentProgress = (torrent: Record<string, any>) => {
|
|
const progress = Number(torrent.progress)
|
|
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) return Math.round(progress * 1000) / 10
|
|
const supplied = Number(torrent.progressPercent)
|
|
if (!Number.isNaN(supplied) && supplied >= 0 && supplied <= 100) return Math.round(supplied * 10) / 10
|
|
return null
|
|
}
|
|
|
|
const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, '')}% complete`
|
|
|
|
const formatDuration = (duration?: number | null) => {
|
|
if (typeof duration !== 'number' || Number.isNaN(duration)) return null
|
|
if (duration < 1000) return `${Math.max(0, Math.round(duration))}ms`
|
|
return `${(duration / 1000).toFixed(1)}s`
|
|
}
|
|
|
|
const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => {
|
|
if (String(current.request_id) !== String(live.request_id)) return current
|
|
const stageState = live.state === 'completed'
|
|
? 'complete'
|
|
: ['missing', 'error'].includes(live.state)
|
|
? 'attention'
|
|
: 'active'
|
|
const presentation = current.presentation ?? {}
|
|
const pipeline = (presentation.pipeline ?? fallbackPipeline(current)).map((stage) =>
|
|
stage.id === 'download'
|
|
? { ...stage, state: stageState, summary: live.summary, visible: true, torrents: live.torrents }
|
|
: stage
|
|
)
|
|
return {
|
|
...current,
|
|
presentation: {
|
|
...presentation,
|
|
download: {
|
|
...(presentation.download ?? {}),
|
|
visible: true,
|
|
state: live.state,
|
|
summary: live.summary,
|
|
torrents: live.torrents,
|
|
lastSeenAt: live.updated_at,
|
|
},
|
|
pipeline,
|
|
},
|
|
}
|
|
}
|
|
|
|
const fallbackStatusLabel = (state: string) => {
|
|
const labels: Record<string, string> = {
|
|
REQUESTED: 'Waiting for approval',
|
|
APPROVED: 'Approved — preparing collection',
|
|
NEEDS_ADD: 'Approved, but not yet in the library queue',
|
|
ADDED_TO_ARR: 'Added to library queue',
|
|
SEARCHING: 'Searching for a matching release',
|
|
GRABBED: 'Download queued',
|
|
DOWNLOADING: 'Download in progress',
|
|
IMPORTING: 'Preparing the collected media',
|
|
COMPLETED: 'Available to watch',
|
|
AVAILABLE: 'Available to watch',
|
|
FAILED: 'This request needs attention',
|
|
UNKNOWN: 'Checking request status',
|
|
}
|
|
return labels[state] ?? 'Checking request status'
|
|
}
|
|
|
|
const fallbackPipeline = (snapshot: Snapshot): PipelineStage[] => {
|
|
const approved = snapshot.state !== 'REQUESTED'
|
|
const complete = ['COMPLETED', 'AVAILABLE'].includes(snapshot.state)
|
|
const indexing = snapshot.state === 'IMPORTING'
|
|
return [
|
|
{ id: 'requested', label: 'Requested', state: 'complete', summary: 'Request received' },
|
|
{
|
|
id: 'approved',
|
|
label: 'Approved',
|
|
state: approved ? 'complete' : 'active',
|
|
summary: approved ? 'Approved for collection' : 'Waiting for approval',
|
|
},
|
|
{
|
|
id: 'library',
|
|
label: 'Library collection',
|
|
state: complete ? 'complete' : approved ? 'active' : 'waiting',
|
|
summary: complete ? 'Collection complete' : 'Waiting for collector information',
|
|
},
|
|
{ id: 'search', label: 'Release search', state: 'waiting', summary: 'Search state unavailable' },
|
|
{ id: 'download', label: 'Download', state: 'waiting', summary: 'No download attempt yet' },
|
|
{
|
|
id: 'available',
|
|
label: complete ? 'Available to watch' : indexing ? 'Adding to Grizzlyflix' : 'Media server',
|
|
state: complete ? 'complete' : indexing ? 'active' : 'waiting',
|
|
stateLabel: complete ? 'Ready' : indexing ? 'Indexing' : 'Waiting',
|
|
summary: complete
|
|
? 'This title is ready to watch in Grizzlyflix.'
|
|
: indexing
|
|
? 'The download is complete. Grizzlyflix is indexing this title now.'
|
|
: 'This title has not reached Grizzlyflix yet.',
|
|
link: snapshot.raw?.jellyfin?.link,
|
|
},
|
|
]
|
|
}
|
|
|
|
const formatWhen = (value?: string | null) => {
|
|
if (!value) return 'Time unavailable'
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return value
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
export default function RequestTimelinePage() {
|
|
const params = useParams<{ id: string | string[] }>()
|
|
const requestId = Array.isArray(params?.id) ? params.id[0] : params?.id
|
|
const router = useRouter()
|
|
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [loadError, setLoadError] = useState<string | null>(null)
|
|
const [showDetails, setShowDetails] = useState(false)
|
|
const [actionMessage, setActionMessage] = useState<string | null>(null)
|
|
const [actionError, setActionError] = useState<string | null>(null)
|
|
const [busyAction, setBusyAction] = useState<string | null>(null)
|
|
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
|
|
const [releasePickerOpen, setReleasePickerOpen] = useState(false)
|
|
const [releaseCollector, setReleaseCollector] = useState<string | null>(null)
|
|
const [releaseSearchMessage, setReleaseSearchMessage] = useState<string | null>(null)
|
|
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
|
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
|
|
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
|
|
const [isAdmin, setIsAdmin] = useState(false)
|
|
const awaitingMediaIndex = Boolean(
|
|
snapshot?.presentation?.pipeline?.some(
|
|
(stage) => stage.id === 'available' && stage.state === 'active'
|
|
)
|
|
)
|
|
const repairIsActive = Boolean(
|
|
snapshot?.presentation?.repairActivity?.visible &&
|
|
!['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '')
|
|
)
|
|
|
|
const closeReleasePicker = () => {
|
|
if (busyAction?.startsWith('grab:')) return
|
|
setReleasePickerOpen(false)
|
|
setReleaseOptions([])
|
|
setReleaseSearchMessage(null)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!releasePickerOpen) return
|
|
const previousOverflow = document.body.style.overflow
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') closeReleasePicker()
|
|
}
|
|
document.body.style.overflow = 'hidden'
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow
|
|
window.removeEventListener('keydown', handleKeyDown)
|
|
}
|
|
}, [releasePickerOpen, busyAction])
|
|
|
|
useEffect(() => {
|
|
if (!requestId) return
|
|
const load = async () => {
|
|
setLoading(true)
|
|
setLoadError(null)
|
|
try {
|
|
if (!getToken()) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
const baseUrl = getApiBase()
|
|
const [meResponse, snapshotResponse] = await Promise.all([
|
|
authFetch(`${baseUrl}/auth/me`),
|
|
authFetch(`${baseUrl}/requests/${requestId}/snapshot`),
|
|
])
|
|
if ([meResponse, snapshotResponse].some((response) => response.status === 401)) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
if (!meResponse.ok) {
|
|
throw new Error('Unable to verify your request access.')
|
|
}
|
|
const me = await meResponse.json()
|
|
const viewerIsAdmin = me?.role === 'admin'
|
|
setIsAdmin(viewerIsAdmin)
|
|
if (!snapshotResponse.ok) {
|
|
throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
|
|
}
|
|
const snapshotData = await snapshotResponse.json()
|
|
if (!isSnapshotPayload(snapshotData)) throw new Error('Unable to load this request.')
|
|
setSnapshot(snapshotData)
|
|
if (viewerIsAdmin) {
|
|
const [historyResponse, actionsResponse] = await Promise.all([
|
|
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
|
|
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
|
|
])
|
|
if (historyResponse.ok) {
|
|
const historyData = await historyResponse.json()
|
|
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots)
|
|
}
|
|
if (actionsResponse.ok) {
|
|
const actionsData = await actionsResponse.json()
|
|
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions)
|
|
}
|
|
} else {
|
|
setHistorySnapshots([])
|
|
setHistoryActions([])
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
setLoadError(error instanceof Error ? error.message : 'Unable to load this request.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
void load()
|
|
}, [requestId, router])
|
|
|
|
useEffect(() => {
|
|
if (!getToken() || !requestId) return
|
|
let stopped = false
|
|
const refresh = async () => {
|
|
if (document.visibilityState === 'hidden') return
|
|
try {
|
|
const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`)
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
if (!response.ok) return
|
|
const payload = await response.json()
|
|
if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload)
|
|
} catch (error) {
|
|
if (!stopped) console.error(error)
|
|
}
|
|
}
|
|
const timer = window.setInterval(
|
|
() => void refresh(),
|
|
awaitingMediaIndex || repairIsActive ? 5_000 : 15_000,
|
|
)
|
|
return () => {
|
|
stopped = true
|
|
window.clearInterval(timer)
|
|
}
|
|
}, [awaitingMediaIndex, repairIsActive, requestId, router])
|
|
|
|
const liveDownloadKey = useMemo(() => {
|
|
const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download')
|
|
if (!downloadStage?.visible || downloadStage.state !== 'active') return ''
|
|
return (downloadStage.torrents ?? [])
|
|
.filter((torrent) => (torrentProgress(torrent) ?? 100) < 100)
|
|
.map((torrent) => String(torrent.hash ?? torrent.name ?? 'download'))
|
|
.sort()
|
|
.join('|')
|
|
}, [snapshot])
|
|
|
|
useEffect(() => {
|
|
if (!getToken() || !requestId || !liveDownloadKey) return
|
|
let stopped = false
|
|
let timer: number | undefined
|
|
let controller: AbortController | null = null
|
|
const schedule = () => {
|
|
if (!stopped) timer = window.setTimeout(() => void refresh(), 2_000)
|
|
}
|
|
const refresh = async () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
schedule()
|
|
return
|
|
}
|
|
controller = new AbortController()
|
|
try {
|
|
const response = await authFetch(`${getApiBase()}/requests/${requestId}/download-progress`, {
|
|
signal: controller.signal,
|
|
cache: 'no-store',
|
|
})
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
if (response.ok) {
|
|
const payload = await response.json() as LiveDownloadProgress
|
|
if (!stopped && Array.isArray(payload.torrents)) {
|
|
setSnapshot((current) => current ? mergeLiveDownload(current, payload) : current)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!stopped && !(error instanceof DOMException && error.name === 'AbortError')) console.error(error)
|
|
} finally {
|
|
controller = null
|
|
schedule()
|
|
}
|
|
}
|
|
timer = window.setTimeout(() => void refresh(), 750)
|
|
return () => {
|
|
stopped = true
|
|
if (timer !== undefined) window.clearTimeout(timer)
|
|
controller?.abort()
|
|
}
|
|
}, [liveDownloadKey, requestId, router])
|
|
|
|
const actionsById = useMemo(
|
|
() => new Map((snapshot?.actions ?? []).map((action) => [action.id, action])),
|
|
[snapshot?.actions]
|
|
)
|
|
|
|
if (loading) {
|
|
return (
|
|
<main className="card request-detail-page">
|
|
<div className="loading-center" role="status" aria-live="polite">
|
|
<div className="spinner" aria-hidden="true" />
|
|
<div className="loading-text">Building a clear request update…</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (loadError || !snapshot) {
|
|
return (
|
|
<main className="card request-detail-page">
|
|
<section className="request-error-state">
|
|
<span className="section-kicker">Request unavailable</span>
|
|
<h1>We could not load this request</h1>
|
|
<p>{loadError ?? 'The request API did not return a valid status.'}</p>
|
|
<div className="request-error-actions">
|
|
<button type="button" onClick={() => window.location.reload()}>Retry</button>
|
|
<button type="button" className="ghost-button" onClick={() => router.push('/')}>Back to requests</button>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
const presentation = snapshot.presentation ?? {}
|
|
const pipeline = presentation.pipeline?.length ? presentation.pipeline : fallbackPipeline(snapshot)
|
|
const availableStage = pipeline.find((stage) => stage.id === 'available')
|
|
const mediaServerLink = availableStage?.state === 'complete' && availableStage.link
|
|
? availableStage.link
|
|
: null
|
|
const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state)
|
|
const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
|
|
const download = presentation.download
|
|
const repairActivity = presentation.repairActivity
|
|
const downloadVisible = Boolean(download?.visible)
|
|
const nextStep = presentation.nextStep ?? {
|
|
title: snapshot.actions[0]?.label ?? 'No action needed right now',
|
|
description: snapshot.actions.length ? 'Choose an option below to continue.' : 'Magent will keep checking automatically.',
|
|
actionIds: snapshot.actions.slice(0, 2).map((action) => action.id),
|
|
}
|
|
const recommendedActions = (nextStep.actionIds ?? [])
|
|
.map((actionId) => actionsById.get(actionId))
|
|
.filter((action): action is RequestAction => Boolean(action))
|
|
const posterUrl = snapshot.artwork?.poster_url
|
|
const resolvedPoster = posterUrl?.startsWith('http') ? posterUrl : posterUrl ? `${getApiBase()}${posterUrl}` : null
|
|
|
|
const trackedPost = async (label: string, url: string, init: RequestInit = {}) => {
|
|
const operationId = typeof crypto?.randomUUID === 'function'
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
const headers = new Headers(init.headers ?? {})
|
|
headers.set('X-Magent-Operation-ID', operationId)
|
|
headers.set('X-Magent-Operation-Label', label)
|
|
setOperationProgress({
|
|
id: operationId,
|
|
label,
|
|
status: 'running',
|
|
duration_ms: null,
|
|
events: [
|
|
{
|
|
id: 'sending',
|
|
service: 'Magent',
|
|
state: 'active',
|
|
message: 'Sending the action to Magent…',
|
|
},
|
|
],
|
|
})
|
|
|
|
let stopped = false
|
|
const refreshProgress = async () => {
|
|
try {
|
|
const progressResponse = await authFetch(`${getApiBase()}/operations/${operationId}`, {
|
|
cache: 'no-store',
|
|
})
|
|
if (!stopped && progressResponse.ok) {
|
|
const progress = await progressResponse.json()
|
|
if (Array.isArray(progress?.events)) setOperationProgress(progress)
|
|
}
|
|
} catch (error) {
|
|
if (!stopped) console.error(error)
|
|
}
|
|
}
|
|
|
|
const request = authFetch(url, { ...init, method: 'POST', headers })
|
|
const timer = window.setInterval(() => void refreshProgress(), 650)
|
|
try {
|
|
const response = await request
|
|
await refreshProgress()
|
|
return response
|
|
} finally {
|
|
stopped = true
|
|
window.clearInterval(timer)
|
|
}
|
|
}
|
|
|
|
const recheckRequest = async () => {
|
|
setBusyAction('recheck_pipeline')
|
|
setActionError(null)
|
|
setActionMessage(null)
|
|
setReleaseOptions([])
|
|
setReleasePickerOpen(false)
|
|
setReleaseSearchMessage(null)
|
|
try {
|
|
const response = await trackedPost(
|
|
'Recheck request status',
|
|
`${getApiBase()}/requests/${snapshot.request_id}/actions/recheck`,
|
|
)
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(await readApiError(response, 'The request could not be rechecked.'))
|
|
}
|
|
const data = await response.json()
|
|
if (!isSnapshotPayload(data?.snapshot)) {
|
|
throw new Error('The request was checked, but Magent did not return a valid pipeline.')
|
|
}
|
|
setSnapshot(data.snapshot)
|
|
setActionMessage(data?.message ?? 'Request status rebuilt from live service data.')
|
|
} catch (error) {
|
|
console.error(error)
|
|
setActionError(error instanceof Error ? error.message : 'The request could not be rechecked.')
|
|
} finally {
|
|
setBusyAction(null)
|
|
}
|
|
}
|
|
|
|
const runAction = async (action: RequestAction) => {
|
|
if (action.requires_confirmation && !window.confirm(`Run “${action.label}”?`)) return
|
|
const actionPaths: Record<string, string> = {
|
|
search_releases: 'actions/search',
|
|
search_auto: 'actions/search_auto',
|
|
resume_torrent: 'actions/qbit/resume',
|
|
readd_to_arr: 'actions/readd',
|
|
}
|
|
const path = actionPaths[action.id]
|
|
if (!path) {
|
|
setActionError('This action is not connected yet.')
|
|
return
|
|
}
|
|
if (action.id === 'search_releases') {
|
|
setReleaseOptions([])
|
|
setReleaseCollector(snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr')
|
|
setReleaseSearchMessage(null)
|
|
setReleasePickerOpen(true)
|
|
}
|
|
setBusyAction(action.id)
|
|
setActionError(null)
|
|
setActionMessage(null)
|
|
try {
|
|
const response = await trackedPost(
|
|
action.label,
|
|
`${getApiBase()}/requests/${snapshot.request_id}/${path}`
|
|
)
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
if (!response.ok) throw new Error(await readApiError(response, `${action.label} could not be completed.`))
|
|
const data = await response.json()
|
|
if (action.id === 'search_releases') {
|
|
const releases = Array.isArray(data.releases) ? data.releases : []
|
|
setReleaseOptions(releases)
|
|
setReleaseCollector(data?.collector ?? (snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'))
|
|
setReleaseSearchMessage(
|
|
data?.message ??
|
|
(
|
|
releases.length
|
|
? `Found ${releases.length} approved release${releases.length === 1 ? '' : 's'}.`
|
|
: 'No releases currently meet the assigned quality profile.'
|
|
)
|
|
)
|
|
setActionMessage(null)
|
|
} else {
|
|
setActionMessage(data?.message ?? `${action.label} was started successfully.`)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
setActionError(error instanceof Error ? error.message : `${action.label} could not be completed.`)
|
|
} finally {
|
|
setBusyAction(null)
|
|
}
|
|
}
|
|
|
|
const downloadRelease = async (release: ReleaseOption) => {
|
|
if (!release.guid || !release.indexerId) {
|
|
setActionError('This release is missing the details needed to start it.')
|
|
return
|
|
}
|
|
const collector = snapshot.request_type === 'tv' ? 'Sonarr' : 'Radarr'
|
|
setBusyAction(`grab:${release.guid}`)
|
|
setActionError(null)
|
|
try {
|
|
const response = await trackedPost(
|
|
`Send release through ${collector}`,
|
|
`${getApiBase()}/requests/${snapshot.request_id}/actions/grab`, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(release),
|
|
})
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
if (!response.ok) throw new Error(await readApiError(response, 'The selected release could not be started.'))
|
|
const data = await response.json()
|
|
setActionMessage(data?.message ?? 'The selected release was queued for download.')
|
|
setReleaseOptions([])
|
|
setReleasePickerOpen(false)
|
|
setReleaseSearchMessage(null)
|
|
} catch (error) {
|
|
console.error(error)
|
|
setActionError(error instanceof Error ? error.message : 'The selected release could not be started.')
|
|
} finally {
|
|
setBusyAction(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="card request-detail-page">
|
|
<div className="request-header">
|
|
<div className="request-header-main">
|
|
{resolvedPoster && (
|
|
<Image className="request-poster" src={resolvedPoster} alt={`${snapshot.title} poster`} width={90} height={135} sizes="90px" unoptimized />
|
|
)}
|
|
<div>
|
|
<span className="section-kicker">Request #{snapshot.request_id}</span>
|
|
<h1>{snapshot.title}</h1>
|
|
<div className="meta">{snapshot.request_type.toUpperCase()} {snapshot.year ?? ''}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<strong>{statusLabel}</strong>
|
|
</div>
|
|
<div className="request-overview-block">
|
|
<span className="request-overview-label">What this means</span>
|
|
<p>{statusMeaning}</p>
|
|
</div>
|
|
{downloadVisible && (
|
|
<div className="request-overview-block">
|
|
<span className="request-overview-label">Current download state</span>
|
|
<strong>{download?.summary ?? 'A download attempt has been observed.'}</strong>
|
|
{download?.lastSeenAt && !download?.torrents?.length && <small>Last observed {formatWhen(download.lastSeenAt)}</small>}
|
|
</div>
|
|
)}
|
|
<div className="request-overview-block request-next-step">
|
|
<div className="request-next-step-main">
|
|
<div className="request-next-step-copy">
|
|
<span className="request-overview-label">Next step</span>
|
|
<strong>{nextStep.title}</strong>
|
|
<p>{nextStep.description}</p>
|
|
</div>
|
|
{mediaServerLink && (
|
|
<a
|
|
className="request-watch-button"
|
|
href={mediaServerLink}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Watch on Grizzlyflix <span aria-hidden="true">→</span>
|
|
</a>
|
|
)}
|
|
</div>
|
|
<div className="request-action-row">
|
|
{recommendedActions.map((action) => (
|
|
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>
|
|
{busyAction === action.id ? 'Working…' : action.label}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="request-recheck-button"
|
|
disabled={Boolean(busyAction)}
|
|
onClick={() => void recheckRequest()}
|
|
title="Recheck Seerr, the library collector, qBittorrent, and the media server"
|
|
>
|
|
{busyAction === 'recheck_pipeline' ? 'Rechecking…' : 'Recheck request'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{(actionMessage || actionError) && (
|
|
<div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status">
|
|
{actionError ?? actionMessage}
|
|
</div>
|
|
)}
|
|
{operationProgress && (
|
|
<div className={`request-operation-progress is-${operationProgress.status}`} aria-live="polite">
|
|
<div className="request-operation-heading">
|
|
<div>
|
|
<span className="request-overview-label">Remote activity</span>
|
|
<strong>{operationProgress.label}</strong>
|
|
</div>
|
|
<div className="request-operation-heading-actions">
|
|
{formatDuration(operationProgress.duration_ms) && (
|
|
<span>{formatDuration(operationProgress.duration_ms)}</span>
|
|
)}
|
|
<span className={`request-operation-status is-${operationProgress.status}`}>
|
|
{operationProgress.status === 'running' ? 'In progress' : operationProgress.status}
|
|
</span>
|
|
{operationProgress.status !== 'running' && (
|
|
<button type="button" onClick={() => setOperationProgress(null)}>Dismiss</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="request-operation-events">
|
|
{operationProgress.events.map((event) => (
|
|
<div className={`request-operation-event is-${event.state}`} key={event.id}>
|
|
<i aria-hidden="true" />
|
|
<div>
|
|
<strong>{event.service}</strong>
|
|
<span>{event.message}</span>
|
|
</div>
|
|
<small>
|
|
{event.state === 'active'
|
|
? 'Waiting…'
|
|
: formatDuration(event.duration_ms) ?? (event.status_code ? `HTTP ${event.status_code}` : 'Done')}
|
|
</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{repairActivity?.visible && (
|
|
<section className={`request-repair-activity is-${repairActivity.state ?? 'searching'}`} aria-live="polite">
|
|
<div className="request-repair-heading">
|
|
<div>
|
|
<span className="section-kicker">Repair activity</span>
|
|
<h2>{repairActivity.headline ?? 'Repair in progress'}</h2>
|
|
<p>{repairActivity.message ?? 'Magent is checking the repair with the connected services.'}</p>
|
|
</div>
|
|
<div className="request-repair-meta">
|
|
<span className={`request-operation-status is-${repairActivity.state === 'attention' ? 'error' : repairActivity.state === 'complete' ? 'complete' : 'running'}`}>
|
|
{repairActivity.state === 'complete' ? 'Complete' : repairActivity.state === 'attention' ? 'Attention' : 'Live'}
|
|
</span>
|
|
{repairActivity.updatedAt && <small>Updated {formatWhen(repairActivity.updatedAt)}</small>}
|
|
</div>
|
|
</div>
|
|
<div className="request-repair-steps">
|
|
{(repairActivity.steps ?? []).map((step) => (
|
|
<div className={`request-repair-step is-${step.state}`} key={step.id}>
|
|
<i aria-hidden="true" />
|
|
<div>
|
|
<strong>{step.label}</strong>
|
|
<span>{step.detail}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<section className="request-journey" aria-labelledby="request-journey-heading">
|
|
<div className="request-journey-heading">
|
|
<div>
|
|
<span className="section-kicker">Live collection path</span>
|
|
<h2 id="request-journey-heading">Where your request is now</h2>
|
|
</div>
|
|
<span className="request-live-indicator"><i />Live status</span>
|
|
</div>
|
|
|
|
<div className="request-stage-grid">
|
|
{pipeline.map((stage, index) => {
|
|
const stageActions = (stage.actionIds ?? [])
|
|
.map((actionId) => actionsById.get(actionId))
|
|
.filter((action): action is RequestAction => Boolean(action))
|
|
const content = (
|
|
<>
|
|
<div className="request-stage-topline">
|
|
<span className="request-stage-number">{String(index + 1).padStart(2, '0')}</span>
|
|
<span className={`request-stage-state state-${stage.state}`}>{stage.stateLabel ?? stage.state}</span>
|
|
</div>
|
|
<h3>{stage.label}</h3>
|
|
<p>{stage.summary}</p>
|
|
|
|
{stage.id === 'library' && Boolean(stage.total) && (
|
|
<div className="request-availability-meter">
|
|
<div className="request-meter-copy"><span>{stage.available ?? 0} collected</span><span>{stage.missing ?? 0} missing</span></div>
|
|
<div
|
|
className="request-meter-track"
|
|
role="progressbar"
|
|
aria-label="Collection progress"
|
|
aria-valuemin={0}
|
|
aria-valuemax={stage.total ?? 0}
|
|
aria-valuenow={stage.available ?? 0}
|
|
>
|
|
<span style={{ width: `${Math.round(((stage.available ?? 0) / Math.max(stage.total ?? 1, 1)) * 100)}%` }} />
|
|
</div>
|
|
{stage.seasons?.map((season) => (
|
|
<div className="request-season-row" key={season.seasonNumber}><span>Season {season.seasonNumber}</span><span>{season.available} collected · {season.missing} missing</span></div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{stage.id === 'library' && stage.missingEpisodes && Object.keys(stage.missingEpisodes).length > 0 && (
|
|
<div className="request-missing-list">
|
|
{Object.entries(stage.missingEpisodes).map(([season, episodes]) => (
|
|
<div key={season}><span>Missing from season {season}</span><strong>{episodes.map((episode) => `E${episode}`).join(', ')}</strong></div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{stage.id === 'download' && stage.visible && stage.torrents?.map((torrent) => {
|
|
const progress = torrentProgress(torrent)
|
|
return (
|
|
<div className="request-torrent" key={torrent.hash ?? torrent.name}>
|
|
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : formatProgress(progress)}</span></div>
|
|
{progress !== null && (
|
|
<div
|
|
className="request-meter-track is-live-download"
|
|
role="progressbar"
|
|
aria-label={`${torrent.name ?? 'Download'} progress`}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={progress}
|
|
>
|
|
<span style={{ width: `${progress}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{stageActions.length > 0 && (
|
|
<div className="request-stage-actions">
|
|
{stageActions.map((action) => (
|
|
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>{busyAction === action.id ? 'Working…' : action.label}</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
return stage.id === 'available' && stage.link ? (
|
|
<a className={`request-stage stage-${stage.state} is-link`} href={stage.link} target="_blank" rel="noreferrer" key={stage.id}>{content}<span className="request-stage-link">Open on media server →</span></a>
|
|
) : (
|
|
<article className={`request-stage stage-${stage.state}`} key={stage.id}>{content}</article>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
</section>
|
|
|
|
{releasePickerOpen && (
|
|
<div className="request-release-modal-layer">
|
|
<button
|
|
type="button"
|
|
className="request-release-modal-backdrop"
|
|
aria-label="Close available releases"
|
|
onClick={closeReleasePicker}
|
|
/>
|
|
<section
|
|
className="request-release-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="release-picker-title"
|
|
>
|
|
<header className="request-release-modal-header">
|
|
<div>
|
|
<span className="section-kicker">Approved by {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>
|
|
</div>
|
|
<button type="button" className="ghost-button" onClick={closeReleasePicker} disabled={busyAction?.startsWith('grab:')}>Close</button>
|
|
</header>
|
|
|
|
<div className="request-release-modal-body">
|
|
{busyAction === 'search_releases' && (
|
|
<div className="request-release-searching" role="status">
|
|
<i aria-hidden="true" />
|
|
<div>
|
|
<strong>Checking available releases</strong>
|
|
<span>{releaseCollector ?? 'The collector'} is applying its quality profile and ranking the results.</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{busyAction !== 'search_releases' && releaseSearchMessage && (
|
|
<div className="request-release-profile-note">
|
|
<strong>Quality limits applied</strong>
|
|
<span>{releaseSearchMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{busyAction !== 'search_releases' && actionError && (
|
|
<div className="request-release-empty is-error" role="alert">
|
|
<strong>The release search could not be completed</strong>
|
|
<span>{actionError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
</div>
|
|
)}
|
|
|
|
{releaseOptions.length > 0 && (
|
|
<div className="request-release-list">
|
|
{releaseOptions.map((release, index) => {
|
|
const isBestPick = release.bestPick || index === 0
|
|
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.quality && <span>{release.quality}</span>}
|
|
{release.fullSeason && <span>Season {release.seasonNumber ?? ''} pack</span>}
|
|
</div>
|
|
<strong>{release.title ?? 'Unknown release'}</strong>
|
|
<span>
|
|
{release.indexer ?? 'Unknown indexer'} · {release.seeders ?? 0} seeders · {formatBytes(release.size)}
|
|
{typeof release.customFormatScore === 'number' ? ` · Score ${release.customFormatScore}` : ''}
|
|
</span>
|
|
{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}
|
|
onClick={() => void downloadRelease(release)}
|
|
>
|
|
{busyAction === `grab:${release.guid}`
|
|
? 'Sending…'
|
|
: isBestPick
|
|
? 'Download best pick'
|
|
: 'Download this release'}
|
|
</button>
|
|
</article>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{isAdmin && <section className="request-advanced">
|
|
<button type="button" className="request-advanced-toggle" aria-expanded={showDetails} onClick={() => setShowDetails((current) => !current)}>
|
|
<span><strong>Advanced details</strong><small>Service diagnostics, status history and recorded actions</small></span>
|
|
<span>{showDetails ? 'Hide' : 'Show'}</span>
|
|
</button>
|
|
{showDetails && (
|
|
<div className="request-advanced-content">
|
|
<div className="request-diagnostics-grid">
|
|
{snapshot.timeline.map((hop, index) => (
|
|
<article className="request-diagnostic" key={`${hop.service}-${index}`}>
|
|
<div><strong>{hop.service}</strong><span>{hop.status}</span></div>
|
|
{hop.details && <pre>{JSON.stringify(hop.details, null, 2)}</pre>}
|
|
</article>
|
|
))}
|
|
</div>
|
|
<div className="history-grid">
|
|
<div className="summary-card">
|
|
<h3>Status changes</h3>
|
|
<ul>
|
|
{historySnapshots.length === 0 ? <li>No distinct status changes recorded yet.</li> : historySnapshots.map((entry) => (
|
|
<li key={`${entry.created_at}-${entry.state}`}><span>{fallbackStatusLabel(entry.state)}</span><small>{entry.state_reason ?? 'No additional detail.'} · {formatWhen(entry.created_at)}</small></li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className="summary-card">
|
|
<h3>Recorded actions</h3>
|
|
<ul>
|
|
{historyActions.length === 0 ? <li>No actions have been run for this request.</li> : historyActions.map((entry) => (
|
|
<li key={`${entry.created_at}-${entry.action_id}`}><span>{entry.label}</span><small>{entry.message ?? entry.status} · {formatWhen(entry.created_at)}</small></li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>}
|
|
</main>
|
|
)
|
|
}
|