fix: multi-container docker-compose proxy, audiobookshelf sync bugs, add sync button

Fixes #158 -- fresh docker-compose install shows login instead of setup
wizard because Next.js rewrites were compiled with 127.0.0.1:3006 baked
in. Added NEXT_PUBLIC_BACKEND_URL as a Dockerfile build arg, defaulting
to http://backend:3006 in docker-compose.yml so the frontend container
proxies to the backend service correctly.

Audiobookshelf sync fixes:
- syncAudiobook returns boolean; skipped books no longer count as synced
- Sync notification now surfaces failed/skipped counts
- downloadCover has 10s fetch timeout (was unbounded)
- book.size changed to book.media?.size for audio-only size

Added sync button on audiobooks page so users can trigger a sync without
going through settings or the enrichment system.

Bump to v1.7.2
This commit is contained in:
Your Name
2026-03-18 13:50:18 -05:00
parent 6000d31738
commit 60892c12e7
9 changed files with 77 additions and 26 deletions
+14
View File
@@ -5,6 +5,20 @@ All notable changes to Kima will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.2] - 2026-03-18
### Fixed
- **Multi-container docker-compose: fresh install shows login instead of setup wizard (#158)**: Next.js rewrites were compiled at build time with `127.0.0.1:3006` baked in because `NEXT_PUBLIC_BACKEND_URL` was never passed as a build arg. In multi-container mode the frontend container can't reach `127.0.0.1:3006` (that's the backend container). Fix: added `NEXT_PUBLIC_BACKEND_URL` as a Dockerfile build arg, defaulting to `http://backend:3006` in docker-compose.yml. The reporter's CORS issue was a secondary symptom -- once the proxy works, all requests are same-origin.
- **Audiobookshelf sync silently skips books**: Books with no title returned early without throwing, but `syncAll` counted them as synced. Now `syncAudiobook` returns a boolean; skipped books increment `result.skipped` instead of `result.synced`.
- **Audiobookshelf sync notification hides failures**: The "Synced N audiobooks" notification now includes failed/skipped counts when non-zero.
- **Audiobookshelf cover download can hang indefinitely**: `downloadCover()` had no fetch timeout. Added `AbortSignal.timeout(10_000)` to prevent a single slow cover from stalling the entire sync.
- **Audiobookshelf book size includes non-audio files**: Changed `book.size` to `book.media?.size` for audio-only size.
### Added
- **Sync button on audiobooks page**: Users can now sync audiobooks directly from the audiobooks page without going through settings or triggering a full enrichment cycle. Shows synced/failed/skipped counts in a toast and refreshes the grid automatically.
## [1.7.1] - 2026-03-17 ## [1.7.1] - 2026-03-17
### Fixed ### Fixed
+1 -1
View File
@@ -352,7 +352,7 @@ Production-ready releases. Updated when new stable versions are released.
```bash ```bash
docker pull chevron7locked/kima:latest docker pull chevron7locked/kima:latest
# or specific version # or specific version
docker pull chevron7locked/kima:v1.7.1 docker pull chevron7locked/kima:v1.7.2
``` ```
### 🔴 Nightly (Development) ### 🔴 Nightly (Development)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "kima-backend", "name": "kima-backend",
"version": "1.7.1", "version": "1.7.2",
"description": "Kima backend API server", "description": "Kima backend API server",
"license": "GPL-3.0", "license": "GPL-3.0",
"repository": { "repository": {
+3 -3
View File
@@ -7,7 +7,7 @@ import { audiobookshelfService } from "../services/audiobookshelf";
import { audiobookCacheService } from "../services/audiobookCache"; import { audiobookCacheService } from "../services/audiobookCache";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { requireAuthOrToken } from "../middleware/auth"; import { requireAuthOrToken } from "../middleware/auth";
import { imageLimiter, apiLimiter } from "../middleware/rateLimiter"; import { apiLimiter } from "../middleware/rateLimiter";
import { getSystemSettings } from "../utils/systemSettings"; import { getSystemSettings } from "../utils/systemSettings";
import { notificationService } from "../services/notificationService"; import { notificationService } from "../services/notificationService";
import { config } from "../config"; import { config } from "../config";
@@ -96,7 +96,7 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
await notificationService.notifySystem( await notificationService.notifySystem(
req.user.id, req.user.id,
"Audiobook Sync Complete", "Audiobook Sync Complete",
`Synced ${result.synced || 0} audiobooks (${seriesCount} with series)` `Synced ${result.synced || 0} audiobooks (${seriesCount} with series)${result.failed ? `, ${result.failed} failed` : ""}${result.skipped ? `, ${result.skipped} skipped` : ""}`
); );
} }
@@ -114,7 +114,7 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
* Debug endpoint to see raw series data from Audiobookshelf * Debug endpoint to see raw series data from Audiobookshelf
*/ */
// Debug endpoint for series data // Debug endpoint for series data
router.get("/debug-series", requireAuthOrToken, async (req, res) => { router.get("/debug-series", requireAuthOrToken, async (_req, res) => {
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
return res.status(404).json({ error: "Not found" }); return res.status(404).json({ error: "Not found" });
} }
+18 -18
View File
@@ -74,23 +74,20 @@ export class AudiobookCacheService {
for (const book of audiobooks) { for (const book of audiobooks) {
try { try {
await this.syncAudiobook(book); const synced = await this.syncAudiobook(book);
result.synced++; if (synced) {
// Extract title and author from nested structure for logging result.synced++;
const metadata = book.media?.metadata || book; const metadata = book.media?.metadata || book;
const title = const title = metadata.title || book.title || "Unknown Title";
metadata.title || book.title || "Unknown Title"; const author = metadata.authorName || metadata.author || book.author || "Unknown Author";
const author = logger.debug(` Synced: ${title} by ${author}`);
metadata.authorName || } else {
metadata.author || result.skipped++;
book.author || }
"Unknown Author";
logger.debug(` Synced: ${title} by ${author}`);
} catch (error: any) { } catch (error: any) {
result.failed++; result.failed++;
const metadata = book.media?.metadata || book; const metadata = book.media?.metadata || book;
const title = const title = metadata.title || book.title || "Unknown Title";
metadata.title || book.title || "Unknown Title";
const errorMsg = `Failed to sync ${title}: ${error.message}`; const errorMsg = `Failed to sync ${title}: ${error.message}`;
result.errors.push(errorMsg); result.errors.push(errorMsg);
logger.error(` ${errorMsg}`); logger.error(` ${errorMsg}`);
@@ -117,13 +114,13 @@ export class AudiobookCacheService {
/** /**
* Sync a single audiobook * Sync a single audiobook
*/ */
private async syncAudiobook(book: any): Promise<void> { private async syncAudiobook(book: any): Promise<boolean> {
const metadata = book.media?.metadata || book; const metadata = book.media?.metadata || book;
const title = metadata.title || book.title; const title = metadata.title || book.title;
if (!title) { if (!title) {
logger.warn(` Skipping audiobook ${book.id} - missing title`); logger.warn(` Skipping audiobook ${book.id} - missing title`);
return; return false;
} }
const author = metadata.authorName || metadata.author || null; const author = metadata.authorName || metadata.author || null;
@@ -141,7 +138,7 @@ export class AudiobookCacheService {
const duration = book.media?.duration || null; const duration = book.media?.duration || null;
const numTracks = book.media?.numTracks || null; const numTracks = book.media?.numTracks || null;
const numChapters = book.media?.numChapters || null; const numChapters = book.media?.numChapters || null;
const size = book.size ? BigInt(book.size) : null; const size = book.media?.size ? BigInt(book.media.size) : null;
const libraryId = book.libraryId || null; const libraryId = book.libraryId || null;
const coverPath = book.media?.coverPath || null; const coverPath = book.media?.coverPath || null;
@@ -265,6 +262,8 @@ export class AudiobookCacheService {
lastSyncedAt: new Date(), lastSyncedAt: new Date(),
}, },
}); });
return true;
} }
/** /**
@@ -322,6 +321,7 @@ export class AudiobookCacheService {
headers: { headers: {
Authorization: `Bearer ${settings.audiobookshelfApiKey}`, Authorization: `Bearer ${settings.audiobookshelfApiKey}`,
}, },
signal: AbortSignal.timeout(10_000),
}); });
if (!response.ok) { if (!response.ok) {
@@ -434,5 +434,5 @@ export class AudiobookCacheService {
} }
} }
// Export singleton instance // Singleton instance
export const audiobookCacheService = new AudiobookCacheService(); export const audiobookCacheService = new AudiobookCacheService();
+3
View File
@@ -77,6 +77,9 @@ services:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
# Build type: "nightly" for dev builds, unset/empty for tagged releases # Build type: "nightly" for dev builds, unset/empty for tagged releases
NEXT_PUBLIC_BUILD_TYPE: ${NEXT_PUBLIC_BUILD_TYPE:-nightly} NEXT_PUBLIC_BUILD_TYPE: ${NEXT_PUBLIC_BUILD_TYPE:-nightly}
# Backend URL for Next.js rewrites (compiled at build time).
# In multi-container mode, frontend proxies to the backend service.
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-http://backend:3006}
container_name: kima_frontend container_name: kima_frontend
environment: environment:
NODE_ENV: ${NODE_ENV:-production} NODE_ENV: ${NODE_ENV:-production}
+3 -1
View File
@@ -22,11 +22,13 @@ COPY --from=deps /app/package*.json ./
# Copy source code # Copy source code
COPY . . COPY . .
# Accept build args for environment variables # Build args for environment variables
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_BUILD_TYPE=nightly ARG NEXT_PUBLIC_BUILD_TYPE=nightly
ARG NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_BUILD_TYPE=$NEXT_PUBLIC_BUILD_TYPE ENV NEXT_PUBLIC_BUILD_TYPE=$NEXT_PUBLIC_BUILD_TYPE
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
# Build Next.js application # Build Next.js application
RUN npm run build RUN npm run build
+33 -1
View File
@@ -9,7 +9,8 @@ import { api } from "@/lib/api";
import { useAudioState, useAudioControls } from "@/lib/audio-context"; import { useAudioState, useAudioControls } from "@/lib/audio-context";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { useToast } from "@/lib/toast-context"; import { useToast } from "@/lib/toast-context";
import { useAudiobooksQuery } from "@/hooks/useQueries"; import { useAudiobooksQuery, queryKeys } from "@/hooks/useQueries";
import { useQueryClient } from "@tanstack/react-query";
import { import {
Book, Book,
ListTree, ListTree,
@@ -17,6 +18,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ArrowUpDown, ArrowUpDown,
RefreshCw,
} from "lucide-react"; } from "lucide-react";
import { shuffleArray } from "@/utils/shuffle"; import { shuffleArray } from "@/utils/shuffle";
@@ -66,7 +68,9 @@ export default function AudiobooksPage() {
const { currentAudiobook } = useAudioState(); const { currentAudiobook } = useAudioState();
const { pause } = useAudioControls(); const { pause } = useAudioControls();
const queryClient = useQueryClient();
const { data: audiobooksData, isLoading, error } = useAudiobooksQuery(); const { data: audiobooksData, isLoading, error } = useAudiobooksQuery();
const [isSyncing, setIsSyncing] = useState(false);
const [filter, setFilter] = useState<FilterType>("all"); const [filter, setFilter] = useState<FilterType>("all");
const [sortBy, setSortBy] = useState<SortType>("title"); const [sortBy, setSortBy] = useState<SortType>("title");
@@ -233,6 +237,24 @@ export default function AudiobooksPage() {
} }
}; };
const handleSync = async () => {
if (isSyncing) return;
setIsSyncing(true);
try {
const res = await api.post<{ result: { synced: number; failed: number; skipped: number } }>("/audiobooks/sync", {});
const result = res?.result;
const parts = [`Synced ${result?.synced ?? 0} audiobooks`];
if (result?.failed) parts.push(`${result.failed} failed`);
if (result?.skipped) parts.push(`${result.skipped} skipped`);
toast.success(parts.join(", "));
queryClient.invalidateQueries({ queryKey: queryKeys.audiobooks() });
} catch {
toast.error("Audiobook sync failed");
} finally {
setIsSyncing(false);
}
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-[#0a0a0a]"> <div className="flex items-center justify-center min-h-screen bg-[#0a0a0a]">
@@ -376,6 +398,16 @@ export default function AudiobooksPage() {
<span className="hidden sm:inline">Random</span> <span className="hidden sm:inline">Random</span>
</button> </button>
<button
onClick={handleSync}
disabled={isSyncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 text-white/50 hover:bg-white/10 hover:text-white border border-white/10 hover:border-white/20 font-black text-xs uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed"
title="Sync audiobooks from Audiobookshelf"
>
<RefreshCw className={`w-3.5 h-3.5 ${isSyncing ? "animate-spin" : ""}`} />
<span className="hidden sm:inline">{isSyncing ? "Syncing..." : "Sync"}</span>
</button>
<span className="hidden md:inline text-xs font-mono text-white/30 ml-auto uppercase tracking-wider"> <span className="hidden md:inline text-xs font-mono text-white/30 ml-auto uppercase tracking-wider">
{filteredBooks.length} {filteredBooks.length === 1 ? "book" : "books"} {filteredBooks.length} {filteredBooks.length === 1 ? "book" : "books"}
</span> </span>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "kima-frontend", "name": "kima-frontend",
"version": "1.7.1", "version": "1.7.2",
"description": "Kima web frontend", "description": "Kima web frontend",
"license": "GPL-3.0", "license": "GPL-3.0",
"repository": { "repository": {