v7.1.2
This commit is contained in:
+5
-60
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -19,12 +18,6 @@ type AmazonDownloader struct {
|
||||
regions []string
|
||||
}
|
||||
|
||||
type SongLinkResponse struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
type AmazonStreamResponse struct {
|
||||
StreamURL string `json:"streamUrl"`
|
||||
DecryptionKey string `json:"decryptionKey"`
|
||||
@@ -40,65 +33,17 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
|
||||
spotifyBase := "https://open.spotify.com/track/"
|
||||
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
||||
|
||||
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
||||
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
fmt.Println("Getting Amazon URL...")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
client := NewSongLinkClient()
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
var songLinkResp SongLinkResponse
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
|
||||
if !ok || amazonLink.URL == "" {
|
||||
amazonURL := normalizeAmazonMusicURL(urls.AmazonURL)
|
||||
if amazonURL == "" {
|
||||
return "", fmt.Errorf("amazon Music link not found")
|
||||
}
|
||||
|
||||
amazonURL := amazonLink.URL
|
||||
|
||||
if strings.Contains(amazonURL, "trackAsin=") {
|
||||
parts := strings.Split(amazonURL, "trackAsin=")
|
||||
if len(parts) > 1 {
|
||||
trackAsin := strings.Split(parts[1], "&")[0]
|
||||
amazonURL = fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
||||
return amazonURL, nil
|
||||
}
|
||||
@@ -111,7 +56,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
|
||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.qzz.io/api/track/%s", asin)
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
+21
-184
@@ -2,170 +2,26 @@ package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-flac/go-flac"
|
||||
mewflac "github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
type AnalysisResult struct {
|
||||
FilePath string `json:"file_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
Bitrate int `json:"bit_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
||||
}
|
||||
|
||||
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||
if !fileExists(filepath) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
result := &AnalysisResult{
|
||||
FilePath: filepath,
|
||||
FileSize: fileInfo.Size(),
|
||||
}
|
||||
|
||||
if len(f.Meta) > 0 {
|
||||
streamInfo := f.Meta[0]
|
||||
if streamInfo.Type == flac.StreamInfo {
|
||||
|
||||
data := streamInfo.Data
|
||||
if len(data) >= 18 {
|
||||
|
||||
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
||||
|
||||
result.Channels = ((data[12] >> 1) & 0x07) + 1
|
||||
|
||||
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
||||
|
||||
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
||||
uint64(data[14])<<24 |
|
||||
uint64(data[15])<<16 |
|
||||
uint64(data[16])<<8 |
|
||||
uint64(data[17])
|
||||
|
||||
if result.SampleRate > 0 {
|
||||
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spectrum, err := AnalyzeSpectrum(filepath)
|
||||
if err != nil {
|
||||
|
||||
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
|
||||
} else {
|
||||
result.Spectrum = spectrum
|
||||
|
||||
calculateRealAudioMetrics(result, filepath)
|
||||
}
|
||||
|
||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
||||
|
||||
samples, err := decodeFLACForMetrics(filepath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var peak float64
|
||||
var sumSquares float64
|
||||
|
||||
for _, sample := range samples {
|
||||
absVal := sample
|
||||
if absVal < 0 {
|
||||
absVal = -absVal
|
||||
}
|
||||
if absVal > peak {
|
||||
peak = absVal
|
||||
}
|
||||
sumSquares += sample * sample
|
||||
}
|
||||
|
||||
peakDB := 20.0 * math.Log10(peak)
|
||||
result.PeakAmplitude = peakDB
|
||||
|
||||
rms := math.Sqrt(sumSquares / float64(len(samples)))
|
||||
rmsDB := 20.0 * math.Log10(rms)
|
||||
result.RMSLevel = rmsDB
|
||||
|
||||
result.DynamicRange = peakDB - rmsDB
|
||||
}
|
||||
|
||||
func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
||||
stream, err := mewflac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
maxSamples := 10000000
|
||||
samples := make([]float64, 0, maxSamples)
|
||||
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var channelSamples []int32
|
||||
if len(frame.Subframes) > 0 {
|
||||
channelSamples = frame.Subframes[0].Samples
|
||||
}
|
||||
|
||||
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
|
||||
for _, sample := range channelSamples {
|
||||
if len(samples) >= maxSamples {
|
||||
return samples, nil
|
||||
}
|
||||
normalized := float64(sample) / maxVal
|
||||
samples = append(samples, normalized)
|
||||
}
|
||||
|
||||
if len(samples) >= maxSamples {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
func GetFileSize(filepath string) (int64, error) {
|
||||
info, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
FilePath string `json:"file_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
Bitrate int `json:"bit_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
}
|
||||
|
||||
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
||||
@@ -194,20 +50,23 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
filePath,
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output))
|
||||
return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output))
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) < 4 {
|
||||
return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output))
|
||||
infoMap := make(map[string]string)
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
res := &AnalysisResult{
|
||||
@@ -218,28 +77,6 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
||||
res.FileSize = info.Size()
|
||||
}
|
||||
|
||||
infoMap := make(map[string]string)
|
||||
|
||||
args = []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
filePath,
|
||||
}
|
||||
cmd = exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
lines = strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if val, ok := infoMap["sample_rate"]; ok {
|
||||
s, _ := strconv.Atoi(val)
|
||||
res.SampleRate = uint32(s)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+7
-1
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func ValidateExecutable(path string) error {
|
||||
@@ -650,6 +651,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
|
||||
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||
outputFile = norm.NFC.String(outputFile)
|
||||
|
||||
if inputExt == outputExt {
|
||||
result.Error = "Input and output formats are the same"
|
||||
@@ -671,7 +673,11 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
||||
}
|
||||
|
||||
coverArtPath, _ = ExtractCoverArt(inputFile)
|
||||
inputFile = norm.NFC.String(inputFile)
|
||||
coverArtPath, err = ExtractCoverArt(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract cover art from %s: %v\n", inputFile, err)
|
||||
}
|
||||
lyrics, err = ExtractLyrics(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+17
-1
@@ -50,12 +50,28 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
|
||||
|
||||
func SelectFileDialog(ctx context.Context) (string, error) {
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select FLAC File for Analysis",
|
||||
Title: "Select Audio File for Analysis",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{
|
||||
DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)",
|
||||
Pattern: "*.flac;*.mp3;*.m4a;*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "FLAC Audio Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "MP3 Audio Files (*.mp3)",
|
||||
Pattern: "*.mp3",
|
||||
},
|
||||
{
|
||||
DisplayName: "M4A Audio Files (*.m4a)",
|
||||
Pattern: "*.m4a",
|
||||
},
|
||||
{
|
||||
DisplayName: "AAC Audio Files (*.aac)",
|
||||
Pattern: "*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
|
||||
@@ -22,6 +22,7 @@ type HistoryItem struct {
|
||||
Quality string `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
|
||||
+112
-5
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-flac/flacpicture"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
"github.com/go-flac/go-flac"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
@@ -218,16 +219,68 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
}
|
||||
|
||||
func ExtractCoverArt(filePath string) (string, error) {
|
||||
filePath = norm.NFC.String(filePath)
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
var coverPath string
|
||||
var err error
|
||||
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return extractCoverFromMp3(filePath)
|
||||
coverPath, err = extractCoverFromMp3(filePath)
|
||||
case ".m4a", ".flac":
|
||||
return extractCoverFromM4AOrFlac(filePath)
|
||||
coverPath, err = extractCoverFromM4AOrFlac(filePath)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
|
||||
if err != nil || coverPath == "" {
|
||||
fmt.Printf("[ExtractCoverArt] Library extraction failed for %s, trying FFmpeg fallback...\n", filePath)
|
||||
ffmpegCover, ffmpegErr := extractCoverWithFFmpeg(filePath)
|
||||
if ffmpegErr == nil {
|
||||
return ffmpegCover, nil
|
||||
}
|
||||
return coverPath, err
|
||||
}
|
||||
|
||||
return coverPath, nil
|
||||
}
|
||||
|
||||
func extractCoverWithFFmpeg(filePath string) (string, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
|
||||
cmd := exec.Command(ffmpegPath,
|
||||
"-i", filePath,
|
||||
"-an",
|
||||
"-vframes", "1",
|
||||
"-f", "image2",
|
||||
"-update", "1",
|
||||
"-y",
|
||||
tmpPath,
|
||||
)
|
||||
|
||||
setHideWindow(cmd)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("ffmpeg cover extraction failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("ffmpeg produced empty cover file")
|
||||
}
|
||||
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
func extractCoverFromMp3(filePath string) (string, error) {
|
||||
@@ -298,19 +351,71 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
||||
}
|
||||
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
filePath = norm.NFC.String(filePath)
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
var lyrics string
|
||||
var err error
|
||||
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return extractLyricsFromMp3(filePath)
|
||||
lyrics, err = extractLyricsFromMp3(filePath)
|
||||
case ".flac":
|
||||
return extractLyricsFromFlac(filePath)
|
||||
lyrics, err = extractLyricsFromFlac(filePath)
|
||||
case ".m4a":
|
||||
|
||||
return "", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
|
||||
if (err != nil || lyrics == "") && ext != ".m4a" {
|
||||
fmt.Printf("[ExtractLyrics] Library extraction failed for %s, trying ffprobe fallback...\n", filePath)
|
||||
ffprobeLyrics, ffprobeErr := extractLyricsWithFFprobe(filePath)
|
||||
if ffprobeErr == nil && ffprobeLyrics != "" {
|
||||
return ffprobeLyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
return lyrics, err
|
||||
}
|
||||
|
||||
func extractLyricsWithFFprobe(filePath string) (string, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-show_entries", "format_tags=lyrics:format_tags=unsyncedlyrics:format_tags=lyric",
|
||||
"-of", "json",
|
||||
filePath,
|
||||
)
|
||||
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Format struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tags := result.Format.Tags
|
||||
for _, key := range []string{"lyrics", "unsyncedlyrics", "lyric", "LYRICS", "UNSYNCEDLYRICS", "LYRIC"} {
|
||||
if val, ok := tags[key]; ok && val != "" {
|
||||
return val, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func extractLyricsFromMp3(filePath string) (string, error) {
|
||||
@@ -688,6 +793,7 @@ func parseLRCTimestamp(timestamp string) int64 {
|
||||
}
|
||||
|
||||
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||
filePath = norm.NFC.String(filePath)
|
||||
var metadata Metadata
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
@@ -796,6 +902,7 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||
}
|
||||
|
||||
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
|
||||
filePath = norm.NFC.String(filePath)
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
|
||||
@@ -77,7 +77,7 @@ func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre
|
||||
return meta, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@exyezed.cc )", AppVersion))
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion))
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
|
||||
+3
-3
@@ -119,7 +119,7 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
}
|
||||
|
||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||
if strings.Contains(apiBase, "qbz.afkarxyz.fun") {
|
||||
if strings.Contains(apiBase, "qbz.afkarxyz.qzz.io") {
|
||||
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||
}
|
||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||
@@ -174,7 +174,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
||||
standardAPIs := []string{
|
||||
"https://dab.yeet.su/api/stream?trackId=",
|
||||
"https://dabmusic.xyz/api/stream?trackId=",
|
||||
"https://qbz.afkarxyz.fun/api/track/",
|
||||
"https://qbz.afkarxyz.qzz.io/api/track/",
|
||||
}
|
||||
|
||||
downloadFunc := func(qual string) (string, error) {
|
||||
@@ -365,7 +365,7 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF
|
||||
var deezerISRC string
|
||||
if spotifyID != "" {
|
||||
songlinkClient := NewSongLinkClient()
|
||||
isrc, err := songlinkClient.GetISRC(spotifyID)
|
||||
isrc, err := songlinkClient.GetISRCDirect(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get ISRC: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type FlacInfo struct {
|
||||
Path string `json:"path"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
}
|
||||
|
||||
func GetFlacInfoBatch(paths []string) []FlacInfo {
|
||||
results := make([]FlacInfo, len(paths))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, path := range paths {
|
||||
wg.Add(1)
|
||||
go func(idx int, p string) {
|
||||
defer wg.Done()
|
||||
info := FlacInfo{Path: p}
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
results[idx] = info
|
||||
return
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
p,
|
||||
}
|
||||
cmd := exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
results[idx] = info
|
||||
return
|
||||
}
|
||||
|
||||
kvMap := make(map[string]string)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if parts := strings.SplitN(line, "=", 2); len(parts) == 2 {
|
||||
kvMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := kvMap["sample_rate"]; ok {
|
||||
if s, err := strconv.Atoi(v); err == nil {
|
||||
info.SampleRate = uint32(s)
|
||||
}
|
||||
}
|
||||
|
||||
bits := 0
|
||||
if v, ok := kvMap["bits_per_raw_sample"]; ok && v != "N/A" && v != "" {
|
||||
bits, _ = strconv.Atoi(v)
|
||||
}
|
||||
if bits == 0 {
|
||||
if v, ok := kvMap["bits_per_sample"]; ok && v != "N/A" && v != "" {
|
||||
bits, _ = strconv.Atoi(v)
|
||||
}
|
||||
}
|
||||
info.BitsPerSample = uint8(bits)
|
||||
|
||||
results[idx] = info
|
||||
}(i, path)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
type ResampleRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
}
|
||||
|
||||
type ResampleResult struct {
|
||||
InputFile string `json:"input_file"`
|
||||
OutputFile string `json:"output_file"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func buildFolderLabel(sampleRate, bitDepth string) string {
|
||||
var parts []string
|
||||
|
||||
if bitDepth != "" {
|
||||
parts = append(parts, bitDepth+"bit")
|
||||
}
|
||||
|
||||
switch sampleRate {
|
||||
case "44100":
|
||||
parts = append(parts, "44.1kHz")
|
||||
case "48000":
|
||||
parts = append(parts, "48kHz")
|
||||
case "96000":
|
||||
parts = append(parts, "96kHz")
|
||||
case "192000":
|
||||
parts = append(parts, "192kHz")
|
||||
default:
|
||||
if sampleRate != "" {
|
||||
parts = append(parts, sampleRate+"Hz")
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "Resampled"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func ResampleAudio(req ResampleRequest) ([]ResampleResult, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
installed, err := IsFFmpegInstalled()
|
||||
if err != nil || !installed {
|
||||
return nil, fmt.Errorf("ffmpeg is not installed")
|
||||
}
|
||||
|
||||
if req.SampleRate == "" && req.BitDepth == "" {
|
||||
return nil, fmt.Errorf("at least one of sample rate or bit depth must be specified")
|
||||
}
|
||||
|
||||
results := make([]ResampleResult, len(req.InputFiles))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
folderLabel := buildFolderLabel(req.SampleRate, req.BitDepth)
|
||||
|
||||
for i, inputFile := range req.InputFiles {
|
||||
wg.Add(1)
|
||||
go func(idx int, inputFile string) {
|
||||
defer wg.Done()
|
||||
|
||||
result := ResampleResult{
|
||||
InputFile: inputFile,
|
||||
}
|
||||
|
||||
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||
inputDir := filepath.Dir(inputFile)
|
||||
|
||||
outputDir := filepath.Join(inputDir, folderLabel)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
outputFile := filepath.Join(outputDir, baseName+".flac")
|
||||
result.OutputFile = outputFile
|
||||
|
||||
args := []string{
|
||||
"-i", inputFile,
|
||||
"-y",
|
||||
}
|
||||
|
||||
if req.BitDepth != "" {
|
||||
switch req.BitDepth {
|
||||
case "16":
|
||||
args = append(args, "-c:a", "flac", "-sample_fmt", "s16")
|
||||
case "24":
|
||||
args = append(args, "-c:a", "flac", "-sample_fmt", "s32", "-bits_per_raw_sample", "24")
|
||||
default:
|
||||
args = append(args, "-c:a", "flac")
|
||||
}
|
||||
} else {
|
||||
args = append(args, "-c:a", "flac")
|
||||
}
|
||||
|
||||
if req.SampleRate != "" {
|
||||
args = append(args, "-ar", req.SampleRate)
|
||||
}
|
||||
|
||||
args = append(args, "-map_metadata", "0")
|
||||
args = append(args, outputFile)
|
||||
|
||||
fmt.Printf("[Resample] %s -> %s\n", inputFile, outputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("resampling failed: %s - %s", err.Error(), string(output))
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
fmt.Printf("[Resample] Done: %s\n", outputFile)
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
}(i, inputFile)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
+778
-318
File diff suppressed because it is too large
Load Diff
@@ -1,181 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/cmplx"
|
||||
|
||||
"github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
type SpectrumData struct {
|
||||
TimeSlices []TimeSlice `json:"time_slices"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
FreqBins int `json:"freq_bins"`
|
||||
Duration float64 `json:"duration"`
|
||||
MaxFreq float64 `json:"max_freq"`
|
||||
}
|
||||
|
||||
type TimeSlice struct {
|
||||
Time float64 `json:"time"`
|
||||
Magnitudes []float64 `json:"magnitudes"`
|
||||
}
|
||||
|
||||
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
||||
|
||||
stream, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
info := stream.Info
|
||||
sampleRate := int(info.SampleRate)
|
||||
channels := int(info.NChannels)
|
||||
|
||||
samples, err := readSamples(stream, channels)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read samples: %w", err)
|
||||
}
|
||||
|
||||
if len(samples) == 0 {
|
||||
return nil, fmt.Errorf("no audio samples found")
|
||||
}
|
||||
|
||||
return calculateSpectrum(samples, sampleRate), nil
|
||||
}
|
||||
|
||||
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
||||
var allSamples []float64
|
||||
maxSamples := 10 * 1024 * 1024
|
||||
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
for i := 0; i < frame.Subframes[0].NSamples; i++ {
|
||||
var sample float64
|
||||
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
sample += float64(frame.Subframes[ch].Samples[i])
|
||||
}
|
||||
sample /= float64(channels)
|
||||
|
||||
allSamples = append(allSamples, sample)
|
||||
|
||||
if len(allSamples) >= maxSamples {
|
||||
return allSamples, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allSamples, nil
|
||||
}
|
||||
|
||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
||||
fftSize := 8192
|
||||
numTimeSlices := 300
|
||||
|
||||
duration := float64(len(samples)) / float64(sampleRate)
|
||||
|
||||
samplesPerSlice := len(samples) / numTimeSlices
|
||||
if samplesPerSlice < fftSize {
|
||||
samplesPerSlice = fftSize
|
||||
numTimeSlices = len(samples) / fftSize
|
||||
}
|
||||
|
||||
timeSlices := make([]TimeSlice, 0, numTimeSlices)
|
||||
freqBins := fftSize / 2
|
||||
maxFreq := float64(sampleRate) / 2.0
|
||||
|
||||
for i := 0; i < numTimeSlices; i++ {
|
||||
startIdx := i * samplesPerSlice
|
||||
if startIdx+fftSize > len(samples) {
|
||||
break
|
||||
}
|
||||
|
||||
window := samples[startIdx : startIdx+fftSize]
|
||||
|
||||
windowedSamples := applyHannWindow(window)
|
||||
|
||||
spectrum := fft(windowedSamples)
|
||||
|
||||
magnitudes := make([]float64, freqBins)
|
||||
for j := 0; j < freqBins; j++ {
|
||||
magnitude := cmplx.Abs(spectrum[j])
|
||||
|
||||
if magnitude < 1e-10 {
|
||||
magnitude = 1e-10
|
||||
}
|
||||
magnitudes[j] = 20 * math.Log10(magnitude)
|
||||
}
|
||||
|
||||
timeSlice := TimeSlice{
|
||||
Time: float64(startIdx) / float64(sampleRate),
|
||||
Magnitudes: magnitudes,
|
||||
}
|
||||
timeSlices = append(timeSlices, timeSlice)
|
||||
}
|
||||
|
||||
return &SpectrumData{
|
||||
TimeSlices: timeSlices,
|
||||
SampleRate: sampleRate,
|
||||
FreqBins: freqBins,
|
||||
Duration: duration,
|
||||
MaxFreq: maxFreq,
|
||||
}
|
||||
}
|
||||
|
||||
func applyHannWindow(samples []float64) []float64 {
|
||||
n := len(samples)
|
||||
windowed := make([]float64, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
|
||||
windowed[i] = samples[i] * window
|
||||
}
|
||||
|
||||
return windowed
|
||||
}
|
||||
|
||||
func fft(samples []float64) []complex128 {
|
||||
n := len(samples)
|
||||
|
||||
x := make([]complex128, n)
|
||||
for i := 0; i < n; i++ {
|
||||
x[i] = complex(samples[i], 0)
|
||||
}
|
||||
|
||||
return fftRecursive(x)
|
||||
}
|
||||
|
||||
func fftRecursive(x []complex128) []complex128 {
|
||||
n := len(x)
|
||||
|
||||
if n <= 1 {
|
||||
return x
|
||||
}
|
||||
|
||||
even := make([]complex128, n/2)
|
||||
odd := make([]complex128, n/2)
|
||||
|
||||
for i := 0; i < n/2; i++ {
|
||||
even[i] = x[2*i]
|
||||
odd[i] = x[2*i+1]
|
||||
}
|
||||
|
||||
evenFFT := fftRecursive(even)
|
||||
oddFFT := fftRecursive(odd)
|
||||
|
||||
result := make([]complex128, n)
|
||||
for k := 0; k < n/2; k++ {
|
||||
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
|
||||
result[k] = evenFFT[k] + t
|
||||
result[k+n/2] = evenFFT[k] - t
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
+21
-33
@@ -485,7 +485,7 @@ func extractDuration(ms float64) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]interface{}) map[string]interface{} {
|
||||
func FilterTrack(data map[string]interface{}, separator string, albumFetchData ...map[string]interface{}) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
trackData := getMap(dataMap, "trackUnion")
|
||||
if len(trackData) == 0 {
|
||||
@@ -555,7 +555,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
copyrightData := getMap(albumData, "copyright")
|
||||
if len(copyrightData) > 0 {
|
||||
copyrightItems := getSlice(copyrightData, "items")
|
||||
if copyrightItems != nil {
|
||||
if len(copyrightItems) > 0 {
|
||||
for _, item := range copyrightItems {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -574,7 +574,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
if len(tracksData) > 0 {
|
||||
discNumbers := make(map[int]bool)
|
||||
trackItems := getSlice(tracksData, "items")
|
||||
if trackItems != nil {
|
||||
if len(trackItems) > 0 {
|
||||
for _, item := range trackItems {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -656,7 +656,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
|
||||
albumArtistsString := ""
|
||||
albumLabel := ""
|
||||
if albumFetchDataMap != nil && len(albumFetchDataMap) > 0 {
|
||||
if len(albumFetchDataMap) > 0 {
|
||||
albumUnionData := getMap(getMap(albumFetchDataMap, "data"), "albumUnion")
|
||||
if len(albumUnionData) > 0 {
|
||||
albumArtists := extractArtists(getMap(albumUnionData, "artists"))
|
||||
@@ -665,7 +665,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
||||
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||
}
|
||||
if albumArtistsString == "" {
|
||||
albumArtistsString = getString(albumUnionData, "artists")
|
||||
@@ -681,7 +681,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
||||
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,7 +715,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
for _, artist := range artists {
|
||||
artistNames = append(artistNames, getString(artist, "name"))
|
||||
}
|
||||
artistsString := strings.Join(artistNames, GetSeparator())
|
||||
artistsString := strings.Join(artistNames, separator)
|
||||
|
||||
copyrightTexts := []string{}
|
||||
for _, item := range copyrightInfo {
|
||||
@@ -802,7 +802,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
return filtered
|
||||
}
|
||||
|
||||
func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
func FilterAlbum(data map[string]interface{}, separator string) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
albumData := getMap(dataMap, "albumUnion")
|
||||
if len(albumData) == 0 {
|
||||
@@ -814,7 +814,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range artists {
|
||||
artistNames = append(artistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString := strings.Join(artistNames, GetSeparator())
|
||||
albumArtistsString := strings.Join(artistNames, separator)
|
||||
|
||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||
var cover interface{}
|
||||
@@ -875,7 +875,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range trackArtists {
|
||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||
}
|
||||
trackArtistsString := strings.Join(trackArtistNames, GetSeparator())
|
||||
trackArtistsString := strings.Join(trackArtistNames, separator)
|
||||
|
||||
trackURI := getString(track, "uri")
|
||||
trackID := ""
|
||||
@@ -943,7 +943,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
return filtered
|
||||
}
|
||||
|
||||
func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
func FilterPlaylist(data map[string]interface{}, separator string) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
playlistData := getMap(dataMap, "playlistV2")
|
||||
if len(playlistData) == 0 {
|
||||
@@ -957,21 +957,9 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
avatarData := getMap(ownerData, "avatar")
|
||||
if len(avatarData) > 0 {
|
||||
sources := getSlice(avatarData, "sources")
|
||||
if sources != nil {
|
||||
for _, source := range sources {
|
||||
sourceMap, ok := source.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if getFloat64(sourceMap, "width") == 300 {
|
||||
avatarURL = getString(sourceMap, "url")
|
||||
break
|
||||
}
|
||||
}
|
||||
if avatarURL == nil && len(sources) > 0 {
|
||||
if firstSource, ok := sources[0].(map[string]interface{}); ok {
|
||||
avatarURL = getString(firstSource, "url")
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if firstSource, ok := sources[0].(map[string]interface{}); ok {
|
||||
avatarURL = getString(firstSource, "url")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1075,7 +1063,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range trackArtists {
|
||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||
}
|
||||
artistsString := strings.Join(trackArtistNames, GetSeparator())
|
||||
artistsString := strings.Join(trackArtistNames, separator)
|
||||
|
||||
trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds")
|
||||
durationObj := extractDuration(trackDurationMs)
|
||||
@@ -1121,7 +1109,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
||||
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1291,11 +1279,11 @@ func extractDiscographyItems(itemsData map[string]interface{}) []map[string]inte
|
||||
}
|
||||
|
||||
func stripHTMLTags(s string) string {
|
||||
re := regexp.MustCompile(`<[^>]*>`)
|
||||
re := regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
return re.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
||||
func FilterArtist(data map[string]interface{}, separator string) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
artistData := getMap(dataMap, "artistUnion")
|
||||
if len(artistData) == 0 {
|
||||
@@ -1424,7 +1412,7 @@ func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
||||
return filtered
|
||||
}
|
||||
|
||||
func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
func FilterSearch(data map[string]interface{}, separator string) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
searchData := getMap(dataMap, "searchV2")
|
||||
if len(searchData) == 0 {
|
||||
@@ -1514,7 +1502,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range trackArtists {
|
||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||
}
|
||||
trackArtistsString := strings.Join(trackArtistNames, GetSeparator())
|
||||
trackArtistsString := strings.Join(trackArtistNames, separator)
|
||||
|
||||
durationString := getString(trackDuration, "formatted")
|
||||
|
||||
@@ -1586,7 +1574,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString := strings.Join(albumArtistNames, GetSeparator())
|
||||
albumArtistsString := strings.Join(albumArtistNames, separator)
|
||||
|
||||
dateInfo := getMap(album, "date")
|
||||
var year interface{}
|
||||
|
||||
@@ -11,10 +11,37 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
if !useAPI || apiBaseURL == "" {
|
||||
func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error {
|
||||
if callback == nil || len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay)
|
||||
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)
|
||||
}
|
||||
|
||||
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
|
||||
@@ -22,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)
|
||||
@@ -63,22 +94,75 @@ 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 TrackResponse:
|
||||
t := payload.Track
|
||||
callback([]AlbumTrackMetadata{{
|
||||
SpotifyID: t.SpotifyID,
|
||||
Artists: t.Artists,
|
||||
Name: t.Name,
|
||||
AlbumName: t.AlbumName,
|
||||
AlbumArtist: t.AlbumArtist,
|
||||
DurationMS: t.DurationMS,
|
||||
Images: t.Images,
|
||||
ReleaseDate: t.ReleaseDate,
|
||||
TrackNumber: t.TrackNumber,
|
||||
TotalTracks: t.TotalTracks,
|
||||
DiscNumber: t.DiscNumber,
|
||||
TotalDiscs: t.TotalDiscs,
|
||||
ExternalURL: t.ExternalURL,
|
||||
Plays: t.Plays,
|
||||
PreviewURL: t.PreviewURL,
|
||||
IsExplicit: t.IsExplicit,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
|
||||
+100
-30
@@ -18,13 +18,17 @@ var (
|
||||
errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||
)
|
||||
|
||||
type MetadataCallback func(data interface{})
|
||||
|
||||
type SpotifyMetadataClient struct {
|
||||
httpClient *http.Client
|
||||
Separator string
|
||||
}
|
||||
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
return &SpotifyMetadataClient{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
Separator: ", ",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,54 +346,57 @@ type SearchResponse struct {
|
||||
Playlists []SearchResult `json:"playlists"`
|
||||
}
|
||||
|
||||
func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) {
|
||||
client := NewSpotifyMetadataClient()
|
||||
return client.GetFilteredData(ctx, spotifyURL, batch, delay)
|
||||
if separator != "" {
|
||||
client.Separator = separator
|
||||
}
|
||||
return client.GetFilteredData(ctx, spotifyURL, batch, delay, callback)
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) {
|
||||
parsed, err := parseSpotifyURI(spotifyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay)
|
||||
raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay, callback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.processSpotifyData(ctx, raw)
|
||||
return c.processSpotifyData(ctx, raw, callback)
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration) (interface{}, error) {
|
||||
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) {
|
||||
switch parsed.Type {
|
||||
case "playlist":
|
||||
return c.fetchPlaylist(ctx, parsed.ID)
|
||||
return c.fetchPlaylist(ctx, parsed.ID, callback)
|
||||
case "album":
|
||||
return c.fetchAlbum(ctx, parsed.ID)
|
||||
return c.fetchAlbum(ctx, parsed.ID, callback)
|
||||
case "track":
|
||||
return c.fetchTrack(ctx, parsed.ID)
|
||||
case "artist_discography":
|
||||
return c.fetchArtistDiscography(ctx, parsed)
|
||||
return c.fetchArtistDiscography(ctx, parsed, callback)
|
||||
case "artist":
|
||||
|
||||
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
|
||||
return c.fetchArtistDiscography(ctx, discographyParsed)
|
||||
return c.fetchArtistDiscography(ctx, discographyParsed, callback)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) {
|
||||
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, callback MetadataCallback) (interface{}, error) {
|
||||
switch payload := raw.(type) {
|
||||
case *apiPlaylistResponse:
|
||||
return c.formatPlaylistData(payload), nil
|
||||
return c.formatPlaylistData(payload, callback), nil
|
||||
case *apiAlbumResponse:
|
||||
return c.formatAlbumData(payload)
|
||||
return c.formatAlbumData(payload, callback)
|
||||
case *apiTrackResponse:
|
||||
return c.formatTrackData(payload), nil
|
||||
case *apiArtistResponse:
|
||||
return c.formatArtistDiscographyData(ctx, payload)
|
||||
return c.formatArtistDiscographyData(ctx, payload, callback)
|
||||
default:
|
||||
return nil, errors.New("unknown raw payload type")
|
||||
}
|
||||
@@ -437,7 +444,7 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
||||
|
||||
if albumID != "" {
|
||||
|
||||
albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID)
|
||||
albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID, nil)
|
||||
if err == nil && albumResponse != nil {
|
||||
|
||||
albumJSON, _ := json.Marshal(albumResponse)
|
||||
@@ -482,7 +489,7 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
||||
}
|
||||
}
|
||||
|
||||
filteredData := FilterTrack(data, albumFetchData)
|
||||
filteredData := FilterTrack(data, c.Separator, albumFetchData)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -497,15 +504,15 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string) (*apiAlbumResponse, error) {
|
||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) {
|
||||
client := NewSpotifyClient()
|
||||
if err := client.Initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||
}
|
||||
return c.fetchAlbumWithClient(ctx, client, albumID)
|
||||
return c.fetchAlbumWithClient(ctx, client, albumID, callback)
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string) (*apiAlbumResponse, error) {
|
||||
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) {
|
||||
|
||||
allItems := []interface{}{}
|
||||
offset := 0
|
||||
@@ -537,6 +544,15 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client
|
||||
|
||||
if data == nil {
|
||||
data = response
|
||||
if callback != nil {
|
||||
filtered := FilterAlbum(data, c.Separator)
|
||||
jsonData, _ := json.Marshal(filtered)
|
||||
var result apiAlbumResponse
|
||||
if json.Unmarshal(jsonData, &result) == nil {
|
||||
formatted, _ := c.formatAlbumData(&result, nil)
|
||||
callback(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
albumData := getMap(getMap(response, "data"), "albumUnion")
|
||||
@@ -579,7 +595,7 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client
|
||||
tracksV2["totalCount"] = len(allItems)
|
||||
}
|
||||
|
||||
filteredData := FilterAlbum(data)
|
||||
filteredData := FilterAlbum(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -594,7 +610,7 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string) (*apiPlaylistResponse, error) {
|
||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string, callback MetadataCallback) (*apiPlaylistResponse, error) {
|
||||
client := NewSpotifyClient()
|
||||
if err := client.Initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||
@@ -630,6 +646,15 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
||||
|
||||
if data == nil {
|
||||
data = response
|
||||
if callback != nil {
|
||||
filtered := FilterPlaylist(data, c.Separator)
|
||||
jsonData, _ := json.Marshal(filtered)
|
||||
var result apiPlaylistResponse
|
||||
if json.Unmarshal(jsonData, &result) == nil {
|
||||
formatted := c.formatPlaylistData(&result, nil)
|
||||
callback(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlistData := getMap(getMap(response, "data"), "playlistV2")
|
||||
@@ -672,7 +697,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
||||
content["totalCount"] = len(allItems)
|
||||
}
|
||||
|
||||
filteredData := FilterPlaylist(data)
|
||||
filteredData := FilterPlaylist(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -687,7 +712,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI) (*apiArtistResponse, error) {
|
||||
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, callback MetadataCallback) (*apiArtistResponse, error) {
|
||||
client := NewSpotifyClient()
|
||||
if err := client.Initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||
@@ -712,6 +737,16 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
||||
return nil, fmt.Errorf("failed to query artist overview: %w", err)
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
filtered := FilterArtist(data, c.Separator)
|
||||
jsonData, _ := json.Marshal(filtered)
|
||||
var result apiArtistResponse
|
||||
if json.Unmarshal(jsonData, &result) == nil {
|
||||
formatted, _ := c.formatArtistDiscographyData(ctx, &result, nil)
|
||||
callback(formatted)
|
||||
}
|
||||
}
|
||||
|
||||
allDiscographyItems := []interface{}{}
|
||||
offset := 0
|
||||
limit := 50
|
||||
@@ -841,7 +876,7 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
||||
}
|
||||
}
|
||||
|
||||
filteredData := FilterArtist(data)
|
||||
filteredData := FilterArtist(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -898,7 +933,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumResponsePayload, error) {
|
||||
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) {
|
||||
var artistID, artistURL string
|
||||
|
||||
info := AlbumInfoMetadata{
|
||||
@@ -911,6 +946,13 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
||||
ArtistURL: artistURL,
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
callback(AlbumResponsePayload{
|
||||
AlbumInfo: info,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
||||
for idx, item := range raw.Tracks {
|
||||
durationMS := parseDuration(item.Duration)
|
||||
@@ -955,13 +997,17 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
||||
})
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
callback(tracks)
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
AlbumInfo: info,
|
||||
TrackList: tracks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) PlaylistResponsePayload {
|
||||
func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse, callback MetadataCallback) PlaylistResponsePayload {
|
||||
var info PlaylistInfoMetadata
|
||||
info.Tracks.Total = raw.Count
|
||||
info.Followers.Total = raw.Followers
|
||||
@@ -971,6 +1017,13 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
||||
info.Cover = raw.Cover
|
||||
info.Description = raw.Description
|
||||
|
||||
if callback != nil {
|
||||
callback(PlaylistResponsePayload{
|
||||
PlaylistInfo: info,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
||||
for _, item := range raw.Tracks {
|
||||
durationMS := parseDuration(item.Duration)
|
||||
@@ -1015,13 +1068,17 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
||||
})
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
callback(tracks)
|
||||
}
|
||||
|
||||
return PlaylistResponsePayload{
|
||||
PlaylistInfo: info,
|
||||
TrackList: tracks,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse) (*ArtistDiscographyPayload, error) {
|
||||
func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse, callback MetadataCallback) (*ArtistDiscographyPayload, error) {
|
||||
discType := "all"
|
||||
|
||||
info := ArtistInfoMetadata{
|
||||
@@ -1067,7 +1124,17 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
Images: alb.Cover,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
||||
})
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
callback(ArtistDiscographyPayload{
|
||||
ArtistInfo: info,
|
||||
AlbumList: albumList,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
}
|
||||
|
||||
for _, alb := range raw.Discography.All {
|
||||
go func(albumID string, albumName string) {
|
||||
sem <- struct{}{}
|
||||
|
||||
@@ -1081,7 +1148,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
default:
|
||||
}
|
||||
|
||||
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID)
|
||||
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
||||
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
||||
@@ -1131,6 +1198,9 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
IsExplicit: tr.IsExplicit,
|
||||
})
|
||||
}
|
||||
if callback != nil {
|
||||
callback(tracks)
|
||||
}
|
||||
resultsChan <- fetchResult{tracks: tracks}
|
||||
}(alb.ID, alb.Name)
|
||||
}
|
||||
@@ -1290,7 +1360,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit
|
||||
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||
}
|
||||
|
||||
filteredData := FilterSearch(data)
|
||||
filteredData := FilterSearch(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -1407,7 +1477,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string,
|
||||
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||
}
|
||||
|
||||
filteredData := FilterSearch(data)
|
||||
filteredData := FilterSearch(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
|
||||
+4
-35
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -91,47 +90,17 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
|
||||
spotifyBase := "https://open.spotify.com/track/"
|
||||
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
||||
|
||||
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
||||
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
fmt.Println("Getting Tidal URL...")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
client := NewSongLinkClient()
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]
|
||||
if !ok || tidalLink.URL == "" {
|
||||
tidalURL := urls.TidalURL
|
||||
if tidalURL == "" {
|
||||
return "", fmt.Errorf("tidal link not found")
|
||||
}
|
||||
|
||||
tidalURL := tidalLink.URL
|
||||
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
|
||||
return tidalURL, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user