From 5ebd28982b646ae60c410ca53375d11f0665b3c1 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Wed, 25 Mar 2026 20:44:31 +0700 Subject: [PATCH] .final --- app.go | 41 ++++++++ backend/cover.go | 69 ++++++++++++++ backend/download_validation.go | 44 +++++++++ backend/fileicon_darwin.go | 45 +++++++++ backend/fileicon_stub.go | 7 ++ backend/spotfetch_api.go | 72 +++++++++++--- frontend/src/App.tsx | 93 ++++++++++++++++++- frontend/src/components/AlbumInfo.tsx | 6 +- frontend/src/components/ArtistInfo.tsx | 47 +++++++++- .../src/components/DownloadProgressToast.tsx | 8 +- frontend/src/components/PlaylistInfo.tsx | 6 +- go.mod | 1 + go.sum | 10 ++ 13 files changed, 423 insertions(+), 26 deletions(-) create mode 100644 backend/download_validation.go create mode 100644 backend/fileicon_darwin.go create mode 100644 backend/fileicon_stub.go diff --git a/app.go b/app.go index ee82710..4a8f023 100644 --- a/app.go +++ b/app.go @@ -106,6 +106,22 @@ type DownloadResponse struct { ItemID string `json:"item_id,omitempty"` } +func cleanupInvalidDownloadArtifacts(paths ...string) { + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if path == "" { + continue + } + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + if err := os.Remove(path); err == nil { + fmt.Printf("Removed invalid download artifact: %s\n", path) + } + } +} + func (a *App) GetStreamingURLs(spotifyTrackID string, region string) (string, error) { if spotifyTrackID == "" { return "", fmt.Errorf("spotify track ID is required") @@ -474,6 +490,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { filename = strings.TrimPrefix(filename, "EXISTS:") } + if !alreadyExists { + validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration) + if validationErr != nil { + cleanupInvalidDownloadArtifacts(filename) + errorMessage := validationErr.Error() + backend.FailDownloadItem(itemID, errorMessage) + return DownloadResponse{ + Success: false, + Error: errorMessage, + ItemID: itemID, + }, fmt.Errorf(errorMessage) + } + if !validated { + fmt.Printf("[DownloadValidation] Skipped duration validation for %s (expected=%ds)\n", filename, req.Duration) + } + } + if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && (strings.HasSuffix(filename, ".flac") || strings.HasSuffix(filename, ".mp3") || strings.HasSuffix(filename, ".m4a")) { fmt.Printf("\nWaiting for lyrics fetch to complete...\n") lyrics := <-lyricsChan @@ -505,6 +538,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { message = "File already exists" backend.SkipDownloadItem(itemID, filename) } else { + if strings.EqualFold(filepath.Ext(filename), ".flac") && req.CoverURL != "" { + coverClient := backend.NewCoverClient() + if iconErr := coverClient.ApplyMacOSFLACFileIcon(filename, req.CoverURL, 256, req.EmbedMaxQualityCover); iconErr != nil { + fmt.Printf("Warning: failed to set macOS FLAC file icon: %v\n", iconErr) + } else { + fmt.Printf("macOS FLAC file icon set: %s\n", filename) + } + } if fileInfo, statErr := os.Stat(filename); statErr == nil { finalSize := float64(fileInfo.Size()) / (1024 * 1024) diff --git a/backend/cover.go b/backend/cover.go index 843dae1..e40256a 100644 --- a/backend/cover.go +++ b/backend/cover.go @@ -1,7 +1,10 @@ package backend import ( + "bytes" "fmt" + "image" + "image/png" "io" "net/http" "os" @@ -9,6 +12,9 @@ import ( "regexp" "strings" "time" + + xdraw "golang.org/x/image/draw" + _ "image/jpeg" ) const ( @@ -170,6 +176,69 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ return nil } +func (c *CoverClient) ApplyMacOSFLACFileIcon(filePath, coverURL string, iconSize int, embedMaxQualityCover bool) error { + if filePath == "" { + return fmt.Errorf("file path is required") + } + if coverURL == "" { + return fmt.Errorf("cover URL is required") + } + + tmpFile, err := os.CreateTemp("", "spotiflac-file-icon-*.jpg") + if err != nil { + return fmt.Errorf("failed to create temporary cover file: %w", err) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpPath) + + if err := c.DownloadCoverToPath(coverURL, tmpPath, embedMaxQualityCover); err != nil { + return err + } + + return SetMacOSFileIconFromImage(filePath, tmpPath, iconSize) +} + +func ResizeImageForIcon(sourcePath string, iconSize int) (string, error) { + if sourcePath == "" { + return "", fmt.Errorf("source image path is required") + } + if iconSize <= 0 { + iconSize = 256 + } + + in, err := os.Open(sourcePath) + if err != nil { + return "", fmt.Errorf("failed to open source image: %w", err) + } + defer in.Close() + + srcImage, _, err := image.Decode(in) + if err != nil { + return "", fmt.Errorf("failed to decode source image: %w", err) + } + + dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize)) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), srcImage, srcImage.Bounds(), xdraw.Over, nil) + + tmpFile, err := os.CreateTemp("", "spotiflac-resized-icon-*.png") + if err != nil { + return "", fmt.Errorf("failed to create resized icon temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer tmpFile.Close() + + var encoded bytes.Buffer + if err := png.Encode(&encoded, dst); err != nil { + return "", fmt.Errorf("failed to encode resized icon image: %w", err) + } + if _, err := io.Copy(tmpFile, &encoded); err != nil { + return "", fmt.Errorf("failed to write resized icon image: %w", err) + } + + return tmpPath, nil +} + func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) { if req.CoverURL == "" { return &CoverDownloadResponse{ diff --git a/backend/download_validation.go b/backend/download_validation.go new file mode 100644 index 0000000..a465211 --- /dev/null +++ b/backend/download_validation.go @@ -0,0 +1,44 @@ +package backend + +import ( + "fmt" + "math" +) + +const ( + previewMaxSeconds = 35 + previewExpectedMinSeconds = 60 + largeMismatchMinExpected = 90 + minAllowedDurationDiff = 15 + durationDiffRatio = 0.25 +) + +func ValidateDownloadedTrackDuration(filePath string, expectedSeconds int) (bool, error) { + if filePath == "" || expectedSeconds <= 0 { + return false, nil + } + + actualDuration, err := GetAudioDuration(filePath) + if err != nil || actualDuration <= 0 { + return false, nil + } + + actualSeconds := int(math.Round(actualDuration)) + if actualSeconds <= 0 { + return false, nil + } + + if expectedSeconds >= previewExpectedMinSeconds && actualSeconds <= previewMaxSeconds { + return true, fmt.Errorf("detected preview/sample download: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds) + } + + if expectedSeconds >= largeMismatchMinExpected { + allowedDiff := int(math.Max(minAllowedDurationDiff, math.Round(float64(expectedSeconds)*durationDiffRatio))) + diff := int(math.Abs(float64(actualSeconds - expectedSeconds))) + if diff > allowedDiff { + return true, fmt.Errorf("downloaded file duration mismatch: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds) + } + } + + return true, nil +} diff --git a/backend/fileicon_darwin.go b/backend/fileicon_darwin.go new file mode 100644 index 0000000..dc62cbf --- /dev/null +++ b/backend/fileicon_darwin.go @@ -0,0 +1,45 @@ +//go:build darwin + +package backend + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error { + if filePath == "" { + return fmt.Errorf("file path is required") + } + if imagePath == "" { + return fmt.Errorf("image path is required") + } + + resizedPath, err := ResizeImageForIcon(imagePath, iconSize) + if err != nil { + return err + } + defer os.Remove(resizedPath) + + script := ` +use framework "AppKit" +on run argv + set imagePath to item 1 of argv + set targetPath to item 2 of argv + set iconImage to current application's NSImage's alloc()'s initWithContentsOfFile:imagePath + if iconImage is missing value then error "Failed to load icon image" + set didSet to (current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:targetPath options:0) as boolean + if didSet is false then error "Failed to set custom file icon" +end run +` + + cmd := exec.Command("osascript", "-", resizedPath, filePath) + cmd.Stdin = strings.NewReader(script) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to apply macOS file icon: %v (%s)", err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/backend/fileicon_stub.go b/backend/fileicon_stub.go new file mode 100644 index 0000000..31be330 --- /dev/null +++ b/backend/fileicon_stub.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package backend + +func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error { + return nil +} diff --git a/backend/spotfetch_api.go b/backend/spotfetch_api.go index 9943aaa..db484c0 100644 --- a/backend/spotfetch_api.go +++ b/backend/spotfetch_api.go @@ -11,6 +11,34 @@ import ( "time" ) +func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error { + if callback == nil || len(tracks) == 0 { + return nil + } + + const chunkSize = 25 + for start := 0; start < len(tracks); start += chunkSize { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + end := start + chunkSize + if end > len(tracks) { + end = len(tracks) + } + + callback(tracks[start:end]) + + if end < len(tracks) { + time.Sleep(15 * time.Millisecond) + } + } + + return nil +} + func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) { if !useAPI || apiBaseURL == "" { return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback) @@ -21,6 +49,10 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL) } + if spotifyType == "artist" { + return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback) + } + apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) @@ -62,36 +94,52 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, return nil, fmt.Errorf("failed to decode album response: %w", err) } data = &albumResp + if callback != nil { + callback(&AlbumResponsePayload{ + AlbumInfo: albumResp.AlbumInfo, + TrackList: []AlbumTrackMetadata{}, + }) + if err := streamTrackListChunks(ctx, albumResp.TrackList, callback); err != nil { + return nil, err + } + } case "playlist": var playlistResp PlaylistResponsePayload if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil { return nil, fmt.Errorf("failed to decode playlist response: %w", err) } data = playlistResp + if callback != nil { + callback(PlaylistResponsePayload{ + PlaylistInfo: playlistResp.PlaylistInfo, + TrackList: []AlbumTrackMetadata{}, + }) + if err := streamTrackListChunks(ctx, playlistResp.TrackList, callback); err != nil { + return nil, err + } + } case "artist": var artistResp ArtistDiscographyPayload if err := json.Unmarshal(bodyBytes, &artistResp); err != nil { return nil, fmt.Errorf("failed to decode artist response: %w", err) } data = &artistResp + if callback != nil { + callback(&ArtistDiscographyPayload{ + ArtistInfo: artistResp.ArtistInfo, + AlbumList: artistResp.AlbumList, + TrackList: []AlbumTrackMetadata{}, + }) + if err := streamTrackListChunks(ctx, artistResp.TrackList, callback); err != nil { + return nil, err + } + } default: return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType) } if callback != nil { switch payload := data.(type) { - case *AlbumResponsePayload: - if len(payload.TrackList) > 0 { - callback(payload.TrackList) - } - case PlaylistResponsePayload: - if len(payload.TrackList) > 0 { - callback(payload.TrackList) - } - case *ArtistDiscographyPayload: - if len(payload.TrackList) > 0 { - callback(payload.TrackList) - } case TrackResponse: t := payload.Track callback([]AlbumTrackMetadata{{ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfa9dc6..8e559f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,80 @@ import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; import { useDownloadProgress } from "@/hooks/useDownloadProgress"; const HISTORY_KEY = "spotiflac_fetch_history"; const MAX_HISTORY = 5; + +function extractSpotifyEntityFromURL(url: string): { type: string; id: string; } | null { + const trimmed = url.trim(); + if (!trimmed) { + return null; + } + + const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i); + if (spotifyUriMatch) { + return { + type: spotifyUriMatch[1].toLowerCase(), + id: spotifyUriMatch[2], + }; + } + + try { + const parsed = new URL(trimmed); + const segments = parsed.pathname.split("/").filter(Boolean); + const supportedTypes = new Set(["track", "album", "playlist", "artist"]); + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i].toLowerCase(); + if (!supportedTypes.has(segment)) { + continue; + } + + const id = segments[i + 1]; + if (id) { + return { type: segment, id }; + } + } + } + catch { + } + + return null; +} + +function normalizeHistoryURL(url: string): string { + const trimmed = url.trim(); + if (!trimmed) + return trimmed; + + const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, ""); + const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery); + if (spotifyEntity) { + return `https://open.spotify.com/${spotifyEntity.type}/${spotifyEntity.id}`; + } + return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1"); +} + +function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string { + const normalizedUrl = normalizeHistoryURL(url); + const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl); + if (spotifyEntity) { + return `${type}:${spotifyEntity.id}`; + } + + return `${type}:${normalizedUrl}`; +} + +function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] { + const seen = new Set(); + const deduped: HistoryItem[] = []; + for (const item of items) { + const normalizedUrl = normalizeHistoryURL(item.url); + const key = getHistoryIdentityKey(item.type, normalizedUrl); + if (seen.has(key)) + continue; + seen.add(key); + deduped.push({ ...item, url: normalizedUrl }); + } + return deduped; +} + function App() { const [currentPage, setCurrentPage] = useState("main"); const [spotifyUrl, setSpotifyUrl] = useState(""); @@ -167,7 +241,9 @@ function App() { try { const saved = localStorage.getItem(HISTORY_KEY); if (saved) { - setFetchHistory(JSON.parse(saved)); + const deduped = dedupeHistoryItems(JSON.parse(saved)); + setFetchHistory(deduped); + localStorage.setItem(HISTORY_KEY, JSON.stringify(deduped)); } } catch (err) { @@ -222,9 +298,12 @@ function App() { }; const addToHistory = (item: Omit) => { setFetchHistory((prev) => { - const filtered = prev.filter((h) => h.url !== item.url); + const normalizedUrl = normalizeHistoryURL(item.url); + const identityKey = getHistoryIdentityKey(item.type, normalizedUrl); + const filtered = prev.filter((h) => getHistoryIdentityKey(h.type, h.url) !== identityKey); const newItem: HistoryItem = { ...item, + url: normalizedUrl, id: crypto.randomUUID(), timestamp: Date.now(), }; @@ -345,6 +424,8 @@ function App() { if ("album_info" in metadata.metadata) { const { album_info, track_list } = metadata.metadata; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { + const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; + setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -359,6 +440,8 @@ function App() { if ("playlist_info" in metadata.metadata) { const { playlist_info, track_list } = metadata.metadata; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { + const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; + setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -373,6 +456,8 @@ function App() { if ("artist_info" in metadata.metadata) { const { artist_info, album_list, track_list } = metadata.metadata; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { + const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; + setSpotifyUrl(pendingArtistUrl); const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -459,6 +544,10 @@ function App() { Cancel -

Download Album Cover

+

Download Separate Album Cover

)} @@ -203,7 +203,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT -

