Compare commits
1 Commits
3989e90a9a
...
dev-1.4
| Author | SHA1 | Date | |
|---|---|---|---|
| f830fc1296 |
@@ -1 +1 @@
|
|||||||
0803262038
|
0803262216
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -34,6 +34,24 @@ class JellyseerrClient(ApiClient):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def create_request(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
media_type: str,
|
||||||
|
media_id: int,
|
||||||
|
seasons: Optional[list[int]] = None,
|
||||||
|
is_4k: Optional[bool] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"mediaType": media_type,
|
||||||
|
"mediaId": media_id,
|
||||||
|
}
|
||||||
|
if isinstance(seasons, list) and seasons:
|
||||||
|
payload["seasons"] = seasons
|
||||||
|
if isinstance(is_4k, bool):
|
||||||
|
payload["is4k"] = is_4k
|
||||||
|
return await self.post("/api/v1/request", payload=payload)
|
||||||
|
|
||||||
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get(
|
return await self.get(
|
||||||
"/api/v1/user",
|
"/api/v1/user",
|
||||||
|
|||||||
@@ -421,6 +421,34 @@ def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Option
|
|||||||
return tmdb_id, media_type
|
return tmdb_id, media_type
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_media_type(value: Any) -> Optional[str]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized in {"movie", "tv"}:
|
||||||
|
return normalized
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_seasons(value: Any) -> list[int]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise HTTPException(status_code=400, detail="seasons must be an array of positive integers")
|
||||||
|
normalized: list[int] = []
|
||||||
|
for raw in value:
|
||||||
|
try:
|
||||||
|
season = int(raw)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="seasons must contain only positive integers"
|
||||||
|
) from exc
|
||||||
|
if season <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="seasons must contain only positive integers")
|
||||||
|
normalized.append(season)
|
||||||
|
return sorted(set(normalized))
|
||||||
|
|
||||||
|
|
||||||
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
|
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
|
||||||
poster_path, backdrop_path = _extract_artwork_paths(payload)
|
poster_path, backdrop_path = _extract_artwork_paths(payload)
|
||||||
tmdb_id, media_type = _extract_tmdb_lookup(payload)
|
tmdb_id, media_type = _extract_tmdb_lookup(payload)
|
||||||
@@ -1864,12 +1892,135 @@ async def search_requests(
|
|||||||
"statusLabel": status_label,
|
"statusLabel": status_label,
|
||||||
"requestedBy": requested_by,
|
"requestedBy": requested_by,
|
||||||
"accessible": accessible,
|
"accessible": accessible,
|
||||||
|
"posterPath": item.get("posterPath") or item.get("poster_path"),
|
||||||
|
"backdropPath": item.get("backdropPath") or item.get("backdrop_path"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"results": results}
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def create_request(
|
||||||
|
payload: Dict[str, Any], user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
|
if not client.configured():
|
||||||
|
raise HTTPException(status_code=400, detail="Seerr not configured")
|
||||||
|
|
||||||
|
media_type = _normalize_media_type(
|
||||||
|
payload.get("mediaType") or payload.get("type") or payload.get("media_type")
|
||||||
|
)
|
||||||
|
if media_type is None:
|
||||||
|
raise HTTPException(status_code=400, detail="mediaType must be 'movie' or 'tv'")
|
||||||
|
|
||||||
|
raw_tmdb_id = payload.get("tmdbId")
|
||||||
|
if raw_tmdb_id is None:
|
||||||
|
raw_tmdb_id = payload.get("mediaId")
|
||||||
|
if raw_tmdb_id is None:
|
||||||
|
raw_tmdb_id = payload.get("id")
|
||||||
|
try:
|
||||||
|
tmdb_id = int(raw_tmdb_id)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="tmdbId must be a valid integer") from exc
|
||||||
|
if tmdb_id <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="tmdbId must be a positive integer")
|
||||||
|
|
||||||
|
seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else []
|
||||||
|
raw_is_4k = payload.get("is4k")
|
||||||
|
if raw_is_4k is not None and not isinstance(raw_is_4k, bool):
|
||||||
|
raise HTTPException(status_code=400, detail="is4k must be true or false")
|
||||||
|
is_4k = raw_is_4k if isinstance(raw_is_4k, bool) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
details = await (client.get_movie(tmdb_id) if media_type == "movie" else client.get_tv(tmdb_id))
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
|
||||||
|
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
raise HTTPException(status_code=502, detail="Invalid response from Seerr media lookup")
|
||||||
|
|
||||||
|
media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
|
||||||
|
requests_list = media_info.get("requests")
|
||||||
|
existing_request: Optional[Dict[str, Any]] = None
|
||||||
|
if isinstance(requests_list, list) and requests_list:
|
||||||
|
first_request = requests_list[0]
|
||||||
|
if isinstance(first_request, dict):
|
||||||
|
existing_request = first_request
|
||||||
|
|
||||||
|
title = details.get("title") or details.get("name")
|
||||||
|
year: Optional[int] = None
|
||||||
|
date_value = details.get("releaseDate") or details.get("firstAirDate")
|
||||||
|
if isinstance(date_value, str) and len(date_value) >= 4 and date_value[:4].isdigit():
|
||||||
|
year = int(date_value[:4])
|
||||||
|
|
||||||
|
if isinstance(existing_request, dict):
|
||||||
|
existing_request_id = _quality_profile_id(existing_request.get("id"))
|
||||||
|
existing_status = existing_request.get("status")
|
||||||
|
if existing_request_id is not None:
|
||||||
|
request_payload = await _get_request_details(client, existing_request_id)
|
||||||
|
if isinstance(request_payload, dict):
|
||||||
|
parsed_payload = _parse_request_payload(request_payload)
|
||||||
|
upsert_request_cache(**_build_request_cache_record(parsed_payload, request_payload))
|
||||||
|
_cache_set(f"request:{existing_request_id}", request_payload)
|
||||||
|
title = parsed_payload.get("title") or title
|
||||||
|
year = parsed_payload.get("year") or year
|
||||||
|
return {
|
||||||
|
"status": "exists",
|
||||||
|
"requestId": existing_request_id,
|
||||||
|
"type": media_type,
|
||||||
|
"tmdbId": tmdb_id,
|
||||||
|
"title": title,
|
||||||
|
"year": year,
|
||||||
|
"statusCode": existing_status,
|
||||||
|
"statusLabel": _status_label(existing_status),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = await client.create_request(
|
||||||
|
media_type=media_type,
|
||||||
|
media_id=tmdb_id,
|
||||||
|
seasons=seasons if media_type == "tv" else None,
|
||||||
|
is_4k=is_4k,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
|
||||||
|
|
||||||
|
if not isinstance(created, dict):
|
||||||
|
raise HTTPException(status_code=502, detail="Invalid response from Seerr request create")
|
||||||
|
|
||||||
|
parsed = _parse_request_payload(created)
|
||||||
|
request_id = _quality_profile_id(parsed.get("request_id"))
|
||||||
|
status_code = parsed.get("status")
|
||||||
|
title = parsed.get("title") or title
|
||||||
|
year = parsed.get("year") or year
|
||||||
|
|
||||||
|
if request_id is not None:
|
||||||
|
upsert_request_cache(**_build_request_cache_record(parsed, created))
|
||||||
|
_cache_set(f"request:{request_id}", created)
|
||||||
|
_recent_cache["updated_at"] = None
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_action,
|
||||||
|
str(request_id),
|
||||||
|
"request_created",
|
||||||
|
"Create request",
|
||||||
|
"ok",
|
||||||
|
f"{media_type} request created from discovery by {user.get('username')}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "created",
|
||||||
|
"requestId": request_id,
|
||||||
|
"type": media_type,
|
||||||
|
"tmdbId": tmdb_id,
|
||||||
|
"title": title,
|
||||||
|
"year": year,
|
||||||
|
"statusCode": status_code,
|
||||||
|
"statusLabel": _status_label(status_code),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/ai/triage", response_model=TriageResult)
|
@router.post("/{request_id}/ai/triage", response_model=TriageResult)
|
||||||
async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult:
|
async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
|
|||||||
@@ -6597,6 +6597,86 @@ textarea {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-discovery-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 140px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-form input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-media {
|
||||||
|
width: 56px;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-media img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-title-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-main p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.portal-form-grid {
|
.portal-form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -6756,6 +6836,15 @@ textarea {
|
|||||||
.portal-item-list {
|
.portal-item-list {
|
||||||
max-height: 460px;
|
max-height: 460px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-discovery-item {
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-actions {
|
||||||
|
grid-column: span 2;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
@@ -6776,4 +6865,22 @@ textarea {
|
|||||||
.portal-mine-toggle {
|
.portal-mine-toggle {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-discovery-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-media {
|
||||||
|
width: 72px;
|
||||||
|
height: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-discovery-actions {
|
||||||
|
grid-column: span 1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ type UserProfile = {
|
|||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DiscoveryResult = {
|
||||||
|
title: string
|
||||||
|
year?: number | null
|
||||||
|
type?: 'movie' | 'tv' | null
|
||||||
|
tmdbId?: number | null
|
||||||
|
requestId?: number | null
|
||||||
|
statusLabel?: string | null
|
||||||
|
status?: number | null
|
||||||
|
accessible?: boolean
|
||||||
|
posterPath?: string | null
|
||||||
|
backdropPath?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const KIND_OPTIONS = [
|
const KIND_OPTIONS = [
|
||||||
{ value: 'request', label: 'Request' },
|
{ value: 'request', label: 'Request' },
|
||||||
{ value: 'issue', label: 'Issue' },
|
{ value: 'issue', label: 'Issue' },
|
||||||
@@ -176,6 +189,11 @@ export default function PortalPage() {
|
|||||||
const [commentText, setCommentText] = useState('')
|
const [commentText, setCommentText] = useState('')
|
||||||
const [commentInternal, setCommentInternal] = useState(false)
|
const [commentInternal, setCommentInternal] = useState(false)
|
||||||
const [preselectedItemId, setPreselectedItemId] = useState<number | null>(null)
|
const [preselectedItemId, setPreselectedItemId] = useState<number | null>(null)
|
||||||
|
const [discoverQuery, setDiscoverQuery] = useState('')
|
||||||
|
const [discoverLoading, setDiscoverLoading] = useState(false)
|
||||||
|
const [discoverResults, setDiscoverResults] = useState<DiscoveryResult[]>([])
|
||||||
|
const [discoverError, setDiscoverError] = useState<string | null>(null)
|
||||||
|
const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const isAdmin = me?.role === 'admin'
|
const isAdmin = me?.role === 'admin'
|
||||||
|
|
||||||
@@ -306,6 +324,123 @@ export default function PortalPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveTmdbArtworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w185') => {
|
||||||
|
if (!path) return null
|
||||||
|
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `https://image.tmdb.org/t/p/${size}${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const runDiscoverySearch = async (event?: React.FormEvent) => {
|
||||||
|
if (event) event.preventDefault()
|
||||||
|
const query = discoverQuery.trim()
|
||||||
|
if (!query) {
|
||||||
|
setDiscoverResults([])
|
||||||
|
setDiscoverError('Enter a title to search.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDiscoverLoading(true)
|
||||||
|
setDiscoverError(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(query)}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || `Search failed (${response.status})`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const mapped: DiscoveryResult[] = Array.isArray(data?.results)
|
||||||
|
? data.results.map((item: any) => ({
|
||||||
|
title: item?.title ?? 'Untitled',
|
||||||
|
year: typeof item?.year === 'number' ? item.year : null,
|
||||||
|
type: item?.type === 'movie' || item?.type === 'tv' ? item.type : null,
|
||||||
|
tmdbId: typeof item?.tmdbId === 'number' ? item.tmdbId : null,
|
||||||
|
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
|
||||||
|
statusLabel: item?.statusLabel ?? null,
|
||||||
|
status: typeof item?.status === 'number' ? item.status : null,
|
||||||
|
accessible: Boolean(item?.accessible),
|
||||||
|
posterPath: item?.posterPath ?? null,
|
||||||
|
backdropPath: item?.backdropPath ?? null,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
setDiscoverResults(mapped)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setDiscoverResults([])
|
||||||
|
setDiscoverError(err instanceof Error ? err.message : 'Search failed.')
|
||||||
|
} finally {
|
||||||
|
setDiscoverLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestDiscoveryItem = async (item: DiscoveryResult) => {
|
||||||
|
if (!item.tmdbId || !item.type) {
|
||||||
|
setError('Could not request this result because required media details are missing.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = `${item.type}:${item.tmdbId}`
|
||||||
|
setRequestingTmdbIds((prev) => ({ ...prev, [key]: true }))
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/requests/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
mediaType: item.type,
|
||||||
|
tmdbId: item.tmdbId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || `Request failed (${response.status})`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const requestId = typeof data?.requestId === 'number' ? data.requestId : null
|
||||||
|
const statusLabel = typeof data?.statusLabel === 'string' ? data.statusLabel : item.statusLabel
|
||||||
|
const statusCode = typeof data?.statusCode === 'number' ? data.statusCode : item.status
|
||||||
|
setDiscoverResults((prev) =>
|
||||||
|
prev.map((entry) =>
|
||||||
|
entry.tmdbId === item.tmdbId && entry.type === item.type
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
requestId,
|
||||||
|
statusLabel,
|
||||||
|
status: statusCode,
|
||||||
|
accessible: true,
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (requestId) {
|
||||||
|
const mode = data?.status === 'exists' ? 'already exists' : 'created'
|
||||||
|
setStatus(`Request ${mode}. Open request #${requestId} for the full pipeline.`)
|
||||||
|
} else {
|
||||||
|
setStatus('Request submitted.')
|
||||||
|
}
|
||||||
|
await Promise.all([loadItems(), loadOverview()])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not create request.')
|
||||||
|
} finally {
|
||||||
|
setRequestingTmdbIds((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -522,6 +657,85 @@ export default function PortalPage() {
|
|||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{status && <div className="status-banner">{status}</div>}
|
{status && <div className="status-banner">{status}</div>}
|
||||||
|
|
||||||
|
<section className="admin-panel portal-discovery-panel">
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Search and request content</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Search Seerr content directly, then submit a request in one click.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="portal-discovery-form" onSubmit={runDiscoverySearch}>
|
||||||
|
<input
|
||||||
|
value={discoverQuery}
|
||||||
|
onChange={(event) => setDiscoverQuery(event.target.value)}
|
||||||
|
placeholder="Search movies or TV shows"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={discoverLoading}>
|
||||||
|
{discoverLoading ? 'Searching…' : 'Search'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{discoverError && <div className="error-banner">{discoverError}</div>}
|
||||||
|
<div className="portal-discovery-results">
|
||||||
|
{discoverLoading ? (
|
||||||
|
<div className="status-banner">Searching Seerr…</div>
|
||||||
|
) : discoverResults.length === 0 ? (
|
||||||
|
<div className="status-banner">No discovery results yet.</div>
|
||||||
|
) : (
|
||||||
|
discoverResults.map((item, index) => {
|
||||||
|
const key = `${item.type ?? 'unknown'}:${item.tmdbId ?? index}`
|
||||||
|
const requesting = Boolean(requestingTmdbIds[key])
|
||||||
|
const poster = resolveTmdbArtworkUrl(item.posterPath, 'w185')
|
||||||
|
const hasRequest = typeof item.requestId === 'number' && item.requestId > 0
|
||||||
|
return (
|
||||||
|
<div key={key} className="portal-discovery-item">
|
||||||
|
<div className="portal-discovery-media">
|
||||||
|
{poster ? <img src={poster} alt="" loading="lazy" /> : <div className="poster-fallback">No artwork</div>}
|
||||||
|
</div>
|
||||||
|
<div className="portal-discovery-main">
|
||||||
|
<div className="portal-discovery-title-row">
|
||||||
|
<strong>{item.title || 'Untitled'}</strong>
|
||||||
|
<span className="small-pill">{item.type ?? 'unknown'}</span>
|
||||||
|
{item.year ? <span className="small-pill is-muted">{item.year}</span> : null}
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{hasRequest ? (
|
||||||
|
<>
|
||||||
|
Already requested
|
||||||
|
{item.statusLabel ? ` · ${item.statusLabel}` : ''}
|
||||||
|
{item.requestId ? ` · #${item.requestId}` : ''}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Not requested yet'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="portal-discovery-actions">
|
||||||
|
{hasRequest ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/requests/${item.requestId}`)}
|
||||||
|
>
|
||||||
|
Open request
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void requestDiscoveryItem(item)}
|
||||||
|
disabled={requesting || !item.tmdbId || !item.type}
|
||||||
|
>
|
||||||
|
{requesting ? 'Requesting…' : 'Request'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="portal-overview-grid">
|
<section className="portal-overview-grid">
|
||||||
<div className="portal-overview-card">
|
<div className="portal-overview-card">
|
||||||
<span>Total items</span>
|
<span>Total items</span>
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0803262038",
|
"version": "0803262216",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0803262038",
|
"version": "0803262216",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0803262038",
|
"version": "0803262216",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
Reference in New Issue
Block a user