mirror of
https://github.com/zhaarey/apple-music-downloader.git
synced 2025-10-23 15:11:05 +00:00
2509 lines
78 KiB
Go
2509 lines
78 KiB
Go
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
|
||
}
|