mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user