diff --git a/app.go b/app.go index b540e6a..67a6674 100644 --- a/app.go +++ b/app.go @@ -52,6 +52,7 @@ type DownloadRequest struct { UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file + EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"` // Whether to embed max quality cover art ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go index bb88541..3593f3c 100644 --- a/backend/ffmpeg.go +++ b/backend/ffmpeg.go @@ -350,7 +350,14 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { var lyrics string coverArtPath, _ = ExtractCoverArt(inputFile) - lyrics, _ = ExtractLyrics(inputFile) + lyrics, err = ExtractLyrics(inputFile) + if err != nil { + fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err) + } else if lyrics != "" { + fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics)) + } else { + fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile) + } // Build ffmpeg command args := []string{ diff --git a/backend/filename.go b/backend/filename.go index 6802062..efc3e80 100644 --- a/backend/filename.go +++ b/backend/filename.go @@ -54,9 +54,12 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include // sanitizeFilename removes invalid characters from filename func sanitizeFilename(name string) string { - // First, remove invalid filesystem characters - re := regexp.MustCompile(`[<>:"/\\|?*]`) - sanitized := re.ReplaceAllString(name, "_") + // Replace forward slash with space (more natural than underscore) + sanitized := strings.ReplaceAll(name, "/", " ") + + // Remove other invalid filesystem characters (replace with space) + re := regexp.MustCompile(`[<>:"\\|?*]`) + sanitized = re.ReplaceAllString(sanitized, " ") // Remove control characters (0x00-0x1F, 0x7F) var result strings.Builder @@ -94,12 +97,16 @@ func sanitizeFilename(name string) string { // Remove leading/trailing dots and spaces (Windows doesn't allow these) sanitized = strings.Trim(sanitized, ". ") - // Remove consecutive spaces and underscores - re = regexp.MustCompile(`[\s_]+`) + // Normalize consecutive spaces to single space + re = regexp.MustCompile(`\s+`) + sanitized = re.ReplaceAllString(sanitized, " ") + + // Normalize consecutive underscores to single underscore + re = regexp.MustCompile(`_+`) sanitized = re.ReplaceAllString(sanitized, "_") - // Remove leading/trailing underscores - sanitized = strings.Trim(sanitized, "_") + // Remove leading/trailing underscores and spaces + sanitized = strings.Trim(sanitized, "_ ") if sanitized == "" { return "Unknown" diff --git a/backend/metadata.go b/backend/metadata.go index 7adbcf9..aa1892a 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -3,6 +3,7 @@ package backend import ( "fmt" "os" + "os/exec" pathfilepath "path/filepath" "strconv" "strings" @@ -364,14 +365,22 @@ func extractLyricsFromMp3(filePath string) (string, error) { usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) if len(usltFrames) == 0 { + fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath) return "", nil } uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame) if !ok { + fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath) return "", nil } + if uslt.Lyrics == "" { + fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath) + return "", nil + } + + fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics)) return uslt.Lyrics, nil } @@ -395,13 +404,16 @@ func extractLyricsFromFlac(filePath string) (string, error) { if len(parts) == 2 { fieldName := strings.ToUpper(parts[0]) if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" { - return parts[1], nil + lyrics := parts[1] + fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics)) + return lyrics, nil } } } } } + fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath) return "", nil } @@ -492,7 +504,58 @@ func EmbedLyricsOnlyMP3(filepath string, lyrics string) error { return nil } -// EmbedLyricsOnlyUniversal embeds lyrics to MP3 or FLAC file +// embedLyricsToM4A adds lyrics to an M4A file using ffmpeg +func embedLyricsToM4A(filepath string, lyrics string) error { + // Use ffmpeg to embed lyrics into M4A file + // M4A uses iTunes metadata format with atom '©lyr' for lyrics + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return fmt.Errorf("ffmpeg not found: %w", err) + } + + // Create temporary output file with proper extension so ffmpeg can detect format + tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath) + defer func() { + // Only remove if file still exists (rename might have failed) + if _, err := os.Stat(tmpOutputFile); err == nil { + os.Remove(tmpOutputFile) + } + }() + + // Use ffmpeg to copy file and add lyrics metadata + // For M4A, we need to use the correct metadata tag format and specify output format + // Use -f ipod for M4A format (iPod format is compatible with M4A) + cmd := exec.Command(ffmpegPath, + "-i", filepath, + "-map", "0", + "-map_metadata", "0", + "-metadata", "lyrics-eng="+lyrics, + "-metadata", "lyrics="+lyrics, + "-codec", "copy", + "-f", "ipod", // Explicitly specify M4A/iPod format + "-y", // Overwrite + tmpOutputFile, + ) + + // Hide console window on Windows + setHideWindow(cmd) + + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output)) + return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err) + } + + // Replace original file with new file + if err := os.Rename(tmpOutputFile, filepath); err != nil { + return fmt.Errorf("failed to replace original file: %w", err) + } + + fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics)) + return nil +} + +// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error { if lyrics == "" { return nil @@ -504,6 +567,8 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error { return EmbedLyricsOnlyMP3(filepath, lyrics) case ".flac": return EmbedLyricsOnly(filepath, lyrics) + case ".m4a": + return embedLyricsToM4A(filepath, lyrics) default: return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext) } diff --git a/frontend/index.html b/frontend/index.html index 4e8b4a3..ac5d143 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,8 +6,7 @@ - - + SpotiFLAC diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 106c1e4..14288a5 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -39c59c100ededac1c1e21fae937a2755 \ No newline at end of file +7d8d0f3230f9ffbfba312943439831fc \ No newline at end of file diff --git a/frontend/src/components/AudioAnalysis.tsx b/frontend/src/components/AudioAnalysis.tsx index 6810228..18b87e4 100644 --- a/frontend/src/components/AudioAnalysis.tsx +++ b/frontend/src/components/AudioAnalysis.tsx @@ -1,4 +1,4 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { Button } from "@/components/ui/button"; import { @@ -17,13 +17,15 @@ interface AudioAnalysisProps { analyzing: boolean; onAnalyze?: () => void; showAnalyzeButton?: boolean; + filePath?: string; } export function AudioAnalysis({ result, analyzing, onAnalyze, - showAnalyzeButton = true + showAnalyzeButton = true, + filePath }: AudioAnalysisProps) { if (analyzing) { return ( @@ -43,7 +45,7 @@ export function AudioAnalysis({
- +

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 && ( - + )} +

Audio Quality Analyzer

+
+ {result && ( + )} -

Audio Quality Analyzer

{/* File Selection */} @@ -102,7 +106,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { style={{ "--wails-drop-target": "drop" } as React.CSSProperties} >
- +

{isDragging @@ -127,25 +131,23 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { {/* Analysis Results */} {result && (

- {/* File Info with Analyze Another Button */} -
-

{selectedFilePath}

- -
+ {/* Detailed Analysis */} + {/* Spectrum Visualization */} - - - {/* Detailed Analysis */} - + {spectrumLoading ? ( +
+
+

Loading spectrum data...

+
+ ) : ( + + )}
)}
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 && ( +
+ + +
+ )}
{/* 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

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