2025-01-11 01:16:31 +08:00

1400 lines
42 KiB
Go

package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/spf13/pflag"
"gopkg.in/yaml.v2"
"github.com/beevik/etree"
"github.com/grafov/m3u8"
"main/utils/runv2"
"main/utils/structs"
)
var (
forbiddenNames = regexp.MustCompile(`[/\\<>:"|?*]`)
dl_atmos bool
dl_aac bool
dl_select bool
artist_select bool
alac_max *int
atmos_max *int
Config structs.ConfigSet
counter structs.Counter
okDict = make(map[string][]int)
)
func loadConfig() error {
// 读取config.yaml文件内容
data, err := ioutil.ReadFile("config.yaml")
if err != nil {
return err
}
// 将yaml解析到config变量中
err = yaml.Unmarshal(data, &Config)
if err != nil {
return err
}
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)\.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 checkUrlPlaylist(url string) (string, string) {
pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|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 checkUrlArtist(url string) (string, string) {
pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|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 getUrlArtistName(artistUrl string, token 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)
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, nil
}
func checkArtist(artistUrl string, token string) ([]string, error) {
storefront, artistId := checkUrlArtist(artistUrl)
Num := 0
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/albums?limit=100&offset=%d&l=%s", storefront, artistId, 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 {
urls = append(urls, album.Attributes.URL)
options = append(options, fmt.Sprintf("%s(%s)", album.Attributes.Name, album.ID))
}
Num = Num + 100
if len(obj.Next) == 0 {
break
}
}
for i, option := range options {
fmt.Printf("%02d: %s\n", i+1, option)
}
if artist_select {
fmt.Println("You have selected all options:")
return urls, nil
}
reader := bufio.NewReader(os.Stdin)
fmt.Println("Please select from the following options (multiple options separated by commas, ranges supported, or type 'all' to select all)")
fmt.Print("Enter your choice: ")
input, _ := reader.ReadString('\n')
// Remove newline and whitespace
input = strings.TrimSpace(input)
if input == "all" {
fmt.Println("You have selected all options:")
return urls, nil
}
// Split input into string slices
selectedOptions := [][]string{}
parts := strings.Split(input, ",")
for _, part := range parts {
if strings.Contains(part, "-") { // Range setting
rangeParts := strings.Split(part, "-")
selectedOptions = append(selectedOptions, rangeParts)
} else { // Single option
selectedOptions = append(selectedOptions, []string{part})
}
}
// Print selected options
fmt.Println("You have selected the following options:")
for _, opt := range selectedOptions {
if len(opt) == 1 { // Single option
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 { // Range
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 getMeta(albumId string, token string, storefront string) (*structs.AutoGenerated, error) {
var mtype string
var page int
if strings.Contains(albumId, "pl.") {
mtype = "playlists"
} else {
mtype = "albums"
}
req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/%s/%s", storefront, mtype, albumId), 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")
query := url.Values{}
query.Set("omit[resource]", "autos")
query.Set("include", "tracks,artists,record-labels")
query.Set("include[songs]", "artists")
query.Set("fields[artists]", "name,artwork")
query.Set("fields[albums:albums]", "artistName,artwork,name,releaseDate,url")
query.Set("fields[record-labels]", "name")
query.Set("extend", "editorialVideo")
query.Set("l", Config.Language)
req.URL.RawQuery = query.Encode()
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.AutoGenerated)
err = json.NewDecoder(do.Body).Decode(&obj)
if err != nil {
return nil, err
}
if strings.Contains(albumId, "pl.") {
obj.Data[0].Attributes.ArtistName = "Apple Music"
if len(obj.Data[0].Relationships.Tracks.Next) > 0 {
page = 0
for {
page = page + 100
pageStr := strconv.Itoa(page)
req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/%s/%s/tracks?offset=%s&l=%s", storefront, mtype, albumId, pageStr, 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)
}
obj2 := new(structs.AutoGeneratedTrack)
err = json.NewDecoder(do.Body).Decode(&obj2)
if err != nil {
return nil, err
}
for _, value := range obj2.Data {
obj.Data[0].Relationships.Tracks.Data = append(obj.Data[0].Relationships.Tracks.Data, value)
}
if len(obj2.Next) == 0 {
break
}
}
}
}
return obj, nil
}
func getSongLyrics(songId string, storefront string, token string, userToken string) (string, error) {
req, err := http.NewRequest("GET",
fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/songs/%s/%s", storefront, songId, Config.LrcType), nil)
if err != nil {
return "", err
}
req.Header.Set("Origin", "https://music.apple.com")
req.Header.Set("Referer", "https://music.apple.com/")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
cookie := http.Cookie{Name: "media-user-token", Value: userToken}
req.AddCookie(&cookie)
do, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer do.Body.Close()
obj := new(structs.SongLyrics)
err = json.NewDecoder(do.Body).Decode(&obj)
if obj.Data != nil {
return obj.Data[0].Attributes.Ttml, nil
} else {
return "", errors.New("failed to get lyrics")
}
}
func writeCover(sanAlbumFolder, name string, url string) error {
covPath := filepath.Join(sanAlbumFolder, name+"."+Config.CoverFormat)
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)
}
exists, err := fileExists(covPath)
if err != nil {
fmt.Println("Failed to check if cover exists.")
return err
}
if exists {
return nil
}
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 {
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 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
}
func rip(albumId string, token string, storefront string, userToken string) error {
var Codec string
if dl_atmos {
Codec = "Atmos"
} else if dl_aac {
Codec = "AAC"
} else {
Codec = "ALAC"
}
meta, err := getMeta(albumId, token, storefront)
if err != nil {
fmt.Println("Failed to get album metadata.\n")
return err
}
var singerFoldername string
if Config.ArtistFolderFormat != "" {
if strings.Contains(albumId, "pl.") {
singerFoldername = strings.NewReplacer(
"{ArtistName}", "Apple Music",
"{ArtistId}", "",
"{UrlArtistName}", "Apple Music",
).Replace(Config.ArtistFolderFormat)
} else 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, "_"))
}
var Quality string
if strings.Contains(Config.AlbumFolderFormat, "Quality") {
if dl_atmos {
Quality = fmt.Sprintf("%dkbps", Config.AtmosMax-2000)
} else {
manifest1, err := getInfoFromAdam(meta.Data[0].Relationships.Tracks.Data[0].ID, token, storefront)
if err != nil {
fmt.Println("Failed to get manifest.\n", err)
} else {
if manifest1.Attributes.ExtendedAssetUrls.EnhancedHls == "" {
fmt.Println("Unavailable.\n")
} 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, err = checkM3u8(meta.Data[0].Relationships.Tracks.Data[0].ID, "album")
if strings.HasSuffix(EnhancedHls_m3u8, ".m3u8") {
manifest1.Attributes.ExtendedAssetUrls.EnhancedHls = EnhancedHls_m3u8
}
}
Quality, err = extractMediaQuality(manifest1.Attributes.ExtendedAssetUrls.EnhancedHls)
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 albumFolder string
if strings.Contains(albumId, "pl.") {
albumFolder = strings.NewReplacer(
"{ArtistName}", "Apple Music",
"{PlaylistName}", LimitString(meta.Data[0].Attributes.Name),
"{PlaylistId}", albumId,
"{Quality}", Quality,
"{Codec}", Codec,
"{Tag}", Tag_string,
).Replace(Config.PlaylistFolderFormat)
} else {
albumFolder = 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(albumFolder, ".") {
albumFolder = strings.ReplaceAll(albumFolder, ".", "")
}
albumFolder = strings.TrimSpace(albumFolder)
sanAlbumFolder := filepath.Join(singerFolder, forbiddenNames.ReplaceAllString(albumFolder, "_"))
os.MkdirAll(sanAlbumFolder, os.ModePerm)
fmt.Println(albumFolder)
//get artist cover
if Config.SaveArtistCover && !(strings.Contains(albumId, "pl.")) {
if len(meta.Data[0].Relationships.Artists.Data) > 0 {
err = writeCover(singerFolder, "folder", meta.Data[0].Relationships.Artists.Data[0].Attributes.Artwork.Url)
if err != nil {
fmt.Println("Failed to write artist cover.")
}
}
}
//get album cover
err = writeCover(sanAlbumFolder, "cover", meta.Data[0].Attributes.Artwork.URL)
if err != nil {
fmt.Println("Failed to write cover.")
}
//get animated artwork
if Config.SaveAnimatedArtwork && meta.Data[0].Attributes.EditorialVideo.MotionDetailSquare.Video != "" {
fmt.Println("Found Animation Artwork.")
motionvideoUrl, err := extractVideo(meta.Data[0].Attributes.EditorialVideo.MotionDetailSquare.Video)
if err != nil {
fmt.Println("no motion video.\n", err)
}
exists, err := fileExists(filepath.Join(sanAlbumFolder, "animated_artwork.mp4"))
if err != nil {
fmt.Println("Failed to check if animated artwork exists.")
}
if exists {
fmt.Println("Animated artwork already exists locally.")
} else {
fmt.Println("Animation Artwork Downloading...")
cmd := exec.Command("ffmpeg", "-loglevel", "quiet", "-y", "-i", motionvideoUrl, "-c", "copy", filepath.Join(sanAlbumFolder, "animated_artwork.mp4"))
if err := cmd.Run(); err != nil {
fmt.Printf("animated artwork dl err: %v\n", err)
} else {
fmt.Println("Animation Artwork Downloaded")
}
if Config.EmbyAnimatedArtwork {
cmd2 := exec.Command("ffmpeg", "-i", filepath.Join(sanAlbumFolder, "animated_artwork.mp4"), "-vf", "scale=440:-1", "-r", "24", "-f", "gif", filepath.Join(sanAlbumFolder, "folder.jpg"))
if err := cmd2.Run(); err != nil {
fmt.Printf("animated artwork to gif err: %v\n", err)
}
}
}
}
trackTotal := len(meta.Data[0].Relationships.Tracks.Data)
arr := make([]int, trackTotal)
for i := 0; i < trackTotal; i++ {
arr[i] = i + 1
}
selected := []int{}
if !dl_select {
selected = arr
} else {
fmt.Print("select: ")
reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
fmt.Println(err)
}
input = strings.TrimSpace(input)
inputs := strings.Fields(input)
for _, str := range inputs {
num, err := strconv.Atoi(str)
if err != nil {
fmt.Printf("wrong '%s', skip...\n", str)
continue
}
found := false
for i := 0; i < len(arr); i++ {
if arr[i] == num {
selected = append(selected, num)
found = true
break
}
}
if !found {
fmt.Printf("Option '%d' not found or already selected, skipping...\n", num)
}
}
fmt.Println("Selected options:", selected)
}
for trackNum, track := range meta.Data[0].Relationships.Tracks.Data {
trackNum++
if isInArray(okDict[albumId], trackNum) {
//fmt.Println("已完成直接跳过.\n")
counter.Total++
counter.Success++
continue
}
if isInArray(selected, trackNum) {
counter.Total++
fmt.Printf("Track %d of %d:\n", trackNum, trackTotal)
manifest, err := getInfoFromAdam(track.ID, token, storefront)
if err != nil {
fmt.Println("\u26A0 Failed to get manifest:", err)
counter.NotSong++
continue
}
if manifest.Attributes.ExtendedAssetUrls.EnhancedHls == "" {
fmt.Println("\u26A0 Unavailable.")
counter.Unavailable++
continue
}
needCheck := false
if Config.GetM3u8Mode == "all" {
needCheck = true
} else if Config.GetM3u8Mode == "hires" && contains(track.Attributes.AudioTraits, "hi-res-lossless") {
needCheck = true
}
var EnhancedHls_m3u8 string
if needCheck {
EnhancedHls_m3u8, err = checkM3u8(track.ID, "song")
if strings.HasSuffix(EnhancedHls_m3u8, ".m3u8") {
manifest.Attributes.ExtendedAssetUrls.EnhancedHls = EnhancedHls_m3u8
}
}
var Quality string
if strings.Contains(Config.SongFileFormat, "Quality") {
if dl_atmos {
Quality = fmt.Sprintf("%dkbps", Config.AtmosMax-2000)
} else {
Quality, err = extractMediaQuality(manifest.Attributes.ExtendedAssetUrls.EnhancedHls)
if err != nil {
fmt.Println("Failed to extract quality from manifest.\n", err)
counter.Error++
continue
}
}
}
stringsToJoin := []string{}
if track.Attributes.IsAppleDigitalMaster {
if Config.AppleMasterChoice != "" {
stringsToJoin = append(stringsToJoin, Config.AppleMasterChoice)
}
}
if track.Attributes.ContentRating == "explicit" {
if Config.ExplicitChoice != "" {
stringsToJoin = append(stringsToJoin, Config.ExplicitChoice)
}
}
if track.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", trackNum),
"{SongName}", LimitString(track.Attributes.Name),
"{DiscNumber}", fmt.Sprintf("%0d", track.Attributes.DiscNumber),
"{TrackNumber}", fmt.Sprintf("%0d", track.Attributes.TrackNumber),
"{Quality}", Quality,
"{Tag}", Tag_string,
"{Codec}", Codec,
).Replace(Config.SongFileFormat)
fmt.Println(songName)
filename := fmt.Sprintf("%s.m4a", forbiddenNames.ReplaceAllString(songName, "_"))
lrcFilename := fmt.Sprintf("%s.%s", forbiddenNames.ReplaceAllString(songName, "_"), Config.LrcFormat)
trackPath := filepath.Join(sanAlbumFolder, filename)
var lrc string = ""
if userToken != "your-media-user-token" && (Config.EmbedLrc || Config.SaveLrcFile) {
ttml, err := getSongLyrics(track.ID, storefront, token, userToken)
if err != nil {
fmt.Println("Failed to get lyrics")
} else if Config.LrcFormat == "ttml"{
if Config.SaveLrcFile {
lrc = ttml
err := writeLyrics(sanAlbumFolder, lrcFilename, lrc)
if err != nil {
fmt.Printf("Failed to write lyrics")
}
lrc = ""
}
} else {
lrc, err = conventTTMLToLRC(ttml)
if err != nil {
fmt.Printf("Failed to parse lyrics: %s \n", err)
} else {
if Config.SaveLrcFile {
err := writeLyrics(sanAlbumFolder, lrcFilename, lrc)
if err != nil {
fmt.Printf("Failed to write lyrics")
}
if !Config.EmbedLrc {
lrc = ""
}
}
}
}
}
exists, err := fileExists(trackPath)
if err != nil {
fmt.Println("Failed to check if track exists.")
}
if exists {
fmt.Println("Track already exists locally.")
counter.Success++
okDict[albumId] = append(okDict[albumId], trackNum)
continue
}
trackM3u8Url, err := extractMedia(manifest.Attributes.ExtendedAssetUrls.EnhancedHls)
if err != nil {
fmt.Println("\u26A0 Failed to extract info from manifest:", err)
counter.Unavailable++
continue
}
//边下载边解密
err = runv2.Run(track.ID, trackM3u8Url, trackPath, Config)
if err != nil {
fmt.Println("Failed to run v2:", err)
counter.Error++
continue
}
//add tags
index := trackNum - 1
tags := []string{
"tool=",
fmt.Sprintf("lyrics=%s", lrc),
fmt.Sprintf("title=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.Name),
fmt.Sprintf("artist=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.ArtistName),
fmt.Sprintf("genre=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.GenreNames[0]),
fmt.Sprintf("created=%s", meta.Data[0].Attributes.ReleaseDate),
fmt.Sprintf("album_artist=%s", meta.Data[0].Attributes.ArtistName),
fmt.Sprintf("composer=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.ComposerName),
fmt.Sprintf("writer=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.ComposerName),
fmt.Sprintf("performer=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.ArtistName),
fmt.Sprintf("copyright=%s", meta.Data[0].Attributes.Copyright),
fmt.Sprintf("ISRC=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.Isrc),
fmt.Sprintf("UPC=%s", meta.Data[0].Attributes.Upc),
}
if strings.Contains(albumId, "pl.") && !Config.UseSongInfoForPlaylist {
tags = append(tags, "disk=1/1")
tags = append(tags, fmt.Sprintf("track=%d", trackNum))
tags = append(tags, fmt.Sprintf("tracknum=%d/%d", trackNum, trackTotal))
tags = append(tags, fmt.Sprintf("album=%s", meta.Data[0].Attributes.Name))
} else {
tags = append(tags, fmt.Sprintf("disk=%d/%d", meta.Data[0].Relationships.Tracks.Data[index].Attributes.DiscNumber, meta.Data[0].Relationships.Tracks.Data[trackTotal-1].Attributes.DiscNumber))
tags = append(tags, fmt.Sprintf("track=%d", meta.Data[0].Relationships.Tracks.Data[index].Attributes.TrackNumber))
tags = append(tags, fmt.Sprintf("tracknum=%d/%d", meta.Data[0].Relationships.Tracks.Data[index].Attributes.TrackNumber, trackTotal))
tags = append(tags, fmt.Sprintf("album=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.AlbumName))
}
if track.Attributes.ContentRating == "explicit" {
tags = append(tags, "rating=1")
} else if track.Attributes.ContentRating == "clean" {
tags = append(tags, "rating=2")
} else {
tags = append(tags, "rating=0")
}
if Config.EmbedCover {
if strings.Contains(albumId, "pl.") && Config.DlAlbumcoverForPlaylist {
err = writeCover(sanAlbumFolder, track.ID, track.Attributes.Artwork.URL)
if err != nil {
fmt.Println("Failed to write cover.")
}
tags = append(tags, fmt.Sprintf("cover=%s/%s.%s", sanAlbumFolder, track.ID, Config.CoverFormat))
} else {
tags = append(tags, fmt.Sprintf("cover=%s/%s.%s", sanAlbumFolder, "cover", Config.CoverFormat))
}
}
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++
continue
}
if strings.Contains(albumId, "pl.") && Config.DlAlbumcoverForPlaylist {
if err := os.Remove(fmt.Sprintf("%s/%s.%s", sanAlbumFolder, track.ID, Config.CoverFormat)); err != nil {
fmt.Printf("Error deleting file: %s/%s.%s\n", sanAlbumFolder, track.ID, Config.CoverFormat)
counter.Error++
continue
}
}
counter.Success++
okDict[albumId] = append(okDict[albumId], trackNum)
}
}
return err
}
func main() {
err := loadConfig()
if err != nil {
fmt.Printf("load Config failed: %v", err)
return
}
token, err := 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
}
}
// Define command-line 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(&artist_select, "all-album", false, "Download all artist albums")
alac_max = pflag.Int("alac-max", -1, "Specify the max quality for download alac")
atmos_max = pflag.Int("atmos-max", -1, "Specify the max quality for download atmos")
// Custom usage message for help
pflag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [options] url1 url2 ...\n", "[main | main.exe | go run main.go]")
fmt.Println("Options:")
pflag.PrintDefaults()
}
// Parse the flag arguments
pflag.Parse()
if *alac_max != -1 {
Config.AlacMax = *alac_max
}
if *atmos_max != -1 {
Config.AtmosMax = *atmos_max
}
args := pflag.Args()
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, err := getUrlArtistName(os.Args[0], token)
if err != nil {
fmt.Println("Failed to get artistname.")
return
}
//fmt.Println("get artistname:", urlArtistName)
Config.ArtistFolderFormat = strings.NewReplacer(
"{UrlArtistName}", LimitString(urlArtistName),
).Replace(Config.ArtistFolderFormat)
newArgs, err := checkArtist(os.Args[0], token)
if err != nil {
fmt.Println("Failed to get artist.")
return
}
os.Args = newArgs
}
albumTotal := len(os.Args)
for {
for albumNum, url := range os.Args {
fmt.Printf("Album %d of %d:\n", albumNum+1, albumTotal)
var storefront, albumId string
if strings.Contains(url, "/playlist/") {
storefront, albumId = checkUrlPlaylist(url)
} else {
storefront, albumId = checkUrl(url)
}
if albumId == "" {
fmt.Printf("Invalid URL: %s\n", url)
continue
}
err = rip(albumId, token, storefront, Config.MediaUserToken)
if err != nil {
fmt.Println("Album failed.")
fmt.Println(err)
}
}
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 conventSyllableTTMLToLRC(ttml string) (string, error) {
parsedTTML := etree.NewDocument()
err := parsedTTML.ReadFromString(ttml)
if err != nil {
return "", err
}
var lrcLines []string
parseTime := func(timeValue string) (string, error) {
var h, m, s, ms int
if strings.Contains(timeValue, ":") {
_, err = fmt.Sscanf(timeValue, "%d:%d:%d.%d", &h, &m, &s, &ms)
if err != nil {
_, err = fmt.Sscanf(timeValue, "%d:%d.%d", &m, &s, &ms)
h = 0
}
} else {
_, err = fmt.Sscanf(timeValue, "%d.%d", &s, &ms)
h, m = 0, 0
}
if err != nil {
return "", err
}
m += h * 60
ms = ms / 10
return fmt.Sprintf("[%02d:%02d.%02d]", m, s, ms), nil
}
for _, div := range parsedTTML.FindElement("tt").FindElement("body").FindElements("div") {
for _, item := range div.ChildElements() {
var lrcSyllables []string
var i int = 0
for _, lyrics := range item.Child {
if _, ok := lyrics.(*etree.CharData); ok {
if i > 0 {
lrcSyllables = append(lrcSyllables, " ")
continue
}
continue
}
lyric := lyrics.(*etree.Element)
if lyric.SelectAttr("begin") == nil {
continue
}
beginTime, err := parseTime(lyric.SelectAttr("begin").Value)
if err != nil {
return "", err
}
var text string
if lyric.SelectAttr("text") == nil {
var textTmp []string
for _, span := range lyric.Child {
if _, ok := span.(*etree.CharData); ok {
textTmp = append(textTmp, span.(*etree.CharData).Data)
} else {
textTmp = append(textTmp, span.(*etree.Element).Text())
}
}
text = strings.Join(textTmp, "")
} else {
text = lyric.SelectAttr("text").Value
}
lrcSyllables = append(lrcSyllables, fmt.Sprintf("%s%s", beginTime, text))
i += 1
}
endTime, err := parseTime(item.SelectAttr("end").Value)
if err != nil {
return "", err
}
lrcLines = append(lrcLines, strings.Join(lrcSyllables, "")+endTime)
}
}
return strings.Join(lrcLines, "\n"), nil
}
func conventTTMLToLRC(ttml string) (string, error) {
parsedTTML := etree.NewDocument()
err := parsedTTML.ReadFromString(ttml)
if err != nil {
return "", err
}
var lrcLines []string
timingAttr := parsedTTML.FindElement("tt").SelectAttr("itunes:timing")
if timingAttr != nil {
if timingAttr.Value == "Word" {
lrc, err := conventSyllableTTMLToLRC(ttml)
return lrc, err
}
if timingAttr.Value == "None" {
for _, p := range parsedTTML.FindElements("//p") {
line := p.Text()
line = strings.TrimSpace(line)
if line != "" {
lrcLines = append(lrcLines, line)
}
}
return strings.Join(lrcLines, "\n"), nil
}
}
for _, item := range parsedTTML.FindElement("tt").FindElement("body").ChildElements() {
for _, lyric := range item.ChildElements() {
var h, m, s, ms int
if lyric.SelectAttr("begin") == nil {
return "", errors.New("no synchronised lyrics")
}
if strings.Contains(lyric.SelectAttr("begin").Value, ":") {
_, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d:%d:%d.%d", &h, &m, &s, &ms)
if err != nil {
_, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d:%d.%d", &m, &s, &ms)
if err != nil {
_, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d:%d", &m, &s)
}
h = 0
}
} else {
_, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d.%d", &s, &ms)
h, m = 0, 0
}
if err != nil {
return "", err
}
var text string
if lyric.SelectAttr("text") == nil {
var textTmp []string
for _, span := range lyric.Child {
if _, ok := span.(*etree.CharData); ok {
textTmp = append(textTmp, span.(*etree.CharData).Data)
} else {
textTmp = append(textTmp, span.(*etree.Element).Text())
}
}
text = strings.Join(textTmp, "")
} else {
text = lyric.SelectAttr("text").Value
}
m += h * 60
ms = ms / 10
lrcLines = append(lrcLines, fmt.Sprintf("[%02d:%02d.%02d]%s", m, s, ms, text))
}
}
return strings.Join(lrcLines, "\n"), 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")
}
// Send the length of adamID and the adamID itself
adamIDBuffer := []byte(adamID)
lengthBuffer := []byte{byte(len(adamIDBuffer))}
// Write length and adamID to the connection
_, 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
}
// Read the response (URL) from the device
response, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
fmt.Println("Error reading response from device:", err)
return "none", err
}
// Trim any newline characters from the response
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 extractMediaQuality(b string) (string, error) {
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)
sort.Slice(master.Variants, func(i, j int) bool {
return master.Variants[i].AverageBandwidth > master.Variants[j].AverageBandwidth
})
var Quality string
for _, variant := range master.Variants {
if dl_aac {
if variant.Codecs == "mp4a.40.2" {
aacregex := regexp.MustCompile(`audio-stereo-\d+`)
replaced := aacregex.ReplaceAllString(variant.Audio, "aac")
split := strings.Split(variant.Audio, "-")
if replaced == Config.AacType {
Quality = fmt.Sprintf("%skbps", 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 {
HZ, err := strconv.Atoi(split[length-2])
if err != nil {
fmt.Println(err)
}
KHZ := float64(HZ) / 1000.0
Quality = fmt.Sprintf("%sB-%.1fkHz", split[length-1], KHZ)
break
}
}
}
}
return Quality, nil
}
func extractMedia(b 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
})
for _, variant := range master.Variants {
if dl_atmos {
if variant.Codecs == "ec-3" {
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 {
fmt.Printf("%s\n", variant.Audio)
streamUrlTemp, err := masterUrl.Parse(variant.URI)
if err != nil {
panic(err)
}
streamUrl = streamUrlTemp
break
}
}
} else if dl_aac {
if variant.Codecs == "mp4a.40.2" {
aacregex := regexp.MustCompile(`audio-stereo-\d+`)
replaced := aacregex.ReplaceAllString(variant.Audio, "aac")
//split := strings.Split(variant.Audio, "-")
if replaced == Config.AacType {
fmt.Printf("%s\n", variant.Audio)
streamUrlTemp, err := masterUrl.Parse(variant.URI)
if err != nil {
panic(err)
}
streamUrl = streamUrlTemp
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 {
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
break
}
}
}
}
if streamUrl == nil {
return "", errors.New("no codec found")
}
return streamUrl.String(), 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)
var streamUrl *url.URL
sort.Slice(video.Variants, func(i, j int) bool {
return video.Variants[i].AverageBandwidth > video.Variants[j].AverageBandwidth
})
if len(video.Variants) > 0 {
highestBandwidthVariant := video.Variants[0]
streamUrl, err = MediaUrl.Parse(highestBandwidthVariant.URI)
if err != nil {
return "", err
}
}
if streamUrl == nil {
return "", errors.New("no video codec found")
}
return streamUrl.String(), nil
}
func getInfoFromAdam(adamId string, token string, storefront string) (*structs.SongData, error) {
request, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/songs/%s", storefront, adamId), nil)
if err != nil {
return nil, err
}
query := url.Values{}
query.Set("extend", "extendedAssetUrls")
query.Set("include", "albums")
query.Set("l", Config.Language)
request.URL.RawQuery = query.Encode()
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
request.Header.Set("User-Agent", "iTunes/12.11.3 (Windows; Microsoft Windows 10 x64 Professional Edition (Build 19041); x64) AppleWebKit/7611.1022.4001.1 (dt:2)")
request.Header.Set("Origin", "https://music.apple.com")
do, err := http.DefaultClient.Do(request)
if err != nil {
return nil, err
}
defer do.Body.Close()
if do.StatusCode != http.StatusOK {
return nil, errors.New(do.Status)
}
obj := new(structs.ApiResult)
err = json.NewDecoder(do.Body).Decode(&obj)
if err != nil {
return nil, err
}
for _, d := range obj.Data {
if d.ID == adamId {
return &d, nil
}
}
return nil, nil
}
func getToken() (string, error) {
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
regex := regexp.MustCompile(`/assets/index-legacy-[^/]+\.js`)
indexJsUri := regex.FindString(string(body))
req, err = http.NewRequest("GET", "https://beta.music.apple.com"+indexJsUri, nil)
if err != nil {
return "", err
}
resp, err = http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
if err != nil {
return "", err
}
regex = regexp.MustCompile(`eyJh([^"]*)`)
token := regex.FindString(string(body))
return token, nil
}