.final
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !darwin
|
||||
|
||||
package backend
|
||||
|
||||
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||
return nil
|
||||
}
|
||||
+60
-12
@@ -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{{
|
||||
|
||||
Reference in New Issue
Block a user