Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import { useState } from "react";
import { api } from "@/lib/api";
import { ApiKey } from "../types";
export function useAPIKeys() {
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [loadingApiKeys, setLoadingApiKeys] = useState(false);
const [showCreateApiKeyDialog, setShowCreateApiKeyDialog] = useState(false);
const [newApiKeyName, setNewApiKeyName] = useState("");
const [generatedApiKey, setGeneratedApiKey] = useState<string | null>(null);
const [creatingApiKey, setCreatingApiKey] = useState(false);
const loadApiKeys = async () => {
try {
setLoadingApiKeys(true);
const response = await api.listApiKeys();
// Map API response to match ApiKey type
const mappedKeys = response.apiKeys.map(key => ({
...key,
lastUsedAt: key.lastUsed,
keyPreview: key.id.substring(0, 8) + "..." // Generate preview from ID
}));
setApiKeys(mappedKeys);
} catch (error) {
console.error("Failed to load API keys:", error);
// Caller handles error display if needed
} finally {
setLoadingApiKeys(false);
}
};
/**
* Create a new API key
* Returns { success: true } or { success: false, error: string }
*/
const createApiKey = async (name: string): Promise<{ success: boolean; error?: string }> => {
const trimmedName = name.trim();
if (!trimmedName) {
return { success: false, error: "Device name required" };
}
try {
setCreatingApiKey(true);
const response = await api.createApiKey(trimmedName);
setGeneratedApiKey(response.apiKey);
setShowCreateApiKeyDialog(false);
setNewApiKeyName("");
await loadApiKeys();
return { success: true };
} catch (error: any) {
console.error("Failed to create API key:", error);
return { success: false, error: error.message || "Failed to create" };
} finally {
setCreatingApiKey(false);
}
};
/**
* Revoke an API key
* Returns { success: true } or { success: false, error: string }
*/
const revokeApiKey = async (id: string): Promise<{ success: boolean; error?: string }> => {
try {
await api.revokeApiKey(id);
await loadApiKeys();
return { success: true };
} catch (error: any) {
console.error("Failed to revoke API key:", error);
return { success: false, error: error.message || "Failed to revoke" };
}
};
const clearGeneratedKey = () => {
setGeneratedApiKey(null);
};
return {
apiKeys,
loadingApiKeys,
showCreateApiKeyDialog,
newApiKeyName,
generatedApiKey,
creatingApiKey,
setShowCreateApiKeyDialog,
setNewApiKeyName,
loadApiKeys,
createApiKey,
revokeApiKey,
clearGeneratedKey,
};
}

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import { UserSettings } from "../types";
const defaultSettings: UserSettings = {
playbackQuality: "original",
wifiOnly: false,
offlineEnabled: false,
maxCacheSizeMb: 5120,
};
export function useSettingsData() {
const { isAuthenticated } = useAuth();
const [settings, setSettings] = useState<UserSettings>(defaultSettings);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (isAuthenticated) {
loadSettings();
}
}, [isAuthenticated]);
const loadSettings = async () => {
try {
setIsLoading(true);
const data = await api.getSettings();
setSettings(data);
} catch (error) {
console.error("Failed to load settings:", error);
// No toast - error visible in UI if settings fail to load
} finally {
setIsLoading(false);
}
};
const saveSettings = async (newSettings: UserSettings) => {
try {
setIsSaving(true);
await api.updateSettings(newSettings);
setSettings(newSettings);
// No toast - caller shows inline status
} catch (error) {
console.error("Failed to save settings:", error);
throw error; // Re-throw so caller can show inline error
} finally {
setIsSaving(false);
}
};
const updateSettings = (updates: Partial<UserSettings>) => {
setSettings((prev) => ({ ...prev, ...updates }));
};
return {
settings,
isLoading,
isSaving,
setSettings,
updateSettings,
saveSettings,
loadSettings,
};
}

View File