Download All Covers

+

Download All Separate Covers

)} {downloadedTracks.size > 0 && ()} + {albumFilters.length > 1 && (
+ {albumFilters.map((filter) => ())} +
)}
- {albumList.map((album) => { + {filteredAlbums.map((album) => { const albumTracks = trackList.filter(t => t.album_name === album.name); const tracksWithId = albumTracks.filter(t => t.spotify_id); const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!)); @@ -495,6 +535,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
); })} + {filteredAlbums.length === 0 && (
+ No releases found for the selected discography filter. +
)} )} {activeTab === "tracks" && trackList.length > 0 && (
@@ -564,7 +607,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort -

Download All Covers

+

Download All Separate Covers

)} {downloadedTracks.size > 0 && (
); diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index f8022b8..5748f2f 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -134,7 +134,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel if (response.already_exists) toast.info("Cover already exists"); else - toast.success("Playlist cover downloaded"); + toast.success("Separate playlist cover downloaded"); } else { toast.error(response.error || "Failed to download cover"); @@ -165,7 +165,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel {downloadingPlaylistCover ? : } -

Download Playlist Cover

+

Download Separate Playlist Cover

)} @@ -213,7 +213,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel -

Download All Covers

+

Download All Separate Covers

)} {downloadedTracks.size > 0 && (