488 lines
15 KiB
Go
488 lines
15 KiB
Go
package backend
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type AmazonDownloader struct {
|
|
client *http.Client
|
|
regions []string
|
|
}
|
|
|
|
func NewAmazonDownloader() *AmazonDownloader {
|
|
return &AmazonDownloader{
|
|
client: &http.Client{
|
|
Timeout: 120 * time.Second,
|
|
},
|
|
regions: []string{"us", "eu"},
|
|
}
|
|
}
|
|
|
|
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
|
fmt.Println("Getting Amazon URL...")
|
|
client := NewSongLinkClient()
|
|
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
|
}
|
|
|
|
amazonURL := normalizeAmazonMusicURL(urls.AmazonURL)
|
|
if amazonURL == "" {
|
|
return "", fmt.Errorf("amazon Music link not found")
|
|
}
|
|
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
|
return amazonURL, nil
|
|
}
|
|
|
|
type amazonCommunityResponse struct {
|
|
ASIN string `json:"asin"`
|
|
Codec string `json:"codec"`
|
|
BitDepth int `json:"bit_depth"`
|
|
URL string `json:"url"`
|
|
StreamURL string `json:"stream_url"`
|
|
Key string `json:"key"`
|
|
KeySpecs []string `json:"key_specs"`
|
|
Captcha string `json:"captcha"`
|
|
}
|
|
|
|
func amazonCommunityNormalizeQuality(quality string) string {
|
|
switch strings.ToLower(strings.TrimSpace(quality)) {
|
|
case "16", "lossless", "cd":
|
|
return "16"
|
|
case "atmos", "eac3", "dolby":
|
|
return "atmos"
|
|
default:
|
|
return "24"
|
|
}
|
|
}
|
|
|
|
func (a *AmazonDownloader) downloadFromCommunity(amazonURL, outputDir, quality string) (string, error) {
|
|
|
|
asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
|
|
asin := asinRegex.FindString(amazonURL)
|
|
if asin == "" {
|
|
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
|
}
|
|
|
|
payload, err := json.Marshal(map[string]string{
|
|
"id": asin,
|
|
"quality": amazonCommunityNormalizeQuality(quality),
|
|
"country": "US",
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
|
resp, err := doCommunityRequest(a.client, "Amazon", func() (*http.Request, error) {
|
|
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetAmazonCommunityDownloadURL(), bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
if err := setCommunityRequestHeaders(req); err != nil {
|
|
return nil, err
|
|
}
|
|
return req, nil
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var apiResp amazonCommunityResponse
|
|
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
|
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
streamURL := strings.TrimSpace(apiResp.StreamURL)
|
|
if streamURL == "" {
|
|
streamURL = strings.TrimSpace(apiResp.URL)
|
|
}
|
|
if streamURL == "" {
|
|
return "", fmt.Errorf("no stream URL found in response")
|
|
}
|
|
|
|
keySpecs := apiResp.KeySpecs
|
|
if len(keySpecs) == 0 {
|
|
if key := strings.TrimSpace(apiResp.Key); key != "" {
|
|
keySpecs = []string{key}
|
|
}
|
|
}
|
|
|
|
encryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.encrypted.mp4", asin))
|
|
out, err := os.Create(encryptedPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
out.Close()
|
|
os.Remove(encryptedPath)
|
|
}()
|
|
|
|
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, streamURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if captcha := strings.TrimSpace(apiResp.Captcha); captcha != "" {
|
|
dlReq.Header.Set("x-captcha-token", captcha)
|
|
}
|
|
|
|
dlResp, err := a.client.Do(dlReq)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer dlResp.Body.Close()
|
|
|
|
fmt.Printf("Downloading track: %s\n", asin)
|
|
pw := NewProgressWriter(out)
|
|
if _, err = io.Copy(pw, dlResp.Body); err != nil {
|
|
return "", err
|
|
}
|
|
out.Close()
|
|
|
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
|
|
|
remuxInput := encryptedPath
|
|
if len(keySpecs) > 0 {
|
|
fmt.Printf("Decrypting file...\n")
|
|
decryptedPath := filepath.Join(outputDir, fmt.Sprintf("%s.decrypted.mp4", asin))
|
|
if err := decryptWithMP4FF(keySpecs, encryptedPath, decryptedPath); err != nil {
|
|
return "", err
|
|
}
|
|
defer os.Remove(decryptedPath)
|
|
remuxInput = decryptedPath
|
|
fmt.Println("Decryption successful")
|
|
}
|
|
|
|
targetExt := ".flac"
|
|
if codec := strings.ToLower(strings.TrimSpace(apiResp.Codec)); codec == "eac3" || codec == "ec-3" || codec == "ac-3" {
|
|
targetExt = ".m4a"
|
|
}
|
|
finalPath := filepath.Join(outputDir, asin+targetExt)
|
|
|
|
if err := amazonRemuxWithFFmpeg(remuxInput, finalPath, targetExt); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if info, err := os.Stat(finalPath); err != nil || info.Size() == 0 {
|
|
return "", fmt.Errorf("remuxed file missing or empty")
|
|
}
|
|
|
|
return finalPath, nil
|
|
}
|
|
|
|
func amazonRemuxWithFFmpeg(inputPath, outputPath, targetExt string) error {
|
|
ffmpegPath, err := GetFFmpegPath()
|
|
if err != nil {
|
|
return fmt.Errorf("ffmpeg not found for remux: %w", err)
|
|
}
|
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
|
return fmt.Errorf("invalid ffmpeg executable: %w", err)
|
|
}
|
|
|
|
runFFmpeg := func(args ...string) (string, error) {
|
|
cmd := exec.Command(ffmpegPath, args...)
|
|
setHideWindow(cmd)
|
|
output, err := cmd.CombinedOutput()
|
|
return string(output), err
|
|
}
|
|
|
|
args := []string{"-y", "-i", inputPath, "-map", "0:a:0", "-vn", "-c:a", "copy"}
|
|
if targetExt == ".m4a" {
|
|
args = append(args, "-f", "mp4")
|
|
}
|
|
args = append(args, outputPath)
|
|
|
|
if output, err := runFFmpeg(args...); err != nil {
|
|
if targetExt == ".flac" {
|
|
if output2, err2 := runFFmpeg("-y", "-i", inputPath, "-map", "0:a:0", "-vn", "-c:a", "flac", outputPath); err2 == nil {
|
|
return nil
|
|
} else {
|
|
output = output2
|
|
err = err2
|
|
}
|
|
}
|
|
if len(output) > 500 {
|
|
output = output[len(output)-500:]
|
|
}
|
|
return fmt.Errorf("ffmpeg remux failed: %v\nTail Output: %s", err, output)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
|
|
return a.downloadFromCommunity(amazonURL, outputDir, quality)
|
|
}
|
|
|
|
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
|
|
|
if outputDir != "." {
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
return "", fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
}
|
|
|
|
if spotifyTrackName != "" && spotifyArtistName != "" {
|
|
filenameArtist := spotifyArtistName
|
|
filenameAlbumArtist := spotifyAlbumArtist
|
|
if useFirstArtistOnly {
|
|
filenameArtist = GetFirstArtist(spotifyArtistName)
|
|
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
|
|
}
|
|
expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false, isrcOverride)
|
|
expectedPath := filepath.Join(outputDir, expectedFilename)
|
|
|
|
if !GetRedownloadWithSuffixSetting() {
|
|
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
|
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
|
|
return "EXISTS:" + expectedPath, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
type mbResult struct {
|
|
ISRC string
|
|
Metadata Metadata
|
|
}
|
|
|
|
metaChan := make(chan mbResult, 1)
|
|
if embedGenre && spotifyURL != "" {
|
|
go func() {
|
|
res := mbResult{}
|
|
var isrc string
|
|
parts := strings.Split(spotifyURL, "/")
|
|
if len(parts) > 0 {
|
|
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
|
if sID != "" {
|
|
client := NewSongLinkClient()
|
|
if val, err := client.GetISRC(sID); err == nil {
|
|
isrc = val
|
|
}
|
|
}
|
|
}
|
|
res.ISRC = isrc
|
|
if isrc != "" {
|
|
if ShouldSkipMusicBrainzMetadataFetch() {
|
|
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
|
|
} else {
|
|
fmt.Println("Fetching MusicBrainz metadata...")
|
|
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
|
res.Metadata = fetchedMeta
|
|
fmt.Println("MusicBrainz metadata fetched")
|
|
} else {
|
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
metaChan <- res
|
|
}()
|
|
} else {
|
|
close(metaChan)
|
|
}
|
|
|
|
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
|
|
|
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
isrc := strings.TrimSpace(isrcOverride)
|
|
var mbMeta Metadata
|
|
if spotifyURL != "" {
|
|
result := <-metaChan
|
|
if isrc == "" {
|
|
isrc = result.ISRC
|
|
}
|
|
mbMeta = result.Metadata
|
|
}
|
|
|
|
upc := ""
|
|
if spotifyURL != "" {
|
|
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
|
|
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
|
|
isrc = strings.TrimSpace(identifiers.ISRC)
|
|
}
|
|
upc = strings.TrimSpace(identifiers.UPC)
|
|
}
|
|
}
|
|
|
|
originalFileDir := filepath.Dir(filePath)
|
|
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
|
|
|
if spotifyTrackName != "" && spotifyArtistName != "" {
|
|
safeArtist := sanitizeFilename(spotifyArtistName)
|
|
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
|
|
|
if useFirstArtistOnly {
|
|
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
|
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
|
}
|
|
|
|
safeTitle := sanitizeFilename(spotifyTrackName)
|
|
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
|
|
|
year := ""
|
|
if len(spotifyReleaseDate) >= 4 {
|
|
year = spotifyReleaseDate[:4]
|
|
}
|
|
|
|
var newFilename string
|
|
|
|
if strings.Contains(filenameFormat, "{") {
|
|
newFilename = filenameFormat
|
|
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
|
|
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
|
|
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
|
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
|
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
|
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
|
|
newFilename = strings.ReplaceAll(newFilename, "{isrc}", SanitizeOptionalFilename(isrc))
|
|
|
|
if spotifyDiscNumber > 0 {
|
|
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
|
} else {
|
|
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
|
|
}
|
|
|
|
if position > 0 {
|
|
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
|
|
} else {
|
|
|
|
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
|
|
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
|
|
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
|
|
}
|
|
} else {
|
|
|
|
switch filenameFormat {
|
|
case "artist-title":
|
|
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
|
case "title":
|
|
newFilename = safeTitle
|
|
default:
|
|
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
|
}
|
|
|
|
if includeTrackNumber && position > 0 {
|
|
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
|
|
}
|
|
}
|
|
|
|
ext := filepath.Ext(filePath)
|
|
if ext == "" {
|
|
ext = ".flac"
|
|
}
|
|
newFilename = newFilename + ext
|
|
newFilePath := filepath.Join(outputDir, newFilename)
|
|
if GetRedownloadWithSuffixSetting() {
|
|
newFilePath, _ = ResolveOutputPathForDownload(newFilePath, true)
|
|
}
|
|
|
|
if err := os.Rename(filePath, newFilePath); err != nil {
|
|
fmt.Printf("Warning: Failed to rename file: %v\n", err)
|
|
} else {
|
|
filePath = newFilePath
|
|
fmt.Printf("Renamed to: %s\n", newFilename)
|
|
}
|
|
}
|
|
|
|
fmt.Println("Embedding Spotify metadata...")
|
|
|
|
coverPath := ""
|
|
|
|
if spotifyCoverURL != "" {
|
|
coverPath = filePath + ".cover.jpg"
|
|
coverClient := NewCoverClient()
|
|
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
|
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
|
coverPath = ""
|
|
} else {
|
|
defer os.Remove(coverPath)
|
|
fmt.Println("Spotify cover downloaded")
|
|
}
|
|
}
|
|
|
|
trackNumberToEmbed := spotifyTrackNumber
|
|
if trackNumberToEmbed == 0 {
|
|
trackNumberToEmbed = 1
|
|
}
|
|
|
|
metadata := Metadata{
|
|
Title: spotifyTrackName,
|
|
Artist: spotifyArtistName,
|
|
Album: spotifyAlbumName,
|
|
AlbumArtist: spotifyAlbumArtist,
|
|
Date: spotifyReleaseDate,
|
|
TrackNumber: trackNumberToEmbed,
|
|
TotalTracks: spotifyTotalTracks,
|
|
DiscNumber: spotifyDiscNumber,
|
|
TotalDiscs: spotifyTotalDiscs,
|
|
URL: spotifyURL,
|
|
Comment: spotifyURL,
|
|
Copyright: spotifyCopyright,
|
|
Publisher: spotifyPublisher,
|
|
Composer: spotifyComposer,
|
|
Separator: metadataSeparator,
|
|
Description: "https://github.com/spotbye/SpotiFLAC",
|
|
ISRC: isrc,
|
|
UPC: upc,
|
|
Genre: mbMeta.Genre,
|
|
}
|
|
|
|
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
|
|
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
|
|
} else {
|
|
fmt.Println("Metadata embedded successfully")
|
|
}
|
|
|
|
if strings.HasSuffix(strings.ToLower(filePath), ".flac") {
|
|
|
|
originalM4aPath := filepath.Join(originalFileDir, originalFileBase+".m4a")
|
|
if _, err := os.Stat(originalM4aPath); err == nil {
|
|
if err := os.Remove(originalM4aPath); err != nil {
|
|
fmt.Printf("Warning: Failed to remove M4A file: %v\n", err)
|
|
} else {
|
|
fmt.Printf("Cleaned up original M4A file: %s\n", filepath.Base(originalM4aPath))
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Println("Done")
|
|
fmt.Println("Downloaded successfully from Amazon Music")
|
|
return filePath, nil
|
|
}
|
|
|
|
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string,
|
|
useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool,
|
|
) (string, error) {
|
|
|
|
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre)
|
|
}
|