This commit is contained in:
afkarxyz
2026-03-25 20:44:31 +07:00
parent f8ef1180f6
commit 5ebd28982b
13 changed files with 423 additions and 26 deletions
+69
View File
@@ -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{
+44
View File
@@ -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
}
+45
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !darwin
package backend
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
return nil
}
+60 -12
View File
@@ -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{{