Audio Quality Analysis
@@ -82,85 +84,60 @@ export function AudioAnalysis({
return (
-
-
- Audio Quality Analysis
-
+ {filePath && (
+ {filePath}
+ )}
-
-
- {/* Technical Specifications */}
-
-
-
-
- Sample Rate
-
-
{(result.sample_rate / 1000).toFixed(1)} kHz
+
+ {/* Audio Properties - Single line */}
+
+
+
+ Sample Rate:
+ {(result.sample_rate / 1000).toFixed(1)} kHz
-
-
-
-
- Bit Depth
-
-
{result.bit_depth}
+
+
+ Bit Depth:
+ {result.bit_depth}
-
-
-
-
- Channels
-
-
{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels} channels`}
+
+
+ Channels:
+ {result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}
-
-
-
-
- Duration
-
-
{formatDuration(result.duration)}
+
+
+ Duration:
+ {formatDuration(result.duration)}
-
-
-
-
- Nyquist Frequency
-
-
{(nyquistFreq / 1000).toFixed(1)} kHz
+
+
+ Nyquist:
+ {(nyquistFreq / 1000).toFixed(1)} kHz
- {/* Dynamic Range Analysis */}
-
-
-
- Dynamic Range Analysis
+ {/* Dynamic Range - Single line */}
+
+
+
+ Dynamic Range:
+ {formatNumber(result.dynamic_range)} dB
-
-
-
-
Dynamic Range
-
{formatNumber(result.dynamic_range)} dB
-
-
-
Peak Level
-
{formatNumber(result.peak_amplitude)} dB
-
-
-
RMS Level
-
{formatNumber(result.rms_level)} dB
-
+
+ Peak:
+ {formatNumber(result.peak_amplitude)} dB
+
+
+ RMS:
+ {formatNumber(result.rms_level)} dB
+
+
+ Samples:
+ {result.total_samples.toLocaleString()}
-
-
- {/* Technical Info Footer */}
-
-
- Total Samples: {result.total_samples.toLocaleString()}
-
diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx
index 36f2a69..bb90977 100644
--- a/frontend/src/components/AudioAnalysisPage.tsx
+++ b/frontend/src/components/AudioAnalysisPage.tsx
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
-import { Upload, ArrowLeft } from "lucide-react";
+import { Upload, ArrowLeft, Trash2 } from "lucide-react";
import { AudioAnalysis } from "@/components/AudioAnalysis";
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
@@ -13,15 +13,13 @@ interface AudioAnalysisPageProps {
}
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
- const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
- const [selectedFilePath, setSelectedFilePath] = useState
("");
+ const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
- setSelectedFilePath(filePath);
await analyzeFile(filePath);
}
} catch (err) {
@@ -46,7 +44,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
return;
}
- setSelectedFilePath(filePath);
await analyzeFile(filePath);
},
[analyzeFile]
@@ -64,19 +61,26 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const handleAnalyzeAnother = () => {
clearResult();
- setSelectedFilePath("");
};
return (
{/* Header */}
-
- {onBack && (
-
diff --git a/frontend/src/components/AudioConverterPage.tsx b/frontend/src/components/AudioConverterPage.tsx
index b28d5c6..e03ac40 100644
--- a/frontend/src/components/AudioConverterPage.tsx
+++ b/frontend/src/components/AudioConverterPage.tsx
@@ -13,6 +13,7 @@ import {
AlertCircle,
Trash2,
FileMusic,
+ WandSparkles,
} from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
@@ -113,6 +114,19 @@ export function AudioConverterPage() {
saveState({ files, outputFormat, bitrate });
}, [files, outputFormat, bitrate, saveState]);
+ // Auto-set output format to M4A if all files are MP3
+ useEffect(() => {
+ if (files.length === 0) return;
+
+ const allMP3 = files.every((f) => f.format === "mp3");
+ if (allMP3 && outputFormat !== "m4a") {
+ setOutputFormat("m4a");
+ }
+ }, [files, outputFormat]);
+
+ // Check if format selection should be disabled (all files are MP3)
+ const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
+
// Detect fullscreen/maximized window
useEffect(() => {
const checkFullscreen = () => {
@@ -236,7 +250,20 @@ export function AudioConverterPage() {
};
const addFiles = useCallback((paths: string[]) => {
- const validExtensions = [".mp3", ".m4a", ".flac"];
+ const validExtensions = [".mp3", ".flac"];
+
+ // Check for M4A files specifically
+ const m4aFiles = paths.filter((path) => {
+ const ext = path.toLowerCase().slice(path.lastIndexOf("."));
+ return ext === ".m4a";
+ });
+
+ if (m4aFiles.length > 0) {
+ toast.error("M4A files not supported", {
+ description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.",
+ });
+ }
+
setFiles((prev) => {
const newFiles: AudioFile[] = paths
.filter((path) => {
@@ -266,7 +293,7 @@ export function AudioConverterPage() {
return [...prev, ...newFiles];
}
- if (paths.length > 0) {
+ if (paths.length > 0 && m4aFiles.length === 0) {
toast.info("No new files added", {
description: "All files were already added or have unsupported format",
});
@@ -462,8 +489,25 @@ export function AudioConverterPage() {
return (
{/* Header */}
-
+
Audio Converter
+ {files.length > 0 && (
+
+
+
+ Add More
+
+
+
+ Clear All
+
+
+ )}
{/* Drop Zone / File List */}
@@ -492,7 +536,7 @@ export function AudioConverterPage() {
{files.length === 0 ? (
<>
-
+
{isDragging
@@ -500,7 +544,7 @@ export function AudioConverterPage() {
: "Drag and drop audio files here, or click the button below to select"}
- Supported formats: MP3, M4A, FLAC
+ Supported formats: FLAC, MP3
@@ -520,13 +564,16 @@ export function AudioConverterPage() {
variant="outline"
value={outputFormat}
onValueChange={(value) => {
- if (value) setOutputFormat(value as "mp3" | "m4a");
+ if (value && !isFormatDisabled) setOutputFormat(value as "mp3" | "m4a");
}}
+ disabled={isFormatDisabled}
>
-
- MP3
-
-
+ {!isFormatDisabled && (
+
+ MP3
+
+ )}
+
M4A
@@ -552,21 +599,6 @@ export function AudioConverterPage() {
))}
-
-
-
- Add More
-
-
-
- Clear All
-
-
@@ -625,7 +657,7 @@ export function AudioConverterPage() {
>
) : (
<>
-
+
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
>
)}
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx
index c3c7881..fe107e8 100644
--- a/frontend/src/components/SearchBar.tsx
+++ b/frontend/src/components/SearchBar.tsx
@@ -43,6 +43,7 @@ export function SearchBar({
Supports track, album, playlist, and artist URLs
+ Note: Playlist must be public (not private)
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx
index 2582776..9f6a0c9 100644
--- a/frontend/src/components/SettingsPage.tsx
+++ b/frontend/src/components/SettingsPage.tsx
@@ -279,17 +279,27 @@ export function SettingsPage() {
- {/* Embed Lyrics */}
-
-
-
setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}
- />
+ {/* Embed Lyrics & Embed Max Quality Cover */}
+
+
+
+ setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}
+ />
+
+
+
+ setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}
+ />
+
-
+
{/* Folder Structure */}
@@ -339,7 +349,7 @@ export function SettingsPage() {
)}
-
+
{/* Filename Format */}
diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts
index 80ec3b8..44fce29 100644
--- a/frontend/src/hooks/useAudioAnalysis.ts
+++ b/frontend/src/hooks/useAudioAnalysis.ts
@@ -1,13 +1,63 @@
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect } from "react";
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
import type { AnalysisResult } from "@/types/api";
import { logger } from "@/lib/logger";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
+import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
+
+const STORAGE_KEY = "spotiflac_audio_analysis_state";
export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false);
- const [result, setResult] = useState
(null);
+ const [result, setResult] = useState(() => {
+ // Load from sessionStorage on mount - only detail, no spectrum
+ try {
+ const saved = sessionStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ if (parsed.filePath && parsed.result) {
+ // Return result WITHOUT spectrum - spectrum will be loaded async
+ return {
+ ...parsed.result,
+ spectrum: undefined,
+ };
+ }
+ }
+ } catch (err) {
+ console.error("Failed to load saved analysis state:", err);
+ }
+ return null;
+ });
+ const [selectedFilePath, setSelectedFilePath] = useState(() => {
+ // Load file path from sessionStorage
+ try {
+ const saved = sessionStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ return parsed.filePath || "";
+ }
+ } catch (err) {
+ // Ignore
+ }
+ return "";
+ });
const [error, setError] = useState(null);
+ const [spectrumLoading, setSpectrumLoading] = useState(() => {
+ // If result exists from sessionStorage, show loading for spectrum
+ try {
+ const saved = sessionStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ if (parsed.filePath && parsed.result) {
+ // Always show loading initially, will be resolved async
+ return true;
+ }
+ }
+ } catch (err) {
+ // Ignore
+ }
+ return false;
+ });
const analyzeFile = useCallback(async (filePath: string) => {
if (!filePath) {
@@ -18,6 +68,7 @@ export function useAudioAnalysis() {
setAnalyzing(true);
setError(null);
setResult(null);
+ setSelectedFilePath(filePath);
try {
logger.info(`Analyzing audio file: ${filePath}`);
@@ -29,7 +80,24 @@ export function useAudioAnalysis() {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
logger.success(`Audio analysis completed in ${elapsed}s`);
+ // Save spectrum to memory cache
+ if (analysisResult.spectrum) {
+ setSpectrumCache(filePath, analysisResult.spectrum);
+ }
+
+ // Save detail (without spectrum) to sessionStorage
+ const { spectrum, ...detailResult } = analysisResult;
+ try {
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
+ filePath,
+ result: detailResult,
+ }));
+ } catch (err) {
+ console.error("Failed to save analysis state:", err);
+ }
+
setResult(analysisResult);
+ setSpectrumLoading(false); // Spectrum is now available
return analysisResult;
} catch (err) {
@@ -48,12 +116,56 @@ export function useAudioAnalysis() {
const clearResult = useCallback(() => {
setResult(null);
setError(null);
+ setSelectedFilePath("");
+ try {
+ sessionStorage.removeItem(STORAGE_KEY);
+ } catch (err) {
+ // Ignore
+ }
+ clearSpectrumCache();
}, []);
+ // Load spectrum from cache asynchronously after detail is displayed
+ useEffect(() => {
+ // Only load spectrum if we have result without spectrum and are in loading state
+ if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
+ return;
+ }
+
+ // Load spectrum asynchronously to avoid blocking UI
+ // Use requestAnimationFrame to ensure detail renders first
+ let rafId: number;
+ const loadSpectrum = () => {
+ rafId = requestAnimationFrame(() => {
+ const cachedSpectrum = getSpectrumCache(selectedFilePath);
+ if (cachedSpectrum) {
+ setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
+ setSpectrumLoading(false);
+ } else {
+ // Spectrum not in cache - user needs to re-analyze
+ setSpectrumLoading(false);
+ }
+ });
+ };
+
+ // Double RAF to ensure detail is fully rendered
+ requestAnimationFrame(() => {
+ requestAnimationFrame(loadSpectrum);
+ });
+
+ return () => {
+ if (rafId) {
+ cancelAnimationFrame(rafId);
+ }
+ };
+ }, [result, selectedFilePath, spectrumLoading]);
+
return {
analyzing,
result,
error,
+ selectedFilePath,
+ spectrumLoading,
analyzeFile,
clearResult,
};
diff --git a/frontend/src/hooks/useCover.ts b/frontend/src/hooks/useCover.ts
index 6fa06bf..5c9efb1 100644
--- a/frontend/src/hooks/useCover.ts
+++ b/frontend/src/hooks/useCover.ts
@@ -40,18 +40,19 @@ export function useCover() {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
- // Build output path using template system
+ // Replace forward slashes in template data values to prevent them from being interpreted as path separators
+ const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
- artist: artistName,
- album: albumName,
- title: trackName,
+ artist: artistName?.replace(/\//g, placeholder),
+ album: albumName?.replace(/\//g, placeholder),
+ title: trackName?.replace(/\//g, placeholder),
track: position,
- playlist: playlistName,
+ playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
- outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
+ outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
// Apply folder template
@@ -60,7 +61,9 @@ export function useCover() {
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
- outputDir = joinPath(os, outputDir, sanitizePath(part, os));
+ // Restore any slashes that were in the original values as spaces
+ const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
+ outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
@@ -140,18 +143,20 @@ export function useCover() {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
+ // Replace forward slashes in template data values to prevent them from being interpreted as path separators
+ const placeholder = "__SLASH_PLACEHOLDER__";
// Build output path using template system
const templateData: TemplateData = {
- artist: track.artists,
- album: track.album_name,
- title: track.name,
+ artist: track.artists?.replace(/\//g, placeholder),
+ album: track.album_name?.replace(/\//g, placeholder),
+ title: track.name?.replace(/\//g, placeholder),
track: i + 1,
- playlist: playlistName,
+ playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
- outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
+ outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
// Apply folder template
@@ -160,7 +165,9 @@ export function useCover() {
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
- outputDir = joinPath(os, outputDir, sanitizePath(part, os));
+ // Restore any slashes that were in the original values as spaces
+ const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
+ outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts
index 74541bd..ff8367e 100644
--- a/frontend/src/hooks/useDownload.ts
+++ b/frontend/src/hooks/useDownload.ts
@@ -41,20 +41,22 @@ export function useDownload() {
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
+ // Replace forward slashes in template data values to prevent them from being interpreted as path separators
+ const placeholder = "__SLASH_PLACEHOLDER__";
// Build template data for folder path
const templateData: TemplateData = {
- artist: artistName,
- album: albumName,
- title: trackName,
+ artist: artistName?.replace(/\//g, placeholder),
+ album: albumName?.replace(/\//g, placeholder),
+ title: trackName?.replace(/\//g, placeholder),
track: position,
year: releaseYear,
- playlist: playlistName,
+ playlist: playlistName?.replace(/\//g, placeholder),
isrc: isrc,
};
// For playlist/discography downloads, always create a folder with the playlist/artist name
if (playlistName) {
- outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
+ outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
// Apply folder template if available
@@ -63,7 +65,9 @@ export function useDownload() {
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
- outputDir = joinPath(os, outputDir, sanitizePath(part, os));
+ // Restore any slashes that were in the original values as spaces
+ const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
+ outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
@@ -112,6 +116,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
+ embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.tidal_url,
duration: durationSeconds,
item_id: itemID, // Pass the same itemID through all attempts
@@ -146,6 +151,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
+ embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.deezer_url,
item_id: itemID,
});
@@ -178,6 +184,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
+ embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.amazon_url,
item_id: itemID,
});
@@ -208,6 +215,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
+ embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
item_id: itemID,
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
@@ -285,20 +293,21 @@ export function useDownload() {
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
- // Build template data for folder path
+ // Replace forward slashes in template data values to prevent them from being interpreted as path separators
+ const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
- artist: artistName,
- album: albumName,
- title: trackName,
+ artist: artistName?.replace(/\//g, placeholder),
+ album: albumName?.replace(/\//g, placeholder),
+ title: trackName?.replace(/\//g, placeholder),
track: position,
year: releaseYear,
- playlist: folderName,
+ playlist: folderName?.replace(/\//g, placeholder),
isrc: isrc,
};
// For playlist/discography downloads, always create a folder with the playlist/artist name
if (folderName && !isAlbum) {
- outputDir = joinPath(os, outputDir, sanitizePath(folderName, os));
+ outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
}
// Apply folder template if available
@@ -306,10 +315,12 @@ export function useDownload() {
// Parse and apply folder template
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
- // Split by / and sanitize each part
+ // Split by / (template separators), then restore placeholders as spaces
const parts = folderPath.split("/").filter(p => p.trim());
for (const part of parts) {
- outputDir = joinPath(os, outputDir, sanitizePath(part, os));
+ // Restore any slashes that were in the original values as spaces
+ const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
+ outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
@@ -352,6 +363,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
+ embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.tidal_url,
duration: durationSeconds,
item_id: itemID,
@@ -383,6 +395,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
+ embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.deezer_url,
item_id: itemID,
});
@@ -412,6 +425,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
+ embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.amazon_url,
item_id: itemID,
});
@@ -439,6 +453,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
+ embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
item_id: itemID,
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
diff --git a/frontend/src/hooks/useLyrics.ts b/frontend/src/hooks/useLyrics.ts
index 637806d..96fc1b4 100644
--- a/frontend/src/hooks/useLyrics.ts
+++ b/frontend/src/hooks/useLyrics.ts
@@ -37,17 +37,19 @@ export function useLyrics() {
let outputDir = settings.downloadPath;
// Build output path using template system
+ // Replace forward slashes in template data values to prevent them from being interpreted as path separators
+ const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
- artist: artistName,
- album: albumName,
- title: trackName,
+ artist: artistName?.replace(/\//g, placeholder),
+ album: albumName?.replace(/\//g, placeholder),
+ title: trackName?.replace(/\//g, placeholder),
track: position,
- playlist: playlistName,
+ playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
- outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
+ outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
// Apply folder template
@@ -56,7 +58,9 @@ export function useLyrics() {
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
- outputDir = joinPath(os, outputDir, sanitizePath(part, os));
+ // Restore any slashes that were in the original values as spaces
+ const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
+ outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
@@ -136,18 +140,20 @@ export function useLyrics() {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
+ // Replace forward slashes in template data values to prevent them from being interpreted as path separators
+ const placeholder = "__SLASH_PLACEHOLDER__";
// Build output path using template system
const templateData: TemplateData = {
- artist: track.artists,
- album: track.album_name,
- title: track.name,
+ artist: track.artists?.replace(/\//g, placeholder),
+ album: track.album_name?.replace(/\//g, placeholder),
+ title: track.name?.replace(/\//g, placeholder),
track: track.track_number,
- playlist: playlistName,
+ playlist: playlistName?.replace(/\//g, placeholder),
};
// For playlist/discography, prepend the folder name
if (playlistName) {
- outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
+ outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
// Apply folder template
@@ -156,7 +162,9 @@ export function useLyrics() {
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
- outputDir = joinPath(os, outputDir, sanitizePath(part, os));
+ // Restore any slashes that were in the original values as spaces
+ const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
+ outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 3902da5..6d4855e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -125,6 +125,54 @@
@apply text-blue-600;
}
+/* Ensure description text uses same color as title */
+[data-sonner-toast] [data-description],
+[data-sonner-toast] [data-description] * {
+ opacity: 1 !important;
+}
+
+/* Specific color for each toast type - match icon color */
+[data-sonner-toast][data-type="success"] [data-description],
+[data-sonner-toast][data-type="success"] [data-description] * {
+ color: rgb(22 163 74) !important; /* green-600 - same as icon */
+}
+
+[data-sonner-toast][data-type="error"] [data-description],
+[data-sonner-toast][data-type="error"] [data-description] * {
+ color: rgb(220 38 38) !important; /* red-600 - same as icon */
+}
+
+[data-sonner-toast][data-type="warning"] [data-description],
+[data-sonner-toast][data-type="warning"] [data-description] * {
+ color: rgb(202 138 4) !important; /* yellow-600 - same as icon */
+}
+
+[data-sonner-toast][data-type="info"] [data-description],
+[data-sonner-toast][data-type="info"] [data-description] * {
+ color: rgb(37 99 235) !important; /* blue-600 - same as icon */
+}
+
+/* Dark mode - use same icon colors */
+.dark [data-sonner-toast][data-type="success"] [data-description],
+.dark [data-sonner-toast][data-type="success"] [data-description] * {
+ color: rgb(22 163 74) !important; /* green-600 */
+}
+
+.dark [data-sonner-toast][data-type="error"] [data-description],
+.dark [data-sonner-toast][data-type="error"] [data-description] * {
+ color: rgb(220 38 38) !important; /* red-600 */
+}
+
+.dark [data-sonner-toast][data-type="warning"] [data-description],
+.dark [data-sonner-toast][data-type="warning"] [data-description] * {
+ color: rgb(202 138 4) !important; /* yellow-600 */
+}
+
+.dark [data-sonner-toast][data-type="info"] [data-description],
+.dark [data-sonner-toast][data-type="info"] [data-description] * {
+ color: rgb(37 99 235) !important; /* blue-600 */
+}
+
/* Dark mode toast styling */
.dark [data-sonner-toast][data-type="success"] {
@apply bg-green-950 border-green-800 text-green-100;
diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts
index 7872a76..a7128d2 100644
--- a/frontend/src/lib/settings.ts
+++ b/frontend/src/lib/settings.ts
@@ -26,6 +26,7 @@ export interface Settings {
trackNumber: boolean;
sfxEnabled: boolean;
embedLyrics: boolean;
+ embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
// Quality settings for specific sources
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
@@ -86,6 +87,7 @@ export const DEFAULT_SETTINGS: Settings = {
trackNumber: false,
sfxEnabled: true,
embedLyrics: false,
+ embedMaxQualityCover: false,
operatingSystem: detectOS(),
tidalQuality: "LOSSLESS", // Default: 16-bit lossless
qobuzQuality: "6" // Default: FLAC 16-bit
@@ -94,7 +96,7 @@ export const DEFAULT_SETTINGS: Settings = {
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
- { value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist Sans", system-ui, sans-serif' },
+ { value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
diff --git a/frontend/src/lib/spectrum-cache.ts b/frontend/src/lib/spectrum-cache.ts
new file mode 100644
index 0000000..313b6cc
--- /dev/null
+++ b/frontend/src/lib/spectrum-cache.ts
@@ -0,0 +1,21 @@
+// Memory cache for spectrum data (fast access, cleared on page refresh)
+// Key: file path, Value: spectrum data
+
+const spectrumCache = new Map();
+
+export function setSpectrumCache(filePath: string, spectrumData: any): void {
+ spectrumCache.set(filePath, spectrumData);
+}
+
+export function getSpectrumCache(filePath: string): any | null {
+ return spectrumCache.get(filePath) || null;
+}
+
+export function clearSpectrumCache(filePath?: string): void {
+ if (filePath) {
+ spectrumCache.delete(filePath);
+ } else {
+ spectrumCache.clear();
+ }
+}
+
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 30085b2..f17ef15 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -124,6 +124,7 @@ export interface DownloadRequest {
use_album_track_number?: boolean;
spotify_id?: string;
embed_lyrics?: boolean; // Whether to embed lyrics into the audio file
+ embed_max_quality_cover?: boolean; // Whether to embed max quality cover art
service_url?: string;
duration?: number; // Track duration in seconds for better matching
item_id?: string; // Optional queue item ID for multi-service fallback tracking