From b2bcdfde8820232df2f95b5d8b1e112a46aed911 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Sun, 2 Mar 2025 23:44:05 +0800 Subject: [PATCH 01/17] =?UTF-8?q?test:=20=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 1041 +++++++++++++++++------------------- utils/ampapi/album.go | 172 ++++++ utils/ampapi/artist.go | 1 + utils/ampapi/musicvideo.go | 145 +++++ utils/ampapi/playlist.go | 170 ++++++ utils/ampapi/song.go | 153 ++++++ utils/ampapi/token.go | 49 ++ utils/ampapi/track.go | 103 ++++ utils/structs/structs.go | 617 ++++----------------- utils/task/album.go | 193 +++++++ utils/task/playlist.go | 195 +++++++ utils/task/track.go | 44 ++ 12 files changed, 1823 insertions(+), 1060 deletions(-) create mode 100644 utils/ampapi/album.go create mode 100644 utils/ampapi/artist.go create mode 100644 utils/ampapi/musicvideo.go create mode 100644 utils/ampapi/playlist.go create mode 100644 utils/ampapi/song.go create mode 100644 utils/ampapi/token.go create mode 100644 utils/ampapi/track.go create mode 100644 utils/task/album.go create mode 100644 utils/task/playlist.go create mode 100644 utils/task/track.go diff --git a/main.go b/main.go index 362375c..b907c52 100644 --- a/main.go +++ b/main.go @@ -20,10 +20,12 @@ import ( "strings" "time" + "main/utils/ampapi" "main/utils/lyrics" "main/utils/runv2" "main/utils/runv3" "main/utils/structs" + "main/utils/task" "github.com/fatih/color" "github.com/grafov/m3u8" @@ -144,13 +146,13 @@ func checkUrlArtist(url string) (string, string) { } func getUrlSong(songUrl string, token string) (string, error) { storefront, songId := checkUrlSong(songUrl) - manifest, err := getInfoFromAdam(songId, token, storefront) + 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.Relationships.Albums.Data[0].ID + 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 } @@ -316,82 +318,6 @@ func checkArtist(artistUrl string, token string, relationship string) ([]string, return args, nil } -func getMeta(albumId string, token string, storefront string) (*structs.AutoGenerated, error) { - var mtype string - var next string - 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,albums") - 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 { - next = obj.Data[0].Relationships.Tracks.Next - for { - req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/%s&l=%s&include=albums", next, 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) - } - next = obj2.Next - if len(next) == 0 { - break - } - } - } - } - return obj, nil -} - func writeCover(sanAlbumFolder, name string, url string) (string, error) { covPath := filepath.Join(sanAlbumFolder, name+"."+Config.CoverFormat) if Config.CoverFormat == "original" { @@ -465,10 +391,10 @@ func contains(slice []string, item string) bool { return false } -// 下载单曲逻辑 -func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, track structs.TrackData, albumId, token, storefront, mediaUserToken, sanAlbumFolder, Codec string, covPath string) { +func ripTrack(track *task.Track, token string, mediaUserToken string) { + var err error counter.Total++ - fmt.Printf("Track %d of %d:\n", trackNum, trackTotal) + fmt.Printf("Track %d of %d:\n", track.TaskNum, track.TaskTotal) //mv dl dev if track.Type == "music-videos" { @@ -482,7 +408,11 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr counter.Success++ return } - err := mvDownloader(track.ID, sanAlbumFolder, token, storefront, mediaUserToken, meta) + //提前获取到的播放列表下track所在的专辑信息 + if track.PreType == "playlists" && Config.UseSongInfoForPlaylist { + track.GetAlbumData(token) + } + err := mvDownloader(track.ID, track.SaveDir, token, track.Storefront, mediaUserToken, track) if err != nil { fmt.Println("\u26A0 Failed to dl MV:", err) counter.Error++ @@ -491,18 +421,11 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr counter.Success++ return } - - manifest, err := getInfoFromAdam(track.ID, token, storefront) - if err != nil { - fmt.Println("\u26A0 Failed to get manifest:", err) - counter.NotSong++ - return - } needDlAacLc := false if dl_aac && Config.AacType == "aac-lc" { needDlAacLc = true } - if manifest.Attributes.ExtendedAssetUrls.EnhancedHls == "" { + if track.WebM3u8 == "" { if dl_atmos { fmt.Println("Unavailable") counter.Unavailable++ @@ -515,14 +438,15 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr if Config.GetM3u8Mode == "all" { needCheck = true - } else if Config.GetM3u8Mode == "hires" && contains(track.Attributes.AudioTraits, "hi-res-lossless") { + } 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") { - manifest.Attributes.ExtendedAssetUrls.EnhancedHls = EnhancedHls_m3u8 + track.DeviceM3u8 = EnhancedHls_m3u8 + track.M3u8 = EnhancedHls_m3u8 } } var Quality string @@ -532,7 +456,7 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr } else if needDlAacLc { Quality = "256kbps" } else { - _, Quality, err = extractMedia(manifest.Attributes.ExtendedAssetUrls.EnhancedHls, true) + _, Quality, err = extractMedia(track.M3u8, true) if err != nil { fmt.Println("Failed to extract quality from manifest.\n", err) counter.Error++ @@ -540,18 +464,20 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr } } } + track.Quality = Quality + stringsToJoin := []string{} - if track.Attributes.IsAppleDigitalMaster { + if track.Resp.Attributes.IsAppleDigitalMaster { if Config.AppleMasterChoice != "" { stringsToJoin = append(stringsToJoin, Config.AppleMasterChoice) } } - if track.Attributes.ContentRating == "explicit" { + if track.Resp.Attributes.ContentRating == "explicit" { if Config.ExplicitChoice != "" { stringsToJoin = append(stringsToJoin, Config.ExplicitChoice) } } - if track.Attributes.ContentRating == "clean" { + if track.Resp.Attributes.ContentRating == "clean" { if Config.CleanChoice != "" { stringsToJoin = append(stringsToJoin, Config.CleanChoice) } @@ -560,28 +486,29 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr 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), + "{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}", Codec, + "{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) - trackPath := filepath.Join(sanAlbumFolder, filename) //get lrc var lrc string = "" if Config.EmbedLrc || Config.SaveLrcFile { - lrcStr, err := lyrics.Get(storefront, track.ID, Config.LrcType, Config.Language, Config.LrcFormat, token, mediaUserToken) + 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(sanAlbumFolder, lrcFilename, lrcStr) + err := writeLyrics(track.SaveDir, lrcFilename, lrcStr) if err != nil { fmt.Printf("Failed to write lyrics") } @@ -599,7 +526,7 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr if exists { fmt.Println("Track already exists locally.") counter.Success++ - okDict[albumId] = append(okDict[albumId], trackNum) + okDict[track.PreID] = append(okDict[track.PreID], track.TaskNum) return } if needDlAacLc { @@ -615,7 +542,7 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr return } } else { - trackM3u8Url, _, err := extractMedia(manifest.Attributes.ExtendedAssetUrls.EnhancedHls, false) + trackM3u8Url, _, err := extractMedia(track.M3u8, false) if err != nil { fmt.Println("\u26A0 Failed to extract info from manifest:", err) counter.Unavailable++ @@ -631,19 +558,16 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr } tags := []string{ "tool=", - fmt.Sprintf("artist=%s", meta.Data[0].Attributes.ArtistName), + fmt.Sprintf("artist=%s", track.Resp.Attributes.ArtistName), //fmt.Sprintf("lyrics=%s", lrc), } - var trackCovPath string if Config.EmbedCover { - if strings.Contains(albumId, "pl.") && Config.DlAlbumcoverForPlaylist { - trackCovPath, err = writeCover(sanAlbumFolder, track.ID, track.Attributes.Artwork.URL) + if strings.Contains(track.PreID, "pl.") && 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", trackCovPath)) - } else { - tags = append(tags, fmt.Sprintf("cover=%s", covPath)) + tags = append(tags, fmt.Sprintf("cover=%s", track.CoverPath)) } } tagsString := strings.Join(tags, ":") @@ -653,29 +577,35 @@ func downloadTrack(trackNum int, trackTotal int, meta *structs.AutoGenerated, tr counter.Error++ return } - if strings.Contains(albumId, "pl.") && Config.DlAlbumcoverForPlaylist && trackCovPath != "" { - if err := os.Remove(trackCovPath); err != nil { - fmt.Printf("Error deleting file: %s\n", trackCovPath) + if strings.Contains(track.PreID, "pl.") && Config.DlAlbumcoverForPlaylist { + if err := os.Remove(track.CoverPath); err != nil { + fmt.Printf("Error deleting file: %s\n", track.CoverPath) counter.Error++ return } } - err = writeMP4Tags(trackPath, lrc, meta, trackNum, trackTotal) + track.SavePath = trackPath + //提前获取到的播放列表下track所在的专辑信息 + if track.PreType == "playlists" && Config.UseSongInfoForPlaylist { + track.GetAlbumData(token) + } + err = writeMP4Tags(track, lrc) if err != nil { fmt.Println("\u26A0 Failed to write tags in media:", err) counter.Unavailable++ return } counter.Success++ - okDict[albumId] = append(okDict[albumId], trackNum) + okDict[track.PreID] = append(okDict[track.PreID], track.TaskNum) } - -func rip(albumId string, token string, storefront string, mediaUserToken string, urlArg_i string) error { - meta, err := getMeta(albumId, token, storefront) +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 { - return err + fmt.Println("Failed to get album response.") } - + meta := album.Resp + //debug mode if debug_mode { // Print album info fmt.Println(meta.Data[0].Attributes.ArtistName) @@ -686,7 +616,7 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, 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 := getInfoFromAdam(track.ID, token, storefront) + 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 @@ -694,8 +624,293 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, var m3u8Url string //Web端m3u8 - if manifest.Attributes.ExtendedAssetUrls.EnhancedHls != "" { - m3u8Url = manifest.Attributes.ExtendedAssetUrls.EnhancedHls + if manifest.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls != "" { + m3u8Url = manifest.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls + } + //设备端满血m3u8 + 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 // Return directly without showing statistics + } + // Get Codec + var Codec string + if dl_atmos { + Codec = "ATMOS" + } else if dl_aac { + Codec = "AAC" + } else { + Codec = "ALAC" + } + album.Codec = Codec + // Get Artist Folder + 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, "_")) + } + os.MkdirAll(singerFolder, os.ModePerm) // Create artist folder + album.SaveDir = singerFolder + //Get Quality + 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" + //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, _ = 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) + } + } + } + } + } + //Set Album Folder Tags + 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, " ") + //Get Album Folder Name + 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) // Create album folder + album.SaveName = albumFolderName + fmt.Println(albumFolderName) + //先省略封面相关的获取 + //get playlist cover + covPath, err := writeCover(albumFolderPath, "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.") + + // Download square version + 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 { + // Convert square version to gif + 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) + } + } + + // Download tall version + 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 + } + //Get selected tracks + 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 == "" { + //fmt.Println("URL does not contain parameter 'i'. Please ensure the URL includes 'i' or use another mode.") + //return nil + } else { + for i, _ := range album.Tracks { + if urlArg_i == album.Tracks[i].ID { + ripTrack(&album.Tracks[i], token, mediaUserToken) + //downloadTrack(trackNum, trackTotal, meta, track, albumId, token, storefront, mediaUserToken, albumFolderPath, Codec, covPath) + return nil + } + } + } + return nil + } + var selected []int + if !dl_select { + selected = arr + } else { + selected = album.ShowSelect() + } + //Download tracks + for i, _ := range album.Tracks { + i++ + if isInArray(okDict[albumId], i) { + //fmt.Println("已完成直接跳过.\n") + counter.Total++ + counter.Success++ + continue + } + if isInArray(selected, i) { + ripTrack(&album.Tracks[i-1], token, mediaUserToken) + //downloadTrack(trackNum, trackTotal, meta, track, albumId, token, storefront, mediaUserToken, sanAlbumFolder, Codec, covPath) + } + } + 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 { + // Print album info + 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 + //Web端m3u8 + if manifest.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls != "" { + m3u8Url = manifest.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls } //设备端满血m3u8 needCheck := false @@ -729,27 +944,15 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, } else { Codec = "ALAC" } + playlist.Codec = Codec + // Get Artist Folder 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) - } + singerFoldername = strings.NewReplacer( + "{ArtistName}", "Apple Music", + "{ArtistId}", "", + "{UrlArtistName}", "Apple Music", + ).Replace(Config.ArtistFolderFormat) if strings.HasSuffix(singerFoldername, ".") { singerFoldername = strings.ReplaceAll(singerFoldername, ".", "") } @@ -760,6 +963,10 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, if dl_atmos { singerFolder = filepath.Join(Config.AtmosSaveFolder, forbiddenNames.ReplaceAllString(singerFoldername, "_")) } + os.MkdirAll(singerFolder, os.ModePerm) // Create artist folder + playlist.SaveDir = singerFolder + //Get Quality + var Quality string if strings.Contains(Config.AlbumFolderFormat, "Quality") { if dl_atmos { @@ -767,11 +974,11 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, } else if dl_aac && Config.AacType == "aac-lc" { Quality = "256kbps" } else { - manifest1, err := getInfoFromAdam(meta.Data[0].Relationships.Tracks.Data[0].ID, token, storefront) + 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.Attributes.ExtendedAssetUrls.EnhancedHls == "" { + if manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls == "" { Codec = "AAC" Quality = "256kbps" //fmt.Println("Unavailable.\n") @@ -787,10 +994,10 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, if needCheck { EnhancedHls_m3u8, _ = checkM3u8(meta.Data[0].Relationships.Tracks.Data[0].ID, "album") if strings.HasSuffix(EnhancedHls_m3u8, ".m3u8") { - manifest1.Attributes.ExtendedAssetUrls.EnhancedHls = EnhancedHls_m3u8 + manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls = EnhancedHls_m3u8 } } - _, Quality, err = extractMedia(manifest1.Attributes.ExtendedAssetUrls.EnhancedHls, true) + _, Quality, err = extractMedia(manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls, true) if err != nil { fmt.Println("Failed to extract quality from manifest.\n", err) } @@ -798,6 +1005,7 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, } } } + //Set Playlist Folder Tags stringsToJoin := []string{} if meta.Data[0].Attributes.IsAppleDigitalMaster || meta.Data[0].Attributes.IsMasteredForItunes { if Config.AppleMasterChoice != "" { @@ -815,52 +1023,36 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, } } 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) + //Get Playlist Folder Name + 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, ".", "") } - 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 - covPath, err := writeCover(sanAlbumFolder, "cover", meta.Data[0].Attributes.Artwork.URL) + playlistFolder = strings.TrimSpace(playlistFolder) + playlistFolderPath := filepath.Join(singerFolder, forbiddenNames.ReplaceAllString(playlistFolder, "_")) + os.MkdirAll(playlistFolderPath, os.ModePerm) + playlist.SaveName = playlistFolder + fmt.Println(playlistFolder) + //先省略封面相关的获取 + //get playlist cover + 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 + } + //get animated artwork if Config.SaveAnimatedArtwork && meta.Data[0].Attributes.EditorialVideo.MotionDetailSquare.Video != "" { fmt.Println("Found Animation Artwork.") @@ -870,7 +1062,7 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, if err != nil { fmt.Println("no motion video square.\n", err) } else { - exists, err := fileExists(filepath.Join(sanAlbumFolder, "square_animated_artwork.mp4")) + exists, err := fileExists(filepath.Join(playlistFolderPath, "square_animated_artwork.mp4")) if err != nil { fmt.Println("Failed to check if animated artwork square exists.") } @@ -878,7 +1070,7 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, 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(sanAlbumFolder, "square_animated_artwork.mp4")) + 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 { @@ -889,7 +1081,7 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, if Config.EmbyAnimatedArtwork { // Convert square version to gif - cmd3 := exec.Command("ffmpeg", "-i", filepath.Join(sanAlbumFolder, "square_animated_artwork.mp4"), "-vf", "scale=440:-1", "-r", "24", "-f", "gif", filepath.Join(sanAlbumFolder, "folder.jpg")) + 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) } @@ -900,7 +1092,7 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, if err != nil { fmt.Println("no motion video tall.\n", err) } else { - exists, err := fileExists(filepath.Join(sanAlbumFolder, "tall_animated_artwork.mp4")) + exists, err := fileExists(filepath.Join(playlistFolderPath, "tall_animated_artwork.mp4")) if err != nil { fmt.Println("Failed to check if animated artwork tall exists.") } @@ -908,7 +1100,7 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, 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(sanAlbumFolder, "tall_animated_artwork.mp4")) + 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 { @@ -922,228 +1114,113 @@ func rip(albumId string, token string, storefront string, mediaUserToken string, for i := 0; i < trackTotal; i++ { arr[i] = i + 1 } - selected := []int{} - - if dl_song { - if urlArg_i == "" { - //fmt.Println("URL does not contain parameter 'i'. Please ensure the URL includes 'i' or use another mode.") - //return nil - } else { - for trackNum, track := range meta.Data[0].Relationships.Tracks.Data { - trackNum++ - if urlArg_i == track.ID { - downloadTrack(trackNum, trackTotal, meta, track, albumId, token, storefront, mediaUserToken, sanAlbumFolder, Codec, covPath) - return nil - } - } - } - return nil - } + var selected []int if !dl_select { selected = arr } else { - var data [][]string - for trackNum, track := range meta.Data[0].Relationships.Tracks.Data { - trackNum++ - var trackName string - if meta.Data[0].Type == "albums" { - trackName = fmt.Sprintf("%02d. %s", track.Attributes.TrackNumber, track.Attributes.Name) - } else { - trackName = fmt.Sprintf("%s - %s", track.Attributes.Name, track.Attributes.ArtistName) - } - data = append(data, []string{fmt.Sprint(trackNum), - trackName, - track.Attributes.ContentRating, - track.Type}) - - } - table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"", "Track Name", "Rating", "Type"}) - //table.SetFooter([]string{"", "", "Footer", "Footer4"}) - table.SetRowLine(false) - //table.SetAutoMergeCells(true) - table.SetCaption(meta.Data[0].Type == "albums", fmt.Sprintf("Storefront: %s, %d tracks missing", strings.ToUpper(storefront), meta.Data[0].Attributes.TrackCount-trackTotal)) - table.SetHeaderColor(tablewriter.Colors{}, - tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold}, - tablewriter.Colors{tablewriter.FgBlackColor, tablewriter.Bold}, - tablewriter.Colors{tablewriter.FgBlackColor, tablewriter.Bold}) - - 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 _, row := range data { - if row[2] == "explicit" { - row[2] = "E" - } else if row[2] == "clean" { - row[2] = "C" - } else { - row[2] = "None" - } - if row[3] == "music-videos" { - row[3] = "MV" - } else if row[3] == "songs" { - row[3] = "SONG" - } - table.Append(row) - } - //table.AppendBulk(data) - table.Render() - fmt.Println("Please select from the track options above (multiple options separated by commas, ranges supported, or type 'all' to select all)") - cyanColor := color.New(color.FgCyan) - cyanColor.Print("select: ") - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - fmt.Println(err) - } - input = strings.TrimSpace(input) - if input == "all" { - fmt.Println("You have selected all options:") - selected = arr - } else { - 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}) - } - } - // - 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(arr) { - selected = append(selected, num) - //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(arr) || start > end { - fmt.Println("Range out of range:", opt) - continue - } - for i := start; i <= end; i++ { - //fmt.Println(options[i-1]) - selected = append(selected, i) - } - } else { - fmt.Println("Invalid option:", opt) - } - } - } - fmt.Println("Selected options:", selected) + selected = playlist.ShowSelect() } - for trackNum, track := range meta.Data[0].Relationships.Tracks.Data { - trackNum++ - if isInArray(okDict[albumId], trackNum) { + //Download tracks + for i, _ := range playlist.Tracks { + i++ + if isInArray(okDict[playlistId], i) { //fmt.Println("已完成直接跳过.\n") counter.Total++ counter.Success++ continue } - if isInArray(selected, trackNum) { - downloadTrack(trackNum, trackTotal, meta, track, albumId, token, storefront, mediaUserToken, sanAlbumFolder, Codec, covPath) + if isInArray(selected, i) { + ripTrack(&playlist.Tracks[i-1], token, mediaUserToken) + //downloadTrack(trackNum, trackTotal, meta, track, albumId, token, storefront, mediaUserToken, sanAlbumFolder, Codec, covPath) } } return nil } -func writeMP4Tags(trackPath, lrc string, meta *structs.AutoGenerated, trackNum, trackTotal int) error { - index := trackNum - 1 - +func writeMP4Tags(track *task.Track, lrc string) error { t := &mp4tag.MP4Tags{ - Title: meta.Data[0].Relationships.Tracks.Data[index].Attributes.Name, - TitleSort: meta.Data[0].Relationships.Tracks.Data[index].Attributes.Name, - Artist: meta.Data[0].Relationships.Tracks.Data[index].Attributes.ArtistName, - ArtistSort: meta.Data[0].Relationships.Tracks.Data[index].Attributes.ArtistName, + 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": meta.Data[0].Relationships.Tracks.Data[index].Attributes.ArtistName, - "RELEASETIME": meta.Data[0].Relationships.Tracks.Data[index].Attributes.ReleaseDate, - "ISRC": meta.Data[0].Relationships.Tracks.Data[index].Attributes.Isrc, - "LABEL": meta.Data[0].Attributes.RecordLabel, - "UPC": meta.Data[0].Attributes.Upc, + "PERFORMER": track.Resp.Attributes.ArtistName, + "RELEASETIME": track.Resp.Attributes.ReleaseDate, + "ISRC": track.Resp.Attributes.Isrc, + "LABEL": "", + "UPC": "", }, - Composer: meta.Data[0].Relationships.Tracks.Data[index].Attributes.ComposerName, - ComposerSort: meta.Data[0].Relationships.Tracks.Data[index].Attributes.ComposerName, - Date: meta.Data[0].Attributes.ReleaseDate, - CustomGenre: meta.Data[0].Relationships.Tracks.Data[index].Attributes.GenreNames[0], - Copyright: meta.Data[0].Attributes.Copyright, - Publisher: meta.Data[0].Attributes.RecordLabel, - Lyrics: lrc, + Composer: track.Resp.Attributes.ComposerName, + ComposerSort: track.Resp.Attributes.ComposerName, + //Date: meta.Data[0].Attributes.ReleaseDate, + CustomGenre: track.Resp.Attributes.GenreNames[0], + //Copyright: meta.Data[0].Attributes.Copyright, + //Publisher: meta.Data[0].Attributes.RecordLabel, + 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 !strings.Contains(meta.Data[0].ID, "pl.") { - albumID, err := strconv.ParseUint(meta.Data[0].ID, 10, 32) + if track.PreType == "albums" { + albumID, err := strconv.ParseUint(track.PreID, 10, 32) if err != nil { return err } t.ItunesAlbumID = int32(albumID) } - if len(meta.Data[0].Relationships.Artists.Data) > 0 { - if len(meta.Data[0].Relationships.Tracks.Data[index].Relationships.Artists.Data) > 0 { - artistID, err := strconv.ParseUint(meta.Data[0].Relationships.Tracks.Data[index].Relationships.Artists.Data[0].ID, 10, 32) - if err != nil { - return err - } - t.ItunesArtistID = int32(artistID) + 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 strings.Contains(meta.Data[0].ID, "pl.") && !Config.UseSongInfoForPlaylist { + if track.PreType == "playlists" && !Config.UseSongInfoForPlaylist { t.DiscNumber = 1 t.DiscTotal = 1 - t.TrackNumber = int16(trackNum) - t.TrackTotal = int16(trackTotal) - t.Album = meta.Data[0].Attributes.Name - t.AlbumSort = meta.Data[0].Attributes.Name - t.AlbumArtist = meta.Data[0].Attributes.ArtistName - t.AlbumArtistSort = meta.Data[0].Attributes.ArtistName - } else if strings.Contains(meta.Data[0].ID, "pl.") && Config.UseSongInfoForPlaylist { - t.DiscNumber = int16(meta.Data[0].Relationships.Tracks.Data[index].Attributes.DiscNumber) - t.DiscTotal = int16(meta.Data[0].Relationships.Tracks.Data[trackTotal-1].Attributes.DiscNumber) - t.TrackNumber = int16(meta.Data[0].Relationships.Tracks.Data[index].Attributes.TrackNumber) - t.TrackTotal = int16(trackTotal) - t.Album = meta.Data[0].Relationships.Tracks.Data[index].Attributes.AlbumName - t.AlbumSort = meta.Data[0].Relationships.Tracks.Data[index].Attributes.AlbumName - t.AlbumArtist = meta.Data[0].Relationships.Tracks.Data[index].Relationships.Albums.Data[0].Attributes.ArtistName - t.AlbumArtistSort = meta.Data[0].Relationships.Tracks.Data[index].Relationships.Albums.Data[0].Attributes.ArtistName + 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" && Config.UseSongInfoForPlaylist { + //使用提前获取到的播放列表下track所在的专辑信息 + len := len(track.AlbumData.Relationships.Tracks.Data) + t.DiscTotal = int16(track.AlbumData.Relationships.Tracks.Data[len-1].Attributes.DiscNumber) + 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.DiscNumber = int16(meta.Data[0].Relationships.Tracks.Data[index].Attributes.DiscNumber) - t.DiscTotal = int16(meta.Data[0].Relationships.Tracks.Data[trackTotal-1].Attributes.DiscNumber) - t.TrackNumber = int16(meta.Data[0].Relationships.Tracks.Data[index].Attributes.TrackNumber) - t.TrackTotal = int16(trackTotal) - t.Album = meta.Data[0].Relationships.Tracks.Data[index].Attributes.AlbumName - t.AlbumSort = meta.Data[0].Relationships.Tracks.Data[index].Attributes.AlbumName - t.AlbumArtist = meta.Data[0].Attributes.ArtistName - t.AlbumArtistSort = meta.Data[0].Attributes.ArtistName + 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 meta.Data[0].Relationships.Tracks.Data[index].Attributes.ContentRating == "explicit" { + if track.Resp.Attributes.ContentRating == "explicit" { t.ItunesAdvisory = mp4tag.ItunesAdvisoryExplicit - } else if meta.Data[0].Relationships.Tracks.Data[index].Attributes.ContentRating == "clean" { + } else if track.Resp.Attributes.ContentRating == "clean" { t.ItunesAdvisory = mp4tag.ItunesAdvisoryClean } else { t.ItunesAdvisory = mp4tag.ItunesAdvisoryNone } - mp4, err := mp4tag.Open(trackPath) + mp4, err := mp4tag.Open(track.SavePath) if err != nil { return err } @@ -1161,7 +1238,7 @@ func main() { fmt.Printf("load Config failed: %v", err) return } - token, err := getToken() + token, err := ampapi.GetToken() if err != nil { if Config.AuthorizationToken != "" && Config.AuthorizationToken != "your-authorization-token" { token = strings.Replace(Config.AuthorizationToken, "Bearer ", "", -1) @@ -1233,6 +1310,7 @@ func main() { for albumNum, urlRaw := range os.Args { fmt.Printf("Album %d of %d:\n", albumNum+1, albumTotal) var storefront, albumId string + //mv dl dev if strings.Contains(urlRaw, "/music-video/") { if debug_mode { @@ -1276,24 +1354,26 @@ func main() { fmt.Println("Failed to get Song info.") } } - if strings.Contains(urlRaw, "/playlist/") { - storefront, albumId = checkUrlPlaylist(urlRaw) - } else { - storefront, albumId = checkUrl(urlRaw) - } - if albumId == "" { - fmt.Printf("Invalid URL: %s\n", urlRaw) - continue - } parse, err := url.Parse(urlRaw) if err != nil { log.Fatalf("Invalid URL: %v", err) } var urlArg_i = parse.Query().Get("i") - err = rip(albumId, token, storefront, Config.MediaUserToken, urlArg_i) - if err != nil { - fmt.Println("Album failed.") - fmt.Println(err) + + if strings.Contains(urlRaw, "/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/") { + storefront, albumId = checkUrlPlaylist(urlRaw) + err := ripPlaylist(albumId, token, storefront, Config.MediaUserToken) + if err != nil { + fmt.Println("Failed to rip playlist:", err) + } + } else { + fmt.Println("Invalid URL.") } } fmt.Printf("======= [\u2714 ] Completed: %d/%d | [\u26A0 ] Warnings: %d | [\u2716 ] Errors: %d =======\n", counter.Success, counter.Total, counter.Unavailable+counter.NotSong, counter.Error) @@ -1306,27 +1386,14 @@ func main() { counter = structs.Counter{} } } -func mvDownloader(adamID string, saveDir string, token string, storefront string, mediaUserToken string, meta *structs.AutoGenerated) error { - MVInfo, err := getMVInfoFromAdam(adamID, token, storefront) + +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 } - //获取传入的专辑信息当中该mv所在的位置 - var trackTotal int - var trackNum int - var index int - if meta != nil { - trackTotal = len(meta.Data[0].Relationships.Tracks.Data) - for i, track := range meta.Data[0].Relationships.Tracks.Data { - if adamID == track.ID { - index = i - trackNum = i + 1 - } - } - } - if strings.HasSuffix(saveDir, ".") { saveDir = strings.ReplaceAll(saveDir, ".", "") } @@ -1335,8 +1402,8 @@ func mvDownloader(adamID string, saveDir string, token string, storefront string 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 meta != nil { - mvSaveName = fmt.Sprintf("%02d. %s", trackNum, MVInfo.Data[0].Attributes.Name) + 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, "_"))) @@ -1384,25 +1451,35 @@ func mvDownloader(adamID string, saveDir string, token string, storefront string } //根据情况额外添加可使用的tags - if meta != nil { - if meta.Data[0].Type == "playlists" && !Config.UseSongInfoForPlaylist { + if track != nil { + if track.PreType == "playlists" && !Config.UseSongInfoForPlaylist { tags = append(tags, "disk=1/1") - tags = append(tags, fmt.Sprintf("album=%s", meta.Data[0].Attributes.Name)) - tags = append(tags, fmt.Sprintf("track=%d", trackNum)) - tags = append(tags, fmt.Sprintf("tracknum=%d/%d", trackNum, trackTotal)) - tags = append(tags, fmt.Sprintf("album_artist=%s", meta.Data[0].Attributes.ArtistName)) - tags = append(tags, fmt.Sprintf("performer=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.ArtistName)) - tags = append(tags, fmt.Sprintf("copyright=%s", meta.Data[0].Attributes.Copyright)) - tags = append(tags, fmt.Sprintf("UPC=%s", meta.Data[0].Attributes.Upc)) + 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)) + //tags = append(tags, fmt.Sprintf("copyright=%s", track.PlaylistData.Attributes.Copyright)) + //tags = append(tags, fmt.Sprintf("UPC=%s", track.PlaylistData.Attributes.Upc)) + } else if track.PreType == "playlists" && Config.UseSongInfoForPlaylist { + tags = append(tags, fmt.Sprintf("album=%s", track.AlbumData.Attributes.Name)) + len := len(track.AlbumData.Relationships.Tracks.Data) + tags = append(tags, fmt.Sprintf("disk=%d/%d", track.Resp.Attributes.DiscNumber, track.AlbumData.Relationships.Tracks.Data[len-1].Attributes.DiscNumber)) + 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", meta.Data[0].Relationships.Tracks.Data[index].Attributes.AlbumName)) - 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, meta.Data[0].Attributes.TrackCount)) - tags = append(tags, fmt.Sprintf("album_artist=%s", meta.Data[0].Attributes.ArtistName)) - tags = append(tags, fmt.Sprintf("performer=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.ArtistName)) - tags = append(tags, fmt.Sprintf("copyright=%s", meta.Data[0].Attributes.Copyright)) - tags = append(tags, fmt.Sprintf("UPC=%s", meta.Data[0].Attributes.Upc)) + 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)) @@ -1859,113 +1936,3 @@ func extractVideo(c string) (string, error) { 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 getMVInfoFromAdam(adamId string, token string, storefront string) (*structs.AutoGeneratedMusicVideo, error) { - request, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/music-videos/%s", storefront, adamId), nil) - if err != nil { - return nil, err - } - query := url.Values{} - query.Set("l", Config.Language) - request.URL.RawQuery = query.Encode() - request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - request.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") - 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.AutoGeneratedMusicVideo) - err = json.NewDecoder(do.Body).Decode(&obj) - if err != nil { - return nil, err - } - - return obj, 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 -} diff --git a/utils/ampapi/album.go b/utils/ampapi/album.go new file mode 100644 index 0000000..7ea4267 --- /dev/null +++ b/utils/ampapi/album.go @@ -0,0 +1,172 @@ +package ampapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +func GetAlbumResp(storefront string, id string, language string, token string) (*AlbumResp, error) { + var err error + if token == "" { + token, err = GetToken() + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/albums/%s", storefront, id), 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,extendedAssetUrls") + query.Set("l", 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(AlbumResp) + err = json.NewDecoder(do.Body).Decode(&obj) + if err != nil { + return nil, err + } + return obj, nil +} + +func GetAlbumRespByHref(href string, language string, token string) (*AlbumResp, error) { + var err error + if token == "" { + token, err = GetToken() + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com%s/albums", href), 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,extendedAssetUrls") + query.Set("l", 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(AlbumResp) + err = json.NewDecoder(do.Body).Decode(&obj) + if err != nil { + return nil, err + } + return obj, nil +} + +type AlbumResp struct { + Href string `json:"href"` + Next string `json:"next"` + Data []AlbumRespData `json:"data"` +} + +type AlbumRespData struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + ArtistName string `json:"artistName"` + IsSingle bool `json:"isSingle"` + URL string `json:"url"` + IsComplete bool `json:"isComplete"` + GenreNames []string `json:"genreNames"` + TrackCount int `json:"trackCount"` + IsMasteredForItunes bool `json:"isMasteredForItunes"` + IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` + ContentRating string `json:"contentRating"` + ReleaseDate string `json:"releaseDate"` + Name string `json:"name"` + RecordLabel string `json:"recordLabel"` + Upc string `json:"upc"` + AudioTraits []string `json:"audioTraits"` + Copyright string `json:"copyright"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"playParams"` + IsCompilation bool `json:"isCompilation"` + EditorialVideo struct { + MotionTall struct { + Video string `json:"video"` + } `json:"motionTallVideo3x4"` + MotionSquare struct { + Video string `json:"video"` + } `json:"motionSquareVideo1x1"` + MotionDetailTall struct { + Video string `json:"video"` + } `json:"motionDetailTall"` + MotionDetailSquare struct { + Video string `json:"video"` + } `json:"motionDetailSquare"` + } `json:"editorialVideo"` + } `json:"attributes"` + Relationships struct { + RecordLabels struct { + Href string `json:"href"` + Data []interface{} `json:"data"` + } `json:"record-labels"` + Artists struct { + Href string `json:"href"` + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Name string `json:"name"` + Artwork struct { + Url string `json:"url"` + } `json:"artwork"` + } `json:"attributes"` + } `json:"data"` + } `json:"artists"` + Tracks TrackResp `json:"tracks"` + } `json:"relationships"` +} diff --git a/utils/ampapi/artist.go b/utils/ampapi/artist.go new file mode 100644 index 0000000..1ff71ef --- /dev/null +++ b/utils/ampapi/artist.go @@ -0,0 +1 @@ +package ampapi diff --git a/utils/ampapi/musicvideo.go b/utils/ampapi/musicvideo.go new file mode 100644 index 0000000..f8163b1 --- /dev/null +++ b/utils/ampapi/musicvideo.go @@ -0,0 +1,145 @@ +package ampapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +func GetMusicVideoResp(storefront string, id string, language string, token string) (*MusicVideoResp, error) { + var err error + if token == "" { + token, err = GetToken() + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/music-videos/%s", storefront, id), 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", "albums,artists") + //query.Set("extend", "extendedAssetUrls") + //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", 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(MusicVideoResp) + err = json.NewDecoder(do.Body).Decode(&obj) + if err != nil { + return nil, err + } + return obj, nil +} + +type MusicVideoResp struct { + Href string `json:"href"` + Next string `json:"next"` + Data []MusicVideoRespData `json:"data"` +} + +type MusicVideoRespData struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Previews []struct { + URL string `json:"url"` + } `json:"previews"` + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + AlbumName string `json:"albumName"` + ArtistName string `json:"artistName"` + URL string `json:"url"` + GenreNames []string `json:"genreNames"` + DurationInMillis int `json:"durationInMillis"` + Isrc string `json:"isrc"` + TrackNumber int `json:"trackNumber"` + DiscNumber int `json:"discNumber"` + ContentRating string `json:"contentRating"` + ReleaseDate string `json:"releaseDate"` + Name string `json:"name"` + Has4K bool `json:"has4K"` + HasHDR bool `json:"hasHDR"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"playParams"` + } `json:"attributes"` + Relationships struct { + Artists struct { + Href string `json:"href"` + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Name string `json:"name"` + } `json:"attributes"` + } `json:"data"` + } `json:"artists"` + Albums struct { + Href string `json:"href"` + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + ArtistName string `json:"artistName"` + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + GenreNames []string `json:"genreNames"` + IsCompilation bool `json:"isCompilation"` + IsComplete bool `json:"isComplete"` + IsMasteredForItunes bool `json:"isMasteredForItunes"` + IsPrerelease bool `json:"isPrerelease"` + IsSingle bool `json:"isSingle"` + Name string `json:"name"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"playParams"` + ReleaseDate string `json:"releaseDate"` + TrackCount int `json:"trackCount"` + Upc string `json:"upc"` + URL string `json:"url"` + } `json:"attributes"` + } `json:"data"` + } `json:"albums"` + } `json:"relationships"` +} diff --git a/utils/ampapi/playlist.go b/utils/ampapi/playlist.go new file mode 100644 index 0000000..7b38be3 --- /dev/null +++ b/utils/ampapi/playlist.go @@ -0,0 +1,170 @@ +package ampapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +func GetPlaylistResp(storefront string, id string, language string, token string) (*PlaylistResp, error) { + var err error + if token == "" { + token, err = GetToken() + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/playlists/%s", storefront, id), 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,extendedAssetUrls") + query.Set("l", 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(PlaylistResp) + err = json.NewDecoder(do.Body).Decode(&obj) + if err != nil { + return nil, err + } + if len(obj.Data[0].Relationships.Tracks.Next) > 0 { + next := obj.Data[0].Relationships.Tracks.Next + for { + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/%s", next), 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,albums") + 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,extendedAssetUrls") + query.Set("l", 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) + } + obj2 := new(TrackResp) + err = json.NewDecoder(do.Body).Decode(&obj2) + if err != nil { + return nil, err + } + obj.Data[0].Relationships.Tracks.Data = append(obj.Data[0].Relationships.Tracks.Data, obj2.Data...) + next = obj2.Next + if len(next) == 0 { + break + } + } + } + return obj, nil +} + +type PlaylistResp struct { + Href string `json:"href"` + Next string `json:"next"` + Data []PlaylistRespData `json:"data"` +} + +type PlaylistRespData struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + ArtistName string `json:"artistName"` + IsSingle bool `json:"isSingle"` + URL string `json:"url"` + IsComplete bool `json:"isComplete"` + GenreNames []string `json:"genreNames"` + TrackCount int `json:"trackCount"` + IsMasteredForItunes bool `json:"isMasteredForItunes"` + IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` + ContentRating string `json:"contentRating"` + ReleaseDate string `json:"releaseDate"` + Name string `json:"name"` + RecordLabel string `json:"recordLabel"` + Upc string `json:"upc"` + AudioTraits []string `json:"audioTraits"` + Copyright string `json:"copyright"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"playParams"` + IsCompilation bool `json:"isCompilation"` + EditorialVideo struct { + MotionTall struct { + Video string `json:"video"` + } `json:"motionTallVideo3x4"` + MotionSquare struct { + Video string `json:"video"` + } `json:"motionSquareVideo1x1"` + MotionDetailTall struct { + Video string `json:"video"` + } `json:"motionDetailTall"` + MotionDetailSquare struct { + Video string `json:"video"` + } `json:"motionDetailSquare"` + } `json:"editorialVideo"` + } `json:"attributes"` + Relationships struct { + RecordLabels struct { + Href string `json:"href"` + Data []interface{} `json:"data"` + } `json:"record-labels"` + Artists struct { + Href string `json:"href"` + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Name string `json:"name"` + Artwork struct { + Url string `json:"url"` + } `json:"artwork"` + } `json:"attributes"` + } `json:"data"` + } `json:"artists"` + Tracks TrackResp `json:"tracks"` + } `json:"relationships"` +} diff --git a/utils/ampapi/song.go b/utils/ampapi/song.go new file mode 100644 index 0000000..dda2ace --- /dev/null +++ b/utils/ampapi/song.go @@ -0,0 +1,153 @@ +package ampapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +func GetSongResp(storefront string, id string, language string, token string) (*SongResp, error) { + var err error + if token == "" { + token, err = GetToken() + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/songs/%s", storefront, id), 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", "albums,artists") + query.Set("extend", "extendedAssetUrls") + //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", 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(SongResp) + err = json.NewDecoder(do.Body).Decode(&obj) + if err != nil { + return nil, err + } + return obj, nil +} + +type SongResp struct { + Href string `json:"href"` + Next string `json:"next"` + Data []SongRespData `json:"data"` +} + +type SongRespData struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Previews []struct { + URL string `json:"url"` + } `json:"previews"` + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + ArtistName string `json:"artistName"` + URL string `json:"url"` + DiscNumber int `json:"discNumber"` + GenreNames []string `json:"genreNames"` + HasTimeSyncedLyrics bool `json:"hasTimeSyncedLyrics"` + IsMasteredForItunes bool `json:"isMasteredForItunes"` + IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` + ContentRating string `json:"contentRating"` + DurationInMillis int `json:"durationInMillis"` + ReleaseDate string `json:"releaseDate"` + Name string `json:"name"` + ExtendedAssetUrls struct { + EnhancedHls string `json:"enhancedHls"` + } `json:"extendedAssetUrls"` + Isrc string `json:"isrc"` + AudioTraits []string `json:"audioTraits"` + HasLyrics bool `json:"hasLyrics"` + AlbumName string `json:"albumName"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"playParams"` + TrackNumber int `json:"trackNumber"` + AudioLocale string `json:"audioLocale"` + ComposerName string `json:"composerName"` + } `json:"attributes"` + Relationships struct { + Artists struct { + Href string `json:"href"` + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Name string `json:"name"` + } `json:"attributes"` + } `json:"data"` + } `json:"artists"` + Albums struct { + Href string `json:"href"` + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + ArtistName string `json:"artistName"` + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + GenreNames []string `json:"genreNames"` + IsCompilation bool `json:"isCompilation"` + IsComplete bool `json:"isComplete"` + IsMasteredForItunes bool `json:"isMasteredForItunes"` + IsPrerelease bool `json:"isPrerelease"` + IsSingle bool `json:"isSingle"` + Name string `json:"name"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"playParams"` + ReleaseDate string `json:"releaseDate"` + TrackCount int `json:"trackCount"` + Upc string `json:"upc"` + URL string `json:"url"` + } `json:"attributes"` + } `json:"data"` + } `json:"albums"` + } `json:"relationships"` +} diff --git a/utils/ampapi/token.go b/utils/ampapi/token.go new file mode 100644 index 0000000..a90bb4a --- /dev/null +++ b/utils/ampapi/token.go @@ -0,0 +1,49 @@ +package ampapi + +import ( + "io" + "net/http" + "regexp" +) + +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 +} diff --git a/utils/ampapi/track.go b/utils/ampapi/track.go new file mode 100644 index 0000000..a827149 --- /dev/null +++ b/utils/ampapi/track.go @@ -0,0 +1,103 @@ +package ampapi + +type TrackResp struct { + Href string `json:"href"` + Next string `json:"next"` + Data []TrackRespData `json:"data"` +} + +// 类型为song 或者 music-video +type TrackRespData struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Previews []struct { + URL string `json:"url"` + } `json:"previews"` + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + ArtistName string `json:"artistName"` + URL string `json:"url"` + DiscNumber int `json:"discNumber"` + GenreNames []string `json:"genreNames"` + ExtendedAssetUrls struct { + EnhancedHls string `json:"enhancedHls"` + } `json:"extendedAssetUrls"` + HasTimeSyncedLyrics bool `json:"hasTimeSyncedLyrics"` + IsMasteredForItunes bool `json:"isMasteredForItunes"` + IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` + ContentRating string `json:"contentRating"` + DurationInMillis int `json:"durationInMillis"` + ReleaseDate string `json:"releaseDate"` + Name string `json:"name"` + Isrc string `json:"isrc"` + AudioTraits []string `json:"audioTraits"` + HasLyrics bool `json:"hasLyrics"` + AlbumName string `json:"albumName"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"playParams"` + TrackNumber int `json:"trackNumber"` + AudioLocale string `json:"audioLocale"` + ComposerName string `json:"composerName"` + } `json:"attributes"` + Relationships struct { + Artists struct { + Href string `json:"href"` + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Name string `json:"name"` + } `json:"attributes"` + } `json:"data"` + } `json:"artists"` + Albums struct { + Href string `json:"href"` + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + ArtistName string `json:"artistName"` + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + GenreNames []string `json:"genreNames"` + IsCompilation bool `json:"isCompilation"` + IsComplete bool `json:"isComplete"` + IsMasteredForItunes bool `json:"isMasteredForItunes"` + IsPrerelease bool `json:"isPrerelease"` + IsSingle bool `json:"isSingle"` + Name string `json:"name"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"playParams"` + ReleaseDate string `json:"releaseDate"` + TrackCount int `json:"trackCount"` + Upc string `json:"upc"` + URL string `json:"url"` + } `json:"attributes"` + } `json:"data"` + } `json:"albums"` + } `json:"relationships"` +} diff --git a/utils/structs/structs.go b/utils/structs/structs.go index 4421cd6..58ccb7a 100644 --- a/utils/structs/structs.go +++ b/utils/structs/structs.go @@ -1,523 +1,94 @@ -package structs - -type ConfigSet struct { - MediaUserToken string `yaml:"media-user-token"` - AuthorizationToken string `yaml:"authorization-token"` - Language string `yaml:"language"` - SaveLrcFile bool `yaml:"save-lrc-file"` - LrcType string `yaml:"lrc-type"` - LrcFormat string `yaml:"lrc-format"` - SaveAnimatedArtwork bool `yaml:"save-animated-artwork"` - EmbyAnimatedArtwork bool `yaml:"emby-animated-artwork"` - EmbedLrc bool `yaml:"embed-lrc"` - EmbedCover bool `yaml:"embed-cover"` - SaveArtistCover bool `yaml:"save-artist-cover"` - CoverSize string `yaml:"cover-size"` - CoverFormat string `yaml:"cover-format"` - AlacSaveFolder string `yaml:"alac-save-folder"` - AtmosSaveFolder string `yaml:"atmos-save-folder"` - AlbumFolderFormat string `yaml:"album-folder-format"` - PlaylistFolderFormat string `yaml:"playlist-folder-format"` - ArtistFolderFormat string `yaml:"artist-folder-format"` - SongFileFormat string `yaml:"song-file-format"` - ExplicitChoice string `yaml:"explicit-choice"` - CleanChoice string `yaml:"clean-choice"` - AppleMasterChoice string `yaml:"apple-master-choice"` - MaxMemoryLimit int `yaml:"max-memory-limit"` - DecryptM3u8Port string `yaml:"decrypt-m3u8-port"` - GetM3u8Port string `yaml:"get-m3u8-port"` - GetM3u8Mode string `yaml:"get-m3u8-mode"` - GetM3u8FromDevice bool `yaml:"get-m3u8-from-device"` - AacType string `yaml:"aac-type"` - AlacMax int `yaml:"alac-max"` - AtmosMax int `yaml:"atmos-max"` - LimitMax int `yaml:"limit-max"` - UseSongInfoForPlaylist bool `yaml:"use-songinfo-for-playlist"` - DlAlbumcoverForPlaylist bool `yaml:"dl-albumcover-for-playlist"` - MVAudioType string `yaml:"mv-audio-type"` - MVMax int `yaml:"mv-max"` -} - -type Counter struct { - Unavailable int - NotSong int - Error int - Success int - Total int -} - -type ApiResult struct { - Data []SongData `json:"data"` -} - -type SongAttributes struct { - ArtistName string `json:"artistName"` - DiscNumber int `json:"discNumber"` - GenreNames []string `json:"genreNames"` - ExtendedAssetUrls struct { - EnhancedHls string `json:"enhancedHls"` - } `json:"extendedAssetUrls"` - IsMasteredForItunes bool `json:"isMasteredForItunes"` - IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` - ContentRating string `json:"contentRating"` - ReleaseDate string `json:"releaseDate"` - Name string `json:"name"` - Isrc string `json:"isrc"` - AlbumName string `json:"albumName"` - TrackNumber int `json:"trackNumber"` - ComposerName string `json:"composerName"` -} - -type AlbumAttributes struct { - ArtistName string `json:"artistName"` - IsSingle bool `json:"isSingle"` - IsComplete bool `json:"isComplete"` - GenreNames []string `json:"genreNames"` - TrackCount int `json:"trackCount"` - IsMasteredForItunes bool `json:"isMasteredForItunes"` - IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` - ContentRating string `json:"contentRating"` - ReleaseDate string `json:"releaseDate"` - Name string `json:"name"` - RecordLabel string `json:"recordLabel"` - Upc string `json:"upc"` - Copyright string `json:"copyright"` - IsCompilation bool `json:"isCompilation"` -} - -type SongData struct { - ID string `json:"id"` - Attributes SongAttributes `json:"attributes"` - Relationships struct { - Albums struct { - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes AlbumAttributes `json:"attributes"` - } `json:"data"` - } `json:"albums"` - Artists struct { - Href string `json:"href"` - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - } `json:"data"` - } `json:"artists"` - } `json:"relationships"` -} - -type SongResult struct { - Artwork struct { - Width int `json:"width"` - URL string `json:"url"` - Height int `json:"height"` - TextColor3 string `json:"textColor3"` - TextColor2 string `json:"textColor2"` - TextColor4 string `json:"textColor4"` - HasAlpha bool `json:"hasAlpha"` - TextColor1 string `json:"textColor1"` - BgColor string `json:"bgColor"` - HasP3 bool `json:"hasP3"` - SupportsLayeredImage bool `json:"supportsLayeredImage"` - } `json:"artwork"` - ArtistName string `json:"artistName"` - CollectionID string `json:"collectionId"` - DiscNumber int `json:"discNumber"` - GenreNames []string `json:"genreNames"` - ID string `json:"id"` - DurationInMillis int `json:"durationInMillis"` - ReleaseDate string `json:"releaseDate"` - ContentRatingsBySystem struct { - } `json:"contentRatingsBySystem"` - Name string `json:"name"` - Composer struct { - Name string `json:"name"` - URL string `json:"url"` - } `json:"composer"` - EditorialArtwork struct { - } `json:"editorialArtwork"` - CollectionName string `json:"collectionName"` - AssetUrls struct { - Plus string `json:"plus"` - Lightweight string `json:"lightweight"` - SuperLightweight string `json:"superLightweight"` - LightweightPlus string `json:"lightweightPlus"` - EnhancedHls string `json:"enhancedHls"` - } `json:"assetUrls"` - AudioTraits []string `json:"audioTraits"` - Kind string `json:"kind"` - Copyright string `json:"copyright"` - ArtistID string `json:"artistId"` - Genres []struct { - GenreID string `json:"genreId"` - Name string `json:"name"` - URL string `json:"url"` - MediaType string `json:"mediaType"` - } `json:"genres"` - TrackNumber int `json:"trackNumber"` - AudioLocale string `json:"audioLocale"` - Offers []struct { - ActionText struct { - Short string `json:"short"` - Medium string `json:"medium"` - Long string `json:"long"` - Downloaded string `json:"downloaded"` - Downloading string `json:"downloading"` - } `json:"actionText"` - Type string `json:"type"` - PriceFormatted string `json:"priceFormatted"` - Price float64 `json:"price"` - BuyParams string `json:"buyParams"` - Variant string `json:"variant,omitempty"` - Assets []struct { - Flavor string `json:"flavor"` - Preview struct { - Duration int `json:"duration"` - URL string `json:"url"` - } `json:"preview"` - Size int `json:"size"` - Duration int `json:"duration"` - } `json:"assets"` - } `json:"offers"` -} - -type TrackData struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes struct { - Previews []struct { - URL string `json:"url"` - } `json:"previews"` - Artwork struct { - Width int `json:"width"` - Height int `json:"height"` - URL string `json:"url"` - BgColor string `json:"bgColor"` - TextColor1 string `json:"textColor1"` - TextColor2 string `json:"textColor2"` - TextColor3 string `json:"textColor3"` - TextColor4 string `json:"textColor4"` - } `json:"artwork"` - ArtistName string `json:"artistName"` - URL string `json:"url"` - DiscNumber int `json:"discNumber"` - GenreNames []string `json:"genreNames"` - HasTimeSyncedLyrics bool `json:"hasTimeSyncedLyrics"` - IsMasteredForItunes bool `json:"isMasteredForItunes"` - IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` - ContentRating string `json:"contentRating"` - DurationInMillis int `json:"durationInMillis"` - ReleaseDate string `json:"releaseDate"` - Name string `json:"name"` - Isrc string `json:"isrc"` - AudioTraits []string `json:"audioTraits"` - HasLyrics bool `json:"hasLyrics"` - AlbumName string `json:"albumName"` - PlayParams struct { - ID string `json:"id"` - Kind string `json:"kind"` - } `json:"playParams"` - TrackNumber int `json:"trackNumber"` - AudioLocale string `json:"audioLocale"` - ComposerName string `json:"composerName"` - } `json:"attributes"` - Relationships struct { - Artists struct { - Href string `json:"href"` - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes struct { - Name string `json:"name"` - } `json:"attributes"` - } `json:"data"` - } `json:"artists"` - Albums struct { - Href string `json:"href"` - Data []AlbumData `json:"data"` - } - } `json:"relationships"` -} - -type AlbumData struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes struct { - ArtistName string `json:"artistName"` - Artwork struct { - Width int `json:"width"` - Height int `json:"height"` - URL string `json:"url"` - BgColor string `json:"bgColor"` - TextColor1 string `json:"textColor1"` - TextColor2 string `json:"textColor2"` - TextColor3 string `json:"textColor3"` - TextColor4 string `json:"textColor4"` - } `json:"artwork"` - GenreNames []string `json:"genreNames"` - IsCompilation bool `json:"isCompilation"` - IsComplete bool `json:"isComplete"` - IsMasteredForItunes bool `json:"isMasteredForItunes"` - IsPrerelease bool `json:"isPrerelease"` - IsSingle bool `json:"isSingle"` - Name string `json:"name"` - PlayParams struct { - ID string `json:"id"` - Kind string `json:"kind"` - } `json:"playParams"` - ReleaseDate string `json:"releaseDate"` - TrackCount int `json:"trackCount"` - Upc string `json:"upc"` - URL string `json:"url"` - } -} - -type AutoGenerated struct { - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes struct { - Artwork struct { - Width int `json:"width"` - Height int `json:"height"` - URL string `json:"url"` - BgColor string `json:"bgColor"` - TextColor1 string `json:"textColor1"` - TextColor2 string `json:"textColor2"` - TextColor3 string `json:"textColor3"` - TextColor4 string `json:"textColor4"` - } `json:"artwork"` - ArtistName string `json:"artistName"` - IsSingle bool `json:"isSingle"` - URL string `json:"url"` - IsComplete bool `json:"isComplete"` - GenreNames []string `json:"genreNames"` - TrackCount int `json:"trackCount"` - IsMasteredForItunes bool `json:"isMasteredForItunes"` - IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` - ContentRating string `json:"contentRating"` - ReleaseDate string `json:"releaseDate"` - Name string `json:"name"` - RecordLabel string `json:"recordLabel"` - Upc string `json:"upc"` - AudioTraits []string `json:"audioTraits"` - Copyright string `json:"copyright"` - PlayParams struct { - ID string `json:"id"` - Kind string `json:"kind"` - } `json:"playParams"` - IsCompilation bool `json:"isCompilation"` - EditorialVideo struct { - MotionTall struct { - Video string `json:"video"` - } `json:"motionTallVideo3x4"` - MotionSquare struct { - Video string `json:"video"` - } `json:"motionSquareVideo1x1"` - MotionDetailTall struct { - Video string `json:"video"` - } `json:"motionDetailTall"` - MotionDetailSquare struct { - Video string `json:"video"` - } `json:"motionDetailSquare"` - } `json:"editorialVideo"` - } `json:"attributes"` - Relationships struct { - RecordLabels struct { - Href string `json:"href"` - Data []interface{} `json:"data"` - } `json:"record-labels"` - Artists struct { - Href string `json:"href"` - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes struct { - Name string `json:"name"` - Artwork struct { - Url string `json:"url"` - } `json:"artwork"` - } `json:"attributes"` - } `json:"data"` - } `json:"artists"` - Tracks struct { - Href string `json:"href"` - Next string `json:"next"` - Data []TrackData `json:"data"` - } `json:"tracks"` - } `json:"relationships"` - } `json:"data"` -} - -type AutoGeneratedTrack struct { - Href string `json:"href"` - Next string `json:"next"` - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes struct { - Previews []struct { - URL string `json:"url"` - } `json:"previews"` - Artwork struct { - Width int `json:"width"` - Height int `json:"height"` - URL string `json:"url"` - BgColor string `json:"bgColor"` - TextColor1 string `json:"textColor1"` - TextColor2 string `json:"textColor2"` - TextColor3 string `json:"textColor3"` - TextColor4 string `json:"textColor4"` - } `json:"artwork"` - ArtistName string `json:"artistName"` - URL string `json:"url"` - DiscNumber int `json:"discNumber"` - GenreNames []string `json:"genreNames"` - HasTimeSyncedLyrics bool `json:"hasTimeSyncedLyrics"` - IsMasteredForItunes bool `json:"isMasteredForItunes"` - IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` - ContentRating string `json:"contentRating"` - DurationInMillis int `json:"durationInMillis"` - ReleaseDate string `json:"releaseDate"` - Name string `json:"name"` - Isrc string `json:"isrc"` - AudioTraits []string `json:"audioTraits"` - HasLyrics bool `json:"hasLyrics"` - AlbumName string `json:"albumName"` - PlayParams struct { - ID string `json:"id"` - Kind string `json:"kind"` - } `json:"playParams"` - TrackNumber int `json:"trackNumber"` - AudioLocale string `json:"audioLocale"` - ComposerName string `json:"composerName"` - } `json:"attributes"` - Relationships struct { - Artists struct { - Href string `json:"href"` - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes struct { - Name string `json:"name"` - } `json:"attributes"` - } `json:"data"` - } `json:"artists"` - Albums struct { - Href string `json:"href"` - Data []AlbumData `json:"data"` - } - } `json:"relationships"` - } `json:"data"` -} - -type AutoGeneratedArtist struct { - Next string `json:"next"` - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes struct { - Previews []struct { - URL string `json:"url"` - } `json:"previews"` - Artwork struct { - Width int `json:"width"` - Height int `json:"height"` - URL string `json:"url"` - BgColor string `json:"bgColor"` - TextColor1 string `json:"textColor1"` - TextColor2 string `json:"textColor2"` - TextColor3 string `json:"textColor3"` - TextColor4 string `json:"textColor4"` - } `json:"artwork"` - ArtistName string `json:"artistName"` - URL string `json:"url"` - DiscNumber int `json:"discNumber"` - GenreNames []string `json:"genreNames"` - HasTimeSyncedLyrics bool `json:"hasTimeSyncedLyrics"` - IsMasteredForItunes bool `json:"isMasteredForItunes"` - IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` - ContentRating string `json:"contentRating"` - DurationInMillis int `json:"durationInMillis"` - ReleaseDate string `json:"releaseDate"` - Name string `json:"name"` - Isrc string `json:"isrc"` - AudioTraits []string `json:"audioTraits"` - HasLyrics bool `json:"hasLyrics"` - AlbumName string `json:"albumName"` - PlayParams struct { - ID string `json:"id"` - Kind string `json:"kind"` - } `json:"playParams"` - TrackNumber int `json:"trackNumber"` - AudioLocale string `json:"audioLocale"` - ComposerName string `json:"composerName"` - } `json:"attributes"` - } `json:"data"` -} - -type AutoGeneratedMusicVideo struct { - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Href string `json:"href"` - Attributes struct { - Previews []struct { - URL string `json:"url"` - } `json:"previews"` - Artwork struct { - Width int `json:"width"` - Height int `json:"height"` - URL string `json:"url"` - BgColor string `json:"bgColor"` - TextColor1 string `json:"textColor1"` - TextColor2 string `json:"textColor2"` - TextColor3 string `json:"textColor3"` - TextColor4 string `json:"textColor4"` - } `json:"artwork"` - AlbumName string `json:"albumName"` - ArtistName string `json:"artistName"` - URL string `json:"url"` - GenreNames []string `json:"genreNames"` - DurationInMillis int `json:"durationInMillis"` - Isrc string `json:"isrc"` - TrackNumber int `json:"trackNumber"` - DiscNumber int `json:"discNumber"` - ContentRating string `json:"contentRating"` - ReleaseDate string `json:"releaseDate"` - Name string `json:"name"` - Has4K bool `json:"has4K"` - HasHDR bool `json:"hasHDR"` - PlayParams struct { - ID string `json:"id"` - Kind string `json:"kind"` - } `json:"playParams"` - } `json:"attributes"` - } `json:"data"` -} - -type SongLyrics struct { - Data []struct { - Id string `json:"id"` - Type string `json:"type"` - Attributes struct { - Ttml string `json:"ttml"` - PlayParams struct { - Id string `json:"id"` - Kind string `json:"kind"` - CatalogId string `json:"catalogId"` - DisplayType int `json:"displayType"` - } `json:"playParams"` - } `json:"attributes"` - } `json:"data"` -} +package structs + +type ConfigSet struct { + MediaUserToken string `yaml:"media-user-token"` + AuthorizationToken string `yaml:"authorization-token"` + Language string `yaml:"language"` + SaveLrcFile bool `yaml:"save-lrc-file"` + LrcType string `yaml:"lrc-type"` + LrcFormat string `yaml:"lrc-format"` + SaveAnimatedArtwork bool `yaml:"save-animated-artwork"` + EmbyAnimatedArtwork bool `yaml:"emby-animated-artwork"` + EmbedLrc bool `yaml:"embed-lrc"` + EmbedCover bool `yaml:"embed-cover"` + SaveArtistCover bool `yaml:"save-artist-cover"` + CoverSize string `yaml:"cover-size"` + CoverFormat string `yaml:"cover-format"` + AlacSaveFolder string `yaml:"alac-save-folder"` + AtmosSaveFolder string `yaml:"atmos-save-folder"` + AlbumFolderFormat string `yaml:"album-folder-format"` + PlaylistFolderFormat string `yaml:"playlist-folder-format"` + ArtistFolderFormat string `yaml:"artist-folder-format"` + SongFileFormat string `yaml:"song-file-format"` + ExplicitChoice string `yaml:"explicit-choice"` + CleanChoice string `yaml:"clean-choice"` + AppleMasterChoice string `yaml:"apple-master-choice"` + MaxMemoryLimit int `yaml:"max-memory-limit"` + DecryptM3u8Port string `yaml:"decrypt-m3u8-port"` + GetM3u8Port string `yaml:"get-m3u8-port"` + GetM3u8Mode string `yaml:"get-m3u8-mode"` + GetM3u8FromDevice bool `yaml:"get-m3u8-from-device"` + AacType string `yaml:"aac-type"` + AlacMax int `yaml:"alac-max"` + AtmosMax int `yaml:"atmos-max"` + LimitMax int `yaml:"limit-max"` + UseSongInfoForPlaylist bool `yaml:"use-songinfo-for-playlist"` + DlAlbumcoverForPlaylist bool `yaml:"dl-albumcover-for-playlist"` + MVAudioType string `yaml:"mv-audio-type"` + MVMax int `yaml:"mv-max"` +} + +type Counter struct { + Unavailable int + NotSong int + Error int + Success int + Total int +} + +// 艺术家页面 +type AutoGeneratedArtist struct { + Next string `json:"next"` + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Previews []struct { + URL string `json:"url"` + } `json:"previews"` + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + ArtistName string `json:"artistName"` + URL string `json:"url"` + DiscNumber int `json:"discNumber"` + GenreNames []string `json:"genreNames"` + HasTimeSyncedLyrics bool `json:"hasTimeSyncedLyrics"` + IsMasteredForItunes bool `json:"isMasteredForItunes"` + IsAppleDigitalMaster bool `json:"isAppleDigitalMaster"` + ContentRating string `json:"contentRating"` + DurationInMillis int `json:"durationInMillis"` + ReleaseDate string `json:"releaseDate"` + Name string `json:"name"` + Isrc string `json:"isrc"` + AudioTraits []string `json:"audioTraits"` + HasLyrics bool `json:"hasLyrics"` + AlbumName string `json:"albumName"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + } `json:"playParams"` + TrackNumber int `json:"trackNumber"` + AudioLocale string `json:"audioLocale"` + ComposerName string `json:"composerName"` + } `json:"attributes"` + } `json:"data"` +} diff --git a/utils/task/album.go b/utils/task/album.go new file mode 100644 index 0000000..8c3c742 --- /dev/null +++ b/utils/task/album.go @@ -0,0 +1,193 @@ +package task + +import ( + "bufio" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/fatih/color" + "github.com/olekukonko/tablewriter" + + "main/utils/ampapi" +) + +type Album struct { + Storefront string + ID string + + SaveDir string + SaveName string + Codec string + CoverPath string + + Language string + Resp ampapi.AlbumResp + Name string + Tracks []Track +} + +func NewAlbum(st string, id string) *Album { + a := new(Album) + a.Storefront = st + a.ID = id + + //fmt.Println("Album created") + return a + +} + +func (a *Album) GetResp(token, l string) error { + var err error + a.Language = l + resp, err := ampapi.GetAlbumResp(a.Storefront, a.ID, a.Language, token) + if err != nil { + return errors.New("error getting album response") + } + a.Resp = *resp + //简化高频调用名称 + a.Name = a.Resp.Data[0].Attributes.Name + //fmt.Println("Getting album response") + //从resp中的Tracks数据中提取trackData信息到新的Track结构体中 + for i, trackData := range a.Resp.Data[0].Relationships.Tracks.Data { + len := len(a.Resp.Data[0].Relationships.Tracks.Data) + a.Tracks = append(a.Tracks, Track{ + ID: trackData.ID, + Type: trackData.Type, + Name: trackData.Attributes.Name, + Language: a.Language, + Storefront: a.Storefront, + + //SaveDir: filepath.Join(a.SaveDir, a.SaveName), + //Codec: a.Codec, + TaskNum: i + 1, + TaskTotal: len, + M3u8: trackData.Attributes.ExtendedAssetUrls.EnhancedHls, + WebM3u8: trackData.Attributes.ExtendedAssetUrls.EnhancedHls, + //CoverPath: a.CoverPath, + + Resp: trackData, + PreType: "albums", + DiscTotal: a.Resp.Data[0].Relationships.Tracks.Data[len-1].Attributes.DiscNumber, + PreID: a.ID, + AlbumData: a.Resp.Data[0], + }) + } + return nil +} + +func (a *Album) GetArtwork() string { + return a.Resp.Data[0].Attributes.Artwork.URL +} + +func (a *Album) ShowSelect() []int { + meta := a.Resp + trackTotal := len(meta.Data[0].Relationships.Tracks.Data) + arr := make([]int, trackTotal) + for i := 0; i < trackTotal; i++ { + arr[i] = i + 1 + } + selected := []int{} + var data [][]string + for trackNum, track := range meta.Data[0].Relationships.Tracks.Data { + trackNum++ + trackName := fmt.Sprintf("%02d. %s", track.Attributes.TrackNumber, track.Attributes.Name) + data = append(data, []string{fmt.Sprint(trackNum), + trackName, + track.Attributes.ContentRating, + track.Type}) + + } + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"", "Track Name", "Rating", "Type"}) + //table.SetFooter([]string{"", "", "Footer", "Footer4"}) + table.SetRowLine(false) + //table.SetAutoMergeCells(true) + table.SetCaption(true, fmt.Sprintf("Storefront: %s, %d tracks missing", strings.ToUpper(a.Storefront), meta.Data[0].Attributes.TrackCount-trackTotal)) + table.SetHeaderColor(tablewriter.Colors{}, + tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold}, + tablewriter.Colors{tablewriter.FgBlackColor, tablewriter.Bold}, + tablewriter.Colors{tablewriter.FgBlackColor, tablewriter.Bold}) + + 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 _, row := range data { + if row[2] == "explicit" { + row[2] = "E" + } else if row[2] == "clean" { + row[2] = "C" + } else { + row[2] = "None" + } + if row[3] == "music-videos" { + row[3] = "MV" + } else if row[3] == "songs" { + row[3] = "SONG" + } + table.Append(row) + } + //table.AppendBulk(data) + table.Render() + fmt.Println("Please select from the track options above (multiple options separated by commas, ranges supported, or type 'all' to select all)") + cyanColor := color.New(color.FgCyan) + cyanColor.Print("select: ") + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + fmt.Println(err) + } + input = strings.TrimSpace(input) + if input == "all" { + fmt.Println("You have selected all options:") + selected = arr + } else { + 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}) + } + } + // + 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(arr) { + selected = append(selected, num) + //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(arr) || start > end { + fmt.Println("Range out of range:", opt) + continue + } + for i := start; i <= end; i++ { + //fmt.Println(options[i-1]) + selected = append(selected, i) + } + } else { + fmt.Println("Invalid option:", opt) + } + } + } + return selected +} diff --git a/utils/task/playlist.go b/utils/task/playlist.go new file mode 100644 index 0000000..b02e84b --- /dev/null +++ b/utils/task/playlist.go @@ -0,0 +1,195 @@ +package task + +import ( + "bufio" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/fatih/color" + "github.com/olekukonko/tablewriter" + + "main/utils/ampapi" +) + +type Playlist struct { + Storefront string + ID string + + SaveDir string + SaveName string + Codec string + CoverPath string + + Language string + Resp ampapi.PlaylistResp + Name string + Tracks []Track +} + +func NewPlaylist(st string, id string) *Playlist { + a := new(Playlist) + a.Storefront = st + a.ID = id + + //fmt.Println("Album created") + return a + +} + +func (a *Playlist) GetResp(token, l string) error { + var err error + a.Language = l + resp, err := ampapi.GetPlaylistResp(a.Storefront, a.ID, a.Language, token) + if err != nil { + return errors.New("error getting album response") + } + a.Resp = *resp + + a.Resp.Data[0].Attributes.ArtistName = "Apple Music" + //简化高频调用名称 + a.Name = a.Resp.Data[0].Attributes.Name + //fmt.Println("Getting album response") + //从resp中的Tracks数据中提取trackData信息到新的Track结构体中 + for i, trackData := range a.Resp.Data[0].Relationships.Tracks.Data { + len := len(a.Resp.Data[0].Relationships.Tracks.Data) + a.Tracks = append(a.Tracks, Track{ + ID: trackData.ID, + Type: trackData.Type, + Name: trackData.Attributes.Name, + Language: a.Language, + Storefront: a.Storefront, + + //SaveDir: filepath.Join(a.SaveDir, a.SaveName), + //Codec: a.Codec, + TaskNum: i + 1, + TaskTotal: len, + M3u8: trackData.Attributes.ExtendedAssetUrls.EnhancedHls, + WebM3u8: trackData.Attributes.ExtendedAssetUrls.EnhancedHls, + //CoverPath: a.CoverPath, + + Resp: trackData, + PreType: "playlists", + //DiscTotal: a.Resp.Data[0].Relationships.Tracks.Data[len-1].Attributes.DiscNumber, 在它处获取 + PreID: a.ID, + PlaylistData: a.Resp.Data[0], + }) + } + return nil +} + +func (a *Playlist) GetArtwork() string { + return a.Resp.Data[0].Attributes.Artwork.URL +} + +func (a *Playlist) ShowSelect() []int { + meta := a.Resp + trackTotal := len(meta.Data[0].Relationships.Tracks.Data) + arr := make([]int, trackTotal) + for i := 0; i < trackTotal; i++ { + arr[i] = i + 1 + } + selected := []int{} + var data [][]string + for trackNum, track := range meta.Data[0].Relationships.Tracks.Data { + trackNum++ + trackName := fmt.Sprintf("%s - %s", track.Attributes.Name, track.Attributes.ArtistName) + data = append(data, []string{fmt.Sprint(trackNum), + trackName, + track.Attributes.ContentRating, + track.Type}) + + } + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"", "Track Name", "Rating", "Type"}) + //table.SetFooter([]string{"", "", "Footer", "Footer4"}) + table.SetRowLine(false) + //table.SetAutoMergeCells(true) + table.SetCaption(true, fmt.Sprintf("Playlists: %d tracks", trackTotal)) + table.SetHeaderColor(tablewriter.Colors{}, + tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold}, + tablewriter.Colors{tablewriter.FgBlackColor, tablewriter.Bold}, + tablewriter.Colors{tablewriter.FgBlackColor, tablewriter.Bold}) + + 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 _, row := range data { + if row[2] == "explicit" { + row[2] = "E" + } else if row[2] == "clean" { + row[2] = "C" + } else { + row[2] = "None" + } + if row[3] == "music-videos" { + row[3] = "MV" + } else if row[3] == "songs" { + row[3] = "SONG" + } + table.Append(row) + } + //table.AppendBulk(data) + table.Render() + fmt.Println("Please select from the track options above (multiple options separated by commas, ranges supported, or type 'all' to select all)") + cyanColor := color.New(color.FgCyan) + cyanColor.Print("select: ") + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + fmt.Println(err) + } + input = strings.TrimSpace(input) + if input == "all" { + fmt.Println("You have selected all options:") + selected = arr + } else { + 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}) + } + } + // + 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(arr) { + selected = append(selected, num) + //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(arr) || start > end { + fmt.Println("Range out of range:", opt) + continue + } + for i := start; i <= end; i++ { + //fmt.Println(options[i-1]) + selected = append(selected, i) + } + } else { + fmt.Println("Invalid option:", opt) + } + } + } + return selected +} diff --git a/utils/task/track.go b/utils/task/track.go new file mode 100644 index 0000000..d37079f --- /dev/null +++ b/utils/task/track.go @@ -0,0 +1,44 @@ +package task + +import ( + "main/utils/ampapi" +) + +type Track struct { + ID string + Type string + Name string + Storefront string + Language string + + SaveDir string + SaveName string + SavePath string + Codec string + TaskNum int + TaskTotal int + M3u8 string + WebM3u8 string + DeviceM3u8 string + Quality string + CoverPath string + + Resp ampapi.TrackRespData + PreType string // 上级类型 专辑或者歌单 + PreID string // 上级ID + DiscTotal int + AlbumData ampapi.AlbumRespData + PlaylistData ampapi.PlaylistRespData +} + +func (t *Track) GetAlbumData(token string) error { + var err error + resp, err := ampapi.GetAlbumRespByHref(t.Resp.Href, t.Language, token) + if err != nil { + return err + } + t.AlbumData = resp.Data[0] + //len := len(resp.Data[0].Relationships.Tracks.Data) + //t.DiscTotal = resp.Data[0].Relationships.Tracks.Data[len-1].Attributes.DiscNumber + return nil +} From 21728cabb6a5a927399ef4230e49dc40a1c58b09 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Mon, 3 Mar 2025 03:37:57 +0800 Subject: [PATCH 02/17] test: station dl (need media-user-token) --- main.go | 169 ++++++++++++++++++++++++++++++-- utils/ampapi/station.go | 134 ++++++++++++++++++++++++++ utils/task/station.go | 207 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 501 insertions(+), 9 deletions(-) create mode 100644 utils/ampapi/station.go create mode 100644 utils/task/station.go diff --git a/main.go b/main.go index b907c52..7acbc12 100644 --- a/main.go +++ b/main.go @@ -134,6 +134,17 @@ func checkUrlPlaylist(url string) (string, string) { } } +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)\.apple\.com\/(\w{2})(?:\/artist|\/artist\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?)`) matches := pat.FindAllStringSubmatch(url, -1) @@ -562,7 +573,7 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { //fmt.Sprintf("lyrics=%s", lrc), } if Config.EmbedCover { - if strings.Contains(track.PreID, "pl.") && Config.DlAlbumcoverForPlaylist { + 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.") @@ -577,7 +588,7 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { counter.Error++ return } - if strings.Contains(track.PreID, "pl.") && Config.DlAlbumcoverForPlaylist { + 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++ @@ -598,6 +609,136 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { 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 + } + meta := station.Resp + + var Codec string + if dl_atmos { + Codec = "ATMOS" + } else if dl_aac { + Codec = "AAC" + } else { + Codec = "ALAC" + } + station.Codec = Codec + // Get Artist Folder + 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, "_")) + } + os.MkdirAll(singerFolder, os.ModePerm) // Create artist folder + station.SaveDir = singerFolder + + //Get Playlist Folder Name + playlistFolder := strings.NewReplacer( + "{ArtistName}", "Apple Music Station", + "{PlaylistName}", LimitString(meta.Data[0].Attributes.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) + //先省略封面相关的获取 + //get playlist cover + covPath, err := writeCover(playlistFolderPath, "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.MotionSquare.Video != "" { + fmt.Println("Found Animation Artwork.") + + // Download square version + 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 { + // Convert square version to gif + 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) + } + } + } + + 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 + } + //Download tracks + for i := range station.Tracks { + i++ + // if isInArray(okDict[playlistId], i) { + // //fmt.Println("已完成直接跳过.\n") + // counter.Total++ + // counter.Success++ + // continue + // } + if isInArray(selected, i) { + ripTrack(&station.Tracks[i-1], token, mediaUserToken) + //downloadTrack(trackNum, trackTotal, meta, track, albumId, token, storefront, mediaUserToken, sanAlbumFolder, Codec, covPath) + } + } + 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) @@ -832,8 +973,8 @@ func ripAlbum(albumId string, token string, storefront string, mediaUserToken st } } } - - for i, _ := range album.Tracks { + //填充子track信息 + for i := range album.Tracks { album.Tracks[i].CoverPath = covPath album.Tracks[i].SaveDir = albumFolderPath album.Tracks[i].Codec = Codec @@ -850,7 +991,7 @@ func ripAlbum(albumId string, token string, storefront string, mediaUserToken st //fmt.Println("URL does not contain parameter 'i'. Please ensure the URL includes 'i' or use another mode.") //return nil } else { - for i, _ := range album.Tracks { + for i := range album.Tracks { if urlArg_i == album.Tracks[i].ID { ripTrack(&album.Tracks[i], token, mediaUserToken) //downloadTrack(trackNum, trackTotal, meta, track, albumId, token, storefront, mediaUserToken, albumFolderPath, Codec, covPath) @@ -867,7 +1008,7 @@ func ripAlbum(albumId string, token string, storefront string, mediaUserToken st selected = album.ShowSelect() } //Download tracks - for i, _ := range album.Tracks { + for i := range album.Tracks { i++ if isInArray(okDict[albumId], i) { //fmt.Println("已完成直接跳过.\n") @@ -1047,7 +1188,7 @@ func ripPlaylist(playlistId string, token string, storefront string, mediaUserTo fmt.Println("Failed to write cover.") } - for i, _ := range playlist.Tracks { + for i := range playlist.Tracks { playlist.Tracks[i].CoverPath = covPath playlist.Tracks[i].SaveDir = playlistFolderPath playlist.Tracks[i].Codec = Codec @@ -1122,7 +1263,7 @@ func ripPlaylist(playlistId string, token string, storefront string, mediaUserTo selected = playlist.ShowSelect() } //Download tracks - for i, _ := range playlist.Tracks { + for i := range playlist.Tracks { i++ if isInArray(okDict[playlistId], i) { //fmt.Println("已完成直接跳过.\n") @@ -1189,7 +1330,7 @@ func writeMP4Tags(track *task.Track, lrc string) error { t.AlbumSort = track.PlaylistData.Attributes.Name t.AlbumArtist = track.PlaylistData.Attributes.ArtistName t.AlbumArtistSort = track.PlaylistData.Attributes.ArtistName - } else if track.PreType == "playlists" && Config.UseSongInfoForPlaylist { + } else if (track.PreType == "playlists" && Config.UseSongInfoForPlaylist) || track.PreType == "stations" { //使用提前获取到的播放列表下track所在的专辑信息 len := len(track.AlbumData.Relationships.Tracks.Data) t.DiscTotal = int16(track.AlbumData.Relationships.Tracks.Data[len-1].Attributes.DiscNumber) @@ -1372,6 +1513,16 @@ func main() { if err != nil { fmt.Println("Failed to rip playlist:", err) } + } else if strings.Contains(urlRaw, "/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 URL.") } diff --git a/utils/ampapi/station.go b/utils/ampapi/station.go new file mode 100644 index 0000000..d45aeab --- /dev/null +++ b/utils/ampapi/station.go @@ -0,0 +1,134 @@ +package ampapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +func GetStationResp(storefront string, id string, language string, token string) (*StationResp, error) { + var err error + if token == "" { + token, err = GetToken() + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/stations/%s", storefront, id), 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("extend", "editorialVideo") + query.Set("l", 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(StationResp) + err = json.NewDecoder(do.Body).Decode(&obj) + if err != nil { + return nil, err + } + return obj, nil +} + +func GetStationNextTracks(id, mutoken, language, token string) (*TrackResp, error) { + var err error + if token == "" { + token, err = GetToken() + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest("POST", fmt.Sprintf("https://amp-api.music.apple.com/v1/me/stations/next-tracks/%s", id), 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") + req.Header.Set("Media-User-Token", mutoken) + query := url.Values{} + query.Set("omit[resource]", "autos") + //query.Set("include", "tracks,artists,record-labels") + query.Set("include[songs]", "artists,albums") + query.Set("limit", "10") + query.Set("extend", "editorialVideo,extendedAssetUrls") + query.Set("l", 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(TrackResp) + err = json.NewDecoder(do.Body).Decode(&obj) + if err != nil { + return nil, err + } + return obj, nil +} + +type StationResp struct { + Href string `json:"href"` + Next string `json:"next"` + Data []StationRespData `json:"data"` +} + +type StationRespData struct { + ID string `json:"id"` + Type string `json:"type"` + Href string `json:"href"` + Attributes struct { + Artwork struct { + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + BgColor string `json:"bgColor"` + TextColor1 string `json:"textColor1"` + TextColor2 string `json:"textColor2"` + TextColor3 string `json:"textColor3"` + TextColor4 string `json:"textColor4"` + } `json:"artwork"` + IsLive bool `json:"isLive"` + URL string `json:"url"` + Name string `json:"name"` + EditorialVideo struct { + MotionTall struct { + Video string `json:"video"` + } `json:"motionTallVideo3x4"` + MotionSquare struct { + Video string `json:"video"` + } `json:"motionSquareVideo1x1"` + MotionDetailTall struct { + Video string `json:"video"` + } `json:"motionDetailTall"` + MotionDetailSquare struct { + Video string `json:"video"` + } `json:"motionDetailSquare"` + } `json:"editorialVideo"` + PlayParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + Format string `json:"format"` + StationHash string `json:"stationHash"` + } `json:"playParams"` + } `json:"attributes"` +} diff --git a/utils/task/station.go b/utils/task/station.go new file mode 100644 index 0000000..a31fca0 --- /dev/null +++ b/utils/task/station.go @@ -0,0 +1,207 @@ +package task + +import ( + //"bufio" + "errors" + "fmt" + + //"os" + //"strconv" + //"strings" + + //"github.com/fatih/color" + //"github.com/olekukonko/tablewriter" + + "main/utils/ampapi" +) + +type Station struct { + Storefront string + ID string + + SaveDir string + SaveName string + Codec string + CoverPath string + + Language string + Resp ampapi.StationResp + Type string + Name string + Tracks []Track +} + +func NewStation(st string, id string) *Station { + a := new(Station) + a.Storefront = st + a.ID = id + //fmt.Println("Album created") + return a + +} + +func (a *Station) GetResp(mutoken, token, l string) error { + var err error + a.Language = l + resp, err := ampapi.GetStationResp(a.Storefront, a.ID, a.Language, token) + if err != nil { + return errors.New("error getting station response") + } + a.Resp = *resp + //简化高频调用名称 + a.Type = a.Resp.Data[0].Attributes.PlayParams.Format + if a.Type != "tracks" { + return errors.New("stream类型暂未开发") + } + a.Name = a.Resp.Data[0].Attributes.Name + tracksResp, err := ampapi.GetStationNextTracks(a.ID, mutoken, a.Language, token) + if err != nil { + return errors.New("error getting station tracks response") + } + //fmt.Println("Getting album response") + //从resp中的Tracks数据中提取trackData信息到新的Track结构体中 + for i, trackData := range tracksResp.Data { + albumResp, err := ampapi.GetAlbumRespByHref(trackData.Href, a.Language, token) + if err != nil { + fmt.Println("Error getting album response:", err) + continue + } + albumLen := len(albumResp.Data[0].Relationships.Tracks.Data) + a.Tracks = append(a.Tracks, Track{ + ID: trackData.ID, + Type: trackData.Type, + Name: trackData.Attributes.Name, + Language: a.Language, + Storefront: a.Storefront, + + //SaveDir: filepath.Join(a.SaveDir, a.SaveName), + //Codec: a.Codec, + TaskNum: i + 1, + TaskTotal: len(tracksResp.Data), + M3u8: trackData.Attributes.ExtendedAssetUrls.EnhancedHls, + WebM3u8: trackData.Attributes.ExtendedAssetUrls.EnhancedHls, + //CoverPath: a.CoverPath, + + Resp: trackData, + PreType: "stations", + DiscTotal: albumResp.Data[0].Relationships.Tracks.Data[albumLen-1].Attributes.DiscNumber, + PreID: a.ID, + AlbumData: albumResp.Data[0], + }) + } + return nil +} + +func (a *Station) GetArtwork() string { + return a.Resp.Data[0].Attributes.Artwork.URL +} + +// func (a *Album) ShowSelect() []int { +// meta := a.Resp +// trackTotal := len(meta.Data[0].Relationships.Tracks.Data) +// arr := make([]int, trackTotal) +// for i := 0; i < trackTotal; i++ { +// arr[i] = i + 1 +// } +// selected := []int{} +// var data [][]string +// for trackNum, track := range meta.Data[0].Relationships.Tracks.Data { +// trackNum++ +// trackName := fmt.Sprintf("%02d. %s", track.Attributes.TrackNumber, track.Attributes.Name) +// data = append(data, []string{fmt.Sprint(trackNum), +// trackName, +// track.Attributes.ContentRating, +// track.Type}) + +// } +// table := tablewriter.NewWriter(os.Stdout) +// table.SetHeader([]string{"", "Track Name", "Rating", "Type"}) +// //table.SetFooter([]string{"", "", "Footer", "Footer4"}) +// table.SetRowLine(false) +// //table.SetAutoMergeCells(true) +// table.SetCaption(true, fmt.Sprintf("Storefront: %s, %d tracks missing", strings.ToUpper(a.Storefront), meta.Data[0].Attributes.TrackCount-trackTotal)) +// table.SetHeaderColor(tablewriter.Colors{}, +// tablewriter.Colors{tablewriter.FgRedColor, tablewriter.Bold}, +// tablewriter.Colors{tablewriter.FgBlackColor, tablewriter.Bold}, +// tablewriter.Colors{tablewriter.FgBlackColor, tablewriter.Bold}) + +// 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 _, row := range data { +// if row[2] == "explicit" { +// row[2] = "E" +// } else if row[2] == "clean" { +// row[2] = "C" +// } else { +// row[2] = "None" +// } +// if row[3] == "music-videos" { +// row[3] = "MV" +// } else if row[3] == "songs" { +// row[3] = "SONG" +// } +// table.Append(row) +// } +// //table.AppendBulk(data) +// table.Render() +// fmt.Println("Please select from the track options above (multiple options separated by commas, ranges supported, or type 'all' to select all)") +// cyanColor := color.New(color.FgCyan) +// cyanColor.Print("select: ") +// reader := bufio.NewReader(os.Stdin) +// input, err := reader.ReadString('\n') +// if err != nil { +// fmt.Println(err) +// } +// input = strings.TrimSpace(input) +// if input == "all" { +// fmt.Println("You have selected all options:") +// selected = arr +// } else { +// 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}) +// } +// } +// // +// 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(arr) { +// selected = append(selected, num) +// //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(arr) || start > end { +// fmt.Println("Range out of range:", opt) +// continue +// } +// for i := start; i <= end; i++ { +// //fmt.Println(options[i-1]) +// selected = append(selected, i) +// } +// } else { +// fmt.Println("Invalid option:", opt) +// } +// } +// } +// return selected +// } From acb5a7ce1ea69c2c07280cc6dca733f33aae1769 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Mon, 3 Mar 2025 08:52:26 +0800 Subject: [PATCH 03/17] add: station dl(need media-user-token) --- main.go | 77 ++++++++++++++++++++++++++++++++++++++--- utils/ampapi/station.go | 51 +++++++++++++++++++++++++++ utils/runv3/runv3.go | 20 ++++++++--- utils/task/station.go | 6 ++-- 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index 7acbc12..d702f40 100644 --- a/main.go +++ b/main.go @@ -465,7 +465,7 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { if dl_atmos { Quality = fmt.Sprintf("%dkbps", Config.AtmosMax-2000) } else if needDlAacLc { - Quality = "256kbps" + Quality = "256Kbps" } else { _, Quality, err = extractMedia(track.M3u8, true) if err != nil { @@ -651,7 +651,7 @@ func ripStation(albumId string, token string, storefront string, mediaUserToken //Get Playlist Folder Name playlistFolder := strings.NewReplacer( "{ArtistName}", "Apple Music Station", - "{PlaylistName}", LimitString(meta.Data[0].Attributes.Name), + "{PlaylistName}", LimitString(station.Name), "{PlaylistId}", station.ID, "{Quality}", "", "{Codec}", Codec, @@ -671,6 +671,8 @@ func ripStation(albumId string, token string, storefront string, mediaUserToken if err != nil { fmt.Println("Failed to write cover.") } + station.CoverPath = covPath + //get animated artwork if Config.SaveAnimatedArtwork && meta.Data[0].Attributes.EditorialVideo.MotionSquare.Video != "" { fmt.Println("Found Animation Artwork.") @@ -705,6 +707,73 @@ func ripStation(albumId string, token string, storefront string, mediaUserToken } } } + // 处理stream类型的station + 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}", "01", + "{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, err := ampapi.GetStationAssetsUrl(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") + //testM3U8 := "https://itsliveradio.apple.com/bb/aod/exp_aod/jpopnowradio/AkinaNakamori/cmaf/256/prog_index.m3u8" + keyAndUrls, _ := runv3.Run(station.ID, trackM3U8, token, mediaUserToken, true) + err = runv3.ExtMvData(keyAndUrls, trackPath) + if err != nil { + fmt.Println("Failed to download station stream.", err) + counter.Error++ + return err + } + //tags + 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("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 @@ -1113,7 +1182,7 @@ func ripPlaylist(playlistId string, token string, storefront string, mediaUserTo if dl_atmos { Quality = fmt.Sprintf("%dkbps", Config.AtmosMax-2000) } else if dl_aac && Config.AacType == "aac-lc" { - Quality = "256kbps" + Quality = "256Kbps" } else { manifest1, err := ampapi.GetSongResp(storefront, meta.Data[0].Relationships.Tracks.Data[0].ID, playlist.Language, token) if err != nil { @@ -1121,7 +1190,7 @@ func ripPlaylist(playlistId string, token string, storefront string, mediaUserTo } else { if manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls == "" { Codec = "AAC" - Quality = "256kbps" + Quality = "256Kbps" //fmt.Println("Unavailable.\n") } else { needCheck := false diff --git a/utils/ampapi/station.go b/utils/ampapi/station.go index d45aeab..acb91b2 100644 --- a/utils/ampapi/station.go +++ b/utils/ampapi/station.go @@ -45,6 +45,46 @@ func GetStationResp(storefront string, id string, language string, token string) return obj, nil } +func GetStationAssetsUrl(id string, mutoken string, token string) (string, error) { + var err error + if token == "" { + token, err = GetToken() + if err != nil { + return "", err + } + } + + req, err := http.NewRequest("GET", "https://amp-api.music.apple.com/v1/play/assets", 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") + req.Header.Set("Media-User-Token", mutoken) + query := url.Values{} + //query.Set("omit[resource]", "autos") + //query.Set("extend", "editorialVideo") + query.Set("id", id) + query.Set("kind", "radioStation") + query.Set("keyFormat", "web") + 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(StationAssets) + err = json.NewDecoder(do.Body).Decode(&obj) + if err != nil { + return "", err + } + return obj.Results.Assets[0].Url, nil +} + func GetStationNextTracks(id, mutoken, language, token string) (*TrackResp, error) { var err error if token == "" { @@ -92,6 +132,17 @@ type StationResp struct { Data []StationRespData `json:"data"` } +type StationAssets struct { + Results struct { + Assets []struct { + KeyServerUrl string `json:"keyServerUrl"` + Url string `json:"url"` + WidevineKeyCertificateUrl string `json:"widevineKeyCertificateUrl"` + FairPlayKeyCertificateUrl string `json:"fairPlayKeyCertificateUrl"` + } `json:"assets"` + } `json:"results"` +} + type StationRespData struct { ID string `json:"id"` Type string `json:"type"` diff --git a/utils/runv3/runv3.go b/utils/runv3/runv3.go index e2fbd7d..699fe52 100644 --- a/utils/runv3/runv3.go +++ b/utils/runv3/runv3.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "path/filepath" + "github.com/gospider007/requests" "google.golang.org/protobuf/proto" @@ -154,7 +155,7 @@ func GetWebplayback(adamId string, authtoken string, mutoken string, mvmode bool return obj.List[0].HlsPlaylistUrl, "", nil } // 遍历 Assets - for i, _ := range obj.List[0].Assets { + for i := range obj.List[0].Assets { if obj.List[0].Assets[i].Flavor == "28:ctrp256" { kidBase64, fileurl, err := extractKidBase64(obj.List[0].Assets[i].URL, false) if err != nil { @@ -298,10 +299,19 @@ func Run(adamId string, trackpath string, authtoken string, mutoken string, mvmo AfterRequest: AfterRequest, } key.CdmInit() - keystr, keybt, err := key.GetKey(ctx, "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense", pssh, nil) - if err != nil { - fmt.Println(err) - return "", err + var keybt []byte + if strings.Contains(adamId, "ra.") { + keystr, keybt, err = key.GetKey(ctx, "https://play.itunes.apple.com/WebObjects/MZPlay.woa/web/radio/versions/1/license", pssh, nil) + if err != nil { + fmt.Println(err) + return "", err + } + } else { + keystr, keybt, err = key.GetKey(ctx, "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense", pssh, nil) + if err != nil { + fmt.Println(err) + return "", err + } } if mvmode { keyAndUrls := "1:" + keystr + ";" + fileurl diff --git a/utils/task/station.go b/utils/task/station.go index a31fca0..478167d 100644 --- a/utils/task/station.go +++ b/utils/task/station.go @@ -50,10 +50,10 @@ func (a *Station) GetResp(mutoken, token, l string) error { a.Resp = *resp //简化高频调用名称 a.Type = a.Resp.Data[0].Attributes.PlayParams.Format - if a.Type != "tracks" { - return errors.New("stream类型暂未开发") - } a.Name = a.Resp.Data[0].Attributes.Name + if a.Type != "tracks" { + return nil + } tracksResp, err := ampapi.GetStationNextTracks(a.ID, mutoken, a.Language, token) if err != nil { return errors.New("error getting station tracks response") From be1467340de067e24154f00a02bed0c94c1a5bb9 Mon Sep 17 00:00:00 2001 From: itouakirai <85016486+itouakirai@users.noreply.github.com> Date: Mon, 3 Mar 2025 20:36:33 +0800 Subject: [PATCH 04/17] fix: cover embed --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index d702f40..9f9c1d4 100644 --- a/main.go +++ b/main.go @@ -578,8 +578,8 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { if err != nil { fmt.Println("Failed to write cover.") } - tags = append(tags, fmt.Sprintf("cover=%s", track.CoverPath)) } + tags = append(tags, fmt.Sprintf("cover=%s", track.CoverPath)) } tagsString := strings.Join(tags, ":") cmd := exec.Command("MP4Box", "-itags", tagsString, trackPath) From 7dcd1648855d768db6855a6a69cc7f7d45b9c17e Mon Sep 17 00:00:00 2001 From: itouakirai Date: Tue, 4 Mar 2025 04:31:52 +0800 Subject: [PATCH 05/17] opt: mv and radio dl speed --- main.go | 32 +++++++++++++---------- utils/runv3/runv3.go | 62 ++++++++++++++++++++++++++++++-------------- 2 files changed, 60 insertions(+), 34 deletions(-) diff --git a/main.go b/main.go index 9f9c1d4..c750756 100644 --- a/main.go +++ b/main.go @@ -442,7 +442,7 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { counter.Unavailable++ return } - fmt.Println("Unavailable, Try DL AAC-LC") + fmt.Println("Unavailable, trying to dl aac-lc") needDlAacLc = true } needCheck := false @@ -463,7 +463,7 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { var Quality string if strings.Contains(Config.SongFileFormat, "Quality") { if dl_atmos { - Quality = fmt.Sprintf("%dkbps", Config.AtmosMax-2000) + Quality = fmt.Sprintf("%dKbps", Config.AtmosMax-2000) } else if needDlAacLc { Quality = "256Kbps" } else { @@ -549,6 +549,10 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { _, 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 } @@ -903,9 +907,9 @@ func ripAlbum(albumId string, token string, storefront string, mediaUserToken st var Quality string if strings.Contains(Config.AlbumFolderFormat, "Quality") { if dl_atmos { - Quality = fmt.Sprintf("%dkbps", Config.AtmosMax-2000) + Quality = fmt.Sprintf("%dKbps", Config.AtmosMax-2000) } else if dl_aac && Config.AacType == "aac-lc" { - Quality = "256kbps" + Quality = "256Kbps" } else { manifest1, err := ampapi.GetSongResp(storefront, meta.Data[0].Relationships.Tracks.Data[0].ID, album.Language, token) if err != nil { @@ -913,7 +917,7 @@ func ripAlbum(albumId string, token string, storefront string, mediaUserToken st } else { if manifest1.Data[0].Attributes.ExtendedAssetUrls.EnhancedHls == "" { Codec = "AAC" - Quality = "256kbps" + Quality = "256Kbps" //fmt.Println("Unavailable.\n") } else { needCheck := false @@ -1180,7 +1184,7 @@ func ripPlaylist(playlistId string, token string, storefront string, mediaUserTo var Quality string if strings.Contains(Config.AlbumFolderFormat, "Quality") { if dl_atmos { - Quality = fmt.Sprintf("%dkbps", Config.AtmosMax-2000) + Quality = fmt.Sprintf("%dKbps", Config.AtmosMax-2000) } else if dl_aac && Config.AacType == "aac-lc" { Quality = "256Kbps" } else { @@ -1945,7 +1949,7 @@ func extractMedia(b string, more_mode bool) (string, string, error) { currentBitrate, _ = strconv.Atoi(current) } if bitrate > currentBitrate { - aacQuality = fmt.Sprintf("AAC | 2 Channel | %d kbps", bitrate) + aacQuality = fmt.Sprintf("AAC | 2 Channel | %d Kbps", bitrate) } } } else if variant.Codecs == "ec-3" && strings.Contains(variant.Audio, "atmos") { // Dolby Atmos @@ -1964,7 +1968,7 @@ func extractMedia(b string, more_mode bool) (string, string, error) { currentBitrate, _ = strconv.Atoi(current) } if bitrate > currentBitrate { - atmosQuality = fmt.Sprintf("E-AC-3 | 16 Channel | %d kbps", bitrate) + atmosQuality = fmt.Sprintf("E-AC-3 | 16 Channel | %d Kbps", bitrate) } } } else if variant.Codecs == "alac" { // ALAC (Lossless or Hi-Res) @@ -1986,7 +1990,7 @@ func extractMedia(b string, more_mode bool) (string, string, error) { 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) + dolbyAudioQuality = fmt.Sprintf("AC-3 | 16 Channel | %d Kbps", bitrate) } } } @@ -2007,7 +2011,7 @@ func extractMedia(b string, more_mode bool) (string, string, error) { 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", + fmt.Printf("Debug: Found Dolby Atmos variant - %s (Bitrate: %d Kbps)\n", variant.Audio, variant.Bandwidth/1000) } split := strings.Split(variant.Audio, "-") @@ -2025,12 +2029,12 @@ func extractMedia(b string, more_mode bool) (string, string, error) { return "", "", err } streamUrl = streamUrlTemp - Quality = fmt.Sprintf("%s kbps", split[len(split)-1]) + 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", + fmt.Printf("Debug: Found Dolby Audio variant - %s (Bitrate: %d Kbps)\n", variant.Audio, variant.Bandwidth/1000) } streamUrlTemp, err := masterUrl.Parse(variant.URI) @@ -2039,7 +2043,7 @@ func extractMedia(b string, more_mode bool) (string, string, error) { } streamUrl = streamUrlTemp split := strings.Split(variant.Audio, "-") - Quality = fmt.Sprintf("%s kbps", split[len(split)-1]) + Quality = fmt.Sprintf("%s Kbps", split[len(split)-1]) break } } else if dl_aac { @@ -2059,7 +2063,7 @@ func extractMedia(b string, more_mode bool) (string, string, error) { } streamUrl = streamUrlTemp split := strings.Split(variant.Audio, "-") - Quality = fmt.Sprintf("%s kbps", split[2]) + Quality = fmt.Sprintf("%s Kbps", split[2]) break } } diff --git a/utils/runv3/runv3.go b/utils/runv3/runv3.go index 699fe52..1bf3404 100644 --- a/utils/runv3/runv3.go +++ b/utils/runv3/runv3.go @@ -25,6 +25,7 @@ import ( "net/http" "os/exec" "strings" + "sync" "github.com/grafov/m3u8" "github.com/schollz/progressbar/v3" @@ -166,7 +167,7 @@ func GetWebplayback(adamId string, authtoken string, mutoken string, mvmode bool continue } } - return "", "", nil + return "", "", errors.New("Unavailable") } type Songlist struct { @@ -355,35 +356,56 @@ func ExtMvData(keyAndUrls string, savePath string) error { fmt.Printf("创建文件失败:%v\n", err) return err } - defer tempFile.Close() defer os.Remove(tempFile.Name()) - + defer tempFile.Close() // 依次下载每个链接并写入文件 bar := progressbar.DefaultBytes( -1, "Downloading...", ) barWriter := io.MultiWriter(tempFile, bar) - for _, url := range urls { - resp, err := http.Get(url) - if err != nil { - fmt.Printf("下载链接 %s 失败:%v\n", url, err) + pipeReaders := make([]*io.PipeReader, len(urls)) + var wg sync.WaitGroup + //最多同时5个下载请求 + sem :=make(chan int, 10) + go func(pipeReaders []*io.PipeReader) { + for i, url := range urls { + pr, pw := io.Pipe() + pipeReaders[i] = pr + sem <- 1 + wg.Add(1) + go func(i int, url string, pw *io.PipeWriter) { + //fmt.Printf("协程 %d 开始\n", i) + defer wg.Done() + resp, err := http.Get(url) + if err != nil { + // 出错时,通过 CloseWithError 通知后续读取端 + pw.CloseWithError(err) + fmt.Printf("下载 %s 失败: %v\n", url, err) + return + } + defer resp.Body.Close() + // 将 HTTP 响应体通过 pipe 写出(实现流式传输) + _, err = io.Copy(pw, resp.Body) + // 将可能的错误传递给 pipe + pw.CloseWithError(err) + }(i, url, pw) + } + }(pipeReaders) + // 按顺序读取每个 pipe 的数据并写入文件 + for i := range len(urls) { + <-sem + //fmt.Printf("写入 %d 开始\n", i) + if _, err := io.Copy(barWriter, pipeReaders[i]); err != nil { + fmt.Printf("写入第 %d 部分失败: %v\n", i+1, err) return err } - if resp.StatusCode != http.StatusOK { - fmt.Printf("链接 %s 响应失败:%v\n", url, resp.Status) - return errors.New(resp.Status) - } - // 将响应体写入输出文件 - _, err = io.Copy(barWriter, resp.Body) - defer resp.Body.Close() // 注意及时关闭响应体,避免资源泄露 - if err != nil { - fmt.Printf("写入文件失败:%v\n", err) - return err - } - - //fmt.Printf("第 %d 个链接 %s 下载并写入完成\n", idx+1, url) + pipeReaders[i].Close() // 及时关闭 read + //fmt.Printf("写入 %d 成功\n", i) } + + // 等待所有下载任务完成 + wg.Wait() tempFile.Close() fmt.Println("\nDownloaded.") From c9ecc16a3b0d5597c0bf66540c96b65dfef7de66 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Tue, 4 Mar 2025 13:45:48 +0800 Subject: [PATCH 06/17] =?UTF-8?q?style:=20=E6=98=BE=E7=A4=BA=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 22 +++++++++++------- utils/runv3/runv3.go | 52 +++++++++++++++++++++---------------------- utils/task/station.go | 2 ++ 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/main.go b/main.go index c750756..fef1302 100644 --- a/main.go +++ b/main.go @@ -405,7 +405,7 @@ func contains(slice []string, item string) bool { func ripTrack(track *task.Track, token string, mediaUserToken string) { var err error counter.Total++ - fmt.Printf("Track %d of %d:\n", track.TaskNum, track.TaskTotal) + fmt.Printf("Track %d of %d: %s\n", track.TaskNum, track.TaskTotal, track.Type) //mv dl dev if track.Type == "music-videos" { @@ -620,6 +620,7 @@ func ripStation(albumId string, token string, storefront string, mediaUserToken if err != nil { return err } + fmt.Println(" -", station.Type) meta := station.Resp var Codec string @@ -1394,7 +1395,7 @@ func writeMP4Tags(track *task.Track, lrc string) error { t.ItunesArtistID = int32(artistID) } - if track.PreType == "playlists" && !Config.UseSongInfoForPlaylist { + if (track.PreType == "playlists" || track.PreType == "stations") && !Config.UseSongInfoForPlaylist { t.DiscNumber = 1 t.DiscTotal = 1 t.TrackNumber = int16(track.TaskNum) @@ -1403,7 +1404,7 @@ func writeMP4Tags(track *task.Track, lrc string) error { t.AlbumSort = track.PlaylistData.Attributes.Name t.AlbumArtist = track.PlaylistData.Attributes.ArtistName t.AlbumArtistSort = track.PlaylistData.Attributes.ArtistName - } else if (track.PreType == "playlists" && Config.UseSongInfoForPlaylist) || track.PreType == "stations" { + } else if (track.PreType == "playlists" || track.PreType == "stations") && Config.UseSongInfoForPlaylist { //使用提前获取到的播放列表下track所在的专辑信息 len := len(track.AlbumData.Relationships.Tracks.Data) t.DiscTotal = int16(track.AlbumData.Relationships.Tracks.Data[len-1].Attributes.DiscNumber) @@ -1522,22 +1523,23 @@ func main() { albumTotal := len(os.Args) for { for albumNum, urlRaw := range os.Args { - fmt.Printf("Album %d of %d:\n", albumNum+1, albumTotal) + fmt.Printf("Queue %d of %d: ", albumNum+1, albumTotal) var storefront, albumId string //mv dl dev 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") + 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") + fmt.Println(": mp4decrypt is not found, skip MV dl") counter.Success++ continue } @@ -1562,6 +1564,7 @@ func main() { continue } if strings.Contains(urlRaw, "/song/") { + fmt.Printf("Song->") urlRaw, err = getUrlSong(urlRaw, token) dl_song = true if err != nil { @@ -1575,21 +1578,24 @@ func main() { 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") + fmt.Println(": meida-user-token is not set, skip station dl") continue } err := ripStation(albumId, token, storefront, Config.MediaUserToken) @@ -1597,7 +1603,7 @@ func main() { fmt.Println("Failed to rip station:", err) } } else { - fmt.Println("Invalid URL.") + 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) diff --git a/utils/runv3/runv3.go b/utils/runv3/runv3.go index 1bf3404..66b21e1 100644 --- a/utils/runv3/runv3.go +++ b/utils/runv3/runv3.go @@ -367,35 +367,35 @@ func ExtMvData(keyAndUrls string, savePath string) error { pipeReaders := make([]*io.PipeReader, len(urls)) var wg sync.WaitGroup //最多同时5个下载请求 - sem :=make(chan int, 10) + sem :=make(chan int, 5) go func(pipeReaders []*io.PipeReader) { - for i, url := range urls { - pr, pw := io.Pipe() - pipeReaders[i] = pr - sem <- 1 - wg.Add(1) - go func(i int, url string, pw *io.PipeWriter) { - //fmt.Printf("协程 %d 开始\n", i) - defer wg.Done() - resp, err := http.Get(url) - if err != nil { - // 出错时,通过 CloseWithError 通知后续读取端 - pw.CloseWithError(err) - fmt.Printf("下载 %s 失败: %v\n", url, err) - return - } - defer resp.Body.Close() - // 将 HTTP 响应体通过 pipe 写出(实现流式传输) - _, err = io.Copy(pw, resp.Body) - // 将可能的错误传递给 pipe - pw.CloseWithError(err) - }(i, url, pw) - } - }(pipeReaders) + for i, url := range urls { + pr, pw := io.Pipe() + pipeReaders[i] = pr + sem <- 1 + wg.Add(1) + go func(i int, url string, pw *io.PipeWriter) { + //fmt.Printf("协程 %d 开始\n", i) + defer wg.Done() + resp, err := http.Get(url) + if err != nil { + // 出错时,通过 CloseWithError 通知后续读取端 + pw.CloseWithError(err) + fmt.Printf("下载 %s 失败: %v\n", url, err) + return + } + defer resp.Body.Close() + // 将 HTTP 响应体通过 pipe 写出(实现流式传输) + _, err = io.Copy(pw, resp.Body) + // 将可能的错误传递给 pipe + pw.CloseWithError(err) + }(i, url, pw) + } + }(pipeReaders) // 按顺序读取每个 pipe 的数据并写入文件 for i := range len(urls) { - <-sem - //fmt.Printf("写入 %d 开始\n", i) + <-sem + //fmt.Printf("写入 %d 开始\n", i) if _, err := io.Copy(barWriter, pipeReaders[i]); err != nil { fmt.Printf("写入第 %d 部分失败: %v\n", i+1, err) return err diff --git a/utils/task/station.go b/utils/task/station.go index 478167d..221a529 100644 --- a/utils/task/station.go +++ b/utils/task/station.go @@ -88,6 +88,8 @@ func (a *Station) GetResp(mutoken, token, l string) error { PreID: a.ID, AlbumData: albumResp.Data[0], }) + a.Tracks[i].PlaylistData.Attributes.Name = a.Name + a.Tracks[i].PlaylistData.Attributes.ArtistName = "Apple Music Station" } return nil } From 56d37ccce70a12fdd76b323b789d352926e54847 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Tue, 4 Mar 2025 14:54:00 +0800 Subject: [PATCH 07/17] fix: herf string --- utils/ampapi/album.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utils/ampapi/album.go b/utils/ampapi/album.go index 7ea4267..63a26fd 100644 --- a/utils/ampapi/album.go +++ b/utils/ampapi/album.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "strings" ) func GetAlbumResp(storefront string, id string, language string, token string) (*AlbumResp, error) { @@ -58,7 +59,7 @@ func GetAlbumRespByHref(href string, language string, token string) (*AlbumResp, return nil, err } } - + href = strings.Split(href, "?")[0] req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com%s/albums", href), nil) if err != nil { return nil, err From 8f284cadea794debeb0d537a3cf59dfc14383786 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Wed, 5 Mar 2025 07:44:27 +0800 Subject: [PATCH 08/17] fix: mv tag panic,artist cover miss --- main.go | 36 ++++++++++++++++++------------------ utils/task/track.go | 10 ++++++++-- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/main.go b/main.go index fef1302..0f038c0 100644 --- a/main.go +++ b/main.go @@ -406,7 +406,10 @@ 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 { @@ -419,10 +422,6 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { counter.Success++ return } - //提前获取到的播放列表下track所在的专辑信息 - if track.PreType == "playlists" && Config.UseSongInfoForPlaylist { - track.GetAlbumData(token) - } err := mvDownloader(track.ID, track.SaveDir, token, track.Storefront, mediaUserToken, track) if err != nil { fmt.Println("\u26A0 Failed to dl MV:", err) @@ -436,7 +435,7 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { if dl_aac && Config.AacType == "aac-lc" { needDlAacLc = true } - if track.WebM3u8 == "" { + if track.WebM3u8 == "" && !needDlAacLc { if dl_atmos { fmt.Println("Unavailable") counter.Unavailable++ @@ -549,7 +548,7 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { _, err := runv3.Run(track.ID, trackPath, token, mediaUserToken, false) if err != nil { fmt.Println("Failed to dl aac-lc:", err) - if err.Error() == "Unavailable"{ + if err.Error() == "Unavailable" { counter.Unavailable++ return } @@ -600,10 +599,6 @@ func ripTrack(track *task.Track, token string, mediaUserToken string) { } } track.SavePath = trackPath - //提前获取到的播放列表下track所在的专辑信息 - if track.PreType == "playlists" && Config.UseSongInfoForPlaylist { - track.GetAlbumData(token) - } err = writeMP4Tags(track, lrc) if err != nil { fmt.Println("\u26A0 Failed to write tags in media:", err) @@ -724,7 +719,7 @@ func ripStation(albumId string, token string, storefront string, mediaUserToken "{SongNumer}", "01", "{SongName}", LimitString(station.Name), "{DiscNumber}", "1", - "{TrackNumber}", "01", + "{TrackNumber}", "1", "{Quality}", "256Kbps", "{Tag}", "", "{Codec}", "AAC", @@ -985,7 +980,15 @@ func ripAlbum(albumId string, token string, storefront string, mediaUserToken st os.MkdirAll(albumFolderPath, os.ModePerm) // Create album folder album.SaveName = albumFolderName fmt.Println(albumFolderName) - //先省略封面相关的获取 + //get artist cover + if Config.SaveArtistCover { + 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 playlist cover covPath, err := writeCover(albumFolderPath, "cover", meta.Data[0].Attributes.Artwork.URL) if err != nil { @@ -1405,9 +1408,7 @@ func writeMP4Tags(track *task.Track, lrc string) error { t.AlbumArtist = track.PlaylistData.Attributes.ArtistName t.AlbumArtistSort = track.PlaylistData.Attributes.ArtistName } else if (track.PreType == "playlists" || track.PreType == "stations") && Config.UseSongInfoForPlaylist { - //使用提前获取到的播放列表下track所在的专辑信息 - len := len(track.AlbumData.Relationships.Tracks.Data) - t.DiscTotal = int16(track.AlbumData.Relationships.Tracks.Data[len-1].Attributes.DiscNumber) + t.DiscTotal = int16(track.DiscTotal) t.TrackTotal = int16(track.AlbumData.Attributes.TrackCount) t.AlbumArtist = track.AlbumData.Attributes.ArtistName t.AlbumArtistSort = track.AlbumData.Attributes.ArtistName @@ -1693,8 +1694,7 @@ func mvDownloader(adamID string, saveDir string, token string, storefront string //tags = append(tags, fmt.Sprintf("UPC=%s", track.PlaylistData.Attributes.Upc)) } else if track.PreType == "playlists" && Config.UseSongInfoForPlaylist { tags = append(tags, fmt.Sprintf("album=%s", track.AlbumData.Attributes.Name)) - len := len(track.AlbumData.Relationships.Tracks.Data) - tags = append(tags, fmt.Sprintf("disk=%d/%d", track.Resp.Attributes.DiscNumber, track.AlbumData.Relationships.Tracks.Data[len-1].Attributes.DiscNumber)) + 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)) diff --git a/utils/task/track.go b/utils/task/track.go index d37079f..cb5e87b 100644 --- a/utils/task/track.go +++ b/utils/task/track.go @@ -38,7 +38,13 @@ func (t *Track) GetAlbumData(token string) error { return err } t.AlbumData = resp.Data[0] - //len := len(resp.Data[0].Relationships.Tracks.Data) - //t.DiscTotal = resp.Data[0].Relationships.Tracks.Data[len-1].Attributes.DiscNumber + //尝试获取该track所在album的disk总数 + if len(resp.Data) > 0 { + len := len(resp.Data[0].Relationships.Tracks.Data) + if len > 0 { + t.DiscTotal = resp.Data[0].Relationships.Tracks.Data[len-1].Attributes.DiscNumber + } + } + return nil } From 65172a3d3de621c4ceeb2ac942ed66842a98fd6e Mon Sep 17 00:00:00 2001 From: itouakirai Date: Thu, 6 Mar 2025 08:38:15 +0800 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20=E4=B8=8B=E8=BD=BD=E5=8D=8F?= =?UTF-8?q?=E7=A8=8B=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/runv3/runv3.go | 122 ++++++++++++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 37 deletions(-) diff --git a/utils/runv3/runv3.go b/utils/runv3/runv3.go index 66b21e1..6a8010b 100644 --- a/utils/runv3/runv3.go +++ b/utils/runv3/runv3.go @@ -26,6 +26,7 @@ import ( "os/exec" "strings" "sync" + "time" "github.com/grafov/m3u8" "github.com/schollz/progressbar/v3" @@ -358,55 +359,102 @@ func ExtMvData(keyAndUrls string, savePath string) error { } defer os.Remove(tempFile.Name()) defer tempFile.Close() - // 依次下载每个链接并写入文件 - bar := progressbar.DefaultBytes( - -1, - "Downloading...", - ) + + // 创建上下文用于取消所有下载任务 + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 初始化进度条 + bar := progressbar.DefaultBytes(-1, "Downloading...") barWriter := io.MultiWriter(tempFile, bar) + + // 预先创建所有管道 pipeReaders := make([]*io.PipeReader, len(urls)) - var wg sync.WaitGroup - //最多同时5个下载请求 - sem :=make(chan int, 5) - go func(pipeReaders []*io.PipeReader) { - for i, url := range urls { + pipeWriters := make([]*io.PipeWriter, len(urls)) + for i := range urls { pr, pw := io.Pipe() - pipeReaders[i] = pr - sem <- 1 - wg.Add(1) - go func(i int, url string, pw *io.PipeWriter) { - //fmt.Printf("协程 %d 开始\n", i) - defer wg.Done() - resp, err := http.Get(url) - if err != nil { - // 出错时,通过 CloseWithError 通知后续读取端 - pw.CloseWithError(err) - fmt.Printf("下载 %s 失败: %v\n", url, err) - return - } - defer resp.Body.Close() - // 将 HTTP 响应体通过 pipe 写出(实现流式传输) - _, err = io.Copy(pw, resp.Body) - // 将可能的错误传递给 pipe - pw.CloseWithError(err) - }(i, url, pw) + pipeReaders[i] = pr + pipeWriters[i] = pw + } + + // 控制并发数(使用空结构体节省内存) + sem := make(chan struct{}, 5) + var wg sync.WaitGroup + + // 创建带超时的HTTP Client + client := &http.Client{ + Timeout: 30 * time.Second, + } + + // 启动下载任务 + go func() { + for i, url := range urls { + select { + case <-ctx.Done(): + return // 上下文已取消,直接返回 + default: + sem <- struct{}{} // 获取信号量 + wg.Add(1) + + go func(i int, url string, pw *io.PipeWriter) { + defer func() { + <-sem // 释放信号量 + wg.Done() + }() + + // 创建带上下文的请求 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + pw.CloseWithError(err) + fmt.Printf("创建请求失败: %v\n", err) + return + } + + resp, err := client.Do(req) + if err != nil { + pw.CloseWithError(err) + fmt.Printf("下载失败: %v\n", err) + return + } + defer resp.Body.Close() + + // 检查HTTP状态码 + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("非200状态码: %d", resp.StatusCode) + pw.CloseWithError(err) + fmt.Printf("下载失败: %v\n", err) + return + } + + // 将响应体复制到管道 + if _, err := io.Copy(pw, resp.Body); err != nil { + pw.CloseWithError(err) + } else { + pw.Close() // 正常关闭 + } + }(i, url, pipeWriters[i]) + } } - }(pipeReaders) - // 按顺序读取每个 pipe 的数据并写入文件 - for i := range len(urls) { - <-sem - //fmt.Printf("写入 %d 开始\n", i) + }() + + // 按顺序写入文件 + for i := 0; i < len(urls); i++ { if _, err := io.Copy(barWriter, pipeReaders[i]); err != nil { + cancel() // 取消所有下载任务 fmt.Printf("写入第 %d 部分失败: %v\n", i+1, err) return err } - pipeReaders[i].Close() // 及时关闭 read - //fmt.Printf("写入 %d 成功\n", i) + pipeReaders[i].Close() // 关闭当前读取端 } // 等待所有下载任务完成 wg.Wait() - tempFile.Close() + + // 显式关闭文件(defer会再次调用,但重复关闭是安全的) + if err := tempFile.Close(); err != nil { + fmt.Printf("关闭临时文件失败: %v\n", err) + return err + } fmt.Println("\nDownloaded.") cmd1 := exec.Command("mp4decrypt", "--key", key, tempFile.Name(), filepath.Base(savePath)) From 7c590213ef6ee779e9ac60f95f60e75549c39312 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Thu, 6 Mar 2025 12:11:42 +0800 Subject: [PATCH 10/17] =?UTF-8?q?fix:=E6=92=AD=E6=94=BE=E5=88=97=E8=A1=A8o?= =?UTF-8?q?ffset=E8=8E=B7=E5=8F=96=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/ampapi/playlist.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/utils/ampapi/playlist.go b/utils/ampapi/playlist.go index 7b38be3..bd59154 100644 --- a/utils/ampapi/playlist.go +++ b/utils/ampapi/playlist.go @@ -50,22 +50,17 @@ func GetPlaylistResp(storefront string, id string, language string, token string if len(obj.Data[0].Relationships.Tracks.Next) > 0 { next := obj.Data[0].Relationships.Tracks.Next for { - req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/%s", next), nil) + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com%s", next), 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 := req.URL.Query() query.Set("omit[resource]", "autos") - query.Set("include", "tracks,artists,record-labels,albums") - 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("include", "artists") query.Set("extend", "editorialVideo,extendedAssetUrls") - query.Set("l", language) req.URL.RawQuery = query.Encode() do, err := http.DefaultClient.Do(req) if err != nil { From 421db91731e29c86bd3028da544e9f3efbf272b8 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Sat, 12 Jul 2025 05:37:12 +0800 Subject: [PATCH 11/17] =?UTF-8?q?=E6=9B=B4=E6=94=B9mv=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 1 + utils/runv3/runv3.go | 196 +++++++++++++++++++++++++------------------ 2 files changed, 117 insertions(+), 80 deletions(-) diff --git a/main.go b/main.go index 0f038c0..86fd737 100644 --- a/main.go +++ b/main.go @@ -813,6 +813,7 @@ func ripAlbum(albumId string, token string, storefront string, mediaUserToken st err := album.GetResp(token, Config.Language) if err != nil { fmt.Println("Failed to get album response.") + return err } meta := album.Resp //debug mode diff --git a/utils/runv3/runv3.go b/utils/runv3/runv3.go index 6a8010b..771d019 100644 --- a/utils/runv3/runv3.go +++ b/utils/runv3/runv3.go @@ -26,7 +26,7 @@ import ( "os/exec" "strings" "sync" - "time" + //"time" "github.com/grafov/m3u8" "github.com/schollz/progressbar/v3" @@ -347,6 +347,95 @@ func Run(adamId string, trackpath string, authtoken string, mutoken string, mvmo return "", nil } +// Segment 结构体用于在 Channel 中传递分段数据 +type Segment struct { + Index int + Data []byte +} + +func downloadSegment(url string, index int, wg *sync.WaitGroup, segmentsChan chan<- Segment, client *http.Client, limiter chan struct{}) { + // 函数退出时,从 limiter 中接收一个值,释放一个并发槽位 + defer func() { + <-limiter + wg.Done() + }() + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + fmt.Printf("错误(分段 %d): 创建请求失败: %v\n", index, err) + return + } + + resp, err := client.Do(req) + if err != nil { + fmt.Printf("错误(分段 %d): 下载失败: %v\n", index, err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Printf("错误(分段 %d): 服务器返回状态码 %d\n", index, resp.StatusCode) + return + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf("错误(分段 %d): 读取数据失败: %v\n", index, err) + return + } + + // 将下载好的分段(包含序号和数据)发送到 Channel + segmentsChan <- Segment{Index: index, Data: data} +} + +// fileWriter 从 Channel 接收分段并按顺序写入文件 +func fileWriter(wg *sync.WaitGroup, segmentsChan <-chan Segment, outputFile io.Writer, totalSegments int) { + defer wg.Done() + + // 缓冲区,用于存放乱序到达的分段 + // key 是分段序号,value 是分段数据 + segmentBuffer := make(map[int][]byte) + nextIndex := 0 // 期望写入的下一个分段的序号 + + for segment := range segmentsChan { + // 检查收到的分段是否是当前期望的 + if segment.Index == nextIndex { + //fmt.Printf("写入分段 %d\n", segment.Index) + _, err := outputFile.Write(segment.Data) + if err != nil { + fmt.Printf("错误(分段 %d): 写入文件失败: %v\n", segment.Index, err) + } + nextIndex++ + + // 检查缓冲区中是否有下一个连续的分段 + for { + data, ok := segmentBuffer[nextIndex] + if !ok { + break // 缓冲区里没有下一个,跳出循环,等待下一个分段到达 + } + + //fmt.Printf("从缓冲区写入分段 %d\n", nextIndex) + _, err := outputFile.Write(data) + if err != nil { + fmt.Printf("错误(分段 %d): 从缓冲区写入文件失败: %v\n", nextIndex, err) + } + // 从缓冲区删除已写入的分段,释放内存 + delete(segmentBuffer, nextIndex) + nextIndex++ + } + } else { + // 如果不是期望的分段,先存入缓冲区 + //fmt.Printf("缓冲分段 %d (等待 %d)\n", segment.Index, nextIndex) + segmentBuffer[segment.Index] = segment.Data + } + } + + // 确保所有分段都已写入 + if nextIndex != totalSegments { + fmt.Printf("警告: 写入完成,但似乎有分段丢失。期望 %d 个, 实际写入 %d 个。\n", totalSegments, nextIndex) + } +} + func ExtMvData(keyAndUrls string, savePath string) error { segments := strings.Split(keyAndUrls, ";") key := segments[0] @@ -360,95 +449,42 @@ func ExtMvData(keyAndUrls string, savePath string) error { defer os.Remove(tempFile.Name()) defer tempFile.Close() - // 创建上下文用于取消所有下载任务 - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + var downloadWg, writerWg sync.WaitGroup + segmentsChan := make(chan Segment, len(urls)) + // --- 新增代码: 定义最大并发数 --- + const maxConcurrency = 10 + // --- 新增代码: 创建带缓冲的 Channel 作为信号量 --- + limiter := make(chan struct{}, maxConcurrency) + client := &http.Client{} // 初始化进度条 bar := progressbar.DefaultBytes(-1, "Downloading...") barWriter := io.MultiWriter(tempFile, bar) - // 预先创建所有管道 - pipeReaders := make([]*io.PipeReader, len(urls)) - pipeWriters := make([]*io.PipeWriter, len(urls)) - for i := range urls { - pr, pw := io.Pipe() - pipeReaders[i] = pr - pipeWriters[i] = pw - } + // 启动写入 Goroutine + writerWg.Add(1) + go fileWriter(&writerWg, segmentsChan, barWriter, len(urls)) - // 控制并发数(使用空结构体节省内存) - sem := make(chan struct{}, 5) - var wg sync.WaitGroup + // 启动下载 Goroutines + for i, url := range urls { + // 在启动 Goroutine 前,向 limiter 发送一个值来“获取”一个槽位 + // 如果 limiter 已满 (达到10个),这里会阻塞,直到有其他任务完成并释放槽位 + //fmt.Printf("请求启动任务 %d...\n", i) + limiter <- struct{}{} + //fmt.Printf("...任务 %d 已启动\n", i) - // 创建带超时的HTTP Client - client := &http.Client{ - Timeout: 30 * time.Second, - } - - // 启动下载任务 - go func() { - for i, url := range urls { - select { - case <-ctx.Done(): - return // 上下文已取消,直接返回 - default: - sem <- struct{}{} // 获取信号量 - wg.Add(1) - - go func(i int, url string, pw *io.PipeWriter) { - defer func() { - <-sem // 释放信号量 - wg.Done() - }() - - // 创建带上下文的请求 - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - pw.CloseWithError(err) - fmt.Printf("创建请求失败: %v\n", err) - return - } - - resp, err := client.Do(req) - if err != nil { - pw.CloseWithError(err) - fmt.Printf("下载失败: %v\n", err) - return - } - defer resp.Body.Close() - - // 检查HTTP状态码 - if resp.StatusCode != http.StatusOK { - err := fmt.Errorf("非200状态码: %d", resp.StatusCode) - pw.CloseWithError(err) - fmt.Printf("下载失败: %v\n", err) - return - } - - // 将响应体复制到管道 - if _, err := io.Copy(pw, resp.Body); err != nil { - pw.CloseWithError(err) - } else { - pw.Close() // 正常关闭 - } - }(i, url, pipeWriters[i]) - } - } - }() - - // 按顺序写入文件 - for i := 0; i < len(urls); i++ { - if _, err := io.Copy(barWriter, pipeReaders[i]); err != nil { - cancel() // 取消所有下载任务 - fmt.Printf("写入第 %d 部分失败: %v\n", i+1, err) - return err - } - pipeReaders[i].Close() // 关闭当前读取端 + downloadWg.Add(1) + // 将 limiter 传递给下载函数 + go downloadSegment(url, i, &downloadWg, segmentsChan, client, limiter) } // 等待所有下载任务完成 - wg.Wait() + downloadWg.Wait() + // 下载完成后,关闭 Channel。写入 Goroutine 会在处理完 Channel 中所有数据后退出。 + close(segmentsChan) + + // 等待写入 Goroutine 完成所有写入和缓冲处理 + writerWg.Wait() // 显式关闭文件(defer会再次调用,但重复关闭是安全的) if err := tempFile.Close(); err != nil { From beb95b8730fa2128b3706086b380cd228c6382a0 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Sat, 12 Jul 2025 06:32:17 +0800 Subject: [PATCH 12/17] fix: unable to collect information about albums with more than 300 tracks --- utils/ampapi/album.go | 70 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/utils/ampapi/album.go b/utils/ampapi/album.go index 63a26fd..7a4214a 100644 --- a/utils/ampapi/album.go +++ b/utils/ampapi/album.go @@ -48,6 +48,41 @@ func GetAlbumResp(storefront string, id string, language string, token string) ( if err != nil { return nil, err } + if len(obj.Data[0].Relationships.Tracks.Next) > 0 { + next := obj.Data[0].Relationships.Tracks.Next + for { + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com%s", next), 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 := req.URL.Query() + query.Set("omit[resource]", "autos") + query.Set("include", "artists") + query.Set("extend", "editorialVideo,extendedAssetUrls") + 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) + } + obj2 := new(TrackResp) + err = json.NewDecoder(do.Body).Decode(&obj2) + if err != nil { + return nil, err + } + obj.Data[0].Relationships.Tracks.Data = append(obj.Data[0].Relationships.Tracks.Data, obj2.Data...) + next = obj2.Next + if len(next) == 0 { + break + } + } + } return obj, nil } @@ -90,6 +125,41 @@ func GetAlbumRespByHref(href string, language string, token string) (*AlbumResp, if err != nil { return nil, err } + if len(obj.Data[0].Relationships.Tracks.Next) > 0 { + next := obj.Data[0].Relationships.Tracks.Next + for { + req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com%s", next), 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 := req.URL.Query() + query.Set("omit[resource]", "autos") + query.Set("include", "artists") + query.Set("extend", "editorialVideo,extendedAssetUrls") + 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) + } + obj2 := new(TrackResp) + err = json.NewDecoder(do.Body).Decode(&obj2) + if err != nil { + return nil, err + } + obj.Data[0].Relationships.Tracks.Data = append(obj.Data[0].Relationships.Tracks.Data, obj2.Data...) + next = obj2.Next + if len(next) == 0 { + break + } + } + } return obj, nil } From b6bc55ce1c2693830c23d0c46a66a3fcafc94b0b Mon Sep 17 00:00:00 2001 From: itouakirai Date: Sat, 12 Jul 2025 06:39:07 +0800 Subject: [PATCH 13/17] Classcial link support --- main.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 86fd737..bdc1ac5 100644 --- a/main.go +++ b/main.go @@ -94,7 +94,7 @@ func fileExists(path string) (bool, error) { } func checkUrl(url string) (string, string) { - pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w{2})(?:\/album|\/album\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?)`) + 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 { @@ -114,7 +114,7 @@ func checkUrlMv(url string) (string, string) { } } func checkUrlSong(url string) (string, string) { - pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w{2})(?:\/song|\/song\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?)`) + 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 { @@ -124,7 +124,7 @@ func checkUrlSong(url string) (string, string) { } } func checkUrlPlaylist(url string) (string, string) { - pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w{2})(?:\/playlist|\/playlist\/.+))\/(?:id)?(pl\.[\w-]+)(?:$|\?)`) + 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 { @@ -146,7 +146,7 @@ func checkUrlStation(url string) (string, string) { } func checkUrlArtist(url string) (string, string) { - pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w{2})(?:\/artist|\/artist\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?)`) + 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 { From 85c1c9fc6064aaeb2402e6da0085d6e0fdba5b91 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Sat, 12 Jul 2025 06:47:37 +0800 Subject: [PATCH 14/17] Enhanced LRC support by @AAGaming00 --- utils/lyrics/lyrics.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/utils/lyrics/lyrics.go b/utils/lyrics/lyrics.go index af9fde9..7473857 100644 --- a/utils/lyrics/lyrics.go +++ b/utils/lyrics/lyrics.go @@ -26,7 +26,6 @@ type SongLyrics struct { } `json:"data"` } - func Get(storefront, songId, lrcType, language, lrcFormat, token, mediaUserToken string) (string, error) { if len(mediaUserToken) < 50 { return "", errors.New("MediaUserToken not set") @@ -166,7 +165,7 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { return "", err } var lrcLines []string - parseTime := func(timeValue string) (string, error) { + parseTime := func(timeValue string, newLine bool) (string, error) { var h, m, s, ms int if strings.Contains(timeValue, ":") { _, err = fmt.Sscanf(timeValue, "%d:%d:%d.%d", &h, &m, &s, &ms) @@ -183,7 +182,11 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { } m += h * 60 ms = ms / 10 - return fmt.Sprintf("[%02d:%02d.%02d]", m, s, ms), nil + if newLine { + return fmt.Sprintf("[%02d:%02d.%02d]<%02d:%02d.%02d>", m, s, ms, m, s, ms), nil + } else { + return fmt.Sprintf("<%02d:%02d.%02d>", m, s, ms), nil + } } divs := parsedTTML.FindElement("tt").FindElement("body").FindElements("div") //get trans @@ -217,11 +220,11 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { if lyric.SelectAttr("begin") == nil { continue } - beginTime, err := parseTime(lyric.SelectAttr("begin").Value) + beginTime, err := parseTime(lyric.SelectAttr("begin").Value, i == 0) if err != nil { return "", err } - endTime, err = parseTime(lyric.SelectAttr("end").Value) + endTime, err = parseTime(lyric.SelectAttr("end").Value, false) if err != nil { return "", err } From db3f537e9e1d0fa71b2ed8b10abc012a199ed24c Mon Sep 17 00:00:00 2001 From: itouakirai Date: Sat, 12 Jul 2025 18:07:29 +0800 Subject: [PATCH 15/17] beta: Get translated lyrics --- utils/lyrics/lyrics.go | 79 +++++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/utils/lyrics/lyrics.go b/utils/lyrics/lyrics.go index 7473857..ac9a507 100644 --- a/utils/lyrics/lyrics.go +++ b/utils/lyrics/lyrics.go @@ -16,6 +16,7 @@ type SongLyrics struct { Type string `json:"type"` Attributes struct { Ttml string `json:"ttml"` + TtmlLocalizations string `json:"ttmlLocalizations"` PlayParams struct { Id string `json:"id"` Kind string `json:"kind"` @@ -49,7 +50,7 @@ func Get(storefront, songId, lrcType, language, lrcFormat, token, mediaUserToken } func getSongLyrics(songId string, storefront string, token string, userToken string, lrcType string, language string) (string, error) { req, err := http.NewRequest("GET", - fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/songs/%s/%s?l=%s", storefront, songId, lrcType, language), nil) + fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/songs/%s/%s?l=%s&extend=ttmlLocalizations", storefront, songId, lrcType, language), nil) if err != nil { return "", err } @@ -66,7 +67,10 @@ func getSongLyrics(songId string, storefront string, token string, userToken str obj := new(SongLyrics) _ = json.NewDecoder(do.Body).Decode(&obj) if obj.Data != nil { - return obj.Data[0].Attributes.Ttml, nil + if len(obj.Data[0].Attributes.Ttml) > 0 { + return obj.Data[0].Attributes.Ttml, nil + } + return obj.Data[0].Attributes.TtmlLocalizations, nil } else { return "", errors.New("failed to get lyrics") } @@ -165,7 +169,7 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { return "", err } var lrcLines []string - parseTime := func(timeValue string, newLine bool) (string, error) { + parseTime := func(timeValue string, newLine int) (string, error) { var h, m, s, ms int if strings.Contains(timeValue, ":") { _, err = fmt.Sscanf(timeValue, "%d:%d:%d.%d", &h, &m, &s, &ms) @@ -182,34 +186,22 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { } m += h * 60 ms = ms / 10 - if newLine { + if newLine == 0 { return fmt.Sprintf("[%02d:%02d.%02d]<%02d:%02d.%02d>", m, s, ms, m, s, ms), nil + } else if newLine == -1 { + return fmt.Sprintf("[%02d:%02d.%02d]", m, s, ms), nil } else { return fmt.Sprintf("<%02d:%02d.%02d>", m, s, ms), nil } } divs := parsedTTML.FindElement("tt").FindElement("body").FindElements("div") - //get trans - if len(parsedTTML.FindElement("tt").FindElements("head")) > 0 { - if len(parsedTTML.FindElement("tt").FindElement("head").FindElements("metadata")) > 0 { - Metadata := parsedTTML.FindElement("tt").FindElement("head").FindElement("metadata") - if len(Metadata.FindElements("iTunesMetadata")) > 0 { - iTunesMetadata := Metadata.FindElement("iTunesMetadata") - if len(iTunesMetadata.FindElements("translations")) > 0 { - if len(iTunesMetadata.FindElement("translations").FindElements("translation")) > 0 { - divs = iTunesMetadata.FindElement("translations").FindElements("translation") - } - } - } - } - } for _, div := range divs { - for _, item := range div.ChildElements() { + for _, item := range div.ChildElements() { //LINES var lrcSyllables []string var i int = 0 - var endTime string - for _, lyrics := range item.Child { - if _, ok := lyrics.(*etree.CharData); ok { + var endTime, transLine string + for _, lyrics := range item.Child { //WORDS + if _, ok := lyrics.(*etree.CharData); ok { //是否为span之间的空格 if i > 0 { lrcSyllables = append(lrcSyllables, " ") continue @@ -220,11 +212,12 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { if lyric.SelectAttr("begin") == nil { continue } - beginTime, err := parseTime(lyric.SelectAttr("begin").Value, i == 0) + beginTime, err := parseTime(lyric.SelectAttr("begin").Value, i) if err != nil { - return "", err + return "", err } - endTime, err = parseTime(lyric.SelectAttr("end").Value, false) + + endTime, err = parseTime(lyric.SelectAttr("end").Value, i) if err != nil { return "", err } @@ -243,6 +236,39 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { text = lyric.SelectAttr("text").Value } lrcSyllables = append(lrcSyllables, fmt.Sprintf("%s%s", beginTime, text)) + if i == 0 { + transBeginTime, _ := parseTime(lyric.SelectAttr("begin").Value, -1) + if len(parsedTTML.FindElement("tt").FindElements("head")) > 0 { + if len(parsedTTML.FindElement("tt").FindElement("head").FindElements("metadata")) > 0 { + Metadata := parsedTTML.FindElement("tt").FindElement("head").FindElement("metadata") + if len(Metadata.FindElements("iTunesMetadata")) > 0 { + iTunesMetadata := Metadata.FindElement("iTunesMetadata") + if len(iTunesMetadata.FindElements("translations")) > 0 { + if len(iTunesMetadata.FindElement("translations").FindElements("translation")) > 0 { + xpath := fmt.Sprintf("//text[@for='%s']", item.SelectAttr("itunes:key").Value) + trans := iTunesMetadata.FindElement("translations").FindElement("translation").FindElement(xpath) + var transTxt string + if trans.SelectAttr("text") == nil { + var textTmp []string + for _, span := range trans.Child { + if _, ok := span.(*etree.CharData); ok { + textTmp = append(textTmp, span.(*etree.CharData).Data) + } else { + textTmp = append(textTmp, span.(*etree.Element).Text()) + } + } + transTxt = strings.Join(textTmp, "") + } else { + transTxt = lyric.SelectAttr("text").Value + } + //fmt.Println(transTxt) + transLine = transBeginTime + transTxt + } + } + } + } + } + } i += 1 } //endTime, err := parseTime(item.SelectAttr("end").Value) @@ -250,6 +276,9 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { // return "", err //} lrcLines = append(lrcLines, strings.Join(lrcSyllables, "")+endTime) + if len(transLine) > 0 { + lrcLines = append(lrcLines, transLine) + } } } return strings.Join(lrcLines, "\n"), nil From be89d9e4c14660ee8539cb639d987d8a9f9397d1 Mon Sep 17 00:00:00 2001 From: itouakirai Date: Sat, 12 Jul 2025 18:50:10 +0800 Subject: [PATCH 16/17] =?UTF-8?q?=E6=9A=82=E6=97=B6=E7=A7=BB=E9=99=A4lrc?= =?UTF-8?q?=E7=9A=84=E8=83=8C=E6=99=AF=E4=BA=BA=E5=A3=B0=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/lyrics/lyrics.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/lyrics/lyrics.go b/utils/lyrics/lyrics.go index ac9a507..ec479a8 100644 --- a/utils/lyrics/lyrics.go +++ b/utils/lyrics/lyrics.go @@ -253,13 +253,13 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { for _, span := range trans.Child { if _, ok := span.(*etree.CharData); ok { textTmp = append(textTmp, span.(*etree.CharData).Data) - } else { + } /*else { textTmp = append(textTmp, span.(*etree.Element).Text()) - } + }*/ } transTxt = strings.Join(textTmp, "") } else { - transTxt = lyric.SelectAttr("text").Value + transTxt = trans.SelectAttr("text").Value } //fmt.Println(transTxt) transLine = transBeginTime + transTxt From 040716e93a890c0f79a0328ad18a09529db284f6 Mon Sep 17 00:00:00 2001 From: itouakirai <85016486+itouakirai@users.noreply.github.com> Date: Sat, 12 Jul 2025 23:29:44 +0800 Subject: [PATCH 17/17] =?UTF-8?q?fix=EF=BC=9A=E6=AD=8C=E8=AF=8D=E8=A1=8C?= =?UTF-8?q?=E6=9C=AB=E5=B0=BE=E6=97=B6=E9=97=B4=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/lyrics/lyrics.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/lyrics/lyrics.go b/utils/lyrics/lyrics.go index ec479a8..a29f93a 100644 --- a/utils/lyrics/lyrics.go +++ b/utils/lyrics/lyrics.go @@ -217,7 +217,7 @@ func conventSyllableTTMLToLRC(ttml string) (string, error) { return "", err } - endTime, err = parseTime(lyric.SelectAttr("end").Value, i) + endTime, err = parseTime(lyric.SelectAttr("end").Value, 1) if err != nil { return "", err }