diff --git a/routes/system/config.py b/routes/system/config.py index 95fead3..e86a2b1 100644 --- a/routes/system/config.py +++ b/routes/system/config.py @@ -40,6 +40,90 @@ NOTIFY_PARAMETERS = [ ] +# Helper functions to get final merged configs (simulate save without actually saving) +def get_final_main_config(new_config_data: dict) -> dict: + """Returns the final main config that will be saved after merging with new_config_data.""" + try: + # Load current or default config + existing_config = {} + if MAIN_CONFIG_FILE_PATH.exists(): + with open(MAIN_CONFIG_FILE_PATH, "r") as f_read: + existing_config = json.load(f_read) + else: + existing_config = DEFAULT_MAIN_CONFIG.copy() + + # Update with new data + for key, value in new_config_data.items(): + existing_config[key] = value + + # Migration: unify legacy keys to camelCase + _migrate_legacy_keys_inplace(existing_config) + + # Ensure all default keys are still there + for default_key, default_value in DEFAULT_MAIN_CONFIG.items(): + if default_key not in existing_config: + existing_config[default_key] = default_value + + return existing_config + except Exception as e: + logger.error(f"Error creating final main config: {e}", exc_info=True) + return DEFAULT_MAIN_CONFIG.copy() + + +def get_final_watch_config(new_watch_config_data: dict) -> dict: + """Returns the final watch config that will be saved after merging with new_watch_config_data.""" + try: + # Load current main config + main_cfg: dict = {} + if WATCH_MAIN_CONFIG_FILE_PATH.exists(): + with open(WATCH_MAIN_CONFIG_FILE_PATH, "r") as f: + main_cfg = json.load(f) or {} + else: + main_cfg = DEFAULT_MAIN_CONFIG.copy() + + # Get and update watch config + watch_value = main_cfg.get("watch") + current_watch = ( + watch_value.copy() if isinstance(watch_value, dict) else {} + ).copy() + current_watch.update(new_watch_config_data or {}) + + # Ensure defaults + for k, v in DEFAULT_WATCH_CONFIG.items(): + if k not in current_watch: + current_watch[k] = v + + return current_watch + except Exception as e: + logger.error(f"Error creating final watch config: {e}", exc_info=True) + return DEFAULT_WATCH_CONFIG.copy() + + +def get_final_main_config_for_watch(new_watch_config_data: dict) -> dict: + """Returns the final main config when updating watch config.""" + try: + # Load current main config + main_cfg: dict = {} + if WATCH_MAIN_CONFIG_FILE_PATH.exists(): + with open(WATCH_MAIN_CONFIG_FILE_PATH, "r") as f: + main_cfg = json.load(f) or {} + else: + main_cfg = DEFAULT_MAIN_CONFIG.copy() + + # Migrate legacy keys + _migrate_legacy_keys_inplace(main_cfg) + + # Ensure all default keys are still there + for default_key, default_value in DEFAULT_MAIN_CONFIG.items(): + if default_key not in main_cfg: + main_cfg[default_key] = default_value + + return main_cfg + except Exception as e: + logger.error(f"Error creating final main config for watch: {e}", exc_info=True) + return DEFAULT_MAIN_CONFIG.copy() + + # Helper function to check if credentials exist for a service def has_credentials(service: str) -> bool: """Check if credentials exist for the specified service (spotify or deezer).""" @@ -68,9 +152,12 @@ def validate_config(config_data: dict, watch_config: dict = None) -> tuple[bool, Returns (is_valid, error_message). """ try: - # Get current watch config if not provided + # Get final merged watch config for validation if watch_config is None: - watch_config = get_watch_config_http() + if "watch" in config_data: + watch_config = get_final_watch_config(config_data["watch"]) + else: + watch_config = get_watch_config_http() # Ensure realTimeMultiplier is a valid integer in range 0..10 if provided if "realTimeMultiplier" in config_data or "real_time_multiplier" in config_data: @@ -137,9 +224,9 @@ def validate_watch_config( Returns (is_valid, error_message). """ try: - # Get current main config if not provided + # Get final merged main config for validation if main_config is None: - main_config = get_config() + main_config = get_final_main_config_for_watch(watch_data) # Check if trying to enable watch without download methods if watch_data.get("enabled", False): diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index a7c56f4..f441487 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -1305,11 +1305,35 @@ def _fetch_artist_discography_page(artist_id: str, limit: int, offset: int) -> d for key in ("album_group", "single_group", "compilation_group", "appears_on_group"): grp = artist.get(key) if isinstance(grp, list): - all_items.extend(grp) + # Check if items are strings (IDs) or dictionaries (metadata) + if grp and isinstance(grp[0], str): + # Items are album IDs as strings, fetch metadata for each + for album_id in grp: + try: + album_data = client.get_album(album_id, include_tracks=False) + if album_data: + # Add the album_group type for filtering + album_data["album_group"] = key.replace("_group", "") + all_items.append(album_data) + except Exception as e: + logger.warning(f"Failed to fetch album {album_id}: {e}") + else: + # Items are already dictionaries (album metadata) + for item in grp: + if isinstance(item, dict): + # Ensure album_group is set for filtering + if "album_group" not in item: + item["album_group"] = key.replace("_group", "") + all_items.append(item) elif isinstance(grp, dict): items = grp.get("items") or grp.get("releases") or [] if isinstance(items, list): - all_items.extend(items) + for item in items: + if isinstance(item, dict): + # Ensure album_group is set for filtering + if "album_group" not in item: + item["album_group"] = key.replace("_group", "") + all_items.append(item) total = len(all_items) start = max(0, offset or 0) end = start + max(1, limit or 50) diff --git a/spotizerr-ui/src/components/config/ServerTab.tsx b/spotizerr-ui/src/components/config/ServerTab.tsx index c12732d..5b08ad8 100644 --- a/spotizerr-ui/src/components/config/ServerTab.tsx +++ b/spotizerr-ui/src/components/config/ServerTab.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm, Controller } from "react-hook-form"; import { authApiClient } from "../../lib/api-client"; import { toast } from "sonner"; @@ -16,12 +16,32 @@ interface WebhookSettings { available_events: string[]; // Provided by API, not saved } -// --- API Functions --- -const fetchSpotifyApiConfig = async (): Promise => { - const { data } = await authApiClient.client.get("/credentials/spotify_api_config"); - return data; +interface ServerConfig { + client_id?: string; + client_secret?: string; + utilityConcurrency?: number; + librespotConcurrency?: number; + url?: string; + events?: string[]; +} + +const fetchServerConfig = async (): Promise => { + const [spotifyConfig, generalConfig] = await Promise.all([ + authApiClient.client.get("/credentials/spotify_api_config").catch(() => ({ data: {} })), + authApiClient.getConfig(), + ]); + + return { + ...spotifyConfig.data, + ...generalConfig, + }; +}; + +const saveServerConfig = async (data: Partial) => { + const payload = { ...data }; + const { data: response } = await authApiClient.client.post("/config", payload); + return response; }; -const saveSpotifyApiConfig = (data: SpotifyApiSettings) => authApiClient.client.put("/credentials/spotify_api_config", data); const fetchWebhookConfig = async (): Promise => { // Mock a response since backend endpoint doesn't exist @@ -32,40 +52,34 @@ const fetchWebhookConfig = async (): Promise => { available_events: ["download_start", "download_complete", "download_failed", "watch_added"], }); }; -const saveWebhookConfig = (data: Partial) => { - toast.info("Webhook configuration is not available."); - return Promise.resolve(data); + +const saveWebhookConfig = async (data: Partial) => { + const payload = { ...data }; + const { data: response } = await authApiClient.client.post("/config", payload); + return response; }; + const testWebhook = (url: string) => { toast.info("Webhook testing is not available."); return Promise.resolve(url); }; // --- Components --- -function SpotifyApiForm() { - const queryClient = useQueryClient(); - const { data, isLoading } = useQuery({ queryKey: ["spotifyApiConfig"], queryFn: fetchSpotifyApiConfig }); +function SpotifyApiForm({ config, onConfigChange }: { config: ServerConfig; onConfigChange: (updates: Partial) => void }) { const { register, handleSubmit, reset } = useForm(); - const mutation = useMutation({ - mutationFn: saveSpotifyApiConfig, - onSuccess: () => { - toast.success("Spotify API settings saved!"); - queryClient.invalidateQueries({ queryKey: ["spotifyApiConfig"] }); - }, - onError: (e) => { - console.error("Failed to save Spotify API settings:", (e as any).message); - toast.error(`Failed to save: ${(e as any).message}`); - }, - }); - useEffect(() => { - if (data) reset(data); - }, [data, reset]); + if (config) { + reset({ + client_id: config.client_id || "", + client_secret: config.client_secret || "", + }); + } + }, [config, reset]); - const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData); - - if (isLoading) return