@@ -0,0 +1,210 @@
import { useState, useEffect } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import { SystemSettings } from "../types";
const defaultSystemSettings: SystemSettings = {
lidarrEnabled: true,
lidarrUrl: "http://localhost:8686",
lidarrApiKey: "",
openaiEnabled: false,
openaiApiKey: "",
openaiModel: "gpt-4",
fanartEnabled: false,
fanartApiKey: "",
audiobookshelfEnabled: false,
audiobookshelfUrl: "http://localhost:13378",
audiobookshelfApiKey: "",
soulseekUsername: "",
soulseekPassword: "",
spotifyClientId: "",
spotifyClientSecret: "",
musicPath: "/music",
downloadPath: "/downloads",
transcodeCacheMaxGb: 10,
maxCacheSizeMb: 10240,
autoSync: true,
autoEnrichMetadata: true,
// Download preferences
downloadSource: "soulseek",
soulseekFallback: "none",
};
export function useSystemSettings() {
const { isAuthenticated, user } = useAuth();
const [systemSettings, setSystemSettings] = useState<SystemSettings>(
defaultSystemSettings
);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [changedServices, setChangedServices] = useState<string[]>([]);
const [originalSettings, setOriginalSettings] = useState<SystemSettings>(
defaultSystemSettings
);
const isAdmin = user?.role === "admin";
useEffect(() => {
if (isAuthenticated && isAdmin) {
loadSystemSettings();
}
}, [isAuthenticated, isAdmin]);
const loadSystemSettings = async () => {
try {
setIsLoading(true);
const [sysData, userData] = await Promise.all([
api.getSystemSettings(),
api.getSettings(),
]);
// Sanitize null values to empty strings for controlled inputs
const sanitizeSettings = (settings: any): SystemSettings => {
const sanitized: any = {};
for (const key in settings) {
const value = settings[key];
// Convert null to empty string for string fields
if (value === null && typeof defaultSystemSettings[key as keyof SystemSettings] === 'string') {
sanitized[key] = '';
} else {
sanitized[key] = value;
}
}
return sanitized;
};
const combinedSettings = {
...sanitizeSettings(sysData),
maxCacheSizeMb: userData.maxCacheSizeMb,
};
setSystemSettings(combinedSettings);
setOriginalSettings(combinedSettings);
} catch (error) {
console.error("Failed to load system settings:", error);
// No toast - error will be visible in the UI if settings fail to load
} finally {
setIsLoading(false);
}
};
const saveSystemSettings = async (settingsToSave: SystemSettings, showToast = false) => {
try {
setIsSaving(true);
// Save system settings
await api.updateSystemSettings(settingsToSave);
// Also save user cache setting
await api.updateSettings({
maxCacheSizeMb: settingsToSave.maxCacheSizeMb,
});
// Determine which services changed
const changed: string[] = [];
if (
originalSettings.lidarrEnabled !==
settingsToSave.lidarrEnabled ||
originalSettings.lidarrUrl !== settingsToSave.lidarrUrl ||
originalSettings.lidarrApiKey !== settingsToSave.lidarrApiKey
) {
changed.push("Lidarr");
}
if (
originalSettings.soulseekUsername !== settingsToSave.soulseekUsername ||
originalSettings.soulseekPassword !== settingsToSave.soulseekPassword
) {
changed.push("Soulseek");
}
if (
originalSettings.audiobookshelfEnabled !==
settingsToSave.audiobookshelfEnabled ||
originalSettings.audiobookshelfUrl !==
settingsToSave.audiobookshelfUrl ||
originalSettings.audiobookshelfApiKey !==
settingsToSave.audiobookshelfApiKey
) {
changed.push("Audiobookshelf");
}
setChangedServices(changed);
setOriginalSettings(settingsToSave);
return changed; // Return changed services
} catch (error) {
console.error("Failed to save system settings:", error);
throw error; // Caller handles the error display
} finally {
setIsSaving(false);
}
};
const updateSystemSettings = (updates: Partial<SystemSettings>) => {
setSystemSettings((prev) => ({ ...prev, ...updates }));
};
/**
* Test a service connection
* Returns { success: true, version?: string } or { success: false, error: string }
* Caller handles displaying the result inline
*/
const testService = async (service: string): Promise<{ success: boolean; version?: string; error?: string }> => {
try {
let result;
switch (service) {
case "lidarr":
result = await api.testLidarr(
systemSettings.lidarrUrl,
systemSettings.lidarrApiKey
);
break;
case "openai":
result = await api.testOpenai(
systemSettings.openaiApiKey,
systemSettings.openaiModel
);
break;
case "fanart":
result = await api.testFanart(systemSettings.fanartApiKey);
break;
case "audiobookshelf":
result = await api.testAudiobookshelf(
systemSettings.audiobookshelfUrl,
systemSettings.audiobookshelfApiKey
);
break;
case "soulseek":
result = await api.testSoulseek(
systemSettings.soulseekUsername,
systemSettings.soulseekPassword
);
break;
case "spotify":
result = await api.testSpotify(
systemSettings.spotifyClientId,
systemSettings.spotifyClientSecret
);
break;
default:
throw new Error(`Unknown service: ${service}`);
}
return { success: true, version: result?.version };
} catch (error: any) {
console.error(`Failed to test ${service}:`, error);
return { success: false, error: error.message || `Failed to connect` };
}
};
return {
systemSettings,
isLoading,
isSaving,
changedServices,
setSystemSettings,
updateSystemSettings,
saveSystemSettings,
testService,
loadSystemSettings,
};
}

View File

@@ -0,0 +1,137 @@
import { useState, useCallback, useRef } from "react";
import { api } from "@/lib/api";
export function useTwoFactor() {
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
const [loadingTwoFactor, setLoadingTwoFactor] = useState(false);
const [settingUpTwoFactor, setSettingUpTwoFactor] = useState(false);
const [twoFactorSecret, setTwoFactorSecret] = useState("");
const [twoFactorQR, setTwoFactorQR] = useState("");
const [twoFactorToken, setTwoFactorToken] = useState("");
const [disablingTwoFactor, setDisablingTwoFactor] = useState(false);
const [disableTwoFactorPassword, setDisableTwoFactorPassword] = useState("");
const [disableTwoFactorToken, setDisableTwoFactorToken] = useState("");
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [showRecoveryCodes, setShowRecoveryCodes] = useState(false);
// Retry tracking to prevent infinite loops on failure
const retryCountRef = useRef(0);
const maxRetries = 3;
const hasFailedRef = useRef(false);
const load2FAStatus = useCallback(async () => {
// Don't retry if we've already failed too many times
if (hasFailedRef.current) {
return;
}
try {
setLoadingTwoFactor(true);
const status = await api.get("/auth/2fa/status");
setTwoFactorEnabled(status.enabled);
// Reset retry count on success
retryCountRef.current = 0;
} catch (error) {
console.error("Failed to load 2FA status:", error);
retryCountRef.current++;
// Stop retrying after max attempts
if (retryCountRef.current >= maxRetries) {
hasFailedRef.current = true;
console.warn(`2FA status load failed after ${maxRetries} attempts, giving up`);
}
} finally {
setLoadingTwoFactor(false);
}
}, []);
const setup2FA = async () => {
try {
setLoadingTwoFactor(true);
const response = await api.post("/auth/2fa/setup", {});
setTwoFactorSecret(response.secret);
setTwoFactorQR(response.qrCode);
setSettingUpTwoFactor(true);
} catch (error: any) {
console.error("Failed to setup 2FA:", error);
throw error; // Let caller handle display
} finally {
setLoadingTwoFactor(false);
}
};
const enable2FA = async (token: string) => {
try {
setLoadingTwoFactor(true);
const response = await api.post("/auth/2fa/enable", {
secret: twoFactorSecret,
token,
});
setRecoveryCodes(response.recoveryCodes);
setShowRecoveryCodes(true);
setTwoFactorEnabled(true);
setSettingUpTwoFactor(false);
setTwoFactorToken("");
} catch (error: any) {
console.error("Failed to enable 2FA:", error);
throw error; // Let caller handle display
} finally {
setLoadingTwoFactor(false);
}
};
const disable2FA = async (password: string, token: string) => {
try {
setDisablingTwoFactor(true);
await api.post("/auth/2fa/disable", {
password,
token,
});
setTwoFactorEnabled(false);
setDisableTwoFactorPassword("");
setDisableTwoFactorToken("");
} catch (error: any) {
console.error("Failed to disable 2FA:", error);
throw error; // Let caller handle display
} finally {
setDisablingTwoFactor(false);
}
};
const cancel2FASetup = () => {
setSettingUpTwoFactor(false);
setTwoFactorToken("");
setTwoFactorSecret("");
setTwoFactorQR("");
};
const closeRecoveryCodes = () => {
setShowRecoveryCodes(false);
setRecoveryCodes([]);
};
return {
twoFactorEnabled,
loadingTwoFactor,
settingUpTwoFactor,
twoFactorSecret,
twoFactorQR,
twoFactorToken,
disablingTwoFactor,
disableTwoFactorPassword,
disableTwoFactorToken,
recoveryCodes,
showRecoveryCodes,
setTwoFactorToken,
setDisableTwoFactorPassword,
setDisableTwoFactorToken,
load2FAStatus,
setup2FA,
enable2FA,
disable2FA,
cancel2FASetup,
closeRecoveryCodes,
};
}