From 7d0fde3acc15bd5241222e4446c8809371f6974a Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Fri, 19 Dec 2025 13:29:28 +0700 Subject: [PATCH] v6.9 --- .github/workflows/build.yml | 2 +- app.go | 77 ++- backend/cover.go | 2 +- backend/ffmpeg.go | 44 +- backend/filemanager.go | 400 +++++++++++ backend/filename.go | 36 +- backend/lyrics.go | 23 +- backend/metadata.go | 84 +++ frontend/package.json | 11 +- frontend/package.json.md5 | 2 +- frontend/pnpm-lock.yaml | 642 +++++++++--------- frontend/src/App.tsx | 5 +- .../src/components/AudioConverterPage.tsx | 97 ++- frontend/src/components/DownloadQueue.tsx | 2 +- frontend/src/components/FileManagerPage.tsx | 578 ++++++++++++++++ frontend/src/components/SettingsPage.tsx | 159 +++-- frontend/src/components/Sidebar.tsx | 60 +- frontend/src/components/ui/alert-dialog.tsx | 141 ++++ frontend/src/hooks/useDownload.ts | 201 ++++-- frontend/src/lib/settings.ts | 24 +- go.mod | 2 +- tidal.json | 2 - wails.json | 2 +- 23 files changed, 2061 insertions(+), 535 deletions(-) create mode 100644 backend/filemanager.go create mode 100644 frontend/src/components/FileManagerPage.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e0921fb..fd41909 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: env: - GO_VERSION: '1.25.4' + GO_VERSION: '1.25.5' NODE_VERSION: '24' jobs: diff --git a/app.go b/app.go index 5a91f30..c6f8fdd 100644 --- a/app.go +++ b/app.go @@ -140,8 +140,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if req.OutputDir == "" { req.OutputDir = "." } else { - // Sanitize output directory path to remove invalid characters - req.OutputDir = backend.SanitizeFolderPath(req.OutputDir) + // Only normalize path separators, don't sanitize user's existing folder names + req.OutputDir = backend.NormalizePath(req.OutputDir) } if req.AudioFormat == "" { @@ -661,6 +661,7 @@ type ConvertAudioRequest struct { InputFiles []string `json:"input_files"` OutputFormat string `json:"output_format"` Bitrate string `json:"bitrate"` + Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless) } // ConvertAudio converts audio files using ffmpeg @@ -669,6 +670,7 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul InputFiles: req.InputFiles, OutputFormat: req.OutputFormat, Bitrate: req.Bitrate, + Codec: req.Codec, } return backend.ConvertAudio(backendReq) } @@ -681,3 +683,74 @@ func (a *App) SelectAudioFiles() ([]string, error) { } return files, nil } + +// ListDirectoryFiles lists files and folders in a directory +func (a *App) ListDirectoryFiles(dirPath string) ([]backend.FileInfo, error) { + if dirPath == "" { + return nil, fmt.Errorf("directory path is required") + } + return backend.ListDirectory(dirPath) +} + +// ListAudioFilesInDir lists only audio files in a directory recursively +func (a *App) ListAudioFilesInDir(dirPath string) ([]backend.FileInfo, error) { + if dirPath == "" { + return nil, fmt.Errorf("directory path is required") + } + return backend.ListAudioFiles(dirPath) +} + +// ReadFileMetadata reads metadata from an audio file +func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error) { + if filePath == "" { + return nil, fmt.Errorf("file path is required") + } + return backend.ReadAudioMetadata(filePath) +} + +// PreviewRenameFiles generates a preview of rename operations +func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview { + return backend.PreviewRename(files, format) +} + +// RenameFilesByMetadata renames files based on their metadata +func (a *App) RenameFilesByMetadata(files []string, format string) []backend.RenameResult { + return backend.RenameFiles(files, format) +} + +// CheckFileExistenceRequest represents a track to check for existence +type CheckFileExistenceRequest struct { + ISRC string `json:"isrc"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` +} + +// CheckFilesExistence checks if multiple files already exist in the output directory +// This is done in parallel for better performance +func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []backend.FileExistenceResult { + // Convert to backend struct format + backendTracks := make([]struct { + ISRC string + TrackName string + ArtistName string + }, len(tracks)) + + for i, t := range tracks { + backendTracks[i] = struct { + ISRC string + TrackName string + ArtistName string + }{ + ISRC: t.ISRC, + TrackName: t.TrackName, + ArtistName: t.ArtistName, + } + } + + return backend.CheckFilesExistParallel(outputDir, backendTracks) +} + +// SkipDownloadItem marks a download item as skipped (file already exists) +func (a *App) SkipDownloadItem(itemID, filePath string) { + backend.SkipDownloadItem(itemID, filePath) +} diff --git a/backend/cover.go b/backend/cover.go index 5e4425d..e795dca 100644 --- a/backend/cover.go +++ b/backend/cover.go @@ -161,7 +161,7 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes if outputDir == "" { outputDir = GetDefaultMusicPath() } else { - outputDir = SanitizeFolderPath(outputDir) + outputDir = NormalizePath(outputDir) } if err := os.MkdirAll(outputDir, 0755); err != nil { diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go index 3593f3c..858c2bf 100644 --- a/backend/ffmpeg.go +++ b/backend/ffmpeg.go @@ -273,7 +273,8 @@ func extractTarXz(tarXzPath, destDir string) error { type ConvertAudioRequest struct { InputFiles []string `json:"input_files"` OutputFormat string `json:"output_format"` // mp3, m4a - Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" + Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" (ignored for ALAC) + Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac" } // ConvertAudioResult represents the result of a single file conversion @@ -348,7 +349,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { // Extract cover art and lyrics from input file before conversion var coverArtPath string var lyrics string - + coverArtPath, _ = ExtractCoverArt(inputFile) lyrics, err = ExtractLyrics(inputFile) if err != nil { @@ -378,12 +379,28 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { // Map video stream if exists (for cover art) args = append(args, "-map", "0:v?", "-c:v", "copy") case "m4a": - args = append(args, - "-codec:a", "aac", - "-b:a", req.Bitrate, - "-map", "0:a", // Map audio stream - "-map_metadata", "0", // Copy all metadata - ) + // Determine codec: ALAC (lossless) or AAC (lossy) + codec := req.Codec + if codec == "" { + codec = "aac" // Default to AAC for backward compatibility + } + + if codec == "alac" { + // ALAC - Apple Lossless (no bitrate needed) + args = append(args, + "-codec:a", "alac", + "-map", "0:a", // Map audio stream + "-map_metadata", "0", // Copy all metadata + ) + } else { + // AAC - lossy with bitrate + args = append(args, + "-codec:a", "aac", + "-b:a", req.Bitrate, + "-map", "0:a", // Map audio stream + "-map_metadata", "0", // Copy all metadata + ) + } // Map video stream for cover art in M4A args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic") } @@ -495,7 +512,7 @@ func InstallFFmpegFromFile(filePath string) error { } ffmpegDir := filepath.Dir(ffmpegPath) - + // Create directory if it doesn't exist if err := os.MkdirAll(ffmpegDir, 0755); err != nil { return fmt.Errorf("failed to create ffmpeg directory: %w", err) @@ -519,7 +536,7 @@ func InstallFFmpegFromFile(filePath string) error { destFile.Close() return fmt.Errorf("failed to copy file: %w", err) } - + // Ensure all data is written to disk if err := destFile.Sync(); err != nil { destFile.Close() @@ -531,13 +548,13 @@ func InstallFFmpegFromFile(filePath string) error { // Wait a bit and retry verification maxRetries := 3 retryDelay := 500 * time.Millisecond - + var verifyErr error for i := 0; i < maxRetries; i++ { if i > 0 { time.Sleep(retryDelay) } - + cmd := exec.Command(ffmpegPath, "-version") // Hide console window on Windows setHideWindow(cmd) @@ -546,7 +563,7 @@ func InstallFFmpegFromFile(filePath string) error { break } } - + if verifyErr != nil { return fmt.Errorf("file copied but ffmpeg verification failed after %d attempts: %w", maxRetries, verifyErr) } @@ -554,4 +571,3 @@ func InstallFFmpegFromFile(filePath string) error { fmt.Printf("[FFmpeg] Successfully installed from: %s\n", filePath) return nil } - diff --git a/backend/filemanager.go b/backend/filemanager.go new file mode 100644 index 0000000..8cb6242 --- /dev/null +++ b/backend/filemanager.go @@ -0,0 +1,400 @@ +package backend + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + id3v2 "github.com/bogem/id3v2/v2" + "github.com/go-flac/flacvorbis" + "github.com/go-flac/go-flac" +) + +// FileInfo represents information about a file or folder +type FileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + Children []FileInfo `json:"children,omitempty"` +} + +// AudioMetadata represents metadata read from an audio file +type AudioMetadata struct { + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + AlbumArtist string `json:"album_artist"` + TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` + Year string `json:"year"` + ISRC string `json:"isrc"` +} + +// RenamePreview represents a preview of file rename operation +type RenamePreview struct { + OldPath string `json:"old_path"` + OldName string `json:"old_name"` + NewName string `json:"new_name"` + NewPath string `json:"new_path"` + Error string `json:"error,omitempty"` + Metadata AudioMetadata `json:"metadata"` +} + +// RenameResult represents the result of a rename operation +type RenameResult struct { + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// ListDirectory lists files and folders in a directory +func ListDirectory(dirPath string) ([]FileInfo, error) { + entries, err := os.ReadDir(dirPath) + if err != nil { + return nil, fmt.Errorf("failed to read directory: %w", err) + } + + var result []FileInfo + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + + fileInfo := FileInfo{ + Name: entry.Name(), + Path: filepath.Join(dirPath, entry.Name()), + IsDir: entry.IsDir(), + Size: info.Size(), + } + + // If it's a directory, recursively list its contents + if entry.IsDir() { + children, err := ListDirectory(fileInfo.Path) + if err == nil { + fileInfo.Children = children + } + } + + result = append(result, fileInfo) + } + + return result, nil +} + +// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively +func ListAudioFiles(dirPath string) ([]FileInfo, error) { + var result []FileInfo + + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip files with errors + } + + if info.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".flac" || ext == ".mp3" || ext == ".m4a" { + result = append(result, FileInfo{ + Name: info.Name(), + Path: path, + IsDir: false, + Size: info.Size(), + }) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk directory: %w", err) + } + + return result, nil +} + +// ReadAudioMetadata reads metadata from an audio file +func ReadAudioMetadata(filePath string) (*AudioMetadata, error) { + if !fileExists(filePath) { + return nil, fmt.Errorf("file does not exist") + } + + ext := strings.ToLower(filepath.Ext(filePath)) + + switch ext { + case ".flac": + return readFlacMetadata(filePath) + case ".mp3": + return readMp3Metadata(filePath) + case ".m4a": + return readM4aMetadata(filePath) + default: + return nil, fmt.Errorf("unsupported file format: %s", ext) + } +} + +// readFlacMetadata reads metadata from a FLAC file +func readFlacMetadata(filePath string) (*AudioMetadata, error) { + f, err := flac.ParseFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to parse FLAC file: %w", err) + } + + metadata := &AudioMetadata{} + + for _, block := range f.Meta { + if block.Type == flac.VorbisComment { + cmt, err := flacvorbis.ParseFromMetaDataBlock(*block) + if err != nil { + continue + } + + for _, comment := range cmt.Comments { + parts := strings.SplitN(comment, "=", 2) + if len(parts) != 2 { + continue + } + + fieldName := strings.ToUpper(parts[0]) + value := parts[1] + + switch fieldName { + case "TITLE": + metadata.Title = value + case "ARTIST": + metadata.Artist = value + case "ALBUM": + metadata.Album = value + case "ALBUMARTIST": + metadata.AlbumArtist = value + case "TRACKNUMBER": + if num, err := strconv.Atoi(value); err == nil { + metadata.TrackNumber = num + } + case "DISCNUMBER": + if num, err := strconv.Atoi(value); err == nil { + metadata.DiscNumber = num + } + case "DATE", "YEAR": + metadata.Year = value + case "ISRC": + metadata.ISRC = value + } + } + } + } + + return metadata, nil +} + +// readMp3Metadata reads metadata from an MP3 file +func readMp3Metadata(filePath string) (*AudioMetadata, error) { + tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) + if err != nil { + return nil, fmt.Errorf("failed to open MP3 file: %w", err) + } + defer tag.Close() + + metadata := &AudioMetadata{ + Title: tag.Title(), + Artist: tag.Artist(), + Album: tag.Album(), + Year: tag.Year(), + } + + // Get Album Artist (TPE2) + if frames := tag.GetFrames("TPE2"); len(frames) > 0 { + if textFrame, ok := frames[0].(id3v2.TextFrame); ok { + metadata.AlbumArtist = textFrame.Text + } + } + + // Get Track Number + if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 { + if textFrame, ok := frames[0].(id3v2.TextFrame); ok { + // Format might be "4" or "4/12" + trackStr := strings.Split(textFrame.Text, "/")[0] + if num, err := strconv.Atoi(trackStr); err == nil { + metadata.TrackNumber = num + } + } + } + + // Get Disc Number + if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 { + if textFrame, ok := frames[0].(id3v2.TextFrame); ok { + discStr := strings.Split(textFrame.Text, "/")[0] + if num, err := strconv.Atoi(discStr); err == nil { + metadata.DiscNumber = num + } + } + } + + // Get ISRC (TSRC) + if frames := tag.GetFrames("TSRC"); len(frames) > 0 { + if textFrame, ok := frames[0].(id3v2.TextFrame); ok { + metadata.ISRC = textFrame.Text + } + } + + return metadata, nil +} + +// readM4aMetadata reads metadata from an M4A file +func readM4aMetadata(_ string) (*AudioMetadata, error) { + // For M4A, we'll use a simpler approach - just return empty metadata + // Full M4A metadata reading would require additional libraries + return &AudioMetadata{}, nil +} + +// GenerateFilename generates a new filename based on metadata and format template +func GenerateFilename(metadata *AudioMetadata, format string, ext string) string { + if metadata == nil { + return "" + } + + result := format + + // Replace placeholders + result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title)) + result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist)) + result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album)) + result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist)) + result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(metadata.Year)) + + // Track number with padding + if metadata.TrackNumber > 0 { + result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber)) + } else { + result = strings.ReplaceAll(result, "{track}", "") + } + + // Disc number + if metadata.DiscNumber > 0 { + result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber)) + } else { + result = strings.ReplaceAll(result, "{disc}", "") + } + + // Clean up multiple spaces and trim + result = strings.TrimSpace(result) + result = strings.Join(strings.Fields(result), " ") + + // Remove leading/trailing separators + result = strings.Trim(result, " -._") + + if result == "" { + return "" + } + + return result + ext +} + +// sanitizeFilenameForRename removes invalid characters from filename (for rename operations) +func sanitizeFilenameForRename(name string) string { + // Remove characters that are invalid in filenames + invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"} + result := name + for _, char := range invalid { + result = strings.ReplaceAll(result, char, "") + } + return strings.TrimSpace(result) +} + +// PreviewRename generates a preview of rename operations +func PreviewRename(files []string, format string) []RenamePreview { + var previews []RenamePreview + + for _, filePath := range files { + preview := RenamePreview{ + OldPath: filePath, + OldName: filepath.Base(filePath), + } + + metadata, err := ReadAudioMetadata(filePath) + if err != nil { + preview.Error = err.Error() + previews = append(previews, preview) + continue + } + + preview.Metadata = *metadata + + ext := filepath.Ext(filePath) + newName := GenerateFilename(metadata, format, ext) + + if newName == "" { + preview.Error = "Could not generate filename (missing metadata)" + previews = append(previews, preview) + continue + } + + preview.NewName = newName + preview.NewPath = filepath.Join(filepath.Dir(filePath), newName) + + previews = append(previews, preview) + } + + return previews +} + +// RenameFiles renames files based on their metadata +func RenameFiles(files []string, format string) []RenameResult { + var results []RenameResult + + for _, filePath := range files { + result := RenameResult{ + OldPath: filePath, + } + + metadata, err := ReadAudioMetadata(filePath) + if err != nil { + result.Error = err.Error() + result.Success = false + results = append(results, result) + continue + } + + ext := filepath.Ext(filePath) + newName := GenerateFilename(metadata, format, ext) + + if newName == "" { + result.Error = "Could not generate filename (missing metadata)" + result.Success = false + results = append(results, result) + continue + } + + newPath := filepath.Join(filepath.Dir(filePath), newName) + result.NewPath = newPath + + // Check if new path already exists (and is different from old path) + if newPath != filePath { + if _, err := os.Stat(newPath); err == nil { + result.Error = "File already exists" + result.Success = false + results = append(results, result) + continue + } + } + + // Rename the file + if err := os.Rename(filePath, newPath); err != nil { + result.Error = err.Error() + result.Success = false + results = append(results, result) + continue + } + + result.Success = true + results = append(results, result) + } + + return results +} diff --git a/backend/filename.go b/backend/filename.go index efc3e80..f03bb86 100644 --- a/backend/filename.go +++ b/backend/filename.go @@ -56,11 +56,11 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include func sanitizeFilename(name string) string { // 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 for _, r := range sanitized { @@ -79,49 +79,57 @@ func sanitizeFilename(name string) string { } // Remove emoji ranges (most emoji are in these ranges) if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons - (r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols - (r >= 0x2700 && r <= 0x27BF) || // Dingbats - (r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors + (r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols + (r >= 0x2700 && r <= 0x27BF) || // Dingbats + (r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors (r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs (r >= 0x1F600 && r <= 0x1F64F) || // Emoticons (r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols - (r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags) + (r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags) continue } result.WriteRune(r) } - + sanitized = result.String() sanitized = strings.TrimSpace(sanitized) - + // Remove leading/trailing dots and spaces (Windows doesn't allow these) sanitized = strings.Trim(sanitized, ". ") - + // 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 and spaces sanitized = strings.Trim(sanitized, "_ ") - + if sanitized == "" { return "Unknown" } - + // Ensure the result is valid UTF-8 if !utf8.ValidString(sanitized) { // If invalid UTF-8, try to fix it sanitized = strings.ToValidUTF8(sanitized, "_") } - + return sanitized } +// NormalizePath only normalizes path separators without modifying folder names +// Use this for user-provided paths that already exist on the filesystem +func NormalizePath(folderPath string) string { + // Normalize all forward slashes to backslashes on Windows + return strings.ReplaceAll(folderPath, "/", string(filepath.Separator)) +} + // SanitizeFolderPath sanitizes each component of a folder path and normalizes separators +// Use this only for NEW folders being created (artist names, album names, etc.) func SanitizeFolderPath(folderPath string) string { // Normalize all forward slashes to backslashes on Windows normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator)) diff --git a/backend/lyrics.go b/backend/lyrics.go index efeb6e4..a5b7412 100644 --- a/backend/lyrics.go +++ b/backend/lyrics.go @@ -16,15 +16,15 @@ import ( // LRCLibResponse represents the LRCLIB API response type LRCLibResponse struct { - ID int `json:"id"` - Name string `json:"name"` - TrackName string `json:"trackName"` - ArtistName string `json:"artistName"` - AlbumName string `json:"albumName"` - Duration float64 `json:"duration"` - Instrumental bool `json:"instrumental"` - PlainLyrics string `json:"plainLyrics"` - SyncedLyrics string `json:"syncedLyrics"` + ID int `json:"id"` + Name string `json:"name"` + TrackName string `json:"trackName"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + Duration float64 `json:"duration"` + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"syncedLyrics"` } // LyricsLine represents a single line of lyrics @@ -255,7 +255,7 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st simplifiedTrack := simplifyTrackName(trackName) if simplifiedTrack != trackName { fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack) - + resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName) if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { return resp, "LRCLIB (simplified)", nil @@ -270,7 +270,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return nil, "", fmt.Errorf("lyrics not found in any source") } - // ConvertToLRC converts lyrics response to LRC format func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string { var sb strings.Builder @@ -364,7 +363,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa if outputDir == "" { outputDir = GetDefaultMusicPath() } else { - outputDir = SanitizeFolderPath(outputDir) + outputDir = NormalizePath(outputDir) } if err := os.MkdirAll(outputDir, 0755); err != nil { diff --git a/backend/metadata.go b/backend/metadata.go index 9b49d64..48f19c3 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -7,6 +7,7 @@ import ( pathfilepath "path/filepath" "strconv" "strings" + "sync" id3v2 "github.com/bogem/id3v2/v2" "github.com/go-flac/flacpicture" @@ -600,3 +601,86 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error { return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext) } } + +// FileExistenceResult represents the result of checking if a file exists +type FileExistenceResult struct { + ISRC string `json:"isrc"` + Exists bool `json:"exists"` + FilePath string `json:"file_path,omitempty"` + TrackName string `json:"track_name,omitempty"` + ArtistName string `json:"artist_name,omitempty"` +} + +// CheckFilesExistParallel checks if multiple files exist in parallel +// It builds an ISRC index from the output directory once, then checks all tracks against it +func CheckFilesExistParallel(outputDir string, tracks []struct { + ISRC string + TrackName string + ArtistName string +}) []FileExistenceResult { + results := make([]FileExistenceResult, len(tracks)) + + // Build ISRC index from output directory (scan once) + isrcIndex := buildISRCIndex(outputDir) + + // Check each track against the index (parallel) + var wg sync.WaitGroup + for i, track := range tracks { + wg.Add(1) + go func(idx int, t struct { + ISRC string + TrackName string + ArtistName string + }) { + defer wg.Done() + + result := FileExistenceResult{ + ISRC: t.ISRC, + TrackName: t.TrackName, + ArtistName: t.ArtistName, + Exists: false, + } + + if t.ISRC != "" { + if filePath, exists := isrcIndex[strings.ToUpper(t.ISRC)]; exists { + result.Exists = true + result.FilePath = filePath + } + } + + results[idx] = result + }(i, track) + } + + wg.Wait() + return results +} + +// buildISRCIndex scans a directory and builds a map of ISRC -> file path +func buildISRCIndex(outputDir string) map[string]string { + index := make(map[string]string) + + // Walk directory recursively - only check .flac files for SpotiFLAC + pathfilepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + + ext := strings.ToLower(pathfilepath.Ext(path)) + if ext != ".flac" { + return nil + } + + // Read ISRC from file + isrc, err := ReadISRCFromFile(path) + if err != nil || isrc == "" { + return nil + } + + // Store in index (uppercase for case-insensitive matching) + index[strings.ToUpper(isrc)] = path + return nil + }) + + return index +} diff --git a/frontend/package.json b/frontend/package.json index 74ee19b..daec558 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "generate-icon": "node scripts/generate-icon.js" }, "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -29,7 +30,7 @@ "@tailwindcss/vite": "^4.1.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.561.0", + "lucide-react": "^0.562.0", "next-themes": "^0.4.6", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -39,18 +40,18 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", - "@types/node": "^25.0.2", + "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", "sharp": "^0.34.5", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.49.0", - "vite": "^7.2.7" + "typescript-eslint": "^8.50.0", + "vite": "^7.3.0" } } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index a2d85c8..ae2459d 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d4b3974abd992c8ff941c6fde9f62062 \ No newline at end of file +07ce84ccf0f1355c8d93ec1d8bd235ea \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ee254ee..b7c2d1f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@radix-ui/react-alert-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -52,7 +55,7 @@ importers: version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -60,8 +63,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 lucide-react: - specifier: ^0.561.0 - version: 0.561.0(react@19.2.3) + specifier: ^0.562.0 + version: 0.562.0(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -85,8 +88,8 @@ importers: specifier: ^9.39.2 version: 9.39.2 '@types/node': - specifier: ^25.0.2 - version: 25.0.2 + specifier: ^25.0.3 + version: 25.0.3 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -95,7 +98,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^5.1.2 - version: 5.1.2(vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -103,8 +106,8 @@ importers: specifier: ^7.0.1 version: 7.0.1(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-refresh: - specifier: ^0.4.24 - version: 0.4.24(eslint@9.39.2(jiti@2.6.1)) + specifier: ^0.4.26 + version: 0.4.26(eslint@9.39.2(jiti@2.6.1)) globals: specifier: ^16.5.0 version: 16.5.0 @@ -118,11 +121,11 @@ importers: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.49.0 - version: 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.50.0 + version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: - specifier: ^7.2.7 - version: 7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2) + specifier: ^7.3.0 + version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) packages: @@ -212,158 +215,158 @@ packages: '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -596,6 +599,19 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -1071,113 +1087,113 @@ packages: '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} - '@rollup/rollup-android-arm-eabi@4.53.3': - resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} + '@rollup/rollup-android-arm-eabi@4.53.5': + resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.53.3': - resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} + '@rollup/rollup-android-arm64@4.53.5': + resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.53.3': - resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} + '@rollup/rollup-darwin-arm64@4.53.5': + resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.53.3': - resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} + '@rollup/rollup-darwin-x64@4.53.5': + resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.53.3': - resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} + '@rollup/rollup-freebsd-arm64@4.53.5': + resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.53.3': - resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} + '@rollup/rollup-freebsd-x64@4.53.5': + resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': + resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} + '@rollup/rollup-linux-arm-musleabihf@4.53.5': + resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.53.3': - resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} + '@rollup/rollup-linux-arm64-gnu@4.53.5': + resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.53.3': - resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} + '@rollup/rollup-linux-arm64-musl@4.53.5': + resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.53.3': - resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} + '@rollup/rollup-linux-loong64-gnu@4.53.5': + resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} + '@rollup/rollup-linux-ppc64-gnu@4.53.5': + resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} + '@rollup/rollup-linux-riscv64-gnu@4.53.5': + resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.53.3': - resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} + '@rollup/rollup-linux-riscv64-musl@4.53.5': + resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.53.3': - resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} + '@rollup/rollup-linux-s390x-gnu@4.53.5': + resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.53.3': - resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} + '@rollup/rollup-linux-x64-gnu@4.53.5': + resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.53.3': - resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} + '@rollup/rollup-linux-x64-musl@4.53.5': + resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} cpu: [x64] os: [linux] - '@rollup/rollup-openharmony-arm64@4.53.3': - resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} + '@rollup/rollup-openharmony-arm64@4.53.5': + resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.53.3': - resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} + '@rollup/rollup-win32-arm64-msvc@4.53.5': + resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.53.3': - resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} + '@rollup/rollup-win32-ia32-msvc@4.53.5': + resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.53.3': - resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + '@rollup/rollup-win32-x64-gnu@4.53.5': + resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.53.3': - resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} + '@rollup/rollup-win32-x64-msvc@4.53.5': + resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==} cpu: [x64] os: [win32] @@ -1289,8 +1305,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@25.0.2': - resolution: {integrity: sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==} + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -1300,63 +1316,63 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} - '@typescript-eslint/eslint-plugin@8.49.0': - resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==} + '@typescript-eslint/eslint-plugin@8.50.0': + resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.49.0 + '@typescript-eslint/parser': ^8.50.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.49.0': - resolution: {integrity: sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==} + '@typescript-eslint/parser@8.50.0': + resolution: {integrity: sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.49.0': - resolution: {integrity: sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==} + '@typescript-eslint/project-service@8.50.0': + resolution: {integrity: sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.49.0': - resolution: {integrity: sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==} + '@typescript-eslint/scope-manager@8.50.0': + resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.49.0': - resolution: {integrity: sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==} + '@typescript-eslint/tsconfig-utils@8.50.0': + resolution: {integrity: sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.49.0': - resolution: {integrity: sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==} + '@typescript-eslint/type-utils@8.50.0': + resolution: {integrity: sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.49.0': - resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} + '@typescript-eslint/types@8.50.0': + resolution: {integrity: sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.49.0': - resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==} + '@typescript-eslint/typescript-estree@8.50.0': + resolution: {integrity: sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.49.0': - resolution: {integrity: sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==} + '@typescript-eslint/utils@8.50.0': + resolution: {integrity: sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.49.0': - resolution: {integrity: sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==} + '@typescript-eslint/visitor-keys@8.50.0': + resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@5.1.2': @@ -1392,8 +1408,8 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - baseline-browser-mapping@2.9.7: - resolution: {integrity: sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==} + baseline-browser-mapping@2.9.10: + resolution: {integrity: sha512-2VIKvDx8Z1a9rTB2eCkdPE5nSe28XnA+qivGnWHoB40hMMt/h1hSz0960Zqsn6ZyxWXUie0EBdElKv8may20AA==} hasBin: true brace-expansion@1.1.12: @@ -1471,8 +1487,8 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true @@ -1490,8 +1506,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-plugin-react-refresh@0.4.24: - resolution: {integrity: sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==} + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} peerDependencies: eslint: '>=8.40' @@ -1752,8 +1768,8 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.561.0: - resolution: {integrity: sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==} + lucide-react@0.562.0: + resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1877,8 +1893,8 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - rollup@4.53.3: - resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} + rollup@4.53.5: + resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1954,8 +1970,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.49.0: - resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} + typescript-eslint@8.50.0: + resolution: {integrity: sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -1969,8 +1985,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - update-browserslist-db@1.2.2: - resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -1998,8 +2014,8 @@ packages: '@types/react': optional: true - vite@7.2.7: - resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==} + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2060,8 +2076,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.1.13: - resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} snapshots: @@ -2182,82 +2198,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.25.12': + '@esbuild/aix-ppc64@0.27.2': optional: true - '@esbuild/android-arm64@0.25.12': + '@esbuild/android-arm64@0.27.2': optional: true - '@esbuild/android-arm@0.25.12': + '@esbuild/android-arm@0.27.2': optional: true - '@esbuild/android-x64@0.25.12': + '@esbuild/android-x64@0.27.2': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@esbuild/darwin-arm64@0.27.2': optional: true - '@esbuild/darwin-x64@0.25.12': + '@esbuild/darwin-x64@0.27.2': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@esbuild/freebsd-arm64@0.27.2': optional: true - '@esbuild/freebsd-x64@0.25.12': + '@esbuild/freebsd-x64@0.27.2': optional: true - '@esbuild/linux-arm64@0.25.12': + '@esbuild/linux-arm64@0.27.2': optional: true - '@esbuild/linux-arm@0.25.12': + '@esbuild/linux-arm@0.27.2': optional: true - '@esbuild/linux-ia32@0.25.12': + '@esbuild/linux-ia32@0.27.2': optional: true - '@esbuild/linux-loong64@0.25.12': + '@esbuild/linux-loong64@0.27.2': optional: true - '@esbuild/linux-mips64el@0.25.12': + '@esbuild/linux-mips64el@0.27.2': optional: true - '@esbuild/linux-ppc64@0.25.12': + '@esbuild/linux-ppc64@0.27.2': optional: true - '@esbuild/linux-riscv64@0.25.12': + '@esbuild/linux-riscv64@0.27.2': optional: true - '@esbuild/linux-s390x@0.25.12': + '@esbuild/linux-s390x@0.27.2': optional: true - '@esbuild/linux-x64@0.25.12': + '@esbuild/linux-x64@0.27.2': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@esbuild/netbsd-arm64@0.27.2': optional: true - '@esbuild/netbsd-x64@0.25.12': + '@esbuild/netbsd-x64@0.27.2': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@esbuild/openbsd-arm64@0.27.2': optional: true - '@esbuild/openbsd-x64@0.25.12': + '@esbuild/openbsd-x64@0.27.2': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@esbuild/openharmony-arm64@0.27.2': optional: true - '@esbuild/sunos-x64@0.25.12': + '@esbuild/sunos-x64@0.27.2': optional: true - '@esbuild/win32-arm64@0.25.12': + '@esbuild/win32-arm64@0.27.2': optional: true - '@esbuild/win32-ia32@0.25.12': + '@esbuild/win32-ia32@0.27.2': optional: true - '@esbuild/win32-x64@0.25.12': + '@esbuild/win32-x64@0.27.2': optional: true '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@2.6.1))': @@ -2453,6 +2469,20 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2927,70 +2957,70 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.53': {} - '@rollup/rollup-android-arm-eabi@4.53.3': + '@rollup/rollup-android-arm-eabi@4.53.5': optional: true - '@rollup/rollup-android-arm64@4.53.3': + '@rollup/rollup-android-arm64@4.53.5': optional: true - '@rollup/rollup-darwin-arm64@4.53.3': + '@rollup/rollup-darwin-arm64@4.53.5': optional: true - '@rollup/rollup-darwin-x64@4.53.3': + '@rollup/rollup-darwin-x64@4.53.5': optional: true - '@rollup/rollup-freebsd-arm64@4.53.3': + '@rollup/rollup-freebsd-arm64@4.53.5': optional: true - '@rollup/rollup-freebsd-x64@4.53.3': + '@rollup/rollup-freebsd-x64@4.53.5': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + '@rollup/rollup-linux-arm-gnueabihf@4.53.5': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.53.3': + '@rollup/rollup-linux-arm-musleabihf@4.53.5': optional: true - '@rollup/rollup-linux-arm64-gnu@4.53.3': + '@rollup/rollup-linux-arm64-gnu@4.53.5': optional: true - '@rollup/rollup-linux-arm64-musl@4.53.3': + '@rollup/rollup-linux-arm64-musl@4.53.5': optional: true - '@rollup/rollup-linux-loong64-gnu@4.53.3': + '@rollup/rollup-linux-loong64-gnu@4.53.5': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.53.3': + '@rollup/rollup-linux-ppc64-gnu@4.53.5': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.53.3': + '@rollup/rollup-linux-riscv64-gnu@4.53.5': optional: true - '@rollup/rollup-linux-riscv64-musl@4.53.3': + '@rollup/rollup-linux-riscv64-musl@4.53.5': optional: true - '@rollup/rollup-linux-s390x-gnu@4.53.3': + '@rollup/rollup-linux-s390x-gnu@4.53.5': optional: true - '@rollup/rollup-linux-x64-gnu@4.53.3': + '@rollup/rollup-linux-x64-gnu@4.53.5': optional: true - '@rollup/rollup-linux-x64-musl@4.53.3': + '@rollup/rollup-linux-x64-musl@4.53.5': optional: true - '@rollup/rollup-openharmony-arm64@4.53.3': + '@rollup/rollup-openharmony-arm64@4.53.5': optional: true - '@rollup/rollup-win32-arm64-msvc@4.53.3': + '@rollup/rollup-win32-arm64-msvc@4.53.5': optional: true - '@rollup/rollup-win32-ia32-msvc@4.53.3': + '@rollup/rollup-win32-ia32-msvc@4.53.5': optional: true - '@rollup/rollup-win32-x64-gnu@4.53.3': + '@rollup/rollup-win32-x64-gnu@4.53.5': optional: true - '@rollup/rollup-win32-x64-msvc@4.53.3': + '@rollup/rollup-win32-x64-msvc@4.53.5': optional: true '@tailwindcss/node@4.1.18': @@ -3054,12 +3084,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) '@types/babel__core@7.20.5': dependencies: @@ -3086,7 +3116,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@25.0.2': + '@types/node@25.0.3': dependencies: undici-types: 7.16.0 @@ -3098,14 +3128,14 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.49.0 + '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.50.0 + '@typescript-eslint/type-utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.50.0 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -3114,41 +3144,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.49.0 + '@typescript-eslint/scope-manager': 8.50.0 + '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.50.0 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.49.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.50.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) - '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) + '@typescript-eslint/types': 8.50.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.49.0': + '@typescript-eslint/scope-manager@8.50.0': dependencies: - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/visitor-keys': 8.49.0 + '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/visitor-keys': 8.50.0 - '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.50.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) @@ -3156,14 +3186,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.49.0': {} + '@typescript-eslint/types@8.50.0': {} - '@typescript-eslint/typescript-estree@8.49.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.50.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.49.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/visitor-keys': 8.49.0 + '@typescript-eslint/project-service': 8.50.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) + '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/visitor-keys': 8.50.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 @@ -3173,23 +3203,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.50.0 + '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.49.0': + '@typescript-eslint/visitor-keys@8.50.0': dependencies: - '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/types': 8.50.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@5.1.2(vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -3197,7 +3227,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - supports-color @@ -3226,7 +3256,7 @@ snapshots: balanced-match@1.0.2: {} - baseline-browser-mapping@2.9.7: {} + baseline-browser-mapping@2.9.10: {} brace-expansion@1.1.12: dependencies: @@ -3239,11 +3269,11 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.7 + baseline-browser-mapping: 2.9.10 caniuse-lite: 1.0.30001760 electron-to-chromium: 1.5.267 node-releases: 2.0.27 - update-browserslist-db: 1.2.2(browserslist@4.28.1) + update-browserslist-db: 1.2.3(browserslist@4.28.1) callsites@3.1.0: {} @@ -3295,34 +3325,34 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 - esbuild@0.25.12: + esbuild@0.27.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 escalade@3.2.0: {} @@ -3334,12 +3364,12 @@ snapshots: '@babel/parser': 7.28.5 eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.1.13 - zod-validation-error: 4.0.2(zod@4.1.13) + zod: 4.2.1 + zod-validation-error: 4.0.2(zod@4.2.1) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.4.24(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -3567,7 +3597,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@0.561.0(react@19.2.3): + lucide-react@0.562.0(react@19.2.3): dependencies: react: 19.2.3 @@ -3673,32 +3703,32 @@ snapshots: resolve-from@4.0.0: {} - rollup@4.53.3: + rollup@4.53.5: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.3 - '@rollup/rollup-android-arm64': 4.53.3 - '@rollup/rollup-darwin-arm64': 4.53.3 - '@rollup/rollup-darwin-x64': 4.53.3 - '@rollup/rollup-freebsd-arm64': 4.53.3 - '@rollup/rollup-freebsd-x64': 4.53.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 - '@rollup/rollup-linux-arm-musleabihf': 4.53.3 - '@rollup/rollup-linux-arm64-gnu': 4.53.3 - '@rollup/rollup-linux-arm64-musl': 4.53.3 - '@rollup/rollup-linux-loong64-gnu': 4.53.3 - '@rollup/rollup-linux-ppc64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-musl': 4.53.3 - '@rollup/rollup-linux-s390x-gnu': 4.53.3 - '@rollup/rollup-linux-x64-gnu': 4.53.3 - '@rollup/rollup-linux-x64-musl': 4.53.3 - '@rollup/rollup-openharmony-arm64': 4.53.3 - '@rollup/rollup-win32-arm64-msvc': 4.53.3 - '@rollup/rollup-win32-ia32-msvc': 4.53.3 - '@rollup/rollup-win32-x64-gnu': 4.53.3 - '@rollup/rollup-win32-x64-msvc': 4.53.3 + '@rollup/rollup-android-arm-eabi': 4.53.5 + '@rollup/rollup-android-arm64': 4.53.5 + '@rollup/rollup-darwin-arm64': 4.53.5 + '@rollup/rollup-darwin-x64': 4.53.5 + '@rollup/rollup-freebsd-arm64': 4.53.5 + '@rollup/rollup-freebsd-x64': 4.53.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.5 + '@rollup/rollup-linux-arm-musleabihf': 4.53.5 + '@rollup/rollup-linux-arm64-gnu': 4.53.5 + '@rollup/rollup-linux-arm64-musl': 4.53.5 + '@rollup/rollup-linux-loong64-gnu': 4.53.5 + '@rollup/rollup-linux-ppc64-gnu': 4.53.5 + '@rollup/rollup-linux-riscv64-gnu': 4.53.5 + '@rollup/rollup-linux-riscv64-musl': 4.53.5 + '@rollup/rollup-linux-s390x-gnu': 4.53.5 + '@rollup/rollup-linux-x64-gnu': 4.53.5 + '@rollup/rollup-linux-x64-musl': 4.53.5 + '@rollup/rollup-openharmony-arm64': 4.53.5 + '@rollup/rollup-win32-arm64-msvc': 4.53.5 + '@rollup/rollup-win32-ia32-msvc': 4.53.5 + '@rollup/rollup-win32-x64-gnu': 4.53.5 + '@rollup/rollup-win32-x64-msvc': 4.53.5 fsevents: 2.3.3 scheduler@0.27.0: {} @@ -3780,12 +3810,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -3795,7 +3825,7 @@ snapshots: undici-types@7.16.0: {} - update-browserslist-db@1.2.2(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 escalade: 3.2.0 @@ -3820,16 +3850,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 - vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: - esbuild: 0.25.12 + esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.53.3 + rollup: 4.53.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.0.2 + '@types/node': 25.0.3 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -3844,8 +3874,8 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.1.13): + zod-validation-error@4.0.2(zod@4.2.1): dependencies: - zod: 4.1.13 + zod: 4.2.1 - zod@4.1.13: {} + zod@4.2.1: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 658cd99..9c7864a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import { DownloadQueue } from "@/components/DownloadQueue"; import { DownloadProgressToast } from "@/components/DownloadProgressToast"; import { AudioAnalysisPage } from "@/components/AudioAnalysisPage"; import { AudioConverterPage } from "@/components/AudioConverterPage"; +import { FileManagerPage } from "@/components/FileManagerPage"; import { SettingsPage } from "@/components/SettingsPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage"; import type { HistoryItem } from "@/components/FetchHistory"; @@ -56,7 +57,7 @@ function App() { const [fetchHistory, setFetchHistory] = useState([]); const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "6.8"; + const CURRENT_VERSION = "6.9"; const download = useDownload(); const metadata = useMetadata(); @@ -515,6 +516,8 @@ function App() { return ; case "audio-converter": return ; + case "file-manager": + return ; default: return ( <> diff --git a/frontend/src/components/AudioConverterPage.tsx b/frontend/src/components/AudioConverterPage.tsx index e03ac40..8353752 100644 --- a/frontend/src/components/AudioConverterPage.tsx +++ b/frontend/src/components/AudioConverterPage.tsx @@ -42,6 +42,11 @@ const BITRATE_OPTIONS = [ { value: "128k", label: "128k" }, ]; +const M4A_CODEC_OPTIONS = [ + { value: "aac", label: "AAC" }, + { value: "alac", label: "ALAC" }, +]; + const STORAGE_KEY = "spotiflac_audio_converter_state"; export function AudioConverterPage() { @@ -90,13 +95,27 @@ export function AudioConverterPage() { } return "320k"; }); + const [m4aCodec, setM4aCodec] = useState<"aac" | "alac">(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.m4aCodec === "aac" || parsed.m4aCodec === "alac") { + return parsed.m4aCodec; + } + } + } catch (err) { + // Ignore + } + return "aac"; + }); const [converting, setConverting] = useState(false); const [isDragging, setIsDragging] = useState(false); const [isDraggingFFmpeg, setIsDraggingFFmpeg] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); // Helper function to save state to sessionStorage - const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string }) => { + const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string; m4aCodec: "aac" | "alac" }) => { try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); } catch (err) { @@ -109,10 +128,10 @@ export function AudioConverterPage() { checkFfmpegInstallation(); }, []); - // Save state to sessionStorage whenever files, outputFormat, or bitrate changes + // Save state to sessionStorage whenever files, outputFormat, bitrate, or m4aCodec changes useEffect(() => { - saveState({ files, outputFormat, bitrate }); - }, [files, outputFormat, bitrate, saveState]); + saveState({ files, outputFormat, bitrate, m4aCodec }); + }, [files, outputFormat, bitrate, m4aCodec, saveState]); // Auto-set output format to M4A if all files are MP3 useEffect(() => { @@ -364,6 +383,7 @@ export function AudioConverterPage() { input_files: inputPaths, output_format: outputFormat, bitrate: bitrate, + codec: outputFormat === "m4a" ? m4aCodec : "", }); // Update file statuses based on results @@ -578,27 +598,54 @@ export function AudioConverterPage() { -
- - { - if (value) setBitrate(value); - }} - > - {BITRATE_OPTIONS.map((option) => ( - - {option.label} - - ))} - -
+ {/* Codec selection for M4A */} + {outputFormat === "m4a" && ( +
+ + { + if (value) setM4aCodec(value as "aac" | "alac"); + }} + > + {M4A_CODEC_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
+ )} + {/* Bitrate selection - hide for ALAC (lossless) */} + {!(outputFormat === "m4a" && m4aCodec === "alac") && ( +
+ + { + if (value) setBitrate(value); + }} + > + {BITRATE_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
+ )} diff --git a/frontend/src/components/DownloadQueue.tsx b/frontend/src/components/DownloadQueue.tsx index 67229ab..299ae40 100644 --- a/frontend/src/components/DownloadQueue.tsx +++ b/frontend/src/components/DownloadQueue.tsx @@ -120,7 +120,7 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { -
+
Download Queue
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && ( diff --git a/frontend/src/components/FileManagerPage.tsx b/frontend/src/components/FileManagerPage.tsx new file mode 100644 index 0000000..8383bc8 --- /dev/null +++ b/frontend/src/components/FileManagerPage.tsx @@ -0,0 +1,578 @@ +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + FolderOpen, + RefreshCw, + FileMusic, + ChevronRight, + ChevronDown, + Pencil, + Eye, + Folder, + Info, + X, + RotateCcw, +} from "lucide-react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { Spinner } from "@/components/ui/spinner"; +import { SelectFolder } from "../../wailsjs/go/main/App"; +import { backend } from "../../wailsjs/go/models"; + +// These functions will be available after Wails regenerates bindings +// For now, we call them directly via window.go +const ListDirectoryFiles = (path: string): Promise => + (window as any)['go']['main']['App']['ListDirectoryFiles'](path); +const PreviewRenameFiles = (files: string[], format: string): Promise => + (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format); +const RenameFilesByMetadata = (files: string[], format: string): Promise => + (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format); +import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { getSettings } from "@/lib/settings"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface FileNode { + name: string; + path: string; + is_dir: boolean; + size: number; + children?: FileNode[]; + expanded?: boolean; + selected?: boolean; +} + +const FORMAT_PRESETS: Record = { + "title": { label: "Title", template: "{title}" }, + "title-artist": { label: "Title - Artist", template: "{title} - {artist}" }, + "artist-title": { label: "Artist - Title", template: "{artist} - {title}" }, + "track-title": { label: "Track. Title", template: "{track}. {title}" }, + "track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" }, + "track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" }, + "title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" }, + "track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" }, + "custom": { label: "Custom...", template: "" }, +}; + +const STORAGE_KEY = "spotiflac_file_manager_state"; + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} +const DEFAULT_PRESET = "title-artist"; +const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}"; + +export function FileManagerPage() { + const [rootPath, setRootPath] = useState(() => { + const settings = getSettings(); + return settings.downloadPath || ""; + }); + const [files, setFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [formatPreset, setFormatPreset] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) { + return parsed.formatPreset; + } + } + } catch (err) { + // Ignore + } + return DEFAULT_PRESET; + }); + const [customFormat, setCustomFormat] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.customFormat) { + return parsed.customFormat; + } + } + } catch (err) { + // Ignore + } + return DEFAULT_CUSTOM_FORMAT; + }); + + const renameFormat = formatPreset === "custom" ? customFormat : FORMAT_PRESETS[formatPreset].template; + const [showPreview, setShowPreview] = useState(false); + const [previewData, setPreviewData] = useState([]); + const [renaming, setRenaming] = useState(false); + const [previewOnly, setPreviewOnly] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + + // Save state to sessionStorage + useEffect(() => { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat })); + } catch (err) { + console.error("Failed to save state:", err); + } + }, [formatPreset, customFormat]); + + // Detect fullscreen/maximized window + useEffect(() => { + const checkFullscreen = () => { + const isMaximized = window.innerHeight >= window.screen.height * 0.9; + setIsFullscreen(isMaximized); + }; + + checkFullscreen(); + window.addEventListener("resize", checkFullscreen); + window.addEventListener("focus", checkFullscreen); + + return () => { + window.removeEventListener("resize", checkFullscreen); + window.removeEventListener("focus", checkFullscreen); + }; + }, []); + + const loadFiles = useCallback(async () => { + if (!rootPath) return; + + setLoading(true); + try { + const result = await ListDirectoryFiles(rootPath); + // Filter to only show audio files and folders containing audio files + const filtered = filterAudioFiles(result as FileNode[]); + setFiles(filtered); + setSelectedFiles(new Set()); + } catch (err) { + toast.error("Failed to load files", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setLoading(false); + } + }, [rootPath]); + + useEffect(() => { + if (rootPath) { + loadFiles(); + } + }, [rootPath, loadFiles]); + + const filterAudioFiles = (nodes: FileNode[]): FileNode[] => { + return nodes + .map((node) => { + if (node.is_dir && node.children) { + const filteredChildren = filterAudioFiles(node.children); + if (filteredChildren.length > 0) { + return { ...node, children: filteredChildren }; + } + return null; + } + const ext = node.name.toLowerCase(); + if (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a")) { + return node; + } + return null; + }) + .filter((node): node is FileNode => node !== null); + }; + + const handleSelectFolder = async () => { + try { + const path = await SelectFolder(rootPath); + if (path) { + setRootPath(path); + } + } catch (err) { + toast.error("Failed to select folder", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } + }; + + const toggleExpand = (path: string) => { + setFiles((prev) => toggleNodeExpand(prev, path)); + }; + + const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => { + return nodes.map((node) => { + if (node.path === path) { + return { ...node, expanded: !node.expanded }; + } + if (node.children) { + return { ...node, children: toggleNodeExpand(node.children, path) }; + } + return node; + }); + }; + + const toggleSelect = (path: string, isDir: boolean) => { + if (isDir) return; + + setSelectedFiles((prev) => { + const newSet = new Set(prev); + if (newSet.has(path)) { + newSet.delete(path); + } else { + newSet.add(path); + } + return newSet; + }); + }; + + const selectAll = () => { + const allAudioFiles = getAllAudioFiles(files); + setSelectedFiles(new Set(allAudioFiles.map((f) => f.path))); + }; + + const deselectAll = () => { + setSelectedFiles(new Set()); + }; + + const getAllAudioFiles = (nodes: FileNode[]): FileNode[] => { + const result: FileNode[] = []; + for (const node of nodes) { + if (!node.is_dir) { + result.push(node); + } + if (node.children) { + result.push(...getAllAudioFiles(node.children)); + } + } + return result; + }; + + const resetToDefault = () => { + setFormatPreset(DEFAULT_PRESET); + setCustomFormat(DEFAULT_CUSTOM_FORMAT); + setShowResetConfirm(false); + }; + + const handlePreview = async (isPreviewOnly: boolean) => { + if (selectedFiles.size === 0) { + toast.error("No files selected"); + return; + } + + setLoading(true); + try { + const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat); + setPreviewData(result); + setPreviewOnly(isPreviewOnly); + setShowPreview(true); + } catch (err) { + toast.error("Failed to generate preview", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setLoading(false); + } + }; + + const handleRename = async () => { + if (selectedFiles.size === 0) return; + + setRenaming(true); + try { + const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat); + const successCount = result.filter((r: backend.RenameResult) => r.success).length; + const failCount = result.filter((r: backend.RenameResult) => !r.success).length; + + if (successCount > 0) { + toast.success("Rename Complete", { + description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}`, + }); + } else { + toast.error("Rename Failed", { + description: `All ${failCount} file(s) failed to rename`, + }); + } + + setShowPreview(false); + setSelectedFiles(new Set()); + loadFiles(); + } catch (err) { + toast.error("Rename Failed", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setRenaming(false); + } + }; + + const renderFileTree = (nodes: FileNode[], depth = 0) => { + return nodes.map((node) => ( +
+
(node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path, node.is_dir))} + > + {node.is_dir ? ( + <> + {node.expanded ? ( + + ) : ( + + )} + + + ) : ( + <> + toggleSelect(node.path, node.is_dir)} + onClick={(e) => e.stopPropagation()} + className="shrink-0" + /> + + + )} + {node.name} + {!node.is_dir && ( + {formatFileSize(node.size)} + )} +
+ {node.is_dir && node.expanded && node.children && ( +
{renderFileTree(node.children, depth + 1)}
+ )} +
+ )); + }; + + const allAudioFiles = getAllAudioFiles(files); + const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length; + + return ( +
+
+

File Manager

+
+ + {/* Path Selection */} +
+ setRootPath(e.target.value)} + placeholder="Select a folder..." + className="flex-1" + /> + + +
+ + {/* Rename Format */} +
+
+ + + + + + +

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}

+
+
+
+
+ + {formatPreset === "custom" && ( + setCustomFormat(e.target.value)} + placeholder="{artist} - {title}" + className="flex-1" + /> + )} + + + + + Reset to default + +
+

+ Preview: {renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac +

+
+ + {/* File Tree */} +
+
+
+ + {selectedFiles.size} of {allAudioFiles.length} file(s) selected + + +
+
+ + +
+
+ +
+ {loading ? ( +
+ +
+ ) : files.length === 0 ? ( +
+ {rootPath ? "No audio files found" : "Select a folder to browse"} +
+ ) : ( + renderFileTree(files) + )} +
+
+ + {/* Reset Confirmation Dialog */} + + + + Reset to Default? + + This will reset the rename format to "Title - Artist". Your custom format will be lost. + + + + Cancel + Reset + + + + + {/* Preview Dialog */} + + + +
+ Rename Preview + +
+ + Review the changes before renaming. Files with errors will be skipped. + +
+ +
+ {previewData.map((item, index) => ( +
+
+
{item.old_name}
+ {item.error ? ( +
{item.error}
+ ) : ( +
→ {item.new_name}
+ )} +
+
+ ))} +
+ + + {previewOnly ? ( + + ) : ( + <> + + + + )} + +
+
+
+ ); +} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 7105263..6ff4c39 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -12,6 +12,16 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { FolderOpen, Save, RotateCcw, Info } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Switch } from "@/components/ui/switch"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings"; import { themes, applyTheme } from "@/lib/themes"; @@ -44,6 +54,7 @@ export function SettingsPage() { const [savedSettings, setSavedSettings] = useState(getSettings()); const [tempSettings, setTempSettings] = useState(savedSettings); const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); + const [showResetConfirm, setShowResetConfirm] = useState(false); useEffect(() => { applyThemeMode(savedSettings.themeMode); @@ -94,6 +105,7 @@ export function SettingsPage() { applyThemeMode(defaultSettings.themeMode); applyTheme(defaultSettings.theme); applyFont(defaultSettings.fontFamily); + setShowResetConfirm(false); toast.success("Settings reset to default"); }; @@ -305,48 +317,43 @@ export function SettingsPage() {
- - {tempSettings.folderPreset === "custom" && ( - setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} - placeholder="{artist}/{album}" - className="h-9 text-sm" - /> - )} +
+ + {tempSettings.folderPreset === "custom" && ( + setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} + placeholder="{artist}/{album}" + className="h-9 text-sm flex-1" + /> + )} +
{tempSettings.folderTemplate && (

- Preview: {tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/ + Preview: {tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/

)}
-
- - setTempSettings(prev => ({ ...prev, useAlbumArtist: checked }))} - /> -
+
{/* Filename Format */} @@ -362,37 +369,39 @@ export function SettingsPage() {
- - {tempSettings.filenamePreset === "custom" && ( - setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} - placeholder="{track}. {title}" - className="h-9 text-sm" - /> - )} +
+ + {tempSettings.filenamePreset === "custom" && ( + setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} + placeholder="{track}. {title}" + className="h-9 text-sm flex-1" + /> + )} +
{tempSettings.filenameTemplate && (

- Preview: {tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac + Preview: {tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac

)}
@@ -401,7 +410,7 @@ export function SettingsPage() { {/* Actions */}
- @@ -410,6 +419,22 @@ export function SettingsPage() { Save Changes
+ + {/* Reset Confirmation Dialog */} + + + + Reset to Default? + + This will reset all settings to their default values. Your custom configurations will be lost. + + + + Cancel + Reset + + + ); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a673e59..615e270 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, Settings, Bug, Activity, FileMusic, LayoutGrid } from "lucide-react"; +import { Home, Settings, Bug, Activity, FileMusic, FilePen, LayoutGrid, Coffee, Github } from "lucide-react"; import { Tooltip, TooltipContent, @@ -7,7 +7,7 @@ import { import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; -export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter"; +export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager"; interface SidebarProps { currentPage: PageType; @@ -20,6 +20,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { { id: "settings" as PageType, icon: Settings, label: "Settings" }, { id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" }, { id: "audio-converter" as PageType, icon: FileMusic, label: "Audio Converter" }, + { id: "file-manager" as PageType, icon: FilePen, label: "File Manager" }, { id: "debug" as PageType, icon: Bug, label: "Debug Logs" }, ]; @@ -45,27 +46,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { ))} - {/* GitHub - below debug */} - - - - - -

Report Bug

-
-
- - {/* Other Projects at bottom */} -
+ {/* Bottom icons */} +
+ + + + + +

Report Bug

+
+
+ + +

Support me on Ko-fi

+
+
); -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..5ec5d52 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index c05b9a5..aba0ed9 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -6,6 +6,27 @@ import { joinPath, sanitizePath } from "@/lib/utils"; import { logger } from "@/lib/logger"; import type { TrackMetadata } from "@/types/api"; +// Type definitions for new backend functions +interface CheckFileExistenceRequest { + isrc: string; + track_name: string; + artist_name: string; +} + +interface FileExistenceResult { + isrc: string; + exists: boolean; + file_path?: string; + track_name?: string; + artist_name?: string; +} + +// These functions will be available after Wails regenerates bindings +const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise => + (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks); +const SkipDownloadItem = (itemID: string, filePath: string): Promise => + (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath); + export function useDownload() { const [downloadProgress, setDownloadProgress] = useState(0); const [isDownloading, setIsDownloading] = useState(false); @@ -50,14 +71,10 @@ export function useDownload() { // 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 - let artistFolderName = artistName; - if(settings.useAlbumArtist) { - artistFolderName = albumArtist || artistName; - } - logger.info("Using artist folder name: " + artistFolderName); const templateData: TemplateData = { - artist: artistFolderName?.replace(/\//g, placeholder), + artist: artistName?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), + album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), track: position, year: releaseYear, @@ -301,16 +318,12 @@ export function useDownload() { let outputDir = settings.downloadPath; let useAlbumTrackNumber = false; - let artistFolderName = artistName; - if(settings.useAlbumArtist) { - artistFolderName = albumArtist || artistName; - } - logger.info("Using artist folder name: " + artistFolderName); // Replace forward slashes in template data values to prevent them from being interpreted as path separators const placeholder = "__SLASH_PLACEHOLDER__"; const templateData: TemplateData = { - artist: artistFolderName?.replace(/\//g, placeholder), + artist: artistName?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), + album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), track: position, year: releaseYear, @@ -606,7 +619,40 @@ export function useDownload() { setBulkDownloadType("selected"); setDownloadProgress(0); - // Pre-add ALL tracks to the queue before starting downloads + // Build output directory path + let outputDir = settings.downloadPath; + const os = settings.operatingSystem; + if (folderName && !isAlbum) { + outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); + } + + // Get selected track objects + const selectedTrackObjects = selectedTracks + .map((isrc) => allTracks.find((t) => t.isrc === isrc)) + .filter((t): t is TrackMetadata => t !== undefined); + + // Check file existence in parallel first + logger.info(`checking existing files in parallel...`); + const existenceChecks = selectedTrackObjects.map((track) => ({ + isrc: track.isrc, + track_name: track.name || "", + artist_name: track.artists || "", + })); + + const existenceResults = await CheckFilesExistence(outputDir, existenceChecks); + const existingISRCs = new Set(); + const existingFilePaths = new Map(); + + for (const result of existenceResults) { + if (result.exists) { + existingISRCs.add(result.isrc); + existingFilePaths.set(result.isrc, result.file_path || ""); + } + } + + logger.info(`found ${existingISRCs.size} existing files`); + + // Pre-add ALL tracks to the queue and mark existing ones as skipped const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); const itemIDs: string[] = []; for (const isrc of selectedTracks) { @@ -618,65 +664,78 @@ export function useDownload() { track?.album_name || "" ); itemIDs.push(itemID); + + // Mark existing files as skipped immediately + if (existingISRCs.has(isrc)) { + const filePath = existingFilePaths.get(isrc) || ""; + setTimeout(() => SkipDownloadItem(itemID, filePath), 10); + setSkippedTracks((prev) => new Set(prev).add(isrc)); + setDownloadedTracks((prev) => new Set(prev).add(isrc)); + } } + // Filter out existing tracks + const tracksToDownload = selectedTrackObjects.filter((track) => !existingISRCs.has(track.isrc)); + let successCount = 0; let errorCount = 0; - let skippedCount = 0; + let skippedCount = existingISRCs.size; const total = selectedTracks.length; - for (let i = 0; i < selectedTracks.length; i++) { + // Update progress to reflect already-skipped tracks + setDownloadProgress(Math.round((skippedCount / total) * 100)); + + for (let i = 0; i < tracksToDownload.length; i++) { if (shouldStopDownloadRef.current) { toast.info( - `Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.` + `Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.` ); break; } - const isrc = selectedTracks[i]; - const track = allTracks.find((t) => t.isrc === isrc); - const itemID = itemIDs[i]; + const track = tracksToDownload[i]; + const isrc = track.isrc; + // Find original index and itemID + const originalIndex = selectedTracks.indexOf(isrc); + const itemID = itemIDs[originalIndex]; setDownloadingTrack(isrc); - - if (track) { - setCurrentDownloadInfo({ name: track.name, artists: track.artists }); - } + setCurrentDownloadInfo({ name: track.name, artists: track.artists }); try { // Extract year from release_date (format: YYYY-MM-DD or YYYY) - const releaseYear = track?.release_date?.substring(0, 4); + const releaseYear = track.release_date?.substring(0, 4); // Download with pre-created itemID const response = await downloadWithItemID( isrc, settings, itemID, - track?.name, - track?.artists, - track?.album_name, + track.name, + track.artists, + track.album_name, folderName, - i + 1, // Sequential position based on selection order - track?.spotify_id, - track?.duration_ms, + originalIndex + 1, // Sequential position based on selection order + track.spotify_id, + track.duration_ms, isAlbum, releaseYear, - track?.album_artist || "", // Use album_artist from Spotify metadata - track?.release_date, - track?.images, // Spotify cover URL - track?.track_number, // Spotify album track number - track?.disc_number, // Spotify disc number - track?.total_tracks // Total tracks in album + track.album_artist || "", // Use album_artist from Spotify metadata + track.release_date, + track.images, // Spotify cover URL + track.track_number, // Spotify album track number + track.disc_number, // Spotify disc number + track.total_tracks // Total tracks in album ); if (response.success) { if (response.already_exists) { skippedCount++; - logger.info(`skipped: ${track?.name} - ${track?.artists} (already exists)`); + logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`); setSkippedTracks((prev) => new Set(prev).add(isrc)); } else { successCount++; - logger.success(`downloaded: ${track?.name} - ${track?.artists}`); + logger.success(`downloaded: ${track.name} - ${track.artists}`); } setDownloadedTracks((prev) => new Set(prev).add(isrc)); setFailedTracks((prev) => { @@ -686,19 +745,19 @@ export function useDownload() { }); } else { errorCount++; - logger.error(`failed: ${track?.name} - ${track?.artists}`); + logger.error(`failed: ${track.name} - ${track.artists}`); setFailedTracks((prev) => new Set(prev).add(isrc)); } } catch (err) { errorCount++; - logger.error(`error: ${track?.name} - ${err}`); + logger.error(`error: ${track.name} - ${err}`); setFailedTracks((prev) => new Set(prev).add(isrc)); // Mark item as failed in queue const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); } - setDownloadProgress(Math.round(((i + 1) / total) * 100)); + setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100)); } setDownloadingTrack(null); @@ -749,7 +808,35 @@ export function useDownload() { setBulkDownloadType("all"); setDownloadProgress(0); - // Pre-add ALL tracks to the queue before starting downloads + // Build output directory path + let outputDir = settings.downloadPath; + const os = settings.operatingSystem; + if (folderName && !isAlbum) { + outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); + } + + // Check file existence in parallel first + logger.info(`checking existing files in parallel...`); + const existenceChecks = tracksWithIsrc.map((track) => ({ + isrc: track.isrc, + track_name: track.name || "", + artist_name: track.artists || "", + })); + + const existenceResults = await CheckFilesExistence(outputDir, existenceChecks); + const existingISRCs = new Set(); + const existingFilePaths = new Map(); + + for (const result of existenceResults) { + if (result.exists) { + existingISRCs.add(result.isrc); + existingFilePaths.set(result.isrc, result.file_path || ""); + } + } + + logger.info(`found ${existingISRCs.size} existing files`); + + // Pre-add ALL tracks to the queue and mark existing ones as skipped const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); const itemIDs: string[] = []; for (const track of tracksWithIsrc) { @@ -760,23 +847,39 @@ export function useDownload() { track.album_name || "" ); itemIDs.push(itemID); + + // Mark existing files as skipped immediately + if (existingISRCs.has(track.isrc)) { + const filePath = existingFilePaths.get(track.isrc) || ""; + setTimeout(() => SkipDownloadItem(itemID, filePath), 10); + setSkippedTracks((prev) => new Set(prev).add(track.isrc)); + setDownloadedTracks((prev) => new Set(prev).add(track.isrc)); + } } + // Filter out existing tracks + const tracksToDownload = tracksWithIsrc.filter((track) => !existingISRCs.has(track.isrc)); + let successCount = 0; let errorCount = 0; - let skippedCount = 0; + let skippedCount = existingISRCs.size; const total = tracksWithIsrc.length; - for (let i = 0; i < tracksWithIsrc.length; i++) { + // Update progress to reflect already-skipped tracks + setDownloadProgress(Math.round((skippedCount / total) * 100)); + + for (let i = 0; i < tracksToDownload.length; i++) { if (shouldStopDownloadRef.current) { toast.info( - `Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.` + `Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.` ); break; } - const track = tracksWithIsrc[i]; - const itemID = itemIDs[i]; + const track = tracksToDownload[i]; + // Find original index and itemID + const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc); + const itemID = itemIDs[originalIndex]; setDownloadingTrack(track.isrc); setCurrentDownloadInfo({ name: track.name, artists: track.artists }); @@ -793,7 +896,7 @@ export function useDownload() { track.artists, track.album_name, folderName, - i + 1, + originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, @@ -835,7 +938,7 @@ export function useDownload() { await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); } - setDownloadProgress(Math.round(((i + 1) / total) * 100)); + setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100)); } setDownloadingTrack(null); diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 815b2c3..529cf4b 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -3,10 +3,10 @@ import { GetDefaults } from "../../wailsjs/go/main/App"; export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans"; // Folder structure presets -export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "custom"; +export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "custom"; // Filename format presets -export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "custom"; +export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "custom"; export interface Settings { downloadPath: string; @@ -21,7 +21,6 @@ export interface Settings { filenameTemplate: string; // Legacy settings (kept for migration) filenameFormat?: "title-artist" | "artist-title" | "title"; - useAlbumArtist?: boolean; artistSubfolder?: boolean; albumSubfolder?: boolean; trackNumber: boolean; @@ -41,6 +40,9 @@ export const FOLDER_PRESETS: Record