Files
apple-music-downloader/main.go

2509 lines
78 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"main/utils/ampapi"
"main/utils/lyrics"
"main/utils/runv2"
"main/utils/runv3"
"main/utils/structs"
"main/utils/task"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/grafov/m3u8"
"github.com/olekukonko/tablewriter"
"github.com/spf13/pflag"
"github.com/zhaarey/go-mp4tag"
"gopkg.in/yaml.v2"
)
var (
forbiddenNames = regexp.MustCompile(`[/\\<>:"|?*]`)
dl_atmos bool
dl_aac bool
dl_select bool
dl_song bool
artist_select bool
debug_mode bool
alac_max *int
atmos_max *int
mv_max *int
mv_audio_type *string
aac_type *string
Config structs.ConfigSet
counter structs.Counter
okDict = make(map[string][]int)
)
func loadConfig() error {
data, err := os.ReadFile("config.yaml")
if err != nil {
return err
}
err = yaml.Unmarshal(data, &Config)
if err != nil {
return err
}
if len(Config.Storefront) != 2 {
Config.Storefront = "us"
}
return nil
}
func LimitString(s string) string {
if len([]rune(s)) > Config.LimitMax {
return string([]rune(s)[:Config.LimitMax])
}
return s
}
func isInArray(arr []int, target int) bool {
for _, num := range arr {
if num == target {
return true
}
}
return false
}
func fileExists(path string) (bool, error) {
f, err := os.Stat(path)
if err == nil {
return !f.IsDir(), nil
} else if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func checkUrl(url string) (string, string) {
pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music|classical\.music)\.apple\.com\/(\w{2})(?:\/album|\/album\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?)`)
matches := pat.FindAllStringSubmatch(url, -1)
if matches == nil {
return "", ""
} else {
return matches[0][1], matches[0][2]
}
}
func checkUrlMv(url string) (string, string) {
pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w{2})(?:\/music-video|\/music-video\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?)`)
matches := pat.FindAllStringSubmatch(url, -1)
if matches == nil {
return "", ""
} else {
return matches[0][1], matches[0][2]
}
}
func checkUrlSong(url string) (string, string) {
pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music|classical\.music)\.apple\.com\/(\w{2})(?:\/song|\/song\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?)`)
matches := pat.FindAllStringSubmatch(url, -1)
if matches == nil {
return "", ""
} else {
return matches[0][1], matches[0][2]
}
}
func checkUrlPlaylist(url string) (string, string) {
pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music|classical\.music)\.apple\.com\/(\w{2})(?:\/playlist|\/playlist\/.+))\/(?:id)?(pl\.[\w-]+)(?:$|\?)`)
matches := pat.FindAllStringSubmatch(url, -1)
if matches == nil {
return "", ""
} else {
return matches[0][1], matches[0][2]
}
}
func checkUrlStation(url string) (string, string) {
pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w{2})(?:\/station|\/station\/.+))\/(?:id)?(ra\.[\w-]+)(?:$|\?)`)
matches := pat.FindAllStringSubmatch(url, -1)
if matches == nil {
return "", ""
} else {
return matches[0][1], matches[0][2]
}
}
func checkUrlArtist(url string) (string, string) {
pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music|classical\.music)\.apple\.com\/(\w{2})(?:\/artist|\/artist\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?)`)
matches := pat.FindAllStringSubmatch(url, -1)
if matches == nil {
return "", ""
} else {
return matches[0][1], matches[0][2]
}
}
func getUrlSong(songUrl string, token string) (string, error) {
storefront, songId := checkUrlSong(songUrl)
manifest, err := ampapi.GetSongResp(storefront, songId, Config.Language, token)
if err != nil {
fmt.Println("\u26A0 Failed to get manifest:", err)
counter.NotSong++
return "", err
}
albumId := manifest.Data[0].Relationships.Albums.Data[0].ID
songAlbumUrl := fmt.Sprintf("https://music.apple.com/%s/album/1/%s?i=%s", storefront, albumId, songId)
return songAlbumUrl, nil
}
func getUrlArtistName(artistUrl string, token string) (string, string, error) {
storefront, artistId := checkUrlArtist(artistUrl)
req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/artists/%s", storefront, artistId), nil)
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Origin", "https://music.apple.com")
query := url.Values{}
query.Set("l", Config.Language)
req.URL.RawQuery = query.Encode()
do, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer do.Body.Close()
if do.StatusCode != http.StatusOK {
return "", "", errors.New(do.Status)
}
obj := new(structs.AutoGeneratedArtist)
err = json.NewDecoder(do.Body).Decode(&obj)
if err != nil {
return "", "", err
}
return obj.Data[0].Attributes.Name, obj.Data[0].ID, nil
}
func checkArtist(artistUrl string, token string, relationship string) ([]string, error) {
storefront, artistId := checkUrlArtist(artistUrl)
Num := 0
//id := 1
var args []string
var urls []string
var options [][]string
for {
req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/artists/%s/%s?limit=100&offset=%d&l=%s", storefront, artistId, relationship, Num, Config.Language), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Origin", "https://music.apple.com")
do, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer do.Body.Close()
if do.StatusCode != http.StatusOK {
return nil, errors.New(do.Status)
}
obj := new(structs.AutoGeneratedArtist)
err = json.NewDecoder(do.Body).Decode(&obj)
if err != nil {
return nil, err
}
for _, album := range obj.Data {
options = append(options, []string{album.Attributes.Name, album.Attributes.ReleaseDate, album.ID, album.Attributes.URL})
}
Num = Num + 100
if len(obj.Next) == 0 {
break
}
}
sort.Slice(options, func(i, j int) bool {
// 将日期字符串解析为 time.Time 类型进行比较
dateI, _ := time.Parse("2006-01-02", options[i][1])
dateJ, _ := time.Parse("2006-01-02", options[j][1])
return dateI.Before(dateJ) // 返回 true 表示 i 在 j 前面
})
table := tablewriter.NewWriter(os.Stdout)
if relationship == "albums" {
table.SetHeader([]string{"", "Album Name", "Date", "Album ID"})
} else if relationship == "music-videos" {
table.SetHeader([]string{"", "MV Name", "Date", "MV ID"})
}
table.SetRowLine(false)
table.SetHeaderColor(tablewriter.Colors{},
tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold},
tablewriter.Colors{tablewriter.Bold, tablewriter.FgBlackColor},
tablewriter.Colors{tablewriter.Bold, tablewriter.FgBlackColor})
table.SetColumnColor(tablewriter.Colors{tablewriter.FgCyanColor},
tablewriter.Colors{tablewriter.Bold, tablewriter.FgRedColor},
tablewriter.Colors{tablewriter.Bold, tablewriter.FgBlackColor},
tablewriter.Colors{tablewriter.Bold, tablewriter.FgBlackColor})
for i, v := range options {
urls = append(urls, v[3])
options[i] = append([]string{fmt.Sprint(i + 1)}, v[:3]...)
table.Append(options[i])
}
table.Render()
if artist_select {
fmt.Println("You have selected all options:")
return urls, nil
}
reader := bufio.NewReader(os.Stdin)
fmt.Println("Please select from the " + relationship + " options above (multiple options separated by commas, ranges supported, or type 'all' to select all)")
cyanColor := color.New(color.FgCyan)
cyanColor.Print("Enter your choice: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "all" {
fmt.Println("You have selected all options:")
return urls, nil
}
selectedOptions := [][]string{}
parts := strings.Split(input, ",")
for _, part := range parts {
if strings.Contains(part, "-") {
rangeParts := strings.Split(part, "-")
selectedOptions = append(selectedOptions, rangeParts)
} else {
selectedOptions = append(selectedOptions, []string{part})
}
}
fmt.Println("You have selected the following options:")
for _, opt := range selectedOptions {
if len(opt) == 1 {
num, err := strconv.Atoi(opt[0])
if err != nil {
fmt.Println("Invalid option:", opt[0])
continue
}
if num > 0 && num <= len(options) {
fmt.Println(options[num-1])
args = append(args, urls[num-1])
} else {
fmt.Println("Option out of range:", opt[0])
}
} else if len(opt) == 2 {
start, err1 := strconv.Atoi(opt[0])
end, err2 := strconv.Atoi(opt[1])
if err1 != nil || err2 != nil {
fmt.Println("Invalid range:", opt)
continue
}
if start < 1 || end > len(options) || start > end {
fmt.Println("Range out of range:", opt)
continue
}
for i := start; i <= end; i++ {
fmt.Println(options[i-1])
args = append(args, urls[i-1])
}
} else {
fmt.Println("Invalid option:", opt)
}
}
return args, nil
}
func writeCover(sanAlbumFolder, name string, url string) (string, error) {
originalUrl := url
var ext string
var covPath string
if Config.CoverFormat == "original" {
ext = strings.Split(url, "/")[len(strings.Split(url, "/"))-2]
ext = ext[strings.LastIndex(ext, ".")+1:]
covPath = filepath.Join(sanAlbumFolder, name+"."+ext)
} else {
covPath = filepath.Join(sanAlbumFolder, name+"."+Config.CoverFormat)
}
exists, err := fileExists(covPath)
if err != nil {
fmt.Println("Failed to check if cover exists.")
return "", err
}
if exists {
_ = os.Remove(covPath)
}
if Config.CoverFormat == "png" {
re := regexp.MustCompile(`\{w\}x\{h\}`)
parts := re.Split(url, 2)
url = parts[0] + "{w}x{h}" + strings.Replace(parts[1], ".jpg", ".png", 1)
}
url = strings.Replace(url, "{w}x{h}", Config.CoverSize, 1)
if Config.CoverFormat == "original" {
url = strings.Replace(url, "is1-ssl.mzstatic.com/image/thumb", "a5.mzstatic.com/us/r1000/0", 1)
url = url[:strings.LastIndex(url, "/")]
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
do, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer do.Body.Close()
if do.StatusCode != http.StatusOK {
if Config.CoverFormat == "original" {
fmt.Println("Failed to get cover, falling back to " + ext + " url.")
splitByDot := strings.Split(originalUrl, ".")
last := splitByDot[len(splitByDot)-1]
fallback := originalUrl[:len(originalUrl)-len(last)] + ext
fallback = strings.Replace(fallback, "{w}x{h}", Config.CoverSize, 1)
fmt.Println("Fallback URL:", fallback)
req, err = http.NewRequest("GET", fallback, nil)
if err != nil {
fmt.Println("Failed to create request for fallback url.")
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
do, err = http.DefaultClient.Do(req)
if err != nil {
fmt.Println("Failed to get cover from fallback url.")
return "", err
}
defer do.Body.Close()
if do.StatusCode != http.StatusOK {
fmt.Println(fallback)
return "", errors.New(do.Status)
}
} else {
return "", errors.New(do.Status)
}
}
f, err := os.Create(covPath)
if err != nil {
return "", err
}
defer f.Close()
_, err = io.Copy(f, do.Body)
if err != nil {
return "", err
}
return covPath, nil
}
func writeLyrics(sanAlbumFolder, filename string, lrc string) error {
lyricspath := filepath.Join(sanAlbumFolder, filename)
f, err := os.Create(lyricspath)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(lrc)
if err != nil {
return err
}
return nil
}
func contains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
// START: New functions for search functionality
// SearchResultItem is a unified struct to hold search results for display.
type SearchResultItem struct {
Type string
Name string
Detail string
URL string
ID string
}
// QualityOption holds information about a downloadable quality.
type QualityOption struct {
ID string
Description string
}
// setDlFlags configures the global download flags based on the user's quality selection.
func setDlFlags(quality string) {
dl_atmos = false
dl_aac = false
switch quality {
case "atmos":
dl_atmos = true
fmt.Println("Quality set to: Dolby Atmos")
case "aac":
dl_aac = true
*aac_type = "aac"
fmt.Println("Quality set to: High-Quality (AAC)")
case "alac":
fmt.Println("Quality set to: Lossless (ALAC)")
}
}
// promptForQuality asks the user to select a download quality for the chosen media.
func promptForQuality(item SearchResultItem, token string) (string, error) {
if item.Type == "Artist" {
fmt.Println("Artist selected. Proceeding to list all albums/videos.")
return "default", nil
}
fmt.Printf("\nFetching available qualities for: %s\n", item.Name)
qualities := []QualityOption{
{ID: "alac", Description: "Lossless (ALAC)"},
{ID: "aac", Description: "High-Quality (AAC)"},
{ID: "atmos", Description: "Dolby Atmos"},
}
qualityOptions := []string{}
for _, q := range qualities {
qualityOptions = append(qualityOptions, q.Description)
}
prompt := &survey.Select{
Message: "Select a quality to download:",
Options: qualityOptions,
PageSize: 5,
}
selectedIndex := 0
err := survey.AskOne(prompt, &selectedIndex)
if err != nil {
// This can happen if the user presses Ctrl+C
return "", nil
}
return qualities[selectedIndex].ID, nil
}
// handleSearch manages the entire interactive search process.
func handleSearch(searchType string, queryParts []string, token string) (string, error) {
query := strings.Join(queryParts, " ")
validTypes := map[string]bool{"album": true, "song": true, "artist": true}
if !validTypes[searchType] {
return "", fmt.Errorf("invalid search type: %s. Use 'album', 'song', or 'artist'", searchType)
}
fmt.Printf("Searching for %ss: \"%s\" in storefront \"%s\"\n", searchType, query, Config.Storefront)
offset := 0
limit := 15 // Increased limit for better navigation
apiSearchType := searchType + "s"
for {
searchResp, err := ampapi.Search(Config.Storefront, query, apiSearchType, Config.Language, token, limit, offset)
if err != nil {
return "", fmt.Errorf("error fetching search results: %w", err)
}
var items []SearchResultItem
var displayOptions []string
hasNext := false
// Special options for navigation
const prevPageOpt = "⬅️ Previous Page"
const nextPageOpt = "➡️ Next Page"
// Add previous page option if applicable
if offset > 0 {
displayOptions = append(displayOptions, prevPageOpt)
}
switch searchType {
case "album":
if searchResp.Results.Albums != nil {
for _, item := range searchResp.Results.Albums.Data {
year := ""
if len(item.Attributes.ReleaseDate) >= 4 {
year = item.Attributes.ReleaseDate[:4]
}
trackInfo := fmt.Sprintf("%d tracks", item.Attributes.TrackCount)
detail := fmt.Sprintf("%s (%s, %s)", item.Attributes.ArtistName, year, trackInfo)
displayOptions = append(displayOptions, fmt.Sprintf("%s - %s", item.Attributes.Name, detail))
items = append(items, SearchResultItem{Type: "Album", URL: item.Attributes.URL, ID: item.ID})
}
hasNext = searchResp.Results.Albums.Next != ""
}
case "song":
if searchResp.Results.Songs != nil {
for _, item := range searchResp.Results.Songs.Data {
detail := fmt.Sprintf("%s (%s)", item.Attributes.ArtistName, item.Attributes.AlbumName)
displayOptions = append(displayOptions, fmt.Sprintf("%s - %s", item.Attributes.Name, detail))
items = append(items, SearchResultItem{Type: "Song", URL: item.Attributes.URL, ID: item.ID})
}
hasNext = searchResp.Results.Songs.Next != ""
}
case "artist":
if searchResp.Results.Artists != nil {
for _, item := range searchResp.Results.Artists.Data {
detail := ""
if len(item.Attributes.GenreNames) > 0 {
detail = strings.Join(item.Attributes.GenreNames, ", ")
}
displayOptions = append(displayOptions, fmt.Sprintf("%s (%s)", item.Attributes.Name, detail))
items = append(items, SearchResultItem{Type: "Artist", URL: item.Attributes.URL, ID: item.ID})
}
hasNext = searchResp.Results.Artists.Next != ""
}
}
if len(items) == 0 && offset == 0 {
fmt.Println("No results found.")
return "", nil
}
// Add next page option if applicable
if hasNext {
displayOptions = append(displayOptions, nextPageOpt)
}
prompt := &survey.Select{
Message: "Use arrow keys to navigate, Enter to select:",
Options: displayOptions,
PageSize: limit, // Show a full page of results
}
selectedIndex := 0
err = survey.AskOne(prompt, &selectedIndex)
if err != nil {
// User pressed Ctrl+C
return "", nil
}
selectedOption := displayOptions[selectedIndex]
// Handle pagination
if selectedOption == nextPageOpt {
offset += limit
continue
}
if selectedOption == prevPageOpt {
offset -= limit
continue
}
// Adjust index to match the `items` slice if "Previous Page" was an option
itemIndex := selectedIndex
if offset > 0 {
itemIndex--
}
selectedItem := items[itemIndex]
// Automatically set single song download flag
if selectedItem.Type == "Song" {
dl_song = true
}
quality, err := promptForQuality(selectedItem, token)
if err != nil {
return "", fmt.Errorf("could not process quality selection: %w", err)
}
if quality == "" { // User cancelled quality selection
fmt.Println("Selection cancelled.")
return "", nil
}
if quality != "default" {
setDlFlags(quality)
}
return selectedItem.URL, nil
}
}
// END: New functions for search functionality
// CONVERSION FEATURE: Determine if source codec is lossy (rough heuristic by extension/codec name).
func isLossySource(ext string, codec string) bool {
ext = strings.ToLower(ext)
if ext == ".m4a" && (codec == "AAC" || strings.Contains(codec, "AAC") || strings.Contains(codec, "ATMOS")) {
return true
}
if ext == ".mp3" || ext == ".opus" || ext == ".ogg" {
return true
}
return false
}
// CONVERSION FEATURE: Build ffmpeg arguments for desired target.
func buildFFmpegArgs(ffmpegPath, inPath, outPath, targetFmt, extraArgs string) ([]string, error) {
args := []string{"-y", "-i", inPath, "-vn"}
switch targetFmt {
case "flac":
args = append(args, "-c:a", "flac")
case "mp3":
// VBR quality 2 ~ high quality
args = append(args, "-c:a", "libmp3lame", "-qscale:a", "2")
case "opus":
// Medium/high quality
args = append(args, "-c:a", "libopus", "-b:a", "192k", "-vbr", "on")
case "wav":
args = append(args, "-c:a", "pcm_s16le")
case "copy":
// Just container copy (probably pointless for same container)
args = append(args, "-c", "copy")
default:
return nil, fmt.Errorf("unsupported convert-format: %s", targetFmt)
}
if extraArgs != "" {
// naive split; for complex quoting you could enhance
args = append(args, strings.Fields(extraArgs)...)
}
args = append(args, outPath)
return args, nil
}
// CONVERSION FEATURE: Perform conversion if enabled.
func convertIfNeeded(track *task.Track) {
if !Config.ConvertAfterDownload {
return
}
if Config.ConvertFormat == "" {
return
}
srcPath := track.SavePath
if srcPath == "" {
return
}
ext := strings.ToLower(filepath.Ext(srcPath))
targetFmt := strings.ToLower(Config.ConvertFormat)
// Map extension for output
if targetFmt == "copy" {
fmt.Println("Convert (copy) requested; skipping because it produces no new format.")
return
}
if Config.ConvertSkipIfSourceMatch {
if ext == "."+targetFmt {
fmt.Printf("Conversion skipped (already %s)\n", targetFmt)
return
}
}
outBase := strings.TrimSuffix(srcPath, ext)
outPath := outBase + "." + targetFmt
// Warn about lossy -> lossless
if Config.ConvertWarnLossyToLossless && (targetFmt == "flac" || targetFmt == "wav") &&
isLossySource(ext, track.Codec) {
fmt.Println("Warning: Converting lossy source to lossless container will not improve quality.")
}
if _, err := exec.LookPath(Config.FFmpegPath); err != nil {
fmt.Printf("ffmpeg not found at '%s'; skipping conversion.\n", Config.FFmpegPath)
return
}
args, err := buildFFmpegArgs(Config.FFmpegPath, srcPath, outPath, targetFmt, Config.ConvertExtraArgs)
if err != nil {
fmt.Println("Conversion config error:", err)
return
}
fmt.Printf("Converting -> %s ...\n", targetFmt)
cmd := exec.Command(Config.FFmpegPath, args...)
cmd.Stdout = nil
cmd.Stderr = nil
start := time.Now()
if err := cmd.Run(); err != nil {
fmt.Println("Conversion failed:", err)
// leave original
return
}
fmt.Printf("Conversion completed in %s: %s\n", time.Since(start).Truncate(time.Millisecond), filepath.Base(outPath))
if !Config.ConvertKeepOriginal {
if err := os.Remove(srcPath); err != nil {
fmt.Println("Failed to remove original after conversion:", err)
} else {
track.SavePath = outPath
track.SaveName = filepath.Base(outPath)
fmt.Println("Original removed.")
}
} else {
// Keep both but point track to new file (optional decision)
track.SavePath = outPath
track.SaveName = filepath.Base(outPath)
}
}
func ripTrack(track *task.Track, token string, mediaUserToken string) {
var err error
counter.Total++
fmt.Printf("Track %d of %d: %s\n", track.TaskNum, track.TaskTotal, track.Type)
//提前获取到的播放列表下track所在的专辑信息
if track.PreType == "playlists" && Config.UseSongInfoForPlaylist {
track.GetAlbumData(token)
}
//mv dl dev
if track.Type == "music-videos" {
if len(mediaUserToken) <= 50 {
fmt.Println("meida-user-token is not set, skip MV dl")
counter.Success++
return
}
if _, err := exec.LookPath("mp4decrypt"); err != nil {
fmt.Println("mp4decrypt is not found, skip MV dl")
counter.Success++
return
}
err := mvDownloader(track.ID, track.SaveDir, token, track.Storefront, mediaUserToken, track)
if err != nil {
fmt.Println("\u26A0 Failed to dl MV:", err)
counter.Error++
return
}
counter.Success++
return
}
needDlAacLc := false
if dl_aac && Config.AacType == "aac-lc" {
needDlAacLc = true
}
if track.WebM3u8 == "" && !needDlAacLc {
if dl_atmos {
fmt.Println("Unavailable")
counter.Unavailable++
return
}
fmt.Println("Unavailable, trying to dl aac-lc")
needDlAacLc = true
}
needCheck := false
if Config.GetM3u8Mode == "all" {
needCheck = true
} else if Config.GetM3u8Mode == "hires" && contains(track.Resp.Attributes.AudioTraits, "hi-res-lossless") {
needCheck = true
}
var EnhancedHls_m3u8 string
if needCheck && !needDlAacLc {
EnhancedHls_m3u8, _ = checkM3u8(track.ID, "song")
if strings.HasSuffix(EnhancedHls_m3u8, ".m3u8") {
track.DeviceM3u8 = EnhancedHls_m3u8
track.M3u8 = EnhancedHls_m3u8
}
}
var Quality string
if strings.Contains(Config.SongFileFormat, "Quality") {
if dl_atmos {
Quality = fmt.Sprintf("%dKbps", Config.AtmosMax-2000)
} else if needDlAacLc {
Quality = "256Kbps"
} else {
_, Quality, err = extractMedia(track.M3u8, true)
if err != nil {
fmt.Println("Failed to extract quality from manifest.\n", err)
counter.Error++
return
}
}
}
track.Quality = Quality
stringsToJoin := []string{}
if track.Resp.Attributes.IsAppleDigitalMaster {
if Config.AppleMasterChoice != "" {
stringsToJoin = append(stringsToJoin, Config.AppleMasterChoice)
}
}
if track.Resp.Attributes.ContentRating == "explicit" {
if Config.ExplicitChoice != "" {
stringsToJoin = append(stringsToJoin, Config.ExplicitChoice)
}
}
if track.Resp.Attributes.ContentRating == "clean" {
if Config.CleanChoice != "" {
stringsToJoin = append(stringsToJoin, Config.CleanChoice)
}
}
Tag_string := strings.Join(stringsToJoin, " ")
songName := strings.NewReplacer(
"{SongId}", track.ID,
"{SongNumer}", fmt.Sprintf("%02d", track.TaskNum),
"{SongName}", LimitString(track.Resp.Attributes.Name),
"{DiscNumber}", fmt.Sprintf("%0d", track.Resp.Attributes.DiscNumber),
"{TrackNumber}", fmt.Sprintf("%0d", track.Resp.Attributes.TrackNumber),
"{Quality}", Quality,
"{Tag}", Tag_string,
"{Codec}", track.Codec,
).Replace(Config.SongFileFormat)
fmt.Println(songName)
filename := fmt.Sprintf("%s.m4a", forbiddenNames.ReplaceAllString(songName, "_"))
track.SaveName = filename
trackPath := filepath.Join(track.SaveDir, track.SaveName)
lrcFilename := fmt.Sprintf("%s.%s", forbiddenNames.ReplaceAllString(songName, "_"), Config.LrcFormat)
// Determine possible post-conversion target file (so we can skip re-download)
var convertedPath string
considerConverted := false
if Config.ConvertAfterDownload &&
Config.ConvertFormat != "" &&
strings.ToLower(Config.ConvertFormat) != "copy" &&
!Config.ConvertKeepOriginal {
convertedPath = strings.TrimSuffix(trackPath, filepath.Ext(trackPath)) + "." + strings.ToLower(Config.ConvertFormat)
considerConverted = true
}
//get lrc
var lrc string = ""
if Config.EmbedLrc || Config.SaveLrcFile {
lrcStr, err := lyrics.Get(track.Storefront, track.ID, Config.LrcType, Config.Language, Config.LrcFormat, token, mediaUserToken)
if err != nil {
fmt.Println(err)
} else {
if Config.SaveLrcFile {
err := writeLyrics(track.SaveDir, lrcFilename, lrcStr)
if err != nil {
fmt.Printf("Failed to write lyrics")
}
}
if Config.EmbedLrc {
lrc = lrcStr
}
}
}
// Existence check now considers converted output (if original was deleted)
existsOriginal, err := fileExists(trackPath)
if err != nil {
fmt.Println("Failed to check if track exists.")
}
if existsOriginal {
fmt.Println("Track already exists locally.")
counter.Success++
okDict[track.PreID] = append(okDict[track.PreID], track.TaskNum)
return
}
if considerConverted {
existsConverted, err2 := fileExists(convertedPath)
if err2 == nil && existsConverted {
fmt.Println("Converted track already exists locally.")
counter.Success++
okDict[track.PreID] = append(okDict[track.PreID], track.TaskNum)
return
}
}
if needDlAacLc {
if len(mediaUserToken) <= 50 {
fmt.Println("Invalid media-user-token")
counter.Error++
return
}
_, err := runv3.Run(track.ID, trackPath, token, mediaUserToken, false, "")
if err != nil {
fmt.Println("Failed to dl aac-lc:", err)
if err.Error() == "Unavailable" {
counter.Unavailable++
return
}
counter.Error++
return
}
} else {
trackM3u8Url, _, err := extractMedia(track.M3u8, false)
if err != nil {
fmt.Println("\u26A0 Failed to extract info from manifest:", err)
counter.Unavailable++
return
}
//边下载边解密
err = runv2.Run(track.ID, trackM3u8Url, trackPath, Config)
if err != nil {
fmt.Println("Failed to run v2:", err)
counter.Error++
return
}
}
//这里利用MP4box将fmp4转化为mp4并添加ilst box与cover方便后面的mp4tag添加更多自定义标签
tags := []string{
"tool=",
"artist=AppleMusic",
}
if Config.EmbedCover {
if (strings.Contains(track.PreID, "pl.") || strings.Contains(track.PreID, "ra.")) && Config.DlAlbumcoverForPlaylist {
track.CoverPath, err = writeCover(track.SaveDir, track.ID, track.Resp.Attributes.Artwork.URL)
if err != nil {
fmt.Println("Failed to write cover.")
}
}
tags = append(tags, fmt.Sprintf("cover=%s", track.CoverPath))
}
tagsString := strings.Join(tags, ":")
cmd := exec.Command("MP4Box", "-itags", tagsString, trackPath)
if err := cmd.Run(); err != nil {
fmt.Printf("Embed failed: %v\n", err)
counter.Error++
return
}
if (strings.Contains(track.PreID, "pl.") || strings.Contains(track.PreID, "ra.")) && Config.DlAlbumcoverForPlaylist {
if err := os.Remove(track.CoverPath); err != nil {
fmt.Printf("Error deleting file: %s\n", track.CoverPath)
counter.Error++
return
}
}
track.SavePath = trackPath
err = writeMP4Tags(track, lrc)
if err != nil {
fmt.Println("\u26A0 Failed to write tags in media:", err)
counter.Unavailable++
return
}
// CONVERSION FEATURE hook
convertIfNeeded(track)
counter.Success++
okDict[track.PreID] = append(okDict[track.PreID], track.TaskNum)
}
func ripStation(albumId string, token string, storefront string, mediaUserToken string) error {
station := task.NewStation(storefront, albumId)
err := station.GetResp(mediaUserToken, token, Config.Language)
if err != nil {
return err
}
fmt.Println(" -", station.Type)
meta := station.Resp
var Codec string
if dl_atmos {
Codec = "ATMOS"
} else if dl_aac {
Codec = "AAC"
} else {
Codec = "ALAC"
}
station.Codec = Codec
var singerFoldername string
if Config.ArtistFolderFormat != "" {
singerFoldername = strings.NewReplacer(
"{ArtistName}", "Apple Music Station",
"{ArtistId}", "",
"{UrlArtistName}", "Apple Music Station",
).Replace(Config.ArtistFolderFormat)
if strings.HasSuffix(singerFoldername, ".") {
singerFoldername = strings.ReplaceAll(singerFoldername, ".", "")
}
singerFoldername = strings.TrimSpace(singerFoldername)
fmt.Println(singerFoldername)
}
singerFolder := filepath.Join(Config.AlacSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_"))
if dl_atmos {
singerFolder = filepath.Join(Config.AtmosSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_"))
}
if dl_aac {
singerFolder = filepath.Join(Config.AacSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_"))
}
os.MkdirAll(singerFolder, os.ModePerm)
station.SaveDir = singerFolder
playlistFolder := strings.NewReplacer(
"{ArtistName}", "Apple Music Station",
"{PlaylistName}", LimitString(station.Name),
"{PlaylistId}", station.ID,
"{Quality}", "",
"{Codec}", Codec,
"{Tag}", "",
).Replace(Config.PlaylistFolderFormat)
if strings.HasSuffix(playlistFolder, ".") {
playlistFolder = strings.ReplaceAll(playlistFolder, ".", "")
}
playlistFolder = strings.TrimSpace(playlistFolder)
playlistFolderPath := filepath.Join(singerFolder, forbiddenNames.ReplaceAllString(playlistFolder, "_"))
os.MkdirAll(playlistFolderPath, os.ModePerm)
station.SaveName = playlistFolder
fmt.Println(playlistFolder)
covPath, err := writeCover(playlistFolderPath, "cover", meta.Data[0].Attributes.Artwork.URL)
if err != nil {
fmt.Println("Failed to write cover.")
}
station.CoverPath = covPath
if Config.SaveAnimatedArtwork && meta.Data[0].Attributes.EditorialVideo.MotionSquare.Video != "" {
fmt.Println("Found Animation Artwork.")
motionvideoUrlSquare, err := extractVideo(meta.Data[0].Attributes.EditorialVideo.MotionSquare.Video)
if err != nil {
fmt.Println("no motion video square.\n", err)
} else {
exists, err := fileExists(filepath.Join(playlistFolderPath, "square_animated_artwork.mp4"))
if err != nil {
fmt.Println("Failed to check if animated artwork square exists.")
}
if exists {
fmt.Println("Animated artwork square already exists locally.")
} else {
fmt.Println("Animation Artwork Square Downloading...")
cmd := exec.Command("ffmpeg", "-loglevel", "quiet", "-y", "-i", motionvideoUrlSquare, "-c", "copy", filepath.Join(playlistFolderPath, "square_animated_artwork.mp4"))
if err := cmd.Run(); err != nil {
fmt.Printf("animated artwork square dl err: %v\n", err)
} else {
fmt.Println("Animation Artwork Square Downloaded")
}
}
}
if Config.EmbyAnimatedArtwork {
cmd3 := exec.Command("ffmpeg", "-i", filepath.Join(playlistFolderPath, "square_animated_artwork.mp4"), "-vf", "scale=440:-1", "-r", "24", "-f", "gif", filepath.Join(playlistFolderPath, "folder.jpg"))
if err := cmd3.Run(); err != nil {
fmt.Printf("animated artwork square to gif err: %v\n", err)
}
}
}
if station.Type == "stream" {
counter.Total++
if isInArray(okDict[station.ID], 1) {
counter.Success++
return nil
}
songName := strings.NewReplacer(
"{SongId}", station.ID,
"{SongNumer}", "01",
"{SongName}", LimitString(station.Name),
"{DiscNumber}", "1",
"{TrackNumber}", "1",
"{Quality}", "256Kbps",
"{Tag}", "",
"{Codec}", "AAC",
).Replace(Config.SongFileFormat)
fmt.Println(songName)
trackPath := filepath.Join(playlistFolderPath, fmt.Sprintf("%s.m4a", forbiddenNames.ReplaceAllString(songName, "_")))
exists, _ := fileExists(trackPath)
if exists {
counter.Success++
okDict[station.ID] = append(okDict[station.ID], 1)
fmt.Println("Radio already exists locally.")
return nil
}
assetsUrl, serverUrl, err := ampapi.GetStationAssetsUrlAndServerUrl(station.ID, mediaUserToken, token)
if err != nil {
fmt.Println("Failed to get station assets url.", err)
counter.Error++
return err
}
trackM3U8 := strings.ReplaceAll(assetsUrl, "index.m3u8", "256/prog_index.m3u8")
keyAndUrls, _ := runv3.Run(station.ID, trackM3U8, token, mediaUserToken, true, serverUrl)
err = runv3.ExtMvData(keyAndUrls, trackPath)
if err != nil {
fmt.Println("Failed to download station stream.", err)
counter.Error++
return err
}
tags := []string{
"tool=",
"disk=1/1",
"track=1",
"tracknum=1/1",
fmt.Sprintf("artist=%s", "Apple Music Station"),
fmt.Sprintf("performer=%s", "Apple Music Station"),
fmt.Sprintf("album_artist=%s", "Apple Music Station"),
fmt.Sprintf("album=%s", station.Name),
fmt.Sprintf("title=%s", station.Name),
}
if Config.EmbedCover {
tags = append(tags, fmt.Sprintf("cover=%s", station.CoverPath))
}
tagsString := strings.Join(tags, ":")
cmd := exec.Command("MP4Box", "-itags", tagsString, trackPath)
if err := cmd.Run(); err != nil {
fmt.Printf("Embed failed: %v\n", err)
}
counter.Success++
okDict[station.ID] = append(okDict[station.ID], 1)
return nil
}
for i := range station.Tracks {
station.Tracks[i].CoverPath = covPath
station.Tracks[i].SaveDir = playlistFolderPath
station.Tracks[i].Codec = Codec
}
trackTotal := len(station.Tracks)
arr := make([]int, trackTotal)
for i := 0; i < trackTotal; i++ {
arr[i] = i + 1
}
var selected []int
if true {
selected = arr
}
for i := range station.Tracks {
i++
if isInArray(selected, i) {
ripTrack(&station.Tracks[i-1], token, mediaUserToken)
}
}
return nil
}
func ripAlbum(albumId string, token string, storefront string, mediaUserToken string, urlArg_i string) error {
album := task.NewAlbum(storefront, albumId)
err := album.GetResp(token, Config.Language)
if err != nil {
fmt.Println("Failed to get album response.")
return err
}
meta := album.Resp
if debug_mode {
fmt.Println(meta.Data[0].Attributes.ArtistName)
fmt.Println(meta.Data[0].Attributes.Name)
for trackNum, track := range meta.Data[0].Relationships.Tracks.Data {
trackNum++
fmt.Printf("\nTrack %d of %d:\n", trackNum, len(meta.Data[0].Relationships.Tracks.Data))
fmt.Printf("%02d. %s\n", trackNum, track.Attributes.Name)
manifest, err := ampapi.GetSongResp(storefront, track.ID, album.Language, token)
if err != nil {
fmt.Printf("Failed to get manifest for track %d: %v\n", trackNum, err)
continue
}
var m3u8Url string
if manifest.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls != "" {
m3u8Url = manifest.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls
}
needCheck := false
if Config.GetM3u8Mode == "all" {
needCheck = true
} else if Config.GetM3u8Mode == "hires" && contains(track.Attributes.AudioTraits, "hi-res-lossless") {
needCheck = true
}
if needCheck {
fullM3u8Url, err := checkM3u8(track.ID, "song")
if err == nil && strings.HasSuffix(fullM3u8Url, ".m3u8") {
m3u8Url = fullM3u8Url
} else {
fmt.Println("Failed to get best quality m3u8 from device m3u8 port, will use m3u8 from Web API")
}
}
_, _, err = extractMedia(m3u8Url, true)
if err != nil {
fmt.Printf("Failed to extract quality info for track %d: %v\n", trackNum, err)
continue
}
}
return nil
}
var Codec string
if dl_atmos {
Codec = "ATMOS"
} else if dl_aac {
Codec = "AAC"
} else {
Codec = "ALAC"
}
album.Codec = Codec
var singerFoldername string
if Config.ArtistFolderFormat != "" {
if len(meta.Data[0].Relationships.Artists.Data) > 0 {
singerFoldername = strings.NewReplacer(
"{UrlArtistName}", LimitString(meta.Data[0].Attributes.ArtistName),
"{ArtistName}", LimitString(meta.Data[0].Attributes.ArtistName),
"{ArtistId}", meta.Data[0].Relationships.Artists.Data[0].ID,
).Replace(Config.ArtistFolderFormat)
} else {
singerFoldername = strings.NewReplacer(
"{UrlArtistName}", LimitString(meta.Data[0].Attributes.ArtistName),
"{ArtistName}", LimitString(meta.Data[0].Attributes.ArtistName),
"{ArtistId}", "",
).Replace(Config.ArtistFolderFormat)
}
if strings.HasSuffix(singerFoldername, ".") {
singerFoldername = strings.ReplaceAll(singerFoldername, ".", "")
}
singerFoldername = strings.TrimSpace(singerFoldername)
fmt.Println(singerFoldername)
}
singerFolder := filepath.Join(Config.AlacSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_"))
if dl_atmos {
singerFolder = filepath.Join(Config.AtmosSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_"))
}
if dl_aac {
singerFolder = filepath.Join(Config.AacSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_"))
}
os.MkdirAll(singerFolder, os.ModePerm)
album.SaveDir = singerFolder
var Quality string
if strings.Contains(Config.AlbumFolderFormat, "Quality") {
if dl_atmos {
Quality = fmt.Sprintf("%dKbps", Config.AtmosMax-2000)
} else if dl_aac && Config.AacType == "aac-lc" {
Quality = "256Kbps"
} else {
manifest1, err := ampapi.GetSongResp(storefront, meta.Data[0].Relationships.Tracks.Data[0].ID, album.Language, token)
if err != nil {
fmt.Println("Failed to get manifest.\n", err)
} else {
if manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls == "" {
Codec = "AAC"
Quality = "256Kbps"
} else {
needCheck := false
if Config.GetM3u8Mode == "all" {
needCheck = true
} else if Config.GetM3u8Mode == "hires" && contains(meta.Data[0].Relationships.Tracks.Data[0].Attributes.AudioTraits, "hi-res-lossless") {
needCheck = true
}
var EnhancedHls_m3u8 string
if needCheck {
EnhancedHls_m3u8, _ = checkM3u8(meta.Data[0].Relationships.Tracks.Data[0].ID, "album")
if strings.HasSuffix(EnhancedHls_m3u8, ".m3u8") {
manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls = EnhancedHls_m3u8
}
}
_, Quality, err = extractMedia(manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls, true)
if err != nil {
fmt.Println("Failed to extract quality from manifest.\n", err)
}
}
}
}
}
stringsToJoin := []string{}
if meta.Data[0].Attributes.IsAppleDigitalMaster || meta.Data[0].Attributes.IsMasteredForItunes {
if Config.AppleMasterChoice != "" {
stringsToJoin = append(stringsToJoin, Config.AppleMasterChoice)
}
}
if meta.Data[0].Attributes.ContentRating == "explicit" {
if Config.ExplicitChoice != "" {
stringsToJoin = append(stringsToJoin, Config.ExplicitChoice)
}
}
if meta.Data[0].Attributes.ContentRating == "clean" {
if Config.CleanChoice != "" {
stringsToJoin = append(stringsToJoin, Config.CleanChoice)
}
}
Tag_string := strings.Join(stringsToJoin, " ")
var albumFolderName string
albumFolderName = strings.NewReplacer(
"{ReleaseDate}", meta.Data[0].Attributes.ReleaseDate,
"{ReleaseYear}", meta.Data[0].Attributes.ReleaseDate[:4],
"{ArtistName}", LimitString(meta.Data[0].Attributes.ArtistName),
"{AlbumName}", LimitString(meta.Data[0].Attributes.Name),
"{UPC}", meta.Data[0].Attributes.Upc,
"{RecordLabel}", meta.Data[0].Attributes.RecordLabel,
"{Copyright}", meta.Data[0].Attributes.Copyright,
"{AlbumId}", albumId,
"{Quality}", Quality,
"{Codec}", Codec,
"{Tag}", Tag_string,
).Replace(Config.AlbumFolderFormat)
if strings.HasSuffix(albumFolderName, ".") {
albumFolderName = strings.ReplaceAll(albumFolderName, ".", "")
}
albumFolderName = strings.TrimSpace(albumFolderName)
albumFolderPath := filepath.Join(singerFolder, forbiddenNames.ReplaceAllString(albumFolderName, "_"))
os.MkdirAll(albumFolderPath, os.ModePerm)
album.SaveName = albumFolderName
fmt.Println(albumFolderName)
if Config.SaveArtistCover && len(meta.Data[0].Relationships.Artists.Data) > 0{
if meta.Data[0].Relationships.Artists.Data[0].Attributes.Artwork.Url != "" {
_, err = writeCover(singerFolder, "folder", meta.Data[0].Relationships.Artists.Data[0].Attributes.Artwork.Url)
if err != nil {
fmt.Println("Failed to write artist cover.")
}
}
}
covPath, err := writeCover(albumFolderPath, "cover", meta.Data[0].Attributes.Artwork.URL)
if err != nil {
fmt.Println("Failed to write cover.")
}
if Config.SaveAnimatedArtwork && meta.Data[0].Attributes.EditorialVideo.MotionDetailSquare.Video != "" {
fmt.Println("Found Animation Artwork.")
motionvideoUrlSquare, err := extractVideo(meta.Data[0].Attributes.EditorialVideo.MotionDetailSquare.Video)
if err != nil {
fmt.Println("no motion video square.\n", err)
} else {
exists, err := fileExists(filepath.Join(albumFolderPath, "square_animated_artwork.mp4"))
if err != nil {
fmt.Println("Failed to check if animated artwork square exists.")
}
if exists {
fmt.Println("Animated artwork square already exists locally.")
} else {
fmt.Println("Animation Artwork Square Downloading...")
cmd := exec.Command("ffmpeg", "-loglevel", "quiet", "-y", "-i", motionvideoUrlSquare, "-c", "copy", filepath.Join(albumFolderPath, "square_animated_artwork.mp4"))
if err := cmd.Run(); err != nil {
fmt.Printf("animated artwork square dl err: %v\n", err)
} else {
fmt.Println("Animation Artwork Square Downloaded")
}
}
}
if Config.EmbyAnimatedArtwork {
cmd3 := exec.Command("ffmpeg", "-i", filepath.Join(albumFolderPath, "square_animated_artwork.mp4"), "-vf", "scale=440:-1", "-r", "24", "-f", "gif", filepath.Join(albumFolderPath, "folder.jpg"))
if err := cmd3.Run(); err != nil {
fmt.Printf("animated artwork square to gif err: %v\n", err)
}
}
motionvideoUrlTall, err := extractVideo(meta.Data[0].Attributes.EditorialVideo.MotionDetailTall.Video)
if err != nil {
fmt.Println("no motion video tall.\n", err)
} else {
exists, err := fileExists(filepath.Join(albumFolderPath, "tall_animated_artwork.mp4"))
if err != nil {
fmt.Println("Failed to check if animated artwork tall exists.")
}
if exists {
fmt.Println("Animated artwork tall already exists locally.")
} else {
fmt.Println("Animation Artwork Tall Downloading...")
cmd := exec.Command("ffmpeg", "-loglevel", "quiet", "-y", "-i", motionvideoUrlTall, "-c", "copy", filepath.Join(albumFolderPath, "tall_animated_artwork.mp4"))
if err := cmd.Run(); err != nil {
fmt.Printf("animated artwork tall dl err: %v\n", err)
} else {
fmt.Println("Animation Artwork Tall Downloaded")
}
}
}
}
for i := range album.Tracks {
album.Tracks[i].CoverPath = covPath
album.Tracks[i].SaveDir = albumFolderPath
album.Tracks[i].Codec = Codec
}
trackTotal := len(meta.Data[0].Relationships.Tracks.Data)
arr := make([]int, trackTotal)
for i := 0; i < trackTotal; i++ {
arr[i] = i + 1
}
if dl_song {
if urlArg_i == "" {
} else {
for i := range album.Tracks {
if urlArg_i == album.Tracks[i].ID {
ripTrack(&album.Tracks[i], token, mediaUserToken)
return nil
}
}
}
return nil
}
var selected []int
if !dl_select {
selected = arr
} else {
selected = album.ShowSelect()
}
for i := range album.Tracks {
i++
if isInArray(okDict[albumId], i) {
counter.Total++
counter.Success++
continue
}
if isInArray(selected, i) {
ripTrack(&album.Tracks[i-1], token, mediaUserToken)
}
}
return nil
}
func ripPlaylist(playlistId string, token string, storefront string, mediaUserToken string) error {
playlist := task.NewPlaylist(storefront, playlistId)
err := playlist.GetResp(token, Config.Language)
if err != nil {
fmt.Println("Failed to get playlist response.")
return err
}
meta := playlist.Resp
if debug_mode {
fmt.Println(meta.Data[0].Attributes.ArtistName)
fmt.Println(meta.Data[0].Attributes.Name)
for trackNum, track := range meta.Data[0].Relationships.Tracks.Data {
trackNum++
fmt.Printf("\nTrack %d of %d:\n", trackNum, len(meta.Data[0].Relationships.Tracks.Data))
fmt.Printf("%02d. %s\n", trackNum, track.Attributes.Name)
manifest, err := ampapi.GetSongResp(storefront, track.ID, playlist.Language, token)
if err != nil {
fmt.Printf("Failed to get manifest for track %d: %v\n", trackNum, err)
continue
}
var m3u8Url string
if manifest.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls != "" {
m3u8Url = manifest.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls
}
needCheck := false
if Config.GetM3u8Mode == "all" {
needCheck = true
} else if Config.GetM3u8Mode == "hires" && contains(track.Attributes.AudioTraits, "hi-res-lossless") {
needCheck = true
}
if needCheck {
fullM3u8Url, err := checkM3u8(track.ID, "song")
if err == nil && strings.HasSuffix(fullM3u8Url, ".m3u8") {
m3u8Url = fullM3u8Url
} else {
fmt.Println("Failed to get best quality m3u8 from device m3u8 port, will use m3u8 from Web API")
}
}
_, _, err = extractMedia(m3u8Url, true)
if err != nil {
fmt.Printf("Failed to extract quality info for track %d: %v\n", trackNum, err)
continue
}
}
return nil
}
var Codec string
if dl_atmos {
Codec = "ATMOS"
} else if dl_aac {
Codec = "AAC"
} else {
Codec = "ALAC"
}
playlist.Codec = Codec
var singerFoldername string
if Config.ArtistFolderFormat != "" {
singerFoldername = strings.NewReplacer(
"{ArtistName}", "Apple Music",
"{ArtistId}", "",
"{UrlArtistName}", "Apple Music",
).Replace(Config.ArtistFolderFormat)
if strings.HasSuffix(singerFoldername, ".") {
singerFoldername = strings.ReplaceAll(singerFoldername, ".", "")
}
singerFoldername = strings.TrimSpace(singerFoldername)
fmt.Println(singerFoldername)
}
singerFolder := filepath.Join(Config.AlacSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_"))
if dl_atmos {
singerFolder = filepath.Join(Config.AtmosSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_"))
}
if dl_aac {
singerFolder = filepath.Join(Config.AacSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_"))
}
os.MkdirAll(singerFolder, os.ModePerm)
playlist.SaveDir = singerFolder
var Quality string
if strings.Contains(Config.AlbumFolderFormat, "Quality") {
if dl_atmos {
Quality = fmt.Sprintf("%dKbps", Config.AtmosMax-2000)
} else if dl_aac && Config.AacType == "aac-lc" {
Quality = "256Kbps"
} else {
manifest1, err := ampapi.GetSongResp(storefront, meta.Data[0].Relationships.Tracks.Data[0].ID, playlist.Language, token)
if err != nil {
fmt.Println("Failed to get manifest.\n", err)
} else {
if manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls == "" {
Codec = "AAC"
Quality = "256Kbps"
} else {
needCheck := false
if Config.GetM3u8Mode == "all" {
needCheck = true
} else if Config.GetM3u8Mode == "hires" && contains(meta.Data[0].Relationships.Tracks.Data[0].Attributes.AudioTraits, "hi-res-lossless") {
needCheck = true
}
var EnhancedHls_m3u8 string
if needCheck {
EnhancedHls_m3u8, _ = checkM3u8(meta.Data[0].Relationships.Tracks.Data[0].ID, "album")
if strings.HasSuffix(EnhancedHls_m3u8, ".m3u8") {
manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls = EnhancedHls_m3u8
}
}
_, Quality, err = extractMedia(manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls, true)
if err != nil {
fmt.Println("Failed to extract quality from manifest.\n", err)
}
}
}
}
}
stringsToJoin := []string{}
if meta.Data[0].Attributes.IsAppleDigitalMaster || meta.Data[0].Attributes.IsMasteredForItunes {
if Config.AppleMasterChoice != "" {
stringsToJoin = append(stringsToJoin, Config.AppleMasterChoice)
}
}
if meta.Data[0].Attributes.ContentRating == "explicit" {
if Config.ExplicitChoice != "" {
stringsToJoin = append(stringsToJoin, Config.ExplicitChoice)
}
}
if meta.Data[0].Attributes.ContentRating == "clean" {
if Config.CleanChoice != "" {
stringsToJoin = append(stringsToJoin, Config.CleanChoice)
}
}
Tag_string := strings.Join(stringsToJoin, " ")
playlistFolder := strings.NewReplacer(
"{ArtistName}", "Apple Music",
"{PlaylistName}", LimitString(meta.Data[0].Attributes.Name),
"{PlaylistId}", playlistId,
"{Quality}", Quality,
"{Codec}", Codec,
"{Tag}", Tag_string,
).Replace(Config.PlaylistFolderFormat)
if strings.HasSuffix(playlistFolder, ".") {
playlistFolder = strings.ReplaceAll(playlistFolder, ".", "")
}
playlistFolder = strings.TrimSpace(playlistFolder)
playlistFolderPath := filepath.Join(singerFolder, forbiddenNames.ReplaceAllString(playlistFolder, "_"))
os.MkdirAll(playlistFolderPath, os.ModePerm)
playlist.SaveName = playlistFolder
fmt.Println(playlistFolder)
covPath, err := writeCover(playlistFolderPath, "cover", meta.Data[0].Attributes.Artwork.URL)
if err != nil {
fmt.Println("Failed to write cover.")
}
for i := range playlist.Tracks {
playlist.Tracks[i].CoverPath = covPath
playlist.Tracks[i].SaveDir = playlistFolderPath
playlist.Tracks[i].Codec = Codec
}
if Config.SaveAnimatedArtwork && meta.Data[0].Attributes.EditorialVideo.MotionDetailSquare.Video != "" {
fmt.Println("Found Animation Artwork.")
motionvideoUrlSquare, err := extractVideo(meta.Data[0].Attributes.EditorialVideo.MotionDetailSquare.Video)
if err != nil {
fmt.Println("no motion video square.\n", err)
} else {
exists, err := fileExists(filepath.Join(playlistFolderPath, "square_animated_artwork.mp4"))
if err != nil {
fmt.Println("Failed to check if animated artwork square exists.")
}
if exists {
fmt.Println("Animated artwork square already exists locally.")
} else {
fmt.Println("Animation Artwork Square Downloading...")
cmd := exec.Command("ffmpeg", "-loglevel", "quiet", "-y", "-i", motionvideoUrlSquare, "-c", "copy", filepath.Join(playlistFolderPath, "square_animated_artwork.mp4"))
if err := cmd.Run(); err != nil {
fmt.Printf("animated artwork square dl err: %v\n", err)
} else {
fmt.Println("Animation Artwork Square Downloaded")
}
}
}
if Config.EmbyAnimatedArtwork {
cmd3 := exec.Command("ffmpeg", "-i", filepath.Join(playlistFolderPath, "square_animated_artwork.mp4"), "-vf", "scale=440:-1", "-r", "24", "-f", "gif", filepath.Join(playlistFolderPath, "folder.jpg"))
if err := cmd3.Run(); err != nil {
fmt.Printf("animated artwork square to gif err: %v\n", err)
}
}
motionvideoUrlTall, err := extractVideo(meta.Data[0].Attributes.EditorialVideo.MotionDetailTall.Video)
if err != nil {
fmt.Println("no motion video tall.\n", err)
} else {
exists, err := fileExists(filepath.Join(playlistFolderPath, "tall_animated_artwork.mp4"))
if err != nil {
fmt.Println("Failed to check if animated artwork tall exists.")
}
if exists {
fmt.Println("Animated artwork tall already exists locally.")
} else {
fmt.Println("Animation Artwork Tall Downloading...")
cmd := exec.Command("ffmpeg", "-loglevel", "quiet", "-y", "-i", motionvideoUrlTall, "-c", "copy", filepath.Join(playlistFolderPath, "tall_animated_artwork.mp4"))
if err := cmd.Run(); err != nil {
fmt.Printf("animated artwork tall dl err: %v\n", err)
} else {
fmt.Println("Animation Artwork Tall Downloaded")
}
}
}
}
trackTotal := len(meta.Data[0].Relationships.Tracks.Data)
arr := make([]int, trackTotal)
for i := 0; i < trackTotal; i++ {
arr[i] = i + 1
}
var selected []int
if !dl_select {
selected = arr
} else {
selected = playlist.ShowSelect()
}
for i := range playlist.Tracks {
i++
if isInArray(okDict[playlistId], i) {
counter.Total++
counter.Success++
continue
}
if isInArray(selected, i) {
ripTrack(&playlist.Tracks[i-1], token, mediaUserToken)
}
}
return nil
}
func writeMP4Tags(track *task.Track, lrc string) error {
t := &mp4tag.MP4Tags{
Title: track.Resp.Attributes.Name,
TitleSort: track.Resp.Attributes.Name,
Artist: track.Resp.Attributes.ArtistName,
ArtistSort: track.Resp.Attributes.ArtistName,
Custom: map[string]string{
"PERFORMER": track.Resp.Attributes.ArtistName,
"RELEASETIME": track.Resp.Attributes.ReleaseDate,
"ISRC": track.Resp.Attributes.Isrc,
"LABEL": "",
"UPC": "",
},
Composer: track.Resp.Attributes.ComposerName,
ComposerSort: track.Resp.Attributes.ComposerName,
CustomGenre: track.Resp.Attributes.GenreNames[0],
Lyrics: lrc,
TrackNumber: int16(track.Resp.Attributes.TrackNumber),
DiscNumber: int16(track.Resp.Attributes.DiscNumber),
Album: track.Resp.Attributes.AlbumName,
AlbumSort: track.Resp.Attributes.AlbumName,
}
if track.PreType == "albums" {
albumID, err := strconv.ParseUint(track.PreID, 10, 32)
if err != nil {
return err
}
t.ItunesAlbumID = int32(albumID)
}
if len(track.Resp.Relationships.Artists.Data) > 0 {
artistID, err := strconv.ParseUint(track.Resp.Relationships.Artists.Data[0].ID, 10, 32)
if err != nil {
return err
}
t.ItunesArtistID = int32(artistID)
}
if (track.PreType == "playlists" || track.PreType == "stations") && !Config.UseSongInfoForPlaylist {
t.DiscNumber = 1
t.DiscTotal = 1
t.TrackNumber = int16(track.TaskNum)
t.TrackTotal = int16(track.TaskTotal)
t.Album = track.PlaylistData.Attributes.Name
t.AlbumSort = track.PlaylistData.Attributes.Name
t.AlbumArtist = track.PlaylistData.Attributes.ArtistName
t.AlbumArtistSort = track.PlaylistData.Attributes.ArtistName
} else if (track.PreType == "playlists" || track.PreType == "stations") && Config.UseSongInfoForPlaylist {
t.DiscTotal = int16(track.DiscTotal)
t.TrackTotal = int16(track.AlbumData.Attributes.TrackCount)
t.AlbumArtist = track.AlbumData.Attributes.ArtistName
t.AlbumArtistSort = track.AlbumData.Attributes.ArtistName
t.Custom["UPC"] = track.AlbumData.Attributes.Upc
t.Custom["LABEL"] = track.AlbumData.Attributes.RecordLabel
t.Date = track.AlbumData.Attributes.ReleaseDate
t.Copyright = track.AlbumData.Attributes.Copyright
t.Publisher = track.AlbumData.Attributes.RecordLabel
} else {
t.DiscTotal = int16(track.DiscTotal)
t.TrackTotal = int16(track.AlbumData.Attributes.TrackCount)
t.AlbumArtist = track.AlbumData.Attributes.ArtistName
t.AlbumArtistSort = track.AlbumData.Attributes.ArtistName
t.Custom["UPC"] = track.AlbumData.Attributes.Upc
t.Date = track.AlbumData.Attributes.ReleaseDate
t.Copyright = track.AlbumData.Attributes.Copyright
t.Publisher = track.AlbumData.Attributes.RecordLabel
}
if track.Resp.Attributes.ContentRating == "explicit" {
t.ItunesAdvisory = mp4tag.ItunesAdvisoryExplicit
} else if track.Resp.Attributes.ContentRating == "clean" {
t.ItunesAdvisory = mp4tag.ItunesAdvisoryClean
} else {
t.ItunesAdvisory = mp4tag.ItunesAdvisoryNone
}
mp4, err := mp4tag.Open(track.SavePath)
if err != nil {
return err
}
defer mp4.Close()
err = mp4.Write(t, []string{})
if err != nil {
return err
}
return nil
}
func main() {
err := loadConfig()
if err != nil {
fmt.Printf("load Config failed: %v", err)
return
}
token, err := ampapi.GetToken()
if err != nil {
if Config.AuthorizationToken != "" && Config.AuthorizationToken != "your-authorization-token" {
token = strings.Replace(Config.AuthorizationToken, "Bearer ", "", -1)
} else {
fmt.Println("Failed to get token.")
return
}
}
var search_type string
pflag.StringVar(&search_type, "search", "", "Search for 'album', 'song', or 'artist'. Provide query after flags.")
pflag.BoolVar(&dl_atmos, "atmos", false, "Enable atmos download mode")
pflag.BoolVar(&dl_aac, "aac", false, "Enable adm-aac download mode")
pflag.BoolVar(&dl_select, "select", false, "Enable selective download")
pflag.BoolVar(&dl_song, "song", false, "Enable single song download mode")
pflag.BoolVar(&artist_select, "all-album", false, "Download all artist albums")
pflag.BoolVar(&debug_mode, "debug", false, "Enable debug mode to show audio quality information")
alac_max = pflag.Int("alac-max", Config.AlacMax, "Specify the max quality for download alac")
atmos_max = pflag.Int("atmos-max", Config.AtmosMax, "Specify the max quality for download atmos")
aac_type = pflag.String("aac-type", Config.AacType, "Select AAC type, aac aac-binaural aac-downmix")
mv_audio_type = pflag.String("mv-audio-type", Config.MVAudioType, "Select MV audio type, atmos ac3 aac")
mv_max = pflag.Int("mv-max", Config.MVMax, "Specify the max quality for download MV")
pflag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [options] [url1 url2 ...]\n", "[main | main.exe | go run main.go]")
fmt.Fprintf(os.Stderr, "Search Usage: %s --search [album|song|artist] [query]\n", "[main | main.exe | go run main.go]")
fmt.Println("\nOptions:")
pflag.PrintDefaults()
}
pflag.Parse()
Config.AlacMax = *alac_max
Config.AtmosMax = *atmos_max
Config.AacType = *aac_type
Config.MVAudioType = *mv_audio_type
Config.MVMax = *mv_max
args := pflag.Args()
if search_type != "" {
if len(args) == 0 {
fmt.Println("Error: --search flag requires a query.")
pflag.Usage()
return
}
selectedUrl, err := handleSearch(search_type, args, token)
if err != nil {
fmt.Printf("\nSearch process failed: %v\n", err)
return
}
if selectedUrl == "" {
fmt.Println("\nExiting.")
return
}
os.Args = []string{selectedUrl}
} else {
if len(args) == 0 {
fmt.Println("No URLs provided. Please provide at least one URL.")
pflag.Usage()
return
}
os.Args = args
}
if strings.Contains(os.Args[0], "/artist/") {
urlArtistName, urlArtistID, err := getUrlArtistName(os.Args[0], token)
if err != nil {
fmt.Println("Failed to get artistname.")
return
}
Config.ArtistFolderFormat = strings.NewReplacer(
"{UrlArtistName}", LimitString(urlArtistName),
"{ArtistId}", urlArtistID,
).Replace(Config.ArtistFolderFormat)
albumArgs, err := checkArtist(os.Args[0], token, "albums")
if err != nil {
fmt.Println("Failed to get artist albums.")
return
}
mvArgs, err := checkArtist(os.Args[0], token, "music-videos")
if err != nil {
fmt.Println("Failed to get artist music-videos.")
}
os.Args = append(albumArgs, mvArgs...)
}
albumTotal := len(os.Args)
for {
for albumNum, urlRaw := range os.Args {
fmt.Printf("Queue %d of %d: ", albumNum+1, albumTotal)
var storefront, albumId string
if strings.Contains(urlRaw, "/music-video/") {
fmt.Println("Music Video")
if debug_mode {
continue
}
counter.Total++
if len(Config.MediaUserToken) <= 50 {
fmt.Println(": meida-user-token is not set, skip MV dl")
counter.Success++
continue
}
if _, err := exec.LookPath("mp4decrypt"); err != nil {
fmt.Println(": mp4decrypt is not found, skip MV dl")
counter.Success++
continue
}
mvSaveDir := strings.NewReplacer(
"{ArtistName}", "",
"{UrlArtistName}", "",
"{ArtistId}", "",
).Replace(Config.ArtistFolderFormat)
if mvSaveDir != "" {
mvSaveDir = filepath.Join(Config.AlacSaveFolder, forbiddenNames.ReplaceAllString(mvSaveDir, "_"))
} else {
mvSaveDir = Config.AlacSaveFolder
}
storefront, albumId = checkUrlMv(urlRaw)
err := mvDownloader(albumId, mvSaveDir, token, storefront, Config.MediaUserToken, nil)
if err != nil {
fmt.Println("\u26A0 Failed to dl MV:", err)
counter.Error++
continue
}
counter.Success++
continue
}
if strings.Contains(urlRaw, "/song/") {
fmt.Printf("Song->")
storefront, songId := checkUrlSong(urlRaw)
if storefront == "" || songId == "" {
fmt.Println("Invalid song URL format.")
continue
}
err := ripSong(songId, token, storefront, Config.MediaUserToken)
if err != nil {
fmt.Println("Failed to rip song:", err)
}
continue
}
parse, err := url.Parse(urlRaw)
if err != nil {
log.Fatalf("Invalid URL: %v", err)
}
var urlArg_i = parse.Query().Get("i")
if strings.Contains(urlRaw, "/album/") {
fmt.Println("Album")
storefront, albumId = checkUrl(urlRaw)
err := ripAlbum(albumId, token, storefront, Config.MediaUserToken, urlArg_i)
if err != nil {
fmt.Println("Failed to rip album:", err)
}
} else if strings.Contains(urlRaw, "/playlist/") {
fmt.Println("Playlist")
storefront, albumId = checkUrlPlaylist(urlRaw)
err := ripPlaylist(albumId, token, storefront, Config.MediaUserToken)
if err != nil {
fmt.Println("Failed to rip playlist:", err)
}
} else if strings.Contains(urlRaw, "/station/") {
fmt.Printf("Station")
storefront, albumId = checkUrlStation(urlRaw)
if len(Config.MediaUserToken) <= 50 {
fmt.Println(": meida-user-token is not set, skip station dl")
continue
}
err := ripStation(albumId, token, storefront, Config.MediaUserToken)
if err != nil {
fmt.Println("Failed to rip station:", err)
}
} else {
fmt.Println("Invalid type")
}
}
fmt.Printf("======= [\u2714 ] Completed: %d/%d | [\u26A0 ] Warnings: %d | [\u2716 ] Errors: %d =======\n", counter.Success, counter.Total, counter.Unavailable+counter.NotSong, counter.Error)
if counter.Error == 0 {
break
}
fmt.Println("Error detected, press Enter to try again...")
fmt.Scanln()
fmt.Println("Start trying again...")
counter = structs.Counter{}
}
}
func mvDownloader(adamID string, saveDir string, token string, storefront string, mediaUserToken string, track *task.Track) error {
MVInfo, err := ampapi.GetMusicVideoResp(storefront, adamID, Config.Language, token)
if err != nil {
fmt.Println("\u26A0 Failed to get MV manifest:", err)
return nil
}
if strings.HasSuffix(saveDir, ".") {
saveDir = strings.ReplaceAll(saveDir, ".", "")
}
saveDir = strings.TrimSpace(saveDir)
vidPath := filepath.Join(saveDir, fmt.Sprintf("%s_vid.mp4", adamID))
audPath := filepath.Join(saveDir, fmt.Sprintf("%s_aud.mp4", adamID))
mvSaveName := fmt.Sprintf("%s (%s)", MVInfo.Data[0].Attributes.Name, adamID)
if track != nil {
mvSaveName = fmt.Sprintf("%02d. %s", track.TaskNum, MVInfo.Data[0].Attributes.Name)
}
mvOutPath := filepath.Join(saveDir, fmt.Sprintf("%s.mp4", forbiddenNames.ReplaceAllString(mvSaveName, "_")))
fmt.Println(MVInfo.Data[0].Attributes.Name)
exists, _ := fileExists(mvOutPath)
if exists {
fmt.Println("MV already exists locally.")
return nil
}
mvm3u8url, _, _, _ := runv3.GetWebplayback(adamID, token, mediaUserToken, true)
if mvm3u8url == "" {
return errors.New("media-user-token may wrong or expired")
}
os.MkdirAll(saveDir, os.ModePerm)
videom3u8url, _ := extractVideo(mvm3u8url)
videokeyAndUrls, _ := runv3.Run(adamID, videom3u8url, token, mediaUserToken, true, "")
_ = runv3.ExtMvData(videokeyAndUrls, vidPath)
defer os.Remove(vidPath)
audiom3u8url, _ := extractMvAudio(mvm3u8url)
audiokeyAndUrls, _ := runv3.Run(adamID, audiom3u8url, token, mediaUserToken, true, "")
_ = runv3.ExtMvData(audiokeyAndUrls, audPath)
defer os.Remove(audPath)
tags := []string{
"tool=",
fmt.Sprintf("artist=%s", MVInfo.Data[0].Attributes.ArtistName),
fmt.Sprintf("title=%s", MVInfo.Data[0].Attributes.Name),
fmt.Sprintf("genre=%s", MVInfo.Data[0].Attributes.GenreNames[0]),
fmt.Sprintf("created=%s", MVInfo.Data[0].Attributes.ReleaseDate),
fmt.Sprintf("ISRC=%s", MVInfo.Data[0].Attributes.Isrc),
}
if MVInfo.Data[0].Attributes.ContentRating == "explicit" {
tags = append(tags, "rating=1")
} else if MVInfo.Data[0].Attributes.ContentRating == "clean" {
tags = append(tags, "rating=2")
} else {
tags = append(tags, "rating=0")
}
if track != nil {
if track.PreType == "playlists" && !Config.UseSongInfoForPlaylist {
tags = append(tags, "disk=1/1")
tags = append(tags, fmt.Sprintf("album=%s", track.PlaylistData.Attributes.Name))
tags = append(tags, fmt.Sprintf("track=%d", track.TaskNum))
tags = append(tags, fmt.Sprintf("tracknum=%d/%d", track.TaskNum, track.TaskTotal))
tags = append(tags, fmt.Sprintf("album_artist=%s", track.PlaylistData.Attributes.ArtistName))
tags = append(tags, fmt.Sprintf("performer=%s", track.Resp.Attributes.ArtistName))
} else if track.PreType == "playlists" && Config.UseSongInfoForPlaylist {
tags = append(tags, fmt.Sprintf("album=%s", track.AlbumData.Attributes.Name))
tags = append(tags, fmt.Sprintf("disk=%d/%d", track.Resp.Attributes.DiscNumber, track.DiscTotal))
tags = append(tags, fmt.Sprintf("track=%d", track.Resp.Attributes.TrackNumber))
tags = append(tags, fmt.Sprintf("tracknum=%d/%d", track.Resp.Attributes.TrackNumber, track.AlbumData.Attributes.TrackCount))
tags = append(tags, fmt.Sprintf("album_artist=%s", track.AlbumData.Attributes.ArtistName))
tags = append(tags, fmt.Sprintf("performer=%s", track.Resp.Attributes.ArtistName))
tags = append(tags, fmt.Sprintf("copyright=%s", track.AlbumData.Attributes.Copyright))
tags = append(tags, fmt.Sprintf("UPC=%s", track.AlbumData.Attributes.Upc))
} else {
tags = append(tags, fmt.Sprintf("album=%s", track.AlbumData.Attributes.Name))
tags = append(tags, fmt.Sprintf("disk=%d/%d", track.Resp.Attributes.DiscNumber, track.DiscTotal))
tags = append(tags, fmt.Sprintf("track=%d", track.Resp.Attributes.TrackNumber))
tags = append(tags, fmt.Sprintf("tracknum=%d/%d", track.Resp.Attributes.TrackNumber, track.AlbumData.Attributes.TrackCount))
tags = append(tags, fmt.Sprintf("album_artist=%s", track.AlbumData.Attributes.ArtistName))
tags = append(tags, fmt.Sprintf("performer=%s", track.Resp.Attributes.ArtistName))
tags = append(tags, fmt.Sprintf("copyright=%s", track.AlbumData.Attributes.Copyright))
tags = append(tags, fmt.Sprintf("UPC=%s", track.AlbumData.Attributes.Upc))
}
} else {
tags = append(tags, fmt.Sprintf("album=%s", MVInfo.Data[0].Attributes.AlbumName))
tags = append(tags, fmt.Sprintf("disk=%d", MVInfo.Data[0].Attributes.DiscNumber))
tags = append(tags, fmt.Sprintf("track=%d", MVInfo.Data[0].Attributes.TrackNumber))
tags = append(tags, fmt.Sprintf("tracknum=%d", MVInfo.Data[0].Attributes.TrackNumber))
tags = append(tags, fmt.Sprintf("performer=%s", MVInfo.Data[0].Attributes.ArtistName))
}
var covPath string
if true {
thumbURL := MVInfo.Data[0].Attributes.Artwork.URL
baseThumbName := forbiddenNames.ReplaceAllString(mvSaveName, "_") + "_thumbnail"
covPath, err = writeCover(saveDir, baseThumbName, thumbURL)
if err != nil {
fmt.Println("Failed to save MV thumbnail:", err)
} else {
tags = append(tags, fmt.Sprintf("cover=%s", covPath))
}
}
defer os.Remove(covPath)
tagsString := strings.Join(tags, ":")
muxCmd := exec.Command("MP4Box", "-itags", tagsString, "-quiet", "-add", vidPath, "-add", audPath, "-keep-utc", "-new", mvOutPath)
fmt.Printf("MV Remuxing...")
if err := muxCmd.Run(); err != nil {
fmt.Printf("MV mux failed: %v\n", err)
return err
}
fmt.Printf("\rMV Remuxed. \n")
return nil
}
func extractMvAudio(c string) (string, error) {
MediaUrl, err := url.Parse(c)
if err != nil {
return "", err
}
resp, err := http.Get(c)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", errors.New(resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
audioString := string(body)
from, listType, err := m3u8.DecodeFrom(strings.NewReader(audioString), true)
if err != nil || listType != m3u8.MASTER {
return "", errors.New("m3u8 not of media type")
}
audio := from.(*m3u8.MasterPlaylist)
var audioPriority = []string{"audio-atmos", "audio-ac3", "audio-stereo-256"}
if Config.MVAudioType == "ac3" {
audioPriority = []string{"audio-ac3", "audio-stereo-256"}
} else if Config.MVAudioType == "aac" {
audioPriority = []string{"audio-stereo-256"}
}
re := regexp.MustCompile(`_gr(\d+)_`)
type AudioStream struct {
URL string
Rank int
GroupID string
}
var audioStreams []AudioStream
for _, variant := range audio.Variants {
for _, audiov := range variant.Alternatives {
if audiov.URI != "" {
for _, priority := range audioPriority {
if audiov.GroupId == priority {
matches := re.FindStringSubmatch(audiov.URI)
if len(matches) == 2 {
var rank int
fmt.Sscanf(matches[1], "%d", &rank)
streamUrl, _ := MediaUrl.Parse(audiov.URI)
audioStreams = append(audioStreams, AudioStream{
URL: streamUrl.String(),
Rank: rank,
GroupID: audiov.GroupId,
})
}
}
}
}
}
}
if len(audioStreams) == 0 {
return "", errors.New("no suitable audio stream found")
}
sort.Slice(audioStreams, func(i, j int) bool {
return audioStreams[i].Rank > audioStreams[j].Rank
})
fmt.Println("Audio: " + audioStreams[0].GroupID)
return audioStreams[0].URL, nil
}
func checkM3u8(b string, f string) (string, error) {
var EnhancedHls string
if Config.GetM3u8FromDevice {
adamID := b
conn, err := net.Dial("tcp", Config.GetM3u8Port)
if err != nil {
fmt.Println("Error connecting to device:", err)
return "none", err
}
defer conn.Close()
if f == "song" {
fmt.Println("Connected to device")
}
adamIDBuffer := []byte(adamID)
lengthBuffer := []byte{byte(len(adamIDBuffer))}
_, err = conn.Write(lengthBuffer)
if err != nil {
fmt.Println("Error writing length to device:", err)
return "none", err
}
_, err = conn.Write(adamIDBuffer)
if err != nil {
fmt.Println("Error writing adamID to device:", err)
return "none", err
}
response, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
fmt.Println("Error reading response from device:", err)
return "none", err
}
response = bytes.TrimSpace(response)
if len(response) > 0 {
if f == "song" {
fmt.Println("Received URL:", string(response))
}
EnhancedHls = string(response)
} else {
fmt.Println("Received an empty response")
}
}
return EnhancedHls, nil
}
func formatAvailability(available bool, quality string) string {
if !available {
return "Not Available"
}
return quality
}
func extractMedia(b string, more_mode bool) (string, string, error) {
masterUrl, err := url.Parse(b)
if err != nil {
return "", "", err
}
resp, err := http.Get(b)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", errors.New(resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", err
}
masterString := string(body)
from, listType, err := m3u8.DecodeFrom(strings.NewReader(masterString), true)
if err != nil || listType != m3u8.MASTER {
return "", "", errors.New("m3u8 not of master type")
}
master := from.(*m3u8.MasterPlaylist)
var streamUrl *url.URL
sort.Slice(master.Variants, func(i, j int) bool {
return master.Variants[i].AverageBandwidth > master.Variants[j].AverageBandwidth
})
if debug_mode && more_mode {
fmt.Println("\nDebug: All Available Variants:")
var data [][]string
for _, variant := range master.Variants {
data = append(data, []string{variant.Codecs, variant.Audio, fmt.Sprint(variant.Bandwidth)})
}
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Codec", "Audio", "Bandwidth"})
table.SetAutoMergeCells(true)
table.SetRowLine(true)
table.AppendBulk(data)
table.Render()
var hasAAC, hasLossless, hasHiRes, hasAtmos, hasDolbyAudio bool
var aacQuality, losslessQuality, hiResQuality, atmosQuality, dolbyAudioQuality string
for _, variant := range master.Variants {
if variant.Codecs == "mp4a.40.2" { // AAC
hasAAC = true
split := strings.Split(variant.Audio, "-")
if len(split) >= 3 {
bitrate, _ := strconv.Atoi(split[2])
currentBitrate := 0
if aacQuality != "" {
current := strings.Split(aacQuality, " | ")[2]
current = strings.Split(current, " ")[0]
currentBitrate, _ = strconv.Atoi(current)
}
if bitrate > currentBitrate {
aacQuality = fmt.Sprintf("AAC | 2 Channel | %d Kbps", bitrate)
}
}
} else if variant.Codecs == "ec-3" && strings.Contains(variant.Audio, "atmos") { // Dolby Atmos
hasAtmos = true
split := strings.Split(variant.Audio, "-")
if len(split) > 0 {
bitrateStr := split[len(split)-1]
if len(bitrateStr) == 4 && bitrateStr[0] == '2' {
bitrateStr = bitrateStr[1:]
}
bitrate, _ := strconv.Atoi(bitrateStr)
currentBitrate := 0
if atmosQuality != "" {
current := strings.Split(strings.Split(atmosQuality, " | ")[2], " ")[0]
currentBitrate, _ = strconv.Atoi(current)
}
if bitrate > currentBitrate {
atmosQuality = fmt.Sprintf("E-AC-3 | 16 Channel | %d Kbps", bitrate)
}
}
} else if variant.Codecs == "alac" { // ALAC (Lossless or Hi-Res)
split := strings.Split(variant.Audio, "-")
if len(split) >= 3 {
bitDepth := split[len(split)-1]
sampleRate := split[len(split)-2]
sampleRateInt, _ := strconv.Atoi(sampleRate)
if sampleRateInt > 48000 { // Hi-Res
hasHiRes = true
hiResQuality = fmt.Sprintf("ALAC | 2 Channel | %s-bit/%d kHz", bitDepth, sampleRateInt/1000)
} else { // Standard Lossless
hasLossless = true
losslessQuality = fmt.Sprintf("ALAC | 2 Channel | %s-bit/%d kHz", bitDepth, sampleRateInt/1000)
}
}
} else if variant.Codecs == "ac-3" { // Dolby Audio
hasDolbyAudio = true
split := strings.Split(variant.Audio, "-")
if len(split) > 0 {
bitrate, _ := strconv.Atoi(split[len(split)-1])
dolbyAudioQuality = fmt.Sprintf("AC-3 | 16 Channel | %d Kbps", bitrate)
}
}
}
fmt.Println("Available Audio Formats:")
fmt.Println("------------------------")
fmt.Printf("AAC : %s\n", formatAvailability(hasAAC, aacQuality))
fmt.Printf("Lossless : %s\n", formatAvailability(hasLossless, losslessQuality))
fmt.Printf("Hi-Res Lossless : %s\n", formatAvailability(hasHiRes, hiResQuality))
fmt.Printf("Dolby Atmos : %s\n", formatAvailability(hasAtmos, atmosQuality))
fmt.Printf("Dolby Audio : %s\n", formatAvailability(hasDolbyAudio, dolbyAudioQuality))
fmt.Println("------------------------")
return "", "", nil
}
var Quality string
for _, variant := range master.Variants {
if dl_atmos {
if variant.Codecs == "ec-3" && strings.Contains(variant.Audio, "atmos") {
if debug_mode && !more_mode {
fmt.Printf("Debug: Found Dolby Atmos variant - %s (Bitrate: %d Kbps)\n",
variant.Audio, variant.Bandwidth/1000)
}
split := strings.Split(variant.Audio, "-")
length := len(split)
length_int, err := strconv.Atoi(split[length-1])
if err != nil {
return "", "", err
}
if length_int <= Config.AtmosMax {
if !debug_mode && !more_mode {
fmt.Printf("%s\n", variant.Audio)
}
streamUrlTemp, err := masterUrl.Parse(variant.URI)
if err != nil {
return "", "", err
}
streamUrl = streamUrlTemp
Quality = fmt.Sprintf("%s Kbps", split[len(split)-1])
break
}
} else if variant.Codecs == "ac-3" { // Add Dolby Audio support
if debug_mode && !more_mode {
fmt.Printf("Debug: Found Dolby Audio variant - %s (Bitrate: %d Kbps)\n",
variant.Audio, variant.Bandwidth/1000)
}
streamUrlTemp, err := masterUrl.Parse(variant.URI)
if err != nil {
return "", "", err
}
streamUrl = streamUrlTemp
split := strings.Split(variant.Audio, "-")
Quality = fmt.Sprintf("%s Kbps", split[len(split)-1])
break
}
} else if dl_aac {
if variant.Codecs == "mp4a.40.2" {
if debug_mode && !more_mode {
fmt.Printf("Debug: Found AAC variant - %s (Bitrate: %d)\n", variant.Audio, variant.Bandwidth)
}
aacregex := regexp.MustCompile(`audio-stereo-\d+`)
replaced := aacregex.ReplaceAllString(variant.Audio, "aac")
if replaced == Config.AacType {
if !debug_mode && !more_mode {
fmt.Printf("%s\n", variant.Audio)
}
streamUrlTemp, err := masterUrl.Parse(variant.URI)
if err != nil {
panic(err)
}
streamUrl = streamUrlTemp
split := strings.Split(variant.Audio, "-")
Quality = fmt.Sprintf("%s Kbps", split[2])
break
}
}
} else {
if variant.Codecs == "alac" {
split := strings.Split(variant.Audio, "-")
length := len(split)
length_int, err := strconv.Atoi(split[length-2])
if err != nil {
return "", "", err
}
if length_int <= Config.AlacMax {
if !debug_mode && !more_mode {
fmt.Printf("%s-bit / %s Hz\n", split[length-1], split[length-2])
}
streamUrlTemp, err := masterUrl.Parse(variant.URI)
if err != nil {
panic(err)
}
streamUrl = streamUrlTemp
KHZ := float64(length_int) / 1000.0
Quality = fmt.Sprintf("%sB-%.1fkHz", split[length-1], KHZ)
break
}
}
}
}
if streamUrl == nil {
return "", "", errors.New("no codec found")
}
return streamUrl.String(), Quality, nil
}
func extractVideo(c string) (string, error) {
MediaUrl, err := url.Parse(c)
if err != nil {
return "", err
}
resp, err := http.Get(c)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", errors.New(resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
videoString := string(body)
from, listType, err := m3u8.DecodeFrom(strings.NewReader(videoString), true)
if err != nil || listType != m3u8.MASTER {
return "", errors.New("m3u8 not of media type")
}
video := from.(*m3u8.MasterPlaylist)
re := regexp.MustCompile(`_(\d+)x(\d+)`)
var streamUrl *url.URL
sort.Slice(video.Variants, func(i, j int) bool {
return video.Variants[i].AverageBandwidth > video.Variants[j].AverageBandwidth
})
maxHeight := Config.MVMax
for _, variant := range video.Variants {
matches := re.FindStringSubmatch(variant.URI)
if len(matches) == 3 {
height := matches[2]
var h int
_, err := fmt.Sscanf(height, "%d", &h)
if err != nil {
continue
}
if h <= maxHeight {
streamUrl, err = MediaUrl.Parse(variant.URI)
if err != nil {
return "", err
}
fmt.Println("Video: " + variant.Resolution + "-" + variant.VideoRange)
break
}
}
}
if streamUrl == nil {
return "", errors.New("no suitable video stream found")
}
return streamUrl.String(), nil
}
func ripSong(songId string, token string, storefront string, mediaUserToken string) error {
// Get song info to find album ID
manifest, err := ampapi.GetSongResp(storefront, songId, Config.Language, token)
if err != nil {
fmt.Println("Failed to get song response.")
return err
}
songData := manifest.Data[0]
albumId := songData.Relationships.Albums.Data[0].ID
// Use album approach but only download the specific song
dl_song = true
err = ripAlbum(albumId, token, storefront, mediaUserToken, songId)
if err != nil {
fmt.Println("Failed to rip song:", err)
return err
}
return nil
}