mirror of
https://lavaforge.org/spotizerr/spotizerr.git
synced 2025-12-24 02:39:14 -05:00
feat: Change watchlist behaviour. It now updates progressively based on maxItemsPerRun and runs a batch on intervals determined by watchPollInterval
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "spotizerr-ui",
|
||||
"private": true,
|
||||
"version": "3.2.0",
|
||||
"version": "3.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -13,6 +13,7 @@ interface WatchSettings {
|
||||
enabled: boolean;
|
||||
watchPollIntervalSeconds: number;
|
||||
watchedArtistAlbumGroup: AlbumGroup[];
|
||||
maxItemsPerRun: number;
|
||||
}
|
||||
|
||||
interface DownloadSettings {
|
||||
@@ -92,8 +93,9 @@ export function WatchTab() {
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
queryClient.invalidateQueries({ queryKey: ["watchConfig"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
onError: (error: any) => {
|
||||
const message = error?.response?.data?.error || error?.message || "Unknown error";
|
||||
toast.error(`Failed to save settings: ${message}`);
|
||||
setSaveStatus("error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
},
|
||||
@@ -108,6 +110,7 @@ export function WatchTab() {
|
||||
}, [config, reset]);
|
||||
|
||||
const watchEnabled = watch("enabled");
|
||||
const maxItemsPerRunValue = watch("maxItemsPerRun");
|
||||
|
||||
// Validation effect for watch + download method requirement
|
||||
useEffect(() => {
|
||||
@@ -125,9 +128,15 @@ export function WatchTab() {
|
||||
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]);
|
||||
}, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length, maxItemsPerRunValue]);
|
||||
|
||||
const onSubmit: SubmitHandler<WatchSettings> = (data) => {
|
||||
// Check validation before submitting
|
||||
@@ -148,9 +157,18 @@ export function WatchTab() {
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -225,7 +243,20 @@ export function WatchTab() {
|
||||
{...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 watched items for updates.</p>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -7,165 +7,176 @@ import { useAuth } from "./auth-context";
|
||||
// --- Case Conversion Utility ---
|
||||
// This is added here to simplify the fix and avoid module resolution issues.
|
||||
function snakeToCamel(str: string): string {
|
||||
return str.replace(/(_\w)/g, (m) => m[1].toUpperCase());
|
||||
return str.replace(/(_\w)/g, (m) => m[1].toUpperCase());
|
||||
}
|
||||
|
||||
function convertKeysToCamelCase(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((v) => convertKeysToCamelCase(v));
|
||||
}
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
return Object.keys(obj).reduce((acc: Record<string, unknown>, key: string) => {
|
||||
const camelKey = snakeToCamel(key);
|
||||
acc[camelKey] = convertKeysToCamelCase((obj as Record<string, unknown>)[key]);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return obj;
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((v) => convertKeysToCamelCase(v));
|
||||
}
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
return Object.keys(obj).reduce((acc: Record<string, unknown>, key: string) => {
|
||||
const camelKey = snakeToCamel(key);
|
||||
acc[camelKey] = convertKeysToCamelCase((obj as Record<string, unknown>)[key]);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Redefine AppSettings to match the flat structure of the API response
|
||||
export type FlatAppSettings = {
|
||||
service: "spotify" | "deezer";
|
||||
spotify: string;
|
||||
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||
deezer: string;
|
||||
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||
maxConcurrentDownloads: number;
|
||||
realTime: boolean;
|
||||
fallback: boolean;
|
||||
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||
bitrate: string;
|
||||
maxRetries: number;
|
||||
retryDelaySeconds: number;
|
||||
retryDelayIncrease: number;
|
||||
customDirFormat: string;
|
||||
customTrackFormat: string;
|
||||
tracknumPadding: boolean;
|
||||
saveCover: boolean;
|
||||
explicitFilter: boolean;
|
||||
// Add other fields from the old AppSettings as needed by other parts of the app
|
||||
watch: AppSettings["watch"];
|
||||
// Add defaults for the new download properties
|
||||
threads: number;
|
||||
path: string;
|
||||
skipExisting: boolean;
|
||||
m3u: boolean;
|
||||
hlsThreads: number;
|
||||
// Frontend-only flag used in DownloadsTab
|
||||
recursiveQuality: boolean;
|
||||
separateTracksByUser: boolean;
|
||||
// Add defaults for the new formatting properties
|
||||
track: string;
|
||||
album: string;
|
||||
playlist: string;
|
||||
compilation: string;
|
||||
artistSeparator: string;
|
||||
spotifyMetadata: boolean;
|
||||
realTimeMultiplier: number;
|
||||
service: "spotify" | "deezer";
|
||||
spotify: string;
|
||||
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||
deezer: string;
|
||||
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||
maxConcurrentDownloads: number;
|
||||
realTime: boolean;
|
||||
fallback: boolean;
|
||||
convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | "";
|
||||
bitrate: string;
|
||||
maxRetries: number;
|
||||
retryDelaySeconds: number;
|
||||
retryDelayIncrease: number;
|
||||
customDirFormat: string;
|
||||
customTrackFormat: string;
|
||||
tracknumPadding: boolean;
|
||||
saveCover: boolean;
|
||||
explicitFilter: boolean;
|
||||
// Add other fields from the old AppSettings as needed by other parts of the app
|
||||
watch: AppSettings["watch"];
|
||||
// Add defaults for the new download properties
|
||||
threads: number;
|
||||
path: string;
|
||||
skipExisting: boolean;
|
||||
m3u: boolean;
|
||||
hlsThreads: number;
|
||||
// Frontend-only flag used in DownloadsTab
|
||||
recursiveQuality: boolean;
|
||||
separateTracksByUser: boolean;
|
||||
// Add defaults for the new formatting properties
|
||||
track: string;
|
||||
album: string;
|
||||
playlist: string;
|
||||
compilation: string;
|
||||
artistSeparator: string;
|
||||
spotifyMetadata: boolean;
|
||||
realTimeMultiplier: number;
|
||||
};
|
||||
|
||||
const defaultSettings: FlatAppSettings = {
|
||||
service: "spotify",
|
||||
spotify: "",
|
||||
spotifyQuality: "NORMAL",
|
||||
deezer: "",
|
||||
deezerQuality: "MP3_128",
|
||||
maxConcurrentDownloads: 3,
|
||||
realTime: false,
|
||||
fallback: false,
|
||||
convertTo: "",
|
||||
bitrate: "",
|
||||
maxRetries: 3,
|
||||
retryDelaySeconds: 5,
|
||||
retryDelayIncrease: 5,
|
||||
customDirFormat: "%ar_album%/%album%",
|
||||
customTrackFormat: "%tracknum%. %music%",
|
||||
tracknumPadding: true,
|
||||
saveCover: true,
|
||||
explicitFilter: false,
|
||||
// Add defaults for the new download properties
|
||||
threads: 4,
|
||||
path: "/downloads",
|
||||
skipExisting: true,
|
||||
m3u: false,
|
||||
hlsThreads: 8,
|
||||
// Frontend-only default
|
||||
recursiveQuality: false,
|
||||
separateTracksByUser: false,
|
||||
// Add defaults for the new formatting properties
|
||||
track: "{artist_name}/{album_name}/{track_number} - {track_name}",
|
||||
album: "{artist_name}/{album_name}",
|
||||
playlist: "Playlists/{playlist_name}",
|
||||
compilation: "Compilations/{album_name}",
|
||||
artistSeparator: "; ",
|
||||
spotifyMetadata: true,
|
||||
watch: {
|
||||
enabled: false,
|
||||
},
|
||||
realTimeMultiplier: 0,
|
||||
service: "spotify",
|
||||
spotify: "",
|
||||
spotifyQuality: "NORMAL",
|
||||
deezer: "",
|
||||
deezerQuality: "MP3_128",
|
||||
maxConcurrentDownloads: 3,
|
||||
realTime: false,
|
||||
fallback: false,
|
||||
convertTo: "",
|
||||
bitrate: "",
|
||||
maxRetries: 3,
|
||||
retryDelaySeconds: 5,
|
||||
retryDelayIncrease: 5,
|
||||
customDirFormat: "%ar_album%/%album%",
|
||||
customTrackFormat: "%tracknum%. %music%",
|
||||
tracknumPadding: true,
|
||||
saveCover: true,
|
||||
explicitFilter: false,
|
||||
// Add defaults for the new download properties
|
||||
threads: 4,
|
||||
path: "/downloads",
|
||||
skipExisting: true,
|
||||
m3u: false,
|
||||
hlsThreads: 8,
|
||||
// Frontend-only default
|
||||
recursiveQuality: false,
|
||||
separateTracksByUser: false,
|
||||
// Add defaults for the new formatting properties
|
||||
track: "{artist_name}/{album_name}/{track_number} - {track_name}",
|
||||
album: "{artist_name}/{album_name}",
|
||||
playlist: "Playlists/{playlist_name}",
|
||||
compilation: "Compilations/{album_name}",
|
||||
artistSeparator: "; ",
|
||||
spotifyMetadata: true,
|
||||
watch: {
|
||||
enabled: false,
|
||||
maxItemsPerRun: 50,
|
||||
watchPollIntervalSeconds: 3600,
|
||||
watchedArtistAlbumGroup: ["album", "single"],
|
||||
},
|
||||
realTimeMultiplier: 0,
|
||||
};
|
||||
|
||||
interface FetchedCamelCaseSettings {
|
||||
watchEnabled?: boolean;
|
||||
watch?: { enabled: boolean };
|
||||
[key: string]: unknown;
|
||||
watchEnabled?: boolean;
|
||||
watch?: { enabled: boolean; maxItemsPerRun?: number; watchPollIntervalSeconds?: number; watchedArtistAlbumGroup?: string[] };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const fetchSettings = async (): Promise<FlatAppSettings> => {
|
||||
try {
|
||||
const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([
|
||||
authApiClient.client.get("/config"),
|
||||
authApiClient.client.get("/config/watch"),
|
||||
]);
|
||||
try {
|
||||
const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([
|
||||
authApiClient.client.get("/config"),
|
||||
authApiClient.client.get("/config/watch"),
|
||||
]);
|
||||
|
||||
const combinedConfig = {
|
||||
...generalConfig,
|
||||
watch: watchConfig,
|
||||
};
|
||||
const combinedConfig = {
|
||||
...generalConfig,
|
||||
watch: watchConfig,
|
||||
};
|
||||
|
||||
// Transform the keys before returning the data
|
||||
const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings;
|
||||
// Transform the keys before returning the data
|
||||
const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings;
|
||||
|
||||
const withDefaults: FlatAppSettings = {
|
||||
...(camelData as unknown as FlatAppSettings),
|
||||
// Ensure required frontend-only fields exist
|
||||
recursiveQuality: Boolean((camelData as any).recursiveQuality ?? false),
|
||||
realTimeMultiplier: Number((camelData as any).realTimeMultiplier ?? 0),
|
||||
};
|
||||
const withDefaults: FlatAppSettings = {
|
||||
...(camelData as unknown as FlatAppSettings),
|
||||
// Ensure required frontend-only fields exist
|
||||
recursiveQuality: Boolean((camelData as any).recursiveQuality ?? false),
|
||||
realTimeMultiplier: Number((camelData as any).realTimeMultiplier ?? 0),
|
||||
// Ensure watch subkeys default if missing
|
||||
watch: {
|
||||
...(camelData.watch as any),
|
||||
enabled: Boolean((camelData.watch as any)?.enabled ?? false),
|
||||
maxItemsPerRun: Number((camelData.watch as any)?.maxItemsPerRun ?? 50),
|
||||
watchPollIntervalSeconds: Number((camelData.watch as any)?.watchPollIntervalSeconds ?? 3600),
|
||||
watchedArtistAlbumGroup: (camelData.watch as any)?.watchedArtistAlbumGroup ?? ["album", "single"],
|
||||
},
|
||||
};
|
||||
|
||||
return withDefaults;
|
||||
} catch (error: any) {
|
||||
// If we get authentication errors, return default settings
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
console.log("Authentication required for config access, using default settings");
|
||||
return defaultSettings;
|
||||
}
|
||||
// Re-throw other errors for React Query to handle
|
||||
throw error;
|
||||
}
|
||||
return withDefaults;
|
||||
} catch (error: any) {
|
||||
// If we get authentication errors, return default settings
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
console.log("Authentication required for config access, using default settings");
|
||||
return defaultSettings;
|
||||
}
|
||||
// Re-throw other errors for React Query to handle
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const { isLoading, authEnabled, isAuthenticated, user } = useAuth();
|
||||
|
||||
// Only fetch settings when auth is ready and user is admin (or auth is disabled)
|
||||
const shouldFetchSettings = !isLoading && (!authEnabled || (isAuthenticated && user?.role === "admin"));
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading: isSettingsLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: fetchSettings,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: shouldFetchSettings, // Only run query when auth is ready and user is admin
|
||||
});
|
||||
const { isLoading, authEnabled, isAuthenticated, user } = useAuth();
|
||||
|
||||
// Only fetch settings when auth is ready and user is admin (or auth is disabled)
|
||||
const shouldFetchSettings = !isLoading && (!authEnabled || (isAuthenticated && user?.role === "admin"));
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading: isSettingsLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: fetchSettings,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: shouldFetchSettings, // Only run query when auth is ready and user is admin
|
||||
});
|
||||
|
||||
// Use default settings on error to prevent app crash
|
||||
const value = { settings: isError ? defaultSettings : settings || null, isLoading: isSettingsLoading };
|
||||
// Use default settings on error to prevent app crash
|
||||
const value = { settings: isError ? defaultSettings : settings || null, isLoading: isSettingsLoading };
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ export interface AppSettings {
|
||||
spotifyMetadata: boolean;
|
||||
watch: {
|
||||
enabled: boolean;
|
||||
// Add other watch properties from the old type if they still exist in the API response
|
||||
maxItemsPerRun: number;
|
||||
watchPollIntervalSeconds: number;
|
||||
watchedArtistAlbumGroup: string[];
|
||||
};
|
||||
// Add other root-level properties from the API if they exist
|
||||
realTimeMultiplier: number;
|
||||
|
||||
Reference in New Issue
Block a user