Enforce real-time downloading or download fallback when watch feature is enabled

This commit is contained in:
Xoconoch
2025-08-03 16:12:35 -06:00
parent 95f0345006
commit 9d21a5b34a
4 changed files with 624 additions and 31 deletions

View File

@@ -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"}

View File

@@ -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: <FaStepForward className="icon-warning" />,
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: <FaHourglassHalf className="icon-muted" />,
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 (
<div className="p-4 rounded-xl border-2 shadow-lg mb-3 transition-all duration-300 hover:shadow-xl md:hover:scale-[1.02] bg-gradient-to-r from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/30 border-warning/30 dark:border-warning/40">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0">
{/* Main content */}
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
<div className="text-2xl text-warning bg-white/80 dark:bg-surface-dark/80 p-2 rounded-full shadow-sm flex-shrink-0">
<FaStepForward className="icon-warning" />
</div>
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2">
<TypeIcon className="icon-muted text-sm flex-shrink-0" />
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
{item.name}
</p>
</div>
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
{item.artist}
</p>
{/* Show current track info for parent downloads */}
{(item.downloadType === "album" || item.downloadType === "playlist") && trackInfo.title && (
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={trackInfo.title}>
{trackInfo.current}/{trackInfo.total}: {trackInfo.title}
</p>
)}
</div>
</div>
{/* Status and actions */}
<div className="flex items-center justify-between md:justify-end gap-3 md:gap-3 md:ml-4">
<div className="flex-1 md:flex-none md:text-right">
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm md:text-xs font-semibold text-warning bg-white/60 dark:bg-surface-dark/60 shadow-sm">
Skipped
</div>
</div>
{/* Remove button */}
<div className="flex gap-2 md:gap-1 flex-shrink-0">
<button
onClick={() => removeItem?.(item.id)}
className="p-3 md:p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-error hover:bg-error/10 transition-all duration-200 shadow-sm min-h-[44px] md:min-h-auto flex items-center justify-center"
aria-label="Remove"
>
<FaTimes className="text-base md:text-sm" />
</button>
</div>
</div>
</div>
{/* Skip reason */}
{item.error && (
<div className="mt-3 p-3 md:p-2 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm md:text-xs text-warning font-medium break-words">
Skipped: {item.error}
</p>
</div>
)}
</div>
);
};
// 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 && (
<div className="mt-3 p-3 md:p-2 bg-error/10 border border-error/20 rounded-lg">
<p className="text-sm md:text-xs text-error font-medium break-words">
{status === "cancelled" ? "Cancelled: " : "Error: "}
<div className={`mt-3 p-3 md:p-2 rounded-lg ${
status === "cancelled"
? "bg-warning/10 border border-warning/20"
: status === "skipped"
? "bg-warning/10 border border-warning/20"
: "bg-error/10 border border-error/20"
}`}>
<p className={`text-sm md:text-xs font-medium break-words ${
status === "cancelled"
? "text-warning"
: status === "skipped"
? "text-warning"
: "text-error"
}`}>
{status === "cancelled" ? "Cancelled: " : status === "skipped" ? "Skipped: " : "Error: "}
{item.error}
</p>
</div>
)}
{/* Summary for failed/skipped tracks */}
{isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && (
<div className="mt-3 p-3 md:p-2 bg-surface/50 dark:bg-surface-dark/50 rounded-lg">
{/* Summary for completed downloads with multiple tracks */}
{isTerminal && item.summary && item.downloadType !== "track" && (
<div className="mt-3 p-3 md:p-2 bg-surface/50 dark:bg-surface-dark/50 rounded-lg border border-border/20 dark:border-border-dark/20">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm md:text-xs font-semibold text-content-primary dark:text-content-primary-dark">
Download Summary
</h4>
<span className="text-xs text-content-muted dark:text-content-muted-dark">
{item.summary.total_successful + item.summary.total_failed + item.summary.total_skipped} tracks total
</span>
</div>
<div className="flex flex-wrap gap-3 md:gap-4 text-sm md:text-xs">
{item.summary.total_successful > 0 && (
<span className="flex items-center gap-2 md:gap-1">
<div className="w-3 h-3 md:w-2 md:h-2 bg-success rounded-full flex-shrink-0"></div>
<span className="text-success font-medium">{item.summary.total_successful} successful</span>
</span>
)}
{item.summary.total_failed > 0 && (
<span className="flex items-center gap-2 md:gap-1">
<div className="w-3 h-3 md:w-2 md:h-2 bg-error rounded-full flex-shrink-0"></div>
@@ -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<Map<string, number>>(new Map());
const previousStatusRef = useRef<Map<string, string>>(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<string, string>();
const newlyTerminated = new Map<string, number>();
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 && (
<span className="text-sm font-normal text-content-muted dark:text-content-muted-dark ml-2">
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}
</span>
)}
</h2>
@@ -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 <CancelledTaskCard key={item.id} item={item} />;
}
if (item._cachedStatus === "skipped") {
return <SkippedTaskCard key={item.id} item={item} />;
}
return <QueueItemCard key={item.id} item={item} cachedStatus={item._cachedStatus} />;
})}

View File

@@ -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<DownloadSettings>) => {
return response;
};
const fetchWatchConfig = async (): Promise<WatchConfig> => {
const { data } = await apiClient.get("/config/watch");
return data;
};
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name }));
};
// --- Component ---
export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
const queryClient = useQueryClient();
const [validationError, setValidationError] = useState<string>("");
// 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<DownloadSettings> = (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) {
<label htmlFor="fallbackToggle" className="text-content-primary dark:text-content-primary-dark">Download Fallback</label>
<input id="fallbackToggle" type="checkbox" {...register("fallback")} className="h-6 w-6 rounded" />
</div>
{/* Watch validation info */}
{watchConfig?.enabled && (
<div className="p-3 bg-info/10 border border-info/20 rounded-lg">
<p className="text-sm text-info font-medium mb-1">
Watch is currently enabled
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
At least one download method (Real-time or Fallback) must be enabled when using watch functionality.
</p>
</div>
)}
{/* Fallback account requirements info */}
{fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1">
Fallback accounts required
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
Download Fallback requires accounts for both Spotify and Deezer. Configure missing accounts in the Accounts tab.
</p>
</div>
)}
{/* Validation error display */}
{validationError && (
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
<p className="text-sm text-error font-medium">{validationError}</p>
</div>
)}
</div>
{/* Source Quality Settings */}
@@ -215,7 +327,7 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
<button
type="submit"
disabled={mutation.isPending}
disabled={mutation.isPending || !!validationError}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
>
{mutation.isPending ? "Saving..." : "Save Download Settings"}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm, type SubmitHandler, Controller } from "react-hook-form";
import apiClient from "../../lib/api-client";
import { toast } from "sonner";
@@ -15,12 +15,39 @@ interface WatchSettings {
watchedArtistAlbumGroup: AlbumGroup[];
}
interface DownloadSettings {
realTime: boolean;
fallback: boolean;
maxConcurrentDownloads: number;
convertTo: string;
bitrate: string;
maxRetries: number;
retryDelaySeconds: number;
retryDelayIncrease: number;
deezerQuality: string;
spotifyQuality: string;
}
interface Credential {
name: string;
}
// --- API Functions ---
const fetchWatchConfig = async (): Promise<WatchSettings> => {
const { data } = await apiClient.get("/config/watch");
return data;
};
const fetchDownloadConfig = async (): Promise<DownloadSettings> => {
const { data } = await apiClient.get("/config");
return data;
};
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
return data.map((name) => ({ name }));
};
const saveWatchConfig = async (data: Partial<WatchSettings>) => {
const { data: response } = await apiClient.post("/config/watch", data);
return response;
@@ -29,12 +56,33 @@ const saveWatchConfig = async (data: Partial<WatchSettings>) => {
// --- Component ---
export function WatchTab() {
const queryClient = useQueryClient();
const [validationError, setValidationError] = useState<string>("");
const { data: config, isLoading } = useQuery({
queryKey: ["watchConfig"],
queryFn: fetchWatchConfig,
});
// Fetch download config to validate requirements
const { data: downloadConfig } = useQuery({
queryKey: ["config"],
queryFn: fetchDownloadConfig,
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: saveWatchConfig,
onSuccess: () => {
@@ -46,7 +94,7 @@ export function WatchTab() {
},
});
const { register, handleSubmit, control, reset } = useForm<WatchSettings>();
const { register, handleSubmit, control, reset, watch } = useForm<WatchSettings>();
useEffect(() => {
if (config) {
@@ -54,7 +102,47 @@ export function WatchTab() {
}
}, [config, reset]);
const watchEnabled = watch("enabled");
// Validation effect for watch + download method requirement
useEffect(() => {
let error = "";
// Check if watch can be enabled (need download methods)
if (watchEnabled && downloadConfig && !downloadConfig.realTime && !downloadConfig.fallback) {
error = "To enable watch, either Real-time downloading or Download Fallback must be enabled in Download Settings.";
}
// Check fallback account requirements if watch is enabled and fallback is being used
if (watchEnabled && downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
const missingServices: string[] = [];
if (!spotifyCredentials?.length) missingServices.push("Spotify");
if (!deezerCredentials?.length) missingServices.push("Deezer");
error = `Watch with Fallback requires accounts for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`;
}
setValidationError(error);
}, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length]);
const onSubmit: SubmitHandler<WatchSettings> = (data) => {
// Check validation before submitting
if (data.enabled && downloadConfig && !downloadConfig.realTime && !downloadConfig.fallback) {
setValidationError("To enable watch, either Real-time downloading or Download Fallback must be enabled in Download Settings.");
toast.error("Validation failed: Watch requires at least one download method to be enabled in Download Settings.");
return;
}
// Check fallback account requirements if enabling watch with fallback
if (data.enabled && downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length)) {
const missingServices: string[] = [];
if (!spotifyCredentials?.length) missingServices.push("Spotify");
if (!deezerCredentials?.length) missingServices.push("Deezer");
const error = `Watch with Fallback requires accounts for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`;
setValidationError(error);
toast.error("Validation failed: " + error);
return;
}
mutation.mutate({
...data,
watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds),
@@ -73,6 +161,38 @@ export function WatchTab() {
<label htmlFor="watchEnabledToggle" className="text-content-primary dark:text-content-primary-dark">Enable Watchlist</label>
<input id="watchEnabledToggle" type="checkbox" {...register("enabled")} className="h-6 w-6 rounded" />
</div>
{/* Download requirements info */}
{downloadConfig && (!downloadConfig.realTime && !downloadConfig.fallback) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1">
Download methods required
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
To use watch functionality, enable either Real-time downloading or Download Fallback in the Downloads tab.
</p>
</div>
)}
{/* Fallback account requirements info */}
{downloadConfig?.fallback && (!spotifyCredentials?.length || !deezerCredentials?.length) && (
<div className="p-3 bg-warning/10 border border-warning/20 rounded-lg">
<p className="text-sm text-warning font-medium mb-1">
Fallback accounts required
</p>
<p className="text-xs text-content-muted dark:text-content-muted-dark">
Download Fallback is enabled but requires accounts for both Spotify and Deezer. Configure accounts in the Accounts tab.
</p>
</div>
)}
{/* Validation error display */}
{validationError && (
<div className="p-3 bg-error/10 border border-error/20 rounded-lg">
<p className="text-sm text-error font-medium">{validationError}</p>
</div>
)}
<div className="flex flex-col gap-2">
<label htmlFor="watchPollIntervalSeconds" className="text-content-primary dark:text-content-primary-dark">Watch Poll Interval (seconds)</label>
<input
@@ -117,7 +237,7 @@ export function WatchTab() {
<button
type="submit"
disabled={mutation.isPending}
disabled={mutation.isPending || !!validationError}
className="px-4 py-2 bg-button-primary hover:bg-button-primary-hover text-button-primary-text rounded-md disabled:opacity-50"
>
{mutation.isPending ? "Saving..." : "Save Watch Settings"}