v7.1.7
This commit is contained in:
@@ -0,0 +1,595 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "image/jpeg"
|
||||
)
|
||||
|
||||
const (
|
||||
spotifySize300 = "ab67616d00001e02"
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
|
||||
type CoverDownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
TrackNumber bool `json:"track_number"`
|
||||
Position int `json:"position"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
type CoverDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
type HeaderDownloadRequest struct {
|
||||
HeaderURL string `json:"header_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
type HeaderDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
type CoverClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewCoverClient() *CoverClient {
|
||||
return &CoverClient{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title-artist":
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default:
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d - %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".jpg"
|
||||
}
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||
|
||||
mediumURL := convertSmallToMedium(imageURL)
|
||||
if strings.Contains(mediumURL, spotifySize640) {
|
||||
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
return mediumURL
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
downloadURL := convertSmallToMedium(coverURL)
|
||||
if embedMaxQualityCover {
|
||||
downloadURL = c.getMaxResolutionURL(downloadURL)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %v", err)
|
||||
}
|
||||
|
||||
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{
|
||||
Success: false,
|
||||
Error: "Cover URL is required",
|
||||
}, fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create output directory: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filenameFormat := req.FilenameFormat
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist"
|
||||
}
|
||||
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &CoverDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Cover file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
downloadURL := c.getMaxResolutionURL(req.CoverURL)
|
||||
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download cover: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download cover: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write cover file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &CoverDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Cover downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) {
|
||||
if req.HeaderURL == "" {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Header URL is required",
|
||||
}, fmt.Errorf("header URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(req.ArtistName) + "_Header.jpg"
|
||||
filePath := filepath.Join(artistFolder, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Header file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(req.HeaderURL)
|
||||
if err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download header: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write header file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &HeaderDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Header downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type GalleryImageDownloadRequest struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
ImageIndex int `json:"image_index"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
type GalleryImageDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) {
|
||||
if req.ImageURL == "" {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Image URL is required",
|
||||
}, fmt.Errorf("image URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1)
|
||||
filePath := filepath.Join(artistFolder, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Gallery image file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(req.ImageURL)
|
||||
if err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download gallery image: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write gallery image file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Gallery image downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type AvatarDownloadRequest struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
type AvatarDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) {
|
||||
if req.AvatarURL == "" {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Avatar URL is required",
|
||||
}, fmt.Errorf("avatar URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg"
|
||||
filePath := filepath.Join(artistFolder, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Avatar file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(req.AvatarURL)
|
||||
if err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download avatar: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write avatar file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &AvatarDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Avatar downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user