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/),
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
### Fixed
+1 -1
View File
@@ -352,7 +352,7 @@ Production-ready releases. Updated when new stable versions are released.
```bash
docker pull chevron7locked/kima:latest
# or specific version
docker pull chevron7locked/kima:v1.7.1
docker pull chevron7locked/kima:v1.7.2
```
### 🔴 Nightly (Development)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kima-backend",
"version": "1.7.1",
"version": "1.7.2",
"description": "Kima backend API server",
"license": "GPL-3.0",
"repository": {
+3 -3
View File
@@ -7,7 +7,7 @@ import { audiobookshelfService } from "../services/audiobookshelf";
import { audiobookCacheService } from "../services/audiobookCache";
import { prisma } from "../utils/db";
import { requireAuthOrToken } from "../middleware/auth";
import { imageLimiter, apiLimiter } from "../middleware/rateLimiter";
import { apiLimiter } from "../middleware/rateLimiter";
import { getSystemSettings } from "../utils/systemSettings";
import { notificationService } from "../services/notificationService";
import { config } from "../config";
@@ -96,7 +96,7 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
await notificationService.notifySystem(
req.user.id,
"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 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") {
return res.status(404).json({ error: "Not found" });
}
+18 -18
View File
@@ -74,23 +74,20 @@ export class AudiobookCacheService {
for (const book of audiobooks) {
try {
await this.syncAudiobook(book);
result.synced++;
// Extract title and author from nested structure for logging
const metadata = book.media?.metadata || book;
const title =
metadata.title || book.title || "Unknown Title";
const author =
metadata.authorName ||
metadata.author ||
book.author ||
"Unknown Author";
logger.debug(` Synced: ${title} by ${author}`);
const synced = await this.syncAudiobook(book);
if (synced) {
result.synced++;
const metadata = book.media?.metadata || book;
const title = metadata.title || book.title || "Unknown Title";
const author = metadata.authorName || metadata.author || book.author || "Unknown Author";
logger.debug(` Synced: ${title} by ${author}`);
} else {
result.skipped++;
}
} catch (error: any) {
result.failed++;
const metadata = book.media?.metadata || book;
const title =
metadata.title || book.title || "Unknown Title";
const title = metadata.title || book.title || "Unknown Title";
const errorMsg = `Failed to sync ${title}: ${error.message}`;
result.errors.push(errorMsg);
logger.error(` ${errorMsg}`);
@@ -117,13 +114,13 @@ export class AudiobookCacheService {
/**
* 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 title = metadata.title || book.title;
if (!title) {
logger.warn(` Skipping audiobook ${book.id} - missing title`);
return;
return false;
}
const author = metadata.authorName || metadata.author || null;
@@ -141,7 +138,7 @@ export class AudiobookCacheService {
const duration = book.media?.duration || null;
const numTracks = book.media?.numTracks || 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 coverPath = book.media?.coverPath || null;
@@ -265,6 +262,8 @@ export class AudiobookCacheService {
lastSyncedAt: new Date(),
},
});
return true;
}
/**
@@ -322,6 +321,7 @@ export class AudiobookCacheService {
headers: {
Authorization: `Bearer ${settings.audiobookshelfApiKey}`,
},
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
@@ -434,5 +434,5 @@ export class AudiobookCacheService {
}
}
// Export singleton instance
// Singleton instance
export const audiobookCacheService = new AudiobookCacheService();
+3
View File
@@ -77,6 +77,9 @@ services:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
# Build type: "nightly" for dev builds, unset/empty for tagged releases
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
environment:
NODE_ENV: ${NODE_ENV:-production}
+3 -1
View File
@@ -22,11 +22,13 @@ COPY --from=deps /app/package*.json ./
# Copy source code
COPY . .
# Accept build args for environment variables
# Build args for environment variables
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_BUILD_TYPE=nightly
ARG NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_BUILD_TYPE=$NEXT_PUBLIC_BUILD_TYPE
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
# Build Next.js application
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 { useAuth } from "@/lib/auth-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 {
Book,
ListTree,
@@ -17,6 +18,7 @@ import {
ChevronLeft,
ChevronRight,
ArrowUpDown,
RefreshCw,
} from "lucide-react";
import { shuffleArray } from "@/utils/shuffle";
@@ -66,7 +68,9 @@ export default function AudiobooksPage() {
const { currentAudiobook } = useAudioState();
const { pause } = useAudioControls();
const queryClient = useQueryClient();
const { data: audiobooksData, isLoading, error } = useAudiobooksQuery();
const [isSyncing, setIsSyncing] = useState(false);
const [filter, setFilter] = useState<FilterType>("all");
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) {
return (
<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>
</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">
{filteredBooks.length} {filteredBooks.length === 1 ? "book" : "books"}
</span>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kima-frontend",
"version": "1.7.1",
"version": "1.7.2",
"description": "Kima web frontend",
"license": "GPL-3.0",
"repository": {