From 9d21a5b34a46a1f97dfabbf42035f062a2e5b294 Mon Sep 17 00:00:00 2001 From: Xoconoch Date: Sun, 3 Aug 2025 16:12:35 -0600 Subject: [PATCH] Enforce real-time downloading or download fallback when watch feature is enabled --- routes/config.py | 164 ++++++++++++ spotizerr-ui/src/components/Queue.tsx | 247 ++++++++++++++++-- .../src/components/config/DownloadsTab.tsx | 118 ++++++++- .../src/components/config/WatchTab.tsx | 126 ++++++++- 4 files changed, 624 insertions(+), 31 deletions(-) diff --git a/routes/config.py b/routes/config.py index 1565fac..9318a96 100644 --- a/routes/config.py +++ b/routes/config.py @@ -4,6 +4,7 @@ import json import logging import os from typing import Any +from pathlib import Path # Import the centralized config getters that handle file creation and defaults from routes.utils.celery_config import ( @@ -36,6 +37,101 @@ NOTIFY_PARAMETERS = [ ] +# 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).""" + try: + credentials_path = Path(f"./data/credentials/{service}") + if not credentials_path.exists(): + return False + + # Check if there are any credential files in the directory + credential_files = list(credentials_path.glob("*.json")) + return len(credential_files) > 0 + except Exception as e: + logger.warning(f"Error checking credentials for {service}: {e}") + return False + + +# Validation function for configuration consistency +def validate_config(config_data: dict, watch_config: dict = None) -> tuple[bool, str]: + """ + Validate configuration for consistency and requirements. + Returns (is_valid, error_message). + """ + try: + # Get current watch config if not provided + if watch_config is None: + watch_config = get_watch_config_http() + + # Check if fallback is enabled but missing required accounts + if config_data.get("fallback", False): + has_spotify = has_credentials("spotify") + has_deezer = has_credentials("deezer") + + if not has_spotify or not has_deezer: + missing_services = [] + if not has_spotify: + missing_services.append("Spotify") + if not has_deezer: + missing_services.append("Deezer") + + return False, f"Download Fallback requires accounts to be configured for both services. Missing: {', '.join(missing_services)}. Configure accounts before enabling fallback." + + # Check if watch is enabled but no download methods are available + if watch_config.get("enabled", False): + real_time = config_data.get("realTime", False) + fallback = config_data.get("fallback", False) + + if not real_time and not fallback: + return False, "Watch functionality requires either Real-time downloading or Download Fallback to be enabled." + + return True, "" + + except Exception as e: + logger.error(f"Error validating configuration: {e}", exc_info=True) + return False, f"Configuration validation error: {str(e)}" + + +def validate_watch_config(watch_data: dict, main_config: dict = None) -> tuple[bool, str]: + """ + Validate watch configuration for consistency and requirements. + Returns (is_valid, error_message). + """ + try: + # Get current main config if not provided + if main_config is None: + main_config = get_config() + + # Check if trying to enable watch without download methods + if watch_data.get("enabled", False): + real_time = main_config.get("realTime", False) + fallback = main_config.get("fallback", False) + + if not real_time and not fallback: + return False, "Cannot enable watch: either Real-time downloading or Download Fallback must be enabled in download settings." + + # If fallback is enabled, check for required accounts + if fallback: + has_spotify = has_credentials("spotify") + has_deezer = has_credentials("deezer") + + if not has_spotify or not has_deezer: + missing_services = [] + if not has_spotify: + missing_services.append("Spotify") + if not has_deezer: + missing_services.append("Deezer") + + return False, f"Cannot enable watch with fallback: missing accounts for {', '.join(missing_services)}. Configure accounts before enabling watch." + + return True, "" + + except Exception as e: + logger.error(f"Error validating watch configuration: {e}", exc_info=True) + return False, f"Watch configuration validation error: {str(e)}" + + # Helper to get main config (uses the one from celery_config) def get_config(): """Retrieves the main configuration, creating it with defaults if necessary.""" @@ -136,6 +232,14 @@ async def update_config(request: Request): explicit_filter_env = os.environ.get("EXPLICIT_FILTER", "false").lower() new_config["explicitFilter"] = explicit_filter_env in ("true", "1", "yes", "on") + # Validate configuration before saving + is_valid, error_message = validate_config(new_config) + if not is_valid: + raise HTTPException( + status_code=400, + detail={"error": "Configuration validation failed", "details": error_message} + ) + success, error_msg = save_config(new_config) if success: # Return the updated config @@ -182,6 +286,58 @@ async def check_config_changes(): ) +@router.post("/config/validate") +async def validate_config_endpoint(request: Request): + """Validate configuration without saving it.""" + try: + config_data = await request.json() + if not isinstance(config_data, dict): + raise HTTPException(status_code=400, detail={"error": "Invalid config format"}) + + is_valid, error_message = validate_config(config_data) + + return { + "valid": is_valid, + "message": "Configuration is valid" if is_valid else error_message, + "details": error_message if not is_valid else None + } + + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail={"error": "Invalid JSON data"}) + except Exception as e: + logger.error(f"Error in POST /config/validate: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail={"error": "Failed to validate configuration", "details": str(e)} + ) + + +@router.post("/config/watch/validate") +async def validate_watch_config_endpoint(request: Request): + """Validate watch configuration without saving it.""" + try: + watch_data = await request.json() + if not isinstance(watch_data, dict): + raise HTTPException(status_code=400, detail={"error": "Invalid watch config format"}) + + is_valid, error_message = validate_watch_config(watch_data) + + return { + "valid": is_valid, + "message": "Watch configuration is valid" if is_valid else error_message, + "details": error_message if not is_valid else None + } + + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail={"error": "Invalid JSON data"}) + except Exception as e: + logger.error(f"Error in POST /config/watch/validate: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail={"error": "Failed to validate watch configuration", "details": str(e)} + ) + + @router.get("/config/watch") async def handle_watch_config(): """Handles GET requests for the watch configuration.""" @@ -205,6 +361,14 @@ async def update_watch_config(request: Request): if not isinstance(new_watch_config, dict): raise HTTPException(status_code=400, detail={"error": "Invalid watch config format"}) + # Validate watch configuration before saving + is_valid, error_message = validate_watch_config(new_watch_config) + if not is_valid: + raise HTTPException( + status_code=400, + detail={"error": "Watch configuration validation failed", "details": error_message} + ) + success, error_msg = save_watch_config_http(new_watch_config) if success: return {"message": "Watch configuration updated successfully"} diff --git a/spotizerr-ui/src/components/Queue.tsx b/spotizerr-ui/src/components/Queue.tsx index 3b75133..c190b4f 100644 --- a/spotizerr-ui/src/components/Queue.tsx +++ b/spotizerr-ui/src/components/Queue.tsx @@ -1,5 +1,5 @@ import { useContext, useState, useRef, useEffect } from "react"; -import { FaTimes, FaSync, FaCheckCircle, FaExclamationCircle, FaHourglassHalf, FaMusic, FaCompactDisc } from "react-icons/fa"; +import { FaTimes, FaSync, FaCheckCircle, FaExclamationCircle, FaHourglassHalf, FaMusic, FaCompactDisc, FaStepForward } from "react-icons/fa"; import { QueueContext, type QueueItem, getStatus, getProgress, getCurrentTrackInfo, isActiveStatus, isTerminalStatus } from "@/contexts/queue-context"; // Circular Progress Component @@ -119,6 +119,13 @@ const statusStyles = { borderColor: "border-warning/30 dark:border-warning/40", name: "Cancelled", }, + skipped: { + icon: , + color: "text-warning", + bgColor: "bg-gradient-to-r from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/30", + borderColor: "border-warning/30 dark:border-warning/40", + name: "Skipped", + }, queued: { icon: , color: "text-content-muted dark:text-content-muted-dark", @@ -135,6 +142,77 @@ const statusStyles = { }, } as const; +// Skipped Task Component +const SkippedTaskCard = ({ item }: { item: QueueItem }) => { + const { removeItem } = useContext(QueueContext) || {}; + + const trackInfo = getCurrentTrackInfo(item); + const TypeIcon = item.downloadType === "album" ? FaCompactDisc : FaMusic; + + return ( +
+
+ + {/* Main content */} +
+
+ +
+ +
+
+ +

+ {item.name} +

+
+ +

+ {item.artist} +

+ + {/* Show current track info for parent downloads */} + {(item.downloadType === "album" || item.downloadType === "playlist") && trackInfo.title && ( +

+ {trackInfo.current}/{trackInfo.total}: {trackInfo.title} +

+ )} +
+
+ + {/* Status and actions */} +
+
+
+ Skipped +
+
+ + {/* Remove button */} +
+ +
+
+
+ + {/* Skip reason */} + {item.error && ( +
+

+ Skipped: {item.error} +

+
+ )} +
+ ); +}; + // Cancelled Task Component const CancelledTaskCard = ({ item }: { item: QueueItem }) => { const { removeItem } = useContext(QueueContext) || {}; @@ -314,18 +392,44 @@ const QueueItemCard = ({ item, cachedStatus }: { item: QueueItem, cachedStatus: {/* Error message */} {item.error && ( -
-

- {status === "cancelled" ? "Cancelled: " : "Error: "} +

+

+ {status === "cancelled" ? "Cancelled: " : status === "skipped" ? "Skipped: " : "Error: "} {item.error}

)} - {/* Summary for failed/skipped tracks */} - {isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && ( -
+ {/* Summary for completed downloads with multiple tracks */} + {isTerminal && item.summary && item.downloadType !== "track" && ( +
+
+

+ Download Summary +

+ + {item.summary.total_successful + item.summary.total_failed + item.summary.total_skipped} tracks total + +
+ {item.summary.total_successful > 0 && ( + +
+ {item.summary.total_successful} successful +
+ )} {item.summary.total_failed > 0 && (
@@ -359,8 +463,13 @@ export const Queue = () => { const [visibleItemCount, setVisibleItemCount] = useState(7); const [isLoadingMoreItems, setIsLoadingMoreItems] = useState(false); + // Track items that recently transitioned to terminal states + const [recentlyTerminated, setRecentlyTerminated] = useState>(new Map()); + const previousStatusRef = useRef>(new Map()); + const INITIAL_ITEM_COUNT = 7; const LOAD_MORE_THRESHOLD = 0.8; // Load more when 80% scrolled through visible items + const TERMINAL_STATE_DISPLAY_DURATION = 3000; // 3 seconds const { items = [], @@ -374,6 +483,87 @@ export const Queue = () => { totalTasks = 0 } = context || {}; + // Track status changes and identify transitions to terminal states + useEffect(() => { + if (!items || items.length === 0) return; + + const currentStatuses = new Map(); + const newlyTerminated = new Map(); + const currentTime = Date.now(); + + // Check each item for status changes + items.forEach(item => { + const currentStatus = getStatus(item); + const previousStatus = previousStatusRef.current.get(item.id); + currentStatuses.set(item.id, currentStatus); + + // If item transitioned from non-terminal to terminal state + if (previousStatus && + !isTerminalStatus(previousStatus) && + isTerminalStatus(currentStatus)) { + newlyTerminated.set(item.id, currentTime); + } + }); + + // Update previous statuses + previousStatusRef.current = currentStatuses; + + // Add newly terminated items to tracking + if (newlyTerminated.size > 0) { + setRecentlyTerminated(prev => { + const updated = new Map(prev); + newlyTerminated.forEach((timestamp, itemId) => { + updated.set(itemId, timestamp); + }); + return updated; + }); + + // Set up cleanup timers for newly terminated items + newlyTerminated.forEach((timestamp, itemId) => { + setTimeout(() => { + setRecentlyTerminated(prev => { + const updated = new Map(prev); + // Only remove if the timestamp matches (prevents removing newer entries) + if (updated.get(itemId) === timestamp) { + updated.delete(itemId); + } + return updated; + }); + }, TERMINAL_STATE_DISPLAY_DURATION); + }); + } + }, [items]); + + // Cleanup recently terminated items when items are removed from the queue + useEffect(() => { + if (!items || items.length === 0) { + setRecentlyTerminated(new Map()); + previousStatusRef.current = new Map(); + return; + } + + // Remove tracking for items that are no longer in the queue + const currentItemIds = new Set(items.map(item => item.id)); + setRecentlyTerminated(prev => { + const updated = new Map(); + prev.forEach((timestamp, itemId) => { + if (currentItemIds.has(itemId)) { + updated.set(itemId, timestamp); + } + }); + return updated; + }); + + // Clean up previous status tracking for removed items + const newPreviousStatuses = new Map(); + previousStatusRef.current.forEach((status, itemId) => { + if (currentItemIds.has(itemId)) { + newPreviousStatuses.set(itemId, status); + } + }); + previousStatusRef.current = newPreviousStatuses; + }, [items?.length]); // Trigger when items array length changes + // Infinite scroll and virtual scrolling useEffect(() => { if (!isVisible) return; @@ -575,7 +765,7 @@ export const Queue = () => { const getPriority = (status: string) => { const priorities = { "real-time": 1, downloading: 2, processing: 3, initializing: 4, - retrying: 5, queued: 6, done: 7, completed: 7, error: 8, cancelled: 9 + retrying: 5, queued: 6, done: 7, completed: 7, error: 8, cancelled: 9, skipped: 10 }; return priorities[status as keyof typeof priorities] || 10; }; @@ -583,6 +773,25 @@ export const Queue = () => { return getPriority(statusA) - getPriority(statusB); }); + // Helper function to determine if an item should be visible + const shouldShowItem = (item: QueueItem) => { + const status = getStatus(item); + + // Always show non-terminal items + if (!isTerminalStatus(status)) { + return true; + } + + // Show items that recently transitioned to terminal states (within 3 seconds) + // This includes done, error, cancelled, and skipped states + if (recentlyTerminated.has(item.id)) { + return true; + } + + // Show items with recent callbacks (items that were already terminal when first seen) + return (item.lastCallback && 'timestamp' in item.lastCallback); + }; + return ( <> {/* Mobile backdrop */} @@ -640,17 +849,7 @@ export const Queue = () => { Download Queue ({totalTasks}) {items.length > INITIAL_ITEM_COUNT && ( - Showing {Math.min(visibleItemCount, items.filter(item => { - const status = getStatus(item); - return !isTerminalStatus(status) || - (item.lastCallback && 'timestamp' in item.lastCallback) || - status === "cancelled"; - }).length)} of {items.filter(item => { - const status = getStatus(item); - return !isTerminalStatus(status) || - (item.lastCallback && 'timestamp' in item.lastCallback) || - status === "cancelled"; - }).length} + Showing {Math.min(visibleItemCount, items.filter(shouldShowItem).length)} of {items.filter(shouldShowItem).length} )} @@ -685,12 +884,7 @@ export const Queue = () => { style={{ touchAction: isDragging ? 'none' : 'pan-y' }} > {(() => { - const visibleItems = sortedItems.filter(item => { - const status = item._cachedStatus; - return !isTerminalStatus(status) || - (item.lastCallback && 'timestamp' in item.lastCallback) || - status === "cancelled"; - }); + const visibleItems = sortedItems.filter(shouldShowItem); // Apply virtual scrolling - only show limited number of items const itemsToRender = visibleItems.slice(0, visibleItemCount); @@ -711,6 +905,9 @@ export const Queue = () => { if (item._cachedStatus === "cancelled") { return ; } + if (item._cachedStatus === "skipped") { + return ; + } return ; })} diff --git a/spotizerr-ui/src/components/config/DownloadsTab.tsx b/spotizerr-ui/src/components/config/DownloadsTab.tsx index 850641d..dcf522f 100644 --- a/spotizerr-ui/src/components/config/DownloadsTab.tsx +++ b/spotizerr-ui/src/components/config/DownloadsTab.tsx @@ -1,8 +1,8 @@ import { useForm, type SubmitHandler } from "react-hook-form"; import apiClient from "../../lib/api-client"; import { toast } from "sonner"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; // --- Type Definitions --- interface DownloadSettings { @@ -23,6 +23,16 @@ interface DownloadSettings { spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH"; } +interface WatchConfig { + enabled: boolean; + interval: number; + playlists: string[]; +} + +interface Credential { + name: string; +} + interface DownloadsTabProps { config: DownloadSettings; isLoading: boolean; @@ -44,9 +54,40 @@ const saveDownloadConfig = async (data: Partial) => { return response; }; +const fetchWatchConfig = async (): Promise => { + const { data } = await apiClient.get("/config/watch"); + return data; +}; + +const fetchCredentials = async (service: "spotify" | "deezer"): Promise => { + const { data } = await apiClient.get(`/credentials/${service}`); + return data.map((name) => ({ name })); +}; + // --- Component --- export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { const queryClient = useQueryClient(); + const [validationError, setValidationError] = useState(""); + + // Fetch watch config + const { data: watchConfig } = useQuery({ + queryKey: ["watchConfig"], + queryFn: fetchWatchConfig, + staleTime: 30000, // 30 seconds + }); + + // Fetch credentials for fallback validation + const { data: spotifyCredentials } = useQuery({ + queryKey: ["credentials", "spotify"], + queryFn: () => fetchCredentials("spotify"), + staleTime: 30000, + }); + + const { data: deezerCredentials } = useQuery({ + queryKey: ["credentials", "deezer"], + queryFn: () => fetchCredentials("deezer"), + staleTime: 30000, + }); const mutation = useMutation({ mutationFn: saveDownloadConfig, @@ -70,8 +111,48 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) { }, [config, reset]); const selectedFormat = watch("convertTo"); + const realTime = watch("realTime"); + const fallback = watch("fallback"); + + // Validation effect for watch + download method requirement + useEffect(() => { + let error = ""; + + // Check watch requirements + if (watchConfig?.enabled && !realTime && !fallback) { + error = "When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled."; + } + + // Check fallback account requirements + if (fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) { + const missingServices: string[] = []; + if (!spotifyCredentials?.length) missingServices.push("Spotify"); + if (!deezerCredentials?.length) missingServices.push("Deezer"); + error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`; + } + + setValidationError(error); + }, [watchConfig?.enabled, realTime, fallback, spotifyCredentials?.length, deezerCredentials?.length]); const onSubmit: SubmitHandler = (data) => { + // Check watch requirements + if (watchConfig?.enabled && !data.realTime && !data.fallback) { + setValidationError("When watch is enabled, either Real-time downloading or Download Fallback (or both) must be enabled."); + toast.error("Validation failed: Watch requires at least one download method to be enabled."); + return; + } + + // Check fallback account requirements + if (data.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) { + const missingServices: string[] = []; + if (!spotifyCredentials?.length) missingServices.push("Spotify"); + if (!deezerCredentials?.length) missingServices.push("Deezer"); + const error = `Download Fallback requires accounts to be configured for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`; + setValidationError(error); + toast.error("Validation failed: " + error); + return; + } + mutation.mutate({ ...data, maxConcurrentDownloads: Number(data.maxConcurrentDownloads), @@ -108,6 +189,37 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
+ + {/* Watch validation info */} + {watchConfig?.enabled && ( +
+

+ Watch is currently enabled +

+

+ At least one download method (Real-time or Fallback) must be enabled when using watch functionality. +

+
+ )} + + {/* Fallback account requirements info */} + {fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && ( +
+

+ Fallback accounts required +

+

+ Download Fallback requires accounts for both Spotify and Deezer. Configure missing accounts in the Accounts tab. +

+
+ )} + + {/* Validation error display */} + {validationError && ( +
+

{validationError}

+
+ )}
{/* Source Quality Settings */} @@ -215,7 +327,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
+ + {/* Download requirements info */} + {downloadConfig && (!downloadConfig.realTime && !downloadConfig.fallback) && ( +
+

+ Download methods required +

+

+ To use watch functionality, enable either Real-time downloading or Download Fallback in the Downloads tab. +

+
+ )} + + {/* Fallback account requirements info */} + {downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && ( +
+

+ Fallback accounts required +

+

+ Download Fallback is enabled but requires accounts for both Spotify and Deezer. Configure accounts in the Accounts tab. +

+
+ )} + + {/* Validation error display */} + {validationError && ( +
+

{validationError}

+
+ )} +
{mutation.isPending ? "Saving..." : "Save Watch Settings"}