298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useForm, type SubmitHandler, Controller } from "react-hook-form";
|
||
import { authApiClient } from "../../lib/api-client";
|
||
import { toast } from "sonner";
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||
|
||
// --- Type Definitions ---
|
||
const ALBUM_GROUPS = ["album", "single", "compilation", "appears_on"] as const;
|
||
|
||
type AlbumGroup = (typeof ALBUM_GROUPS)[number];
|
||
|
||
interface WatchSettings {
|
||
enabled: boolean;
|
||
watchPollIntervalSeconds: number;
|
||
watchedArtistAlbumGroup: AlbumGroup[];
|
||
maxItemsPerRun: number;
|
||
}
|
||
|
||
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 authApiClient.client.get("/config/watch");
|
||
return data;
|
||
};
|
||
|
||
const fetchDownloadConfig = async (): Promise<DownloadSettings> => {
|
||
const { data } = await authApiClient.client.get("/config");
|
||
return data;
|
||
};
|
||
|
||
const fetchCredentials = async (service: "spotify" | "deezer"): Promise<Credential[]> => {
|
||
const { data } = await authApiClient.client.get<string[]>(`/credentials/${service}`);
|
||
return data.map((name) => ({ name }));
|
||
};
|
||
|
||
const saveWatchConfig = async (data: Partial<WatchSettings>) => {
|
||
const { data: response } = await authApiClient.client.post("/config/watch", data);
|
||
return response;
|
||
};
|
||
|
||
// --- 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: () => {
|
||
toast.success("Watch settings saved successfully!");
|
||
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
|
||
queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate main config to refresh watch.enabled in SettingsProvider
|
||
},
|
||
onError: (error: any) => {
|
||
const message = error?.response?.data?.error || error?.message || "Unknown error";
|
||
toast.error(`Failed to save settings: ${message}`);
|
||
console.error("Failed to save watch settings:", message);
|
||
},
|
||
});
|
||
|
||
const { register, handleSubmit, control, reset, watch } = useForm<WatchSettings>();
|
||
|
||
useEffect(() => {
|
||
if (config) {
|
||
reset(config);
|
||
}
|
||
}, [config, reset]);
|
||
|
||
const watchEnabled = watch("enabled");
|
||
const maxItemsPerRunValue = watch("maxItemsPerRun");
|
||
|
||
// 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.`;
|
||
}
|
||
|
||
// Validate maxItemsPerRun range (1..50)
|
||
const mir = Number(maxItemsPerRunValue);
|
||
if (!error && (Number.isNaN(mir) || mir < 1 || mir > 50)) {
|
||
error = "Max items per run must be between 1 and 50.";
|
||
}
|
||
|
||
setValidationError(error);
|
||
}, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length, maxItemsPerRunValue]);
|
||
|
||
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;
|
||
}
|
||
|
||
// Validate maxItemsPerRun in handler too, to be safe
|
||
const mir = Number(data.maxItemsPerRun);
|
||
if (Number.isNaN(mir) || mir < 1 || mir > 50) {
|
||
setValidationError("Max items per run must be between 1 and 50.");
|
||
toast.error("Validation failed: Max items per run must be between 1 and 50.");
|
||
return;
|
||
}
|
||
|
||
mutation.mutate({
|
||
...data,
|
||
watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds),
|
||
maxItemsPerRun: Number(data.maxItemsPerRun),
|
||
});
|
||
};
|
||
|
||
if (isLoading) {
|
||
return <div className="text-content-muted dark:text-content-muted-dark">Loading watch settings...</div>;
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||
<div className="flex items-center justify-end mb-4">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="submit"
|
||
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"
|
||
title="Save Watch Settings"
|
||
>
|
||
{mutation.isPending ? (
|
||
<img src="/spinner.svg" alt="Saving" className="w-5 h-5 animate-spin logo" />
|
||
) : (
|
||
<img src="/save.svg" alt="Save" className="w-5 h-5 logo" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">Watchlist Behavior</h3>
|
||
<div className="flex items-center justify-between">
|
||
<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
|
||
id="watchPollIntervalSeconds"
|
||
type="number"
|
||
min="60"
|
||
{...register("watchPollIntervalSeconds")}
|
||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||
/>
|
||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||
How often to check for new items in watchlist.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<label htmlFor="maxItemsPerRun" className="text-content-primary dark:text-content-primary-dark">
|
||
Max Items Per Run
|
||
</label>
|
||
<input
|
||
id="maxItemsPerRun"
|
||
type="number"
|
||
min="1"
|
||
max="50"
|
||
{...register("maxItemsPerRun")}
|
||
className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus"
|
||
/>
|
||
<p className="text-sm text-content-muted dark:text-content-muted-dark mt-1">
|
||
Batch size per watch cycle (1–50).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<h3 className="text-xl font-semibold text-content-primary dark:text-content-primary-dark">
|
||
Artist Album Groups
|
||
</h3>
|
||
<p className="text-sm text-content-muted dark:text-content-muted-dark">
|
||
Select which album groups to monitor for watched artists.
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||
{ALBUM_GROUPS.map((group) => (
|
||
<Controller
|
||
key={group}
|
||
name="watchedArtistAlbumGroup"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<label className="flex items-center gap-2 text-content-primary dark:text-content-primary-dark">
|
||
<input
|
||
type="checkbox"
|
||
className="h-5 w-5 rounded"
|
||
checked={field.value?.includes(group) ?? false}
|
||
onChange={(e) => {
|
||
const value = field.value || [];
|
||
const newValues = e.target.checked ? [...value, group] : value.filter((v) => v !== group);
|
||
field.onChange(newValues);
|
||
}}
|
||
/>
|
||
<span className="capitalize">{group.replace("_", " ")}</span>
|
||
</label>
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|