Base url additions and bug fixes (#519)

- Base URL option in settings for reverse proxy setups
- Fix NZB downloads not deleting on completion
- Fix handling for audiobook files over 100+ parts
- Fix prowlarr search timeout 
- Fix prowlarr categorisation for expanded searches
This commit is contained in:
Alex
2026-01-23 13:03:02 +00:00
committed by GitHub
parent 03c364e375
commit 3be99effe4
32 changed files with 557 additions and 163 deletions
+3 -2
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Shelfmark - Book search and download" />
<base href="/" data-shelfmark-base />
<!-- Theme color with media queries for light/dark mode -->
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
@@ -15,8 +16,8 @@
<meta name="apple-mobile-web-app-title" content="Shelfmark" />
<!-- App Icons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="apple-touch-icon" href="logo.png" />
<title>Shelfmark</title>
<script>
// Apply theme immediately before first paint to prevent flash
+2 -22
View File
@@ -29,6 +29,7 @@ import { ConfigSetupBanner } from './components/ConfigSetupBanner';
import { OnboardingModal } from './components/OnboardingModal';
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import { buildSearchQuery } from './utils/buildSearchQuery';
import { withBasePath } from './utils/basePath';
import { SearchModeProvider } from './contexts/SearchModeContext';
import './styles.css';
@@ -128,15 +129,6 @@ function App() {
};
}, []);
const [featureNoticeDismissed, setFeatureNoticeDismissed] = useState(() => {
return localStorage.getItem('cwa-bd-prowlarr-irc-notice-dismissed') === 'true';
});
const handleDismissFeatureNotice = useCallback(() => {
localStorage.setItem('cwa-bd-prowlarr-irc-notice-dismissed', 'true');
setFeatureNoticeDismissed(true);
}, []);
// URL-based search: parse URL params for automatic search on page load
const urlSearchEnabled = isAuthenticated && config !== null;
const { parsedParams, wasProcessed } = useUrlSearch({ enabled: urlSearchEnabled });
@@ -210,7 +202,7 @@ function App() {
// Auto-download to browser if enabled
if (config?.download_to_browser && book.download_path) {
const link = document.createElement('a');
link.href = `/api/localdownload?id=${encodeURIComponent(bookId)}`;
link.href = withBasePath(`/api/localdownload?id=${encodeURIComponent(bookId)}`);
link.download = '';
document.body.appendChild(link);
link.click();
@@ -626,18 +618,6 @@ function App() {
onContentTypeChange={setContentType}
/>
{isInitialState && !featureNoticeDismissed && (
<div className="absolute bottom-4 left-0 right-0 px-4 text-center text-sm opacity-40">
<span>We've renamed to Shelfmark. New: Torrent, Usenet, IRC and Audiobook support.</span>
<button
onClick={handleDismissFeatureNotice}
className="ml-2 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 underline"
>
Dismiss
</button>
</div>
)}
<ResultsSection
books={books}
visible={hasResults}
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { StatusData, Book } from '../types';
import { withBasePath } from '../utils/basePath';
interface DownloadsSidebarProps {
isOpen: boolean;
@@ -211,7 +212,7 @@ export const DownloadsSidebar = ({
<h3 className="font-semibold text-sm truncate" title={book.title}>
{isCompleted && book.download_path ? (
<a
href={`/api/localdownload?id=${encodeURIComponent(book.id)}`}
href={withBasePath(`/api/localdownload?id=${encodeURIComponent(book.id)}`)}
className="text-sky-600 hover:underline"
>
{book.title || 'Unknown Title'}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { SearchBar, SearchBarHandle } from './SearchBar';
import { ContentType } from '../types';
import { withBasePath } from '../utils/basePath';
export interface HeaderHandle {
submitSearch: () => void;
@@ -345,7 +346,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
// Show persistent toast while gathering logs
const loadingToastId = onShowToast?.('Gathering debug logs... This may take a minute.', 'info', true);
try {
const response = await fetch('/api/debug', {
const response = await fetch(withBasePath('/api/debug'), {
method: 'GET',
credentials: 'include',
});
@@ -394,7 +395,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
</svg>
<span>Debug</span>
</button>
<form action="/api/restart" method="get" className="w-full">
<form action={withBasePath('/api/restart')} method="get" className="w-full">
<button
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
type="submit"
+2 -3
View File
@@ -1,5 +1,6 @@
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { LoginCredentials } from '../types';
import { withBasePath } from '../utils/basePath';
interface LoginFormProps {
onSubmit: (credentials: LoginCredentials) => void;
@@ -97,7 +98,7 @@ export const LoginForm = ({
)}
<form
method="post"
action="/api/login"
action={withBasePath('/api/login')}
autoComplete="on"
id="login-form"
name="login"
@@ -227,5 +228,3 @@ export const LoginForm = ({
</div>
);
};
@@ -75,13 +75,14 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
}
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_auto]';
// Use fixed width for delete button column to ensure header/data alignment.
const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_2rem]';
return (
<div className="space-y-3 min-w-0">
<div className={`hidden sm:grid ${gridTemplate} gap-2 px-1 text-xs font-medium opacity-70`}>
<div className={`hidden sm:grid ${gridTemplate} gap-3 items-start min-w-0 text-xs font-medium opacity-70`}>
{columns.map((col) => (
<div key={col.key} className="truncate">
<div key={col.key} className="min-w-0 truncate">
{col.label}
</div>
))}
@@ -165,12 +166,12 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
);
})}
<div className="flex items-center justify-center">
<div className="flex items-start pt-1.5">
<button
type="button"
onClick={() => removeRow(rowIndex)}
disabled={isDisabled}
className="p-2 rounded hover:bg-[var(--hover-surface)]
className="p-1.5 rounded-full hover:bg-[var(--hover-surface)]
disabled:opacity-60 disabled:cursor-not-allowed"
aria-label="Remove row"
>
@@ -66,8 +66,9 @@ export const FieldWrapper = ({
disabledOverride,
disabledReasonOverride,
}: FieldWrapperProps) => {
// Action buttons and headings handle their own layout
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
// Action buttons, headings, and table fields handle their own layout
// Table fields have column headers, so they don't need a separate label
if (field.type === 'ActionButton' || field.type === 'HeadingField' || field.type === 'TableField') {
return <>{children}</>;
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useRef, useState, ReactNode } from 'react';
import { io, Socket } from 'socket.io-client';
import { withBasePath } from '../utils/basePath';
interface SocketContextValue {
socket: Socket | null;
@@ -23,11 +24,12 @@ export const SocketProvider = ({ children }: SocketProviderProps) => {
const wsUrl = window.location.port === '5173'
? 'http://localhost:8084'
: window.location.origin;
const socketPath = withBasePath('/socket.io');
console.log('SocketProvider: Connecting to', wsUrl);
const socket = io(wsUrl, {
path: '/socket.io',
path: socketPath,
transports: ['polling', 'websocket'],
withCredentials: false,
});
+5 -1
View File
@@ -3,13 +3,17 @@ import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { SocketProvider } from './contexts/SocketContext';
import App from './App';
import { getBasePath } from './utils/basePath';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
const basePath = getBasePath();
const routerBase = basePath === '/' ? undefined : basePath;
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<BrowserRouter basename={routerBase}>
<SocketProvider>
<App />
</SocketProvider>
+21 -8
View File
@@ -1,8 +1,9 @@
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse, ReleaseSource, ReleasesResponse } from '../types';
import { SettingsResponse, ActionResult, UpdateResult } from '../types/settings';
import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers';
import { getApiBase } from '../utils/basePath';
const API_BASE = '/api';
const API_BASE = getApiBase();
// API endpoints
const API = {
@@ -39,12 +40,17 @@ export class TimeoutError extends Error {
// Default request timeout in milliseconds (30 seconds)
const DEFAULT_TIMEOUT_MS = 30000;
const EXPANDED_RELEASES_TIMEOUT_MS = 60000;
// Utility function for JSON fetch with credentials and timeout
async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<T> {
async function fetchJSON<T>(
url: string,
opts: RequestInit = {},
timeoutMs: number | null = DEFAULT_TIMEOUT_MS
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const timeoutId = timeoutMs && timeoutMs > 0
? setTimeout(() => controller.abort(), timeoutMs)
: null;
try {
const res = await fetch(url, {
@@ -60,13 +66,16 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: numb
if (!res.ok) {
// Try to parse error message from response body
let errorMessage = `${res.status} ${res.statusText}`;
let hasServerMessage = false;
try {
const errorData = await res.json();
// Prefer user-friendly 'message' field, fall back to 'error'
if (errorData.message) {
errorMessage = errorData.message;
hasServerMessage = true;
} else if (errorData.error) {
errorMessage = errorData.error;
hasServerMessage = true;
}
} catch (e) {
// Log parse failure for debugging - server may have returned non-JSON (e.g., HTML error page)
@@ -75,7 +84,9 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: numb
// Provide helpful message for gateway/proxy errors
if (res.status === 502 || res.status === 503 || res.status === 504) {
errorMessage = `Server unavailable (${res.status}). If using a reverse proxy, check its configuration.`;
if (!hasServerMessage) {
errorMessage = `Server unavailable (${res.status}). If using a reverse proxy, check its configuration.`;
}
}
// Throw appropriate error based on status code
@@ -94,7 +105,9 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: numb
}
throw error;
} finally {
clearTimeout(timeoutId);
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
@@ -353,6 +366,6 @@ export const getReleases = async (
if (manualQuery) {
params.set('manual_query', manualQuery);
}
const timeoutMs = expandSearch ? EXPANDED_RELEASES_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, timeoutMs);
// Let the backend control timeouts for release searches (can be long-running).
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, null);
};
+45
View File
@@ -0,0 +1,45 @@
const normalizeBasePath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '/';
}
let path = trimmed;
if (!path.startsWith('/')) {
path = `/${path}`;
}
if (path.length > 1 && path.endsWith('/')) {
path = path.slice(0, -1);
}
return path;
};
const resolveBasePath = (): string => {
if (typeof document === 'undefined') {
return '/';
}
const baseHref = document.querySelector('base')?.getAttribute('href') || '/';
try {
return new URL(baseHref, window.location.origin).pathname;
} catch {
return baseHref;
}
};
const BASE_PATH = normalizeBasePath(resolveBasePath());
export const getBasePath = (): string => BASE_PATH;
export const withBasePath = (path: string): string => {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
if (BASE_PATH === '/') {
return normalizedPath;
}
return `${BASE_PATH}${normalizedPath}`;
};
export const getApiBase = (): string => withBasePath('/api');
+3 -2
View File
@@ -2,7 +2,8 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
export default defineConfig(({ command }) => ({
base: command === 'build' ? './' : '/',
plugins: [react()],
resolve: {
alias: {
@@ -29,4 +30,4 @@ export default defineConfig({
outDir: 'dist',
sourcemap: true,
},
});
}));