fix: onboarding validation rejects disabled integrations, hide audiobooks when disabled (#162)

- Backend: conditional URL validation -- skip format check when integration is disabled
- Frontend: send clean payloads for disabled integrations during onboarding
- Sidebar: hide Audiobooks nav item when Audiobookshelf is not enabled
- Feature detection: expose audiobookshelfEnabled flag via /system/features
- Audiobooks: use refetchQueries instead of invalidateQueries after sync for immediate UI update
This commit is contained in:
Your Name
2026-03-23 16:53:50 -05:00
parent 2aec402862
commit 977e94582a
9 changed files with 54 additions and 25 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kima-backend",
"version": "1.7.4",
"version": "1.7.5",
"description": "Kima backend API server",
"license": "GPL-3.0",
"repository": {
+10 -4
View File
@@ -18,16 +18,22 @@ const registerSchema = z.object({
});
const lidarrConfigSchema = z.object({
url: z.string().url().optional().or(z.literal("")),
url: z.string().optional().or(z.literal("")),
apiKey: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
}).refine(
(data) => !data.enabled || !data.url || data.url === "" || z.string().url().safeParse(data.url).success,
{ message: "Invalid url", path: ["url"] },
);
const audiobookshelfConfigSchema = z.object({
url: z.string().url().optional().or(z.literal("")),
url: z.string().optional().or(z.literal("")),
apiKey: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
}).refine(
(data) => !data.enabled || !data.url || data.url === "" || z.string().url().safeParse(data.url).success,
{ message: "Invalid url", path: ["url"] },
);
const soulseekConfigSchema = z.object({
username: z.string().optional().or(z.literal("")),
+18 -3
View File
@@ -10,6 +10,7 @@ const CLAP_ANALYZER_PATH = "/app/audio-analyzer-clap/analyzer.py";
export interface AvailableFeatures {
musicCNN: boolean;
vibeEmbeddings: boolean;
audiobookshelfEnabled: boolean;
}
const HEARTBEAT_TTL = 300000; // 5 minutes
@@ -25,16 +26,17 @@ class FeatureDetectionService {
return this.cache;
}
const [musicCNN, vibeEmbeddings] = await Promise.all([
const [musicCNN, vibeEmbeddings, audiobookshelfEnabled] = await Promise.all([
this.checkMusicCNN(),
this.checkCLAP(),
this.checkAudiobookshelf(),
]);
this.cache = { musicCNN, vibeEmbeddings };
this.cache = { musicCNN, vibeEmbeddings, audiobookshelfEnabled };
this.lastCheck = now;
logger.debug(
`[FEATURE-DETECTION] Features: musicCNN=${musicCNN}, vibeEmbeddings=${vibeEmbeddings}`
`[FEATURE-DETECTION] Features: musicCNN=${musicCNN}, vibeEmbeddings=${vibeEmbeddings}, audiobookshelf=${audiobookshelfEnabled}`
);
return this.cache;
@@ -94,6 +96,19 @@ class FeatureDetectionService {
}
}
private async checkAudiobookshelf(): Promise<boolean> {
try {
const settings = await prisma.systemSettings.findUnique({
where: { id: "default" },
select: { audiobookshelfEnabled: true },
});
return settings?.audiobookshelfEnabled ?? false;
} catch (error) {
logger.error("[FEATURE-DETECTION] Error checking Audiobookshelf:", error);
return false;
}
}
invalidateCache(): void {
this.cache = null;
this.lastCheck = 0;
+1 -1
View File
@@ -247,7 +247,7 @@ export default function AudiobooksPage() {
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() });
await queryClient.refetchQueries({ queryKey: queryKeys.audiobooks() });
} catch {
toast.error("Audiobook sync failed");
} finally {
+4 -3
View File
@@ -159,10 +159,11 @@ export default function OnboardingPage() {
try {
if (step === 2) {
// Save all integration configs and complete onboarding
// Only send field values for enabled integrations; disabled ones get clean payloads
await Promise.all([
api.post("/onboarding/lidarr", lidarr),
api.post("/onboarding/audiobookshelf", audiobookshelf),
api.post("/onboarding/soulseek", soulseek),
api.post("/onboarding/lidarr", lidarr.enabled ? lidarr : { url: "", apiKey: "", enabled: false }),
api.post("/onboarding/audiobookshelf", audiobookshelf.enabled ? audiobookshelf : { url: "", apiKey: "", enabled: false }),
api.post("/onboarding/soulseek", soulseek.enabled ? soulseek : { username: "", password: "", enabled: false }),
]);
await api.post("/onboarding/complete");
router.push("/sync");
+14 -10
View File
@@ -12,18 +12,19 @@ import { queryKeys } from "@/hooks/useQueries";
import { useAudioState } from "@/lib/audio-state-context";
import { useIsMobile, useIsTablet } from "@/hooks/useMediaQuery";
import { useToast } from "@/lib/toast-context";
import { useFeatures } from "@/lib/features-context";
import Image from "next/image";
import { MobileSidebar } from "./MobileSidebar";
const navigation = [
{ name: "Collection", href: "/collection" },
{ name: "Radio", href: "/radio" },
{ name: "Discovery", href: "/discover" },
{ name: "Vibe", href: "/vibe" },
{ name: "Audiobooks", href: "/audiobooks" },
{ name: "Podcasts", href: "/podcasts" },
{ name: "Browse", href: "/browse/playlists" },
] as const;
const allNavigation = [
{ name: "Collection", href: "/collection", feature: null },
{ name: "Radio", href: "/radio", feature: null },
{ name: "Discovery", href: "/discover", feature: null },
{ name: "Vibe", href: "/vibe", feature: null },
{ name: "Audiobooks", href: "/audiobooks", feature: "audiobookshelfEnabled" as const },
{ name: "Podcasts", href: "/podcasts", feature: null },
{ name: "Browse", href: "/browse/playlists", feature: null },
];
interface Playlist {
id: string;
@@ -42,6 +43,7 @@ export function Sidebar() {
const { toast } = useToast();
const { currentTrack, currentAudiobook, currentPodcast, playbackType } =
useAudioState();
const { audiobookshelfEnabled } = useFeatures();
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const isMobileOrTablet = isMobile || isTablet;
@@ -267,7 +269,9 @@ export function Sidebar() {
</span>
</div>
<div className="space-y-0.5">
{navigation.map((item, index) => {
{allNavigation.filter((item) =>
item.feature === null || (item.feature === "audiobookshelfEnabled" && audiobookshelfEnabled)
).map((item, index) => {
const isActive = pathname === item.href;
return (
+2 -2
View File
@@ -742,8 +742,8 @@ class ApiClient {
}
// System Features
async getFeatures(): Promise<{ musicCNN: boolean; vibeEmbeddings: boolean }> {
return this.request<{ musicCNN: boolean; vibeEmbeddings: boolean }>(
async getFeatures(): Promise<{ musicCNN: boolean; vibeEmbeddings: boolean; audiobookshelfEnabled: boolean }> {
return this.request<{ musicCNN: boolean; vibeEmbeddings: boolean; audiobookshelfEnabled: boolean }>(
"/system/features"
);
}
+3
View File
@@ -7,12 +7,14 @@ import { useAuth } from "./auth-context";
interface FeaturesState {
musicCNN: boolean;
vibeEmbeddings: boolean;
audiobookshelfEnabled: boolean;
loading: boolean;
}
const defaultState: FeaturesState = {
musicCNN: false,
vibeEmbeddings: false,
audiobookshelfEnabled: false,
loading: true,
};
@@ -31,6 +33,7 @@ export function FeaturesProvider({ children }: { children: ReactNode }) {
setState({
musicCNN: features.musicCNN,
vibeEmbeddings: features.vibeEmbeddings,
audiobookshelfEnabled: features.audiobookshelfEnabled,
loading: false,
});
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kima-frontend",
"version": "1.7.4",
"version": "1.7.5",
"description": "Kima web frontend",
"license": "GPL-3.0",
"repository": {