mirror of
https://lavaforge.org/spotizerr/spotizerr.git
synced 2025-12-24 02:39:14 -05:00
Enforce real-time downloading or download fallback when watch feature is enabled
This commit is contained in:
164
routes/config.py
164
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"}
|
||||
|
||||
@@ -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} />;
|
||||
})}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user