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 +// }