mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
⭐️ feat: handle missing or unreachable services
Jellyfin is required: when it is not set up or unreachable, show a screen saying so with a retry instead of an empty profile list. Sonarr and Radarr are optional, so hide the actions and pages that need them when they are not configured, and keep options and the dashboard loading when one is down. Connection statuses now say whether a service is configured, optional or half filled in. The status dot only turns red for real failures, and Settings marks optional services and shows unconfigured ones in grey.
This commit is contained in:
1 parent
0e44cfb63f
commit
a110c89e08
5 files changed
+254
-131
No files matched your search
+10
-6
@@ -180,16 +180,20 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/profiles' && method === 'GET') {
|
||||
let config: AppConfig;
|
||||
try {
|
||||
const config = await readConfig();
|
||||
const [jellyfinUsers, connections] = await Promise.all([
|
||||
loadJellyfinUsers(config.jellyfin.baseUrl, config.jellyfin.apiKey),
|
||||
getConnectionStatuses(config)
|
||||
]);
|
||||
sendJson(response, 200, { jellyfinUsers, connections });
|
||||
config = await readConfig();
|
||||
} catch {
|
||||
sendJson(response, 409, { error: 'An administrator must sign in once to migrate the existing configuration.' });
|
||||
return;
|
||||
}
|
||||
// A Jellyfin outage is reported through the connection status, so the
|
||||
// client can show it instead of an empty profile list.
|
||||
const [jellyfinUsers, connections] = await Promise.all([
|
||||
loadJellyfinUsers(config.jellyfin.baseUrl, config.jellyfin.apiKey).catch(() => []),
|
||||
getConnectionStatuses(config)
|
||||
]);
|
||||
sendJson(response, 200, { jellyfinUsers, connections });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+52
-105
@@ -435,12 +435,14 @@ export async function getServiceOptions(config: AppConfig): Promise<ServiceOptio
|
||||
radarrRootFolders,
|
||||
radarrQualityProfiles
|
||||
] = await Promise.all([
|
||||
getJellyfinUsers(config),
|
||||
getSonarrRootFolders(config),
|
||||
getSonarrQualityProfiles(config),
|
||||
getSonarrLanguageProfiles(config),
|
||||
getRadarrRootFolders(config),
|
||||
getRadarrQualityProfiles(config)
|
||||
// A service that is down only empties its own options; the connection
|
||||
// status reports the failure.
|
||||
getJellyfinUsers(config).catch(() => []),
|
||||
getSonarrRootFolders(config).catch(() => []),
|
||||
getSonarrQualityProfiles(config).catch(() => []),
|
||||
getSonarrLanguageProfiles(config).catch(() => []),
|
||||
getRadarrRootFolders(config).catch(() => []),
|
||||
getRadarrQualityProfiles(config).catch(() => [])
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -558,7 +560,9 @@ export async function authenticateJellyfinUser(config: AppConfig, userId: string
|
||||
export async function getDashboard(config: AppConfig): Promise<DashboardResponse> {
|
||||
const [connections, library, downloads, tmdb] = await Promise.all([
|
||||
getConnectionStatuses(config),
|
||||
getLibrarySnapshot(config),
|
||||
// If Jellyfin is down the client shows its unavailable screen from the
|
||||
// connection status, so don't fail the whole dashboard here.
|
||||
getLibrarySnapshot(config).catch(() => ({ continueWatching: [], recentlyAdded: [], allItems: [] })),
|
||||
getDownloads(config),
|
||||
getTmdbSnapshot(config)
|
||||
]);
|
||||
@@ -3757,131 +3761,74 @@ async function getRadarrCalendar(config: AppConfig, start: Date, end: Date): Pro
|
||||
)
|
||||
}
|
||||
|
||||
async function testJellyfin(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey) {
|
||||
return {
|
||||
key: 'jellyfin',
|
||||
label: 'Jellyfin',
|
||||
ok: false,
|
||||
message: 'Not configured'
|
||||
};
|
||||
async function testService(
|
||||
key: ConnectionStatus['key'],
|
||||
label: string,
|
||||
optional: boolean,
|
||||
required: Array<[name: string, value: string]>,
|
||||
check: () => Promise<void>
|
||||
): Promise<ConnectionStatus> {
|
||||
const missing = required.filter(([, value]) => !value.trim()).map(([name]) => name);
|
||||
if (missing.length === required.length) {
|
||||
return { key, label, ok: false, message: 'Not configured', configured: false, optional };
|
||||
}
|
||||
if (missing.length) {
|
||||
return { key, label, ok: false, message: `Incomplete: missing ${missing.join(' and ')}`, configured: false, optional, incomplete: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await check();
|
||||
return { key, label, ok: true, message: 'Connected', configured: true, optional };
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
// Node reports network errors as a bare "fetch failed".
|
||||
return { key, label, ok: false, message: message === 'fetch failed' ? `Can't reach ${label}` : message, configured: true, optional };
|
||||
}
|
||||
}
|
||||
|
||||
function testJellyfin(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('jellyfin', 'Jellyfin', false, [['URL', config.jellyfin.baseUrl], ['API key', config.jellyfin.apiKey]], async () => {
|
||||
await fetchJson(`${config.jellyfin.baseUrl}/System/Info`, {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser Token="${config.jellyfin.apiKey}"`
|
||||
}
|
||||
});
|
||||
return { key: 'jellyfin', label: 'Jellyfin', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'jellyfin',
|
||||
label: 'Jellyfin',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function testSonarr(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
|
||||
return {
|
||||
key: 'sonarr',
|
||||
label: 'Sonarr',
|
||||
ok: false,
|
||||
message: 'Not configured'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
function testSonarr(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('sonarr', 'Sonarr', true, [['URL', config.sonarr.baseUrl], ['API key', config.sonarr.apiKey]], async () => {
|
||||
await fetchJson(`${config.sonarr.baseUrl}/api/v3/health`, {
|
||||
headers: {
|
||||
'X-Api-Key': config.sonarr.apiKey
|
||||
}
|
||||
});
|
||||
return { key: 'sonarr', label: 'Sonarr', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'sonarr',
|
||||
label: 'Sonarr',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function testRadarr(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
|
||||
return {
|
||||
key: 'radarr',
|
||||
label: 'Radarr',
|
||||
ok: false,
|
||||
message: 'Not configured'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
function testRadarr(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('radarr', 'Radarr', true, [['URL', config.radarr.baseUrl], ['API key', config.radarr.apiKey]], async () => {
|
||||
await fetchJson(`${config.radarr.baseUrl}/api/v3/health`, {
|
||||
headers: {
|
||||
'X-Api-Key': config.radarr.apiKey
|
||||
}
|
||||
});
|
||||
return { key: 'radarr', label: 'Radarr', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'radarr',
|
||||
label: 'Radarr',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function testTmdb(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!getTmdbToken(config)) {
|
||||
return {
|
||||
key: 'tmdb',
|
||||
label: 'TMDb',
|
||||
ok: false,
|
||||
message: 'Missing token'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await tmdbFetch(config, '/configuration');
|
||||
return { key: 'tmdb', label: 'TMDb', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'tmdb',
|
||||
label: 'TMDb',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
function testTmdb(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('tmdb', 'TMDb', true, [['token', getTmdbToken(config)]], async () => {
|
||||
// tmdbFetch swallows errors and returns null, so treat that as a failure.
|
||||
if (!await tmdbFetch(config, '/configuration')) {
|
||||
throw new Error('TMDb rejected the token or is unreachable');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function testTvdb(config: AppConfig): Promise<ConnectionStatus> {
|
||||
if (!getTvdbApiKey(config)) {
|
||||
return {
|
||||
key: 'tvdb',
|
||||
label: 'TVDB',
|
||||
ok: false,
|
||||
message: 'Missing API key'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
function testTvdb(config: AppConfig): Promise<ConnectionStatus> {
|
||||
return testService('tvdb', 'TVDB', true, [['API key', getTvdbApiKey(config)]], async () => {
|
||||
await getTvdbToken(config);
|
||||
return { key: 'tvdb', label: 'TVDB', ok: true, message: 'Connected' };
|
||||
} catch (error) {
|
||||
return {
|
||||
key: 'tvdb',
|
||||
label: 'TVDB',
|
||||
ok: false,
|
||||
message: errorMessage(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function jellyfinFetch<T>(config: AppConfig, pathWithQuery: string): Promise<T> {
|
||||
|
||||
@@ -86,6 +86,12 @@ export interface ConnectionStatus {
|
||||
label: string;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
/** Every field the service needs is filled in. */
|
||||
configured: boolean;
|
||||
/** The app works without it; only Jellyfin is required. */
|
||||
optional: boolean;
|
||||
/** Some of its fields are filled in but not all of them. */
|
||||
incomplete?: boolean;
|
||||
}
|
||||
|
||||
export interface DiskSpaceStatus {
|
||||
|
||||
+144
-20
@@ -286,6 +286,24 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetryJellyfin() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (authState === 'app') {
|
||||
await refreshBaseData()
|
||||
} else {
|
||||
const profiles = await api.getProfiles()
|
||||
setOptions((current) => ({ ...current, jellyfinUsers: profiles.jellyfinUsers }))
|
||||
setConnections(profiles.connections)
|
||||
}
|
||||
} catch (retryError) {
|
||||
setError(retryError instanceof Error ? retryError.message : 'Failed to reach Jellyfin.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadJellyfinUsers() {
|
||||
setJellyfinUsersBusy(true)
|
||||
setError('')
|
||||
@@ -454,15 +472,31 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
const jellyfinStatus = getServiceStatus(connections, 'jellyfin')
|
||||
const openAdministration = () => setAuthState(sessionRole === 'admin' ? 'admin' : 'login')
|
||||
|
||||
// Jellyfin is required: without it there are no profiles or library to show.
|
||||
if ((authState === 'profiles' || (authState === 'app' && jellyfinStatus?.configured)) && isJellyfinUnavailable(connections)) {
|
||||
return (
|
||||
<JellyfinUnavailableScreen
|
||||
busy={busy}
|
||||
error={error}
|
||||
onAdmin={openAdministration}
|
||||
onRetry={() => void handleRetryJellyfin()}
|
||||
status={jellyfinStatus}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (authState === 'profiles') {
|
||||
return (
|
||||
<ProfileSelectionScreen
|
||||
busy={busy}
|
||||
currentUserId=""
|
||||
hasServiceFailure={connections.some((connection) => !connection.ok)}
|
||||
hasServiceFailure={hasFailingService(connections)}
|
||||
error={error}
|
||||
notice={notice}
|
||||
onAdmin={() => setAuthState(sessionRole === 'admin' ? 'admin' : 'login')}
|
||||
onAdmin={openAdministration}
|
||||
onSelectUser={handleSelectJellyfinUser}
|
||||
users={options.jellyfinUsers}
|
||||
/>
|
||||
@@ -509,10 +543,10 @@ function App() {
|
||||
<ProfileSelectionScreen
|
||||
busy={busy}
|
||||
currentUserId={sessionRole === 'user' ? config.jellyfin.userId : ''}
|
||||
hasServiceFailure={connections.some((connection) => !connection.ok)}
|
||||
hasServiceFailure={hasFailingService(connections)}
|
||||
error={error}
|
||||
notice={notice}
|
||||
onAdmin={() => setAuthState(sessionRole === 'admin' ? 'admin' : 'login')}
|
||||
onAdmin={openAdministration}
|
||||
onContinue={() => void transitionEntryStage('dashboard')}
|
||||
onSelectUser={handleSelectJellyfinUser}
|
||||
users={options.jellyfinUsers}
|
||||
@@ -547,8 +581,14 @@ function App() {
|
||||
{error ? <p className="error-banner">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
{route === 'calendar' ? <CalendarPage onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })} /> : null}
|
||||
{route === 'downloads' ? <DownloadsPage busyId={downloadDeleteId} completedDownloads={completedDownloads} downloads={downloads} onDelete={handleDeleteDownload} onDismissAllCompleted={handleDismissAllCompleted} onDismissCompleted={handleDismissCompleted} processingItems={processingItems} /> : null}
|
||||
{(route === 'calendar' || route === 'downloads') && !isRouteAvailable(route, connections) ? (
|
||||
<EmptyState
|
||||
copy="Connect Sonarr or Radarr in Administration to see upcoming releases and downloads."
|
||||
title="Nothing to track yet"
|
||||
/>
|
||||
) : null}
|
||||
{route === 'calendar' && isRouteAvailable(route, connections) ? <CalendarPage onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })} /> : null}
|
||||
{route === 'downloads' && isRouteAvailable(route, connections) ? <DownloadsPage busyId={downloadDeleteId} completedDownloads={completedDownloads} downloads={downloads} onDelete={handleDeleteDownload} onDismissAllCompleted={handleDismissAllCompleted} onDismissCompleted={handleDismissCompleted} processingItems={processingItems} /> : null}
|
||||
{route === 'search' ? (
|
||||
<SearchPage
|
||||
loading={searchLoading}
|
||||
@@ -597,6 +637,7 @@ function App() {
|
||||
originRect={selectedCard.originRect}
|
||||
onRequest={handleRequest}
|
||||
currentUser={currentJellyfinUser}
|
||||
services={{ sonarr: isServiceConfigured(connections, 'sonarr'), radarr: isServiceConfigured(connections, 'radarr') }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -622,7 +663,7 @@ function Sidebar(props: {
|
||||
onExitProfile: () => void
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const hasServiceFailure = props.connections.some((connection) => !connection.ok)
|
||||
const hasServiceFailure = hasFailingService(props.connections)
|
||||
|
||||
useEffect(() => {
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
@@ -679,7 +720,7 @@ function Sidebar(props: {
|
||||
</button>
|
||||
|
||||
<div className="sidebar-links">
|
||||
{topSidebarItems.map((item) => (
|
||||
{topSidebarItems.filter((item) => isRouteAvailable(item.route, props.connections)).map((item) => (
|
||||
<button
|
||||
className={item.route === props.route ? 'sidebar-link active' : 'sidebar-link'}
|
||||
key={item.route}
|
||||
@@ -1088,6 +1129,83 @@ function ProfileSelectionScreen(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function getServiceStatus(connections: ConnectionStatus[], key: ConnectionStatus['key']) {
|
||||
return connections.find((connection) => connection.key === key)
|
||||
}
|
||||
|
||||
// Features that depend on Sonarr or Radarr are shown whenever the service is
|
||||
// configured; if it is down, the action itself reports the error.
|
||||
function isServiceConfigured(connections: ConnectionStatus[], key: ConnectionStatus['key']) {
|
||||
return Boolean(getServiceStatus(connections, key)?.configured)
|
||||
}
|
||||
|
||||
// Optional services that aren't set up are not failures; broken or half
|
||||
// filled-in ones are.
|
||||
function hasFailingService(connections: ConnectionStatus[]) {
|
||||
return connections.some((connection) => getConnectionDotState(connection) === 'down')
|
||||
}
|
||||
|
||||
// Upcoming and Downloads only make sense with Sonarr or Radarr behind them.
|
||||
function isRouteAvailable(route: AppRoute, connections: ConnectionStatus[]) {
|
||||
if (route === 'calendar' || route === 'downloads') {
|
||||
return isServiceConfigured(connections, 'sonarr') || isServiceConfigured(connections, 'radarr')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Grey for an optional service nobody set up, red for anything broken.
|
||||
function getConnectionDotState(connection: ConnectionStatus) {
|
||||
if (connection.ok) {
|
||||
return 'ok'
|
||||
}
|
||||
return connection.optional && !connection.configured && !connection.incomplete ? 'off' : 'down'
|
||||
}
|
||||
|
||||
function isJellyfinUnavailable(connections: ConnectionStatus[]) {
|
||||
const jellyfin = getServiceStatus(connections, 'jellyfin')
|
||||
return Boolean(jellyfin && !jellyfin.ok)
|
||||
}
|
||||
|
||||
function JellyfinUnavailableScreen(props: {
|
||||
status?: ConnectionStatus
|
||||
busy: boolean
|
||||
error: string
|
||||
onRetry: () => void
|
||||
onAdmin: () => void
|
||||
}) {
|
||||
const configured = Boolean(props.status?.configured)
|
||||
|
||||
return (
|
||||
<div className="profiles-layout">
|
||||
<div className="profiles-header">
|
||||
<h1>{configured ? 'Jellyfin is unavailable' : 'Jellyfin is not set up'}</h1>
|
||||
{props.error ? <p className="error-banner">{props.error}</p> : null}
|
||||
</div>
|
||||
<div className="jellyfin-unavailable">
|
||||
<p>
|
||||
{configured
|
||||
? 'OmniArr can\'t reach your Jellyfin server, so profiles and your library can\'t load.'
|
||||
: 'OmniArr needs a Jellyfin server to show profiles and your library. An administrator has to connect it.'}
|
||||
</p>
|
||||
{configured && props.status?.message ? <p className="jellyfin-unavailable-detail">{props.status.message}</p> : null}
|
||||
{configured ? (
|
||||
<button className="secondary-button" disabled={props.busy} onClick={props.onRetry} type="button">
|
||||
{props.busy ? 'Checking…' : 'Try again'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="profiles-actions">
|
||||
<button aria-label="Administration" className="profile-admin-button" onClick={props.onAdmin} title="Administration" type="button">
|
||||
<span className="profile-admin-icon">
|
||||
<SettingsGlyph />
|
||||
<span className="profile-admin-status down" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileAvatar({ user }: { user: SelectOption }) {
|
||||
return <div className="profile-avatar">{user.imageUrl ? <img alt="" src={user.imageUrl} /> : user.label.slice(0, 1).toUpperCase()}</div>
|
||||
}
|
||||
@@ -1570,6 +1688,8 @@ function TitleDetailsModal(props: {
|
||||
onRequest: (card: MediaCard, qualityProfileId?: number, seasonNumber?: number) => Promise<void>
|
||||
currentUser: string | null
|
||||
qualityProfiles: { sonarr: SelectOption[]; radarr: SelectOption[] }
|
||||
/** Which download services are configured; their actions are hidden otherwise. */
|
||||
services: { sonarr: boolean; radarr: boolean }
|
||||
}) {
|
||||
const [details, setDetails] = useState<TitleDetailsResponse | null>(null)
|
||||
const [detailsLoading, setDetailsLoading] = useState(true)
|
||||
@@ -1903,7 +2023,7 @@ function TitleDetailsModal(props: {
|
||||
: details?.requestStatus || 'idle'
|
||||
const movieRequestProgress = details?.requestProgress || 0
|
||||
const isMovieRequestActive = details?.mediaType === 'movie' && (movieRequestStatus === 'processing' || movieRequestStatus === 'downloading')
|
||||
const canDeleteMovieAction = Boolean(details?.mediaType === 'movie' && details.radarrMovieId)
|
||||
const canDeleteMovieAction = Boolean(props.services.radarr && details?.mediaType === 'movie' && details.radarrMovieId)
|
||||
const activeSeasonNumber = effectiveActiveSeasonMeta?.seasonNumber
|
||||
const activeSeasonDownloadStatus = effectiveActiveSeasonMeta?.downloadStatus || 'idle'
|
||||
const currentSeasonIndex = details?.seasons.findIndex((season) => season.id === seasonValue) ?? -1
|
||||
@@ -1921,7 +2041,7 @@ function TitleDetailsModal(props: {
|
||||
}
|
||||
: episode)
|
||||
: []
|
||||
const canDownloadSeasonAction = Boolean(
|
||||
const canDownloadSeasonAction = props.services.sonarr && Boolean(
|
||||
activeSeasonDownloadStatus === 'processing'
|
||||
|| activeSeasonDownloadStatus === 'downloading'
|
||||
|| (details?.sonarrSeriesId && typeof activeSeasonNumber === 'number' && activeSeasonNumber > 0)
|
||||
@@ -1932,7 +2052,7 @@ function TitleDetailsModal(props: {
|
||||
const isSeasonFullyDownloadedExceptUnaired = episodesForSeason.length > 0 && episodesForSeason.every(
|
||||
(episode) => episode.availability === 'available' || episode.availability === 'unaired'
|
||||
);
|
||||
const canDeleteSeasonAction = (Boolean(effectiveActiveSeasonMeta?.canDelete) || isSeasonFullyDownloadedExceptUnaired);
|
||||
const canDeleteSeasonAction = props.services.sonarr && (Boolean(effectiveActiveSeasonMeta?.canDelete) || isSeasonFullyDownloadedExceptUnaired);
|
||||
const shouldShowDownloadSeasonAction = canDownloadSeasonAction && !canDeleteSeasonAction;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2484,7 +2604,7 @@ function TitleDetailsModal(props: {
|
||||
<TrashGlyph />
|
||||
</ActionButton>
|
||||
) : null}
|
||||
{!canPlay && canRequest && details?.mediaType !== 'series' ? (
|
||||
{!canPlay && canRequest && props.services.radarr && details?.mediaType !== 'series' ? (
|
||||
isMovieRequestActive ? (
|
||||
movieRequestStatus === 'downloading' ? (
|
||||
<div
|
||||
@@ -2739,7 +2859,7 @@ function TitleDetailsModal(props: {
|
||||
<div className="episode-card-title-row">
|
||||
<strong>{episode.title}</strong>
|
||||
<div className="episode-card-state-icons">
|
||||
{episode.canDownload || episode.downloadStatus === 'processing' || episode.downloadStatus === 'downloading' ? (
|
||||
{props.services.sonarr && (episode.canDownload || episode.downloadStatus === 'processing' || episode.downloadStatus === 'downloading') ? (
|
||||
episode.downloadStatus === 'downloading' ? (
|
||||
<span
|
||||
aria-label="Episode downloading"
|
||||
@@ -3836,8 +3956,9 @@ function SettingsView(props: {
|
||||
{props.connections.map((connection) => (
|
||||
<div className="settings-connection" key={connection.key} title={connection.message}>
|
||||
<div className="settings-connection-label">
|
||||
<span className={connection.ok ? 'status-dot ok' : 'status-dot down'} />
|
||||
<span className={`status-dot ${getConnectionDotState(connection)}`} />
|
||||
<strong>{connection.label}</strong>
|
||||
{connection.optional ? <span className="settings-optional-tag">Optional</span> : null}
|
||||
</div>
|
||||
<span className="settings-connection-message">{connection.message}</span>
|
||||
</div>
|
||||
@@ -3865,7 +3986,7 @@ function SettingsView(props: {
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Sonarr">
|
||||
<SettingsCard optional title="Sonarr">
|
||||
<ServiceFields
|
||||
apiKey={props.config.sonarr.apiKey}
|
||||
baseUrl={props.config.sonarr.baseUrl}
|
||||
@@ -3882,7 +4003,7 @@ function SettingsView(props: {
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Radarr">
|
||||
<SettingsCard optional title="Radarr">
|
||||
<ServiceFields
|
||||
apiKey={props.config.radarr.apiKey}
|
||||
baseUrl={props.config.radarr.baseUrl}
|
||||
@@ -3899,14 +4020,14 @@ function SettingsView(props: {
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="TMDb">
|
||||
<SettingsCard optional title="TMDb">
|
||||
<label>
|
||||
Bearer token
|
||||
<input className="input" onChange={(event) => props.updateConfig('tmdb', { bearerToken: event.target.value })} placeholder="Enter your TMDb bearer token" value={props.config.tmdb.bearerToken} />
|
||||
</label>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="TVDB">
|
||||
<SettingsCard optional title="TVDB">
|
||||
<label>
|
||||
API key
|
||||
<input className="input" onChange={(event) => props.updateConfig('tvdb', { apiKey: event.target.value })} placeholder="Optional if already provided via TVDB_API_KEY" value={props.config.tvdb.apiKey} />
|
||||
@@ -3922,10 +4043,13 @@ function SettingsView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsCard({ title, children }: { title: string; children: ReactNode }) {
|
||||
function SettingsCard({ title, optional, children }: { title: string; optional?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<section className="settings-card">
|
||||
<h2>{title}</h2>
|
||||
<h2>
|
||||
{title}
|
||||
{optional ? <span className="settings-optional-tag">Optional</span> : null}
|
||||
</h2>
|
||||
<div className="settings-fields">{children}</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -289,6 +289,10 @@ img {
|
||||
background: #ff5d73;
|
||||
}
|
||||
|
||||
.status-dot.off {
|
||||
background: #4a5468;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
@@ -3889,6 +3893,20 @@ img {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.settings-optional-tag {
|
||||
margin-left: 0.55rem;
|
||||
color: #72809c;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.settings-connection-label .settings-optional-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.settings-card label,
|
||||
.auth-form label {
|
||||
display: grid;
|
||||
@@ -4150,6 +4168,30 @@ img {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.jellyfin-unavailable {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.6rem;
|
||||
width: min(100%, 32rem);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1.3rem;
|
||||
color: #c4cee0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jellyfin-unavailable p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.jellyfin-unavailable-detail {
|
||||
color: #72809c;
|
||||
font-size: 0.86rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.administration-layout {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
Reference in new issue
Block a user