Loading Spotify API settings...

; + const onSubmit = (formData: SpotifyApiSettings) => { + onConfigChange(formData); + }; return (
@@ -73,15 +87,10 @@ function SpotifyApiForm() {
@@ -110,54 +119,31 @@ function SpotifyApiForm() { ); } -function UtilityConcurrencyForm() { - const queryClient = useQueryClient(); - const { data: configData, isLoading } = useQuery({ - queryKey: ["config"], - queryFn: () => authApiClient.getConfig(), - }); - +function UtilityConcurrencyForm({ config, onConfigChange }: { config: ServerConfig; onConfigChange: (updates: Partial) => void }) { const { register, handleSubmit, reset, formState: { isDirty } } = useForm<{ utilityConcurrency: number }>(); useEffect(() => { - if (configData) { - reset({ utilityConcurrency: Number(configData.utilityConcurrency ?? 1) }); + if (config) { + reset({ utilityConcurrency: Number(config.utilityConcurrency ?? 1) }); } - }, [configData, reset]); - - const mutation = useMutation({ - mutationFn: (payload: { utilityConcurrency: number }) => authApiClient.updateConfig(payload), - onSuccess: () => { - toast.success("Utility worker concurrency saved!"); - queryClient.invalidateQueries({ queryKey: ["config"] }); - }, - onError: (e) => { - toast.error(`Failed to save: ${(e as any).message}`); - }, - }); + }, [config, reset]); const onSubmit = (values: { utilityConcurrency: number }) => { const value = Math.max(1, Number(values.utilityConcurrency || 1)); - mutation.mutate({ utilityConcurrency: value }); + onConfigChange({ utilityConcurrency: value }); }; - if (isLoading) return

Loading server settings...

; - return (
@@ -179,55 +165,32 @@ function UtilityConcurrencyForm() { ); } -function LibrespotConcurrencyForm() { - const queryClient = useQueryClient(); - const { data: configData, isLoading } = useQuery({ - queryKey: ["config"], - queryFn: () => authApiClient.getConfig(), - }); - +function LibrespotConcurrencyForm({ config, onConfigChange }: { config: ServerConfig; onConfigChange: (updates: Partial) => void }) { const { register, handleSubmit, reset, formState: { isDirty } } = useForm<{ librespotConcurrency: number }>(); useEffect(() => { - if (configData) { - reset({ librespotConcurrency: Number(configData.librespotConcurrency ?? 2) }); + if (config) { + reset({ librespotConcurrency: Number(config.librespotConcurrency ?? 2) }); } - }, [configData, reset]); - - const mutation = useMutation({ - mutationFn: (payload: { librespotConcurrency: number }) => authApiClient.updateConfig(payload), - onSuccess: () => { - toast.success("Librespot concurrency saved!"); - queryClient.invalidateQueries({ queryKey: ["config"] }); - }, - onError: (e) => { - toast.error(`Failed to save: ${(e as any).message}`); - }, - }); + }, [config, reset]); const onSubmit = (values: { librespotConcurrency: number }) => { const raw = Number(values.librespotConcurrency || 2); const safe = Math.max(1, Math.min(16, raw)); - mutation.mutate({ librespotConcurrency: safe }); + onConfigChange({ librespotConcurrency: safe }); }; - if (isLoading) return

Loading server settings...

; - return (
@@ -293,7 +256,7 @@ function WebhookForm() { type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50" - title="Save Webhook" + title="Save Webhook Settings" > {mutation.isPending ? ( Saving @@ -356,24 +319,61 @@ function WebhookForm() { } export function ServerTab() { + const queryClient = useQueryClient(); + const [localConfig, setLocalConfig] = useState({}); + + const { data: serverConfig, isLoading } = useQuery({ + queryKey: ["serverConfig"], + queryFn: fetchServerConfig, + }); + + const mutation = useMutation({ + mutationFn: saveServerConfig, + onSuccess: () => { + toast.success("Server settings saved successfully!"); + queryClient.invalidateQueries({ queryKey: ["serverConfig"] }); + queryClient.invalidateQueries({ queryKey: ["config"] }); + }, + onError: (error) => { + console.error("Failed to save server settings", (error as any).message); + toast.error(`Failed to save server settings: ${(error as any).message}`); + }, + }); + + useEffect(() => { + if (serverConfig) { + setLocalConfig(serverConfig); + } + }, [serverConfig]); + + const handleConfigChange = (updates: Partial) => { + const newConfig = { ...localConfig, ...updates }; + setLocalConfig(newConfig); + mutation.mutate(newConfig); + }; + + if (isLoading) { + return
Loading server settings...
; + } + return (

Spotify API

Provide your own API credentials to avoid rate-limiting issues.

- +

Utility Worker

Tune background utility worker concurrency for low-powered systems.

- +

Librespot

Adjust Librespot client worker threads.

- +