package main import ( "bufio" "bytes" "encoding/binary" "encoding/json" "gopkg.in/yaml.v2" "errors" "fmt" "github.com/beevik/etree" "io" "io/ioutil" "math" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "github.com/abema/go-mp4" "github.com/grafov/m3u8" ) const ( defaultId = "0" prefetchKey = "skd://itunes.apple.com/P000000000/s1/e1" ) var ( forbiddenNames = regexp.MustCompile(`[/\\<>:"|?*]`) ) type Config struct { MediaUserToken string `yaml:"media-user-token"` SaveLrcFile bool `yaml:"save-lrc-file"` EmbedLrc bool `yaml:"embed-lrc"` EmbedCover bool `yaml:"embed-cover"` CoverSize string `yaml:"cover-size"` CoverFormat string `yaml:"cover-format"` AlacSaveFolder string `yaml:"alac-save-folder"` AtmosSaveFolder string `yaml:"atmos-save-folder"` } var config Config type SampleInfo struct { data []byte duration uint32 descIndex uint32 } type SongInfo struct { r io.ReadSeeker alacParam *Alac samples []SampleInfo } func loadConfig() error { // 读取config.yaml文件内容 data, err := ioutil.ReadFile("config.yaml") if err != nil { return err } // 将yaml解析到config变量中 err = yaml.Unmarshal(data, &config) if err != nil { return err } return nil } func (s *SongInfo) Duration() (ret uint64) { for i := range s.samples { ret += uint64(s.samples[i].duration) } return } func (*Alac) GetType() mp4.BoxType { return BoxTypeAlac() } func fileExists(path string) (bool, error) { f, err := os.Stat(path) if err == nil { return !f.IsDir(), nil } else if os.IsNotExist(err) { return false, nil } return false, err } func writeM4a(w *mp4.Writer, info *SongInfo, meta *AutoGenerated, data []byte, trackNum, trackTotal int) error { index := trackNum - 1 { // ftyp box, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeFtyp()}) if err != nil { return err } _, err = mp4.Marshal(w, &mp4.Ftyp{ MajorBrand: [4]byte{'M', '4', 'A', ' '}, MinorVersion: 0, CompatibleBrands: []mp4.CompatibleBrandElem{ {CompatibleBrand: [4]byte{'M', '4', 'A', ' '}}, {CompatibleBrand: [4]byte{'m', 'p', '4', '2'}}, {CompatibleBrand: mp4.BrandISOM()}, {CompatibleBrand: [4]byte{0, 0, 0, 0}}, }, }, box.Context) if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } const chunkSize uint32 = 5 duration := info.Duration() numSamples := uint32(len(info.samples)) var stco *mp4.BoxInfo { // moov _, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeMoov()}) if err != nil { return err } box, err := mp4.ExtractBox(info.r, nil, mp4.BoxPath{mp4.BoxTypeMoov()}) if err != nil { return err } moovOri := box[0] { // mvhd _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeMvhd()}) if err != nil { return err } oriBox, err := mp4.ExtractBoxWithPayload(info.r, moovOri, mp4.BoxPath{mp4.BoxTypeMvhd()}) if err != nil { return err } mvhd := oriBox[0].Payload.(*mp4.Mvhd) if mvhd.Version == 0 { mvhd.DurationV0 = uint32(duration) } else { mvhd.DurationV1 = duration } _, err = mp4.Marshal(w, mvhd, oriBox[0].Info.Context) if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } { // trak _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeTrak()}) if err != nil { return err } box, err := mp4.ExtractBox(info.r, moovOri, mp4.BoxPath{mp4.BoxTypeTrak()}) if err != nil { return err } trakOri := box[0] { // tkhd _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeTkhd()}) if err != nil { return err } oriBox, err := mp4.ExtractBoxWithPayload(info.r, trakOri, mp4.BoxPath{mp4.BoxTypeTkhd()}) if err != nil { return err } tkhd := oriBox[0].Payload.(*mp4.Tkhd) if tkhd.Version == 0 { tkhd.DurationV0 = uint32(duration) } else { tkhd.DurationV1 = duration } tkhd.SetFlags(0x7) _, err = mp4.Marshal(w, tkhd, oriBox[0].Info.Context) if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } { // mdia _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeMdia()}) if err != nil { return err } box, err := mp4.ExtractBox(info.r, trakOri, mp4.BoxPath{mp4.BoxTypeMdia()}) if err != nil { return err } mdiaOri := box[0] { // mdhd _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeMdhd()}) if err != nil { return err } oriBox, err := mp4.ExtractBoxWithPayload(info.r, mdiaOri, mp4.BoxPath{mp4.BoxTypeMdhd()}) if err != nil { return err } mdhd := oriBox[0].Payload.(*mp4.Mdhd) if mdhd.Version == 0 { mdhd.DurationV0 = uint32(duration) } else { mdhd.DurationV1 = duration } _, err = mp4.Marshal(w, mdhd, oriBox[0].Info.Context) if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } { // hdlr oriBox, err := mp4.ExtractBox(info.r, mdiaOri, mp4.BoxPath{mp4.BoxTypeHdlr()}) if err != nil { return err } err = w.CopyBox(info.r, oriBox[0]) if err != nil { return err } } { // minf _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeMinf()}) if err != nil { return err } box, err := mp4.ExtractBox(info.r, mdiaOri, mp4.BoxPath{mp4.BoxTypeMinf()}) if err != nil { return err } minfOri := box[0] { // smhd, dinf boxes, err := mp4.ExtractBoxes(info.r, minfOri, []mp4.BoxPath{ {mp4.BoxTypeSmhd()}, {mp4.BoxTypeDinf()}, }) if err != nil { return err } for _, b := range boxes { err = w.CopyBox(info.r, b) if err != nil { return err } } } { // stbl _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeStbl()}) if err != nil { return err } { // stsd box, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeStsd()}) if err != nil { return err } _, err = mp4.Marshal(w, &mp4.Stsd{EntryCount: 1}, box.Context) if err != nil { return err } { // alac _, err = w.StartBox(&mp4.BoxInfo{Type: BoxTypeAlac()}) if err != nil { return err } _, err = w.Write([]byte{ 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0}) if err != nil { return err } err = binary.Write(w, binary.BigEndian, uint16(info.alacParam.NumChannels)) if err != nil { return err } err = binary.Write(w, binary.BigEndian, uint16(info.alacParam.BitDepth)) if err != nil { return err } _, err = w.Write([]byte{0, 0}) if err != nil { return err } err = binary.Write(w, binary.BigEndian, info.alacParam.SampleRate) if err != nil { return err } _, err = w.Write([]byte{0, 0}) if err != nil { return err } box, err := w.StartBox(&mp4.BoxInfo{Type: BoxTypeAlac()}) if err != nil { return err } _, err = mp4.Marshal(w, info.alacParam, box.Context) if err != nil { return err } _, err = w.EndBox() if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } _, err = w.EndBox() if err != nil { return err } } { // stts box, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeStts()}) if err != nil { return err } var stts mp4.Stts for _, sample := range info.samples { if len(stts.Entries) != 0 { last := &stts.Entries[len(stts.Entries)-1] if last.SampleDelta == sample.duration { last.SampleCount++ continue } } stts.Entries = append(stts.Entries, mp4.SttsEntry{ SampleCount: 1, SampleDelta: sample.duration, }) } stts.EntryCount = uint32(len(stts.Entries)) _, err = mp4.Marshal(w, &stts, box.Context) if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } { // stsc box, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeStsc()}) if err != nil { return err } if numSamples%chunkSize == 0 { _, err = mp4.Marshal(w, &mp4.Stsc{ EntryCount: 1, Entries: []mp4.StscEntry{ { FirstChunk: 1, SamplesPerChunk: chunkSize, SampleDescriptionIndex: 1, }, }, }, box.Context) } else { _, err = mp4.Marshal(w, &mp4.Stsc{ EntryCount: 2, Entries: []mp4.StscEntry{ { FirstChunk: 1, SamplesPerChunk: chunkSize, SampleDescriptionIndex: 1, }, { FirstChunk: numSamples/chunkSize + 1, SamplesPerChunk: numSamples % chunkSize, SampleDescriptionIndex: 1, }, }, }, box.Context) } _, err = w.EndBox() if err != nil { return err } } { // stsz box, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeStsz()}) if err != nil { return err } stsz := mp4.Stsz{SampleCount: numSamples} for _, sample := range info.samples { stsz.EntrySize = append(stsz.EntrySize, uint32(len(sample.data))) } _, err = mp4.Marshal(w, &stsz, box.Context) if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } { // stco box, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeStco()}) if err != nil { return err } l := (numSamples + chunkSize - 1) / chunkSize _, err = mp4.Marshal(w, &mp4.Stco{ EntryCount: l, ChunkOffset: make([]uint32, l), }, box.Context) stco, err = w.EndBox() if err != nil { return err } } _, err = w.EndBox() if err != nil { return err } } _, err = w.EndBox() if err != nil { return err } } _, err = w.EndBox() if err != nil { return err } } _, err = w.EndBox() if err != nil { return err } } { // udta ctx := mp4.Context{UnderUdta: true} _, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeUdta(), Context: ctx}) if err != nil { return err } { // meta ctx.UnderIlstMeta = true _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeMeta(), Context: ctx}) if err != nil { return err } _, err = mp4.Marshal(w, &mp4.Meta{}, ctx) if err != nil { return err } { // hdlr _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeHdlr(), Context: ctx}) if err != nil { return err } _, err = mp4.Marshal(w, &mp4.Hdlr{ HandlerType: [4]byte{'m', 'd', 'i', 'r'}, Reserved: [3]uint32{0x6170706c, 0, 0}, }, ctx) if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } { // ilst ctx.UnderIlst = true _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeIlst(), Context: ctx}) if err != nil { return err } marshalData := func(val interface{}) error { _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeData()}) if err != nil { return err } var boxData mp4.Data switch v := val.(type) { case string: boxData.DataType = mp4.DataTypeStringUTF8 boxData.Data = []byte(v) case uint8: boxData.DataType = mp4.DataTypeSignedIntBigEndian boxData.Data = []byte{v} case uint32: boxData.DataType = mp4.DataTypeSignedIntBigEndian boxData.Data = make([]byte, 4) binary.BigEndian.PutUint32(boxData.Data, v) case []byte: boxData.DataType = mp4.DataTypeBinary boxData.Data = v default: panic("unsupported value") } _, err = mp4.Marshal(w, &boxData, ctx) if err != nil { return err } _, err = w.EndBox() return err } addMeta := func(tag mp4.BoxType, val interface{}) error { _, err = w.StartBox(&mp4.BoxInfo{Type: tag}) if err != nil { return err } err = marshalData(val) if err != nil { return err } _, err = w.EndBox() return err } addExtendedMeta := func(name string, val interface{}) error { ctx.UnderIlstFreeMeta = true _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxType{'-', '-', '-', '-'}, Context: ctx}) if err != nil { return err } { _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxType{'m', 'e', 'a', 'n'}, Context: ctx}) if err != nil { return err } _, err = w.Write([]byte{0, 0, 0, 0}) if err != nil { return err } _, err = io.WriteString(w, "com.apple.iTunes") if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } { _, err = w.StartBox(&mp4.BoxInfo{Type: mp4.BoxType{'n', 'a', 'm', 'e'}, Context: ctx}) if err != nil { return err } _, err = w.Write([]byte{0, 0, 0, 0}) if err != nil { return err } _, err = io.WriteString(w, name) if err != nil { return err } _, err = w.EndBox() if err != nil { return err } } err = marshalData(val) if err != nil { return err } ctx.UnderIlstFreeMeta = false _, err = w.EndBox() return err } err = addMeta(mp4.BoxType{'\251', 'n', 'a', 'm'}, meta.Data[0].Relationships.Tracks.Data[index].Attributes.Name) if err != nil { return err } err = addMeta(mp4.BoxType{'\251', 'a', 'l', 'b'}, meta.Data[0].Attributes.Name) if err != nil { return err } err = addMeta(mp4.BoxType{'\251', 'A', 'R', 'T'}, meta.Data[0].Relationships.Tracks.Data[index].Attributes.ArtistName) if err != nil { return err } err = addMeta(mp4.BoxType{'\251', 'w', 'r', 't'}, meta.Data[0].Relationships.Tracks.Data[index].Attributes.ComposerName) if err != nil { return err } err = addMeta(mp4.BoxType{'\251', 'd', 'a', 'y'}, strings.Split(meta.Data[0].Attributes.ReleaseDate, "-")[0]) if err != nil { return err } // cnID, err := strconv.ParseUint(meta.Data[0].Relationships.Tracks.Data[index].ID, 10, 32) // if err != nil { // return err // } // err = addMeta(mp4.BoxType{'c', 'n', 'I', 'D'}, uint32(cnID)) // if err != nil { // return err // } err = addExtendedMeta("ISRC", meta.Data[0].Relationships.Tracks.Data[index].Attributes.Isrc) if err != nil { return err } if len(meta.Data[0].Relationships.Tracks.Data[index].Attributes.GenreNames) > 0 { err = addMeta(mp4.BoxType{'\251', 'g', 'e', 'n'}, meta.Data[0].Relationships.Tracks.Data[index].Attributes.GenreNames[0]) if err != nil { return err } } if len(meta.Data) > 0 { album := meta.Data[0] err = addMeta(mp4.BoxType{'a', 'A', 'R', 'T'}, album.Attributes.ArtistName) if err != nil { return err } err = addMeta(mp4.BoxType{'c', 'p', 'r', 't'}, album.Attributes.Copyright) if err != nil { return err } var isCpil uint8 if album.Attributes.IsCompilation { isCpil = 1 } err = addMeta(mp4.BoxType{'c', 'p', 'i', 'l'}, isCpil) if err != nil { return err } err = addExtendedMeta("LABEL", album.Attributes.RecordLabel) if err != nil { return err } err = addExtendedMeta("UPC", album.Attributes.Upc) if err != nil { return err } // plID, err := strconv.ParseUint(album.ID, 10, 32) // if err != nil { // return err // } // err = addMeta(mp4.BoxType{'p', 'l', 'I', 'D'}, uint32(plID)) // if err != nil { // return err // } } // if len(meta.Data[0].Relationships.Artists.Data) > 0 { // atID, err := strconv.ParseUint(meta.Data[0].Relationships.Artists.Data[index].ID, 10, 32) // if err != nil { // return err // } // err = addMeta(mp4.BoxType{'a', 't', 'I', 'D'}, uint32(atID)) // if err != nil { // return err // } // } trkn := make([]byte, 8) binary.BigEndian.PutUint32(trkn, uint32(trackNum)) binary.BigEndian.PutUint16(trkn[4:], uint16(trackTotal)) err = addMeta(mp4.BoxType{'t', 'r', 'k', 'n'}, trkn) if err != nil { return err } // disk := make([]byte, 8) // binary.BigEndian.PutUint32(disk, uint32(meta.Attributes.DiscNumber)) // err = addMeta(mp4.BoxType{'d', 'i', 's', 'k'}, disk) // if err != nil { // return err // } ctx.UnderIlst = false _, err = w.EndBox() if err != nil { return err } } ctx.UnderIlstMeta = false _, err = w.EndBox() if err != nil { return err } } ctx.UnderUdta = false _, err = w.EndBox() if err != nil { return err } } _, err = w.EndBox() if err != nil { return err } } { box, err := w.StartBox(&mp4.BoxInfo{Type: mp4.BoxTypeMdat()}) if err != nil { return err } _, err = mp4.Marshal(w, &mp4.Mdat{Data: data}, box.Context) if err != nil { return err } mdat, err := w.EndBox() var realStco mp4.Stco offset := mdat.Offset + mdat.HeaderSize for i := uint32(0); i < numSamples; i++ { if i%chunkSize == 0 { realStco.EntryCount++ realStco.ChunkOffset = append(realStco.ChunkOffset, uint32(offset)) } offset += uint64(len(info.samples[i].data)) } _, err = stco.SeekToPayload(w) if err != nil { return err } _, err = mp4.Marshal(w, &realStco, box.Context) if err != nil { return err } } return nil } func decryptSong(info *SongInfo, keys []string, manifest *AutoGenerated, filename string, trackNum, trackTotal int) error { //fmt.Printf("%d-bit / %d Hz\n", info.bitDepth, info.bitRate) conn, err := net.Dial("tcp", "127.0.0.1:10020") if err != nil { return err } defer conn.Close() var decrypted []byte var lastIndex uint32 = math.MaxUint8 fmt.Println("Decrypt start.") for _, sp := range info.samples { if lastIndex != sp.descIndex { if len(decrypted) != 0 { _, err := conn.Write([]byte{0, 0, 0, 0}) if err != nil { return err } } keyUri := keys[sp.descIndex] id := manifest.Data[0].Relationships.Tracks.Data[trackNum-1].ID if keyUri == prefetchKey { id = defaultId } _, err := conn.Write([]byte{byte(len(id))}) if err != nil { return err } _, err = io.WriteString(conn, id) if err != nil { return err } _, err = conn.Write([]byte{byte(len(keyUri))}) if err != nil { return err } _, err = io.WriteString(conn, keyUri) if err != nil { return err } } lastIndex = sp.descIndex err := binary.Write(conn, binary.LittleEndian, uint32(len(sp.data))) if err != nil { return err } _, err = conn.Write(sp.data) if err != nil { return err } de := make([]byte, len(sp.data)) _, err = io.ReadFull(conn, de) if err != nil { return err } decrypted = append(decrypted, de...) } _, _ = conn.Write([]byte{0, 0, 0, 0, 0}) fmt.Println("Decrypt finished.") create, err := os.Create(filename) if err != nil { return err } defer create.Close() return writeM4a(mp4.NewWriter(create), info, manifest, decrypted, trackNum, trackTotal) } func checkUrl(url string) (string, string) { pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w{2})(?:\/album|\/album\/.+))\/(?:id)?(\d[^\D]+)(?:$|\?)`) matches := pat.FindAllStringSubmatch(url, -1) if matches == nil { return "", "" } else { return matches[0][1], matches[0][2] } } func checkUrlPlaylist(url string) (string, string) { pat := regexp.MustCompile(`^(?:https:\/\/(?:beta\.music|music)\.apple\.com\/(\w{2})(?:\/playlist|\/playlist\/.+))\/(?:id)?(pl\.[\w-]+)(?:$|\?)`) matches := pat.FindAllStringSubmatch(url, -1) if matches == nil { return "", "" } else { return matches[0][1], matches[0][2] } } func getMeta(albumId string, token string, storefront string) (*AutoGenerated, error) { var mtype string var page int if strings.Contains(albumId, "pl.") { mtype = "playlists" } else { mtype = "albums" } req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/%s/%s", storefront, mtype, albumId), nil) if err != nil { return nil, err } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") req.Header.Set("Origin", "https://music.apple.com") query := url.Values{} query.Set("omit[resource]", "autos") query.Set("include", "tracks,artists,record-labels") query.Set("include[songs]", "artists") query.Set("fields[artists]", "name") query.Set("fields[albums:albums]", "artistName,artwork,name,releaseDate,url") query.Set("fields[record-labels]", "name") // query.Set("l", "en-gb") 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(AutoGenerated) err = json.NewDecoder(do.Body).Decode(&obj) if err != nil { return nil, err } if strings.Contains(albumId, "pl.") { obj.Data[0].Attributes.ArtistName = "Apple Music" if len(obj.Data[0].Relationships.Tracks.Next) > 0 { page = 0 for { page = page + 100 pageStr := strconv.Itoa(page) req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/%s/%s/tracks?offset=%s", storefront, mtype, albumId, pageStr), 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(AutoGeneratedTrack) err = json.NewDecoder(do.Body).Decode(&obj2) if err != nil { return nil, err } for _, value := range obj2.Data { obj.Data[0].Relationships.Tracks.Data = append(obj.Data[0].Relationships.Tracks.Data, value) } if len(obj2.Next) == 0 { break } } } } return obj, nil } func getSongLyrics(songId string, storefront string, token string, userToken string) (string, error) { req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.music.apple.com/v1/catalog/%s/songs/%s/lyrics", storefront, songId), nil) if err != nil { return "", err } req.Header.Set("Origin", "https://music.apple.com") req.Header.Set("Referer", "https://music.apple.com/") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) cookie := http.Cookie{Name: "media-user-token", Value: userToken} req.AddCookie(&cookie) do, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer do.Body.Close() obj := new(SongLyrics) err = json.NewDecoder(do.Body).Decode(&obj) if obj.Data != nil { return obj.Data[0].Attributes.Ttml, nil } else { return "", errors.New("failed to get lyrics") } } func writeCover(sanAlbumFolder, url string) error { covPath := filepath.Join(sanAlbumFolder, "cover." + config.CoverFormat) exists, err := fileExists(covPath) if err != nil { fmt.Println("Failed to check if cover exists.") return err } if exists { return nil } if config.CoverFormat == "png" { re := regexp.MustCompile(`\{w\}x\{h\}`) parts := re.Split(url, 2) url = parts[0] + "{w}x{h}" + strings.Replace(parts[1], ".jpg", ".png", 1) } url = strings.Replace(url, "{w}x{h}", config.CoverSize, 1) req, err := http.NewRequest("GET", url, nil) if err != nil { return err } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") do, err := http.DefaultClient.Do(req) if err != nil { return err } defer do.Body.Close() if do.StatusCode != http.StatusOK { errors.New(do.Status) } f, err := os.Create(covPath) if err != nil { return err } defer f.Close() _, err = io.Copy(f, do.Body) if err != nil { return err } return nil } func writeLyrics(sanAlbumFolder, filename string, lrc string) error { lyricspath := filepath.Join(sanAlbumFolder, filename) f, err := os.Create(lyricspath) if err != nil { return err } defer f.Close() _, err = f.WriteString(lrc) if err != nil { return err } return nil } func isInArray(arr []int, target int) bool { for _, num := range arr { if num == target { return true } } return false } func rip(albumId string, token string, storefront string, userToken string) error { meta, err := getMeta(albumId, token, storefront) if err != nil { fmt.Println("Failed to get album metadata.\n") return err } singerFoldername := fmt.Sprintf("%s", meta.Data[0].Attributes.ArtistName) if strings.HasSuffix(singerFoldername, ".") { singerFoldername = strings.ReplaceAll(singerFoldername, ".", "") } singerFoldername = strings.TrimSpace(singerFoldername) singerFolder := filepath.Join("AM-DL downloads", forbiddenNames.ReplaceAllString(singerFoldername, "_")) albumFolder := fmt.Sprintf("%s", meta.Data[0].Attributes.Name) 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(singerFoldername) fmt.Println(albumFolder) err = writeCover(sanAlbumFolder, meta.Data[0].Attributes.Artwork.URL) if err != nil { fmt.Println("Failed to write cover.") } trackTotal := len(meta.Data[0].Relationships.Tracks.Data) arr := make([]int, trackTotal) for i := 0; i < trackTotal; i++ { arr[i] = i + 1 } fmt.Print("select: ") reader := bufio.NewReader(os.Stdin) input, err := reader.ReadString('\n') if err != nil { fmt.Println(err) } manually := false if strings.Contains(input, "#") { input = strings.ReplaceAll(input, "#", "") manually = true } input = strings.TrimSpace(input) inputs := strings.Fields(input) for _, str := range inputs { num, err := strconv.Atoi(str) if err != nil { fmt.Printf("wrong '%s', skip...\n", str) } for i := 0; i < len(arr); i++ { if arr[i] == num { arr = append(arr[:i], arr[i+1:]...) break } } } for trackNum, track := range meta.Data[0].Relationships.Tracks.Data { trackNum++ if isInArray(arr, trackNum) { continue } else { fmt.Printf("Track %d of %d:\n", trackNum, trackTotal) manifest, err := getInfoFromAdam(track.ID, token, storefront) if err != nil { fmt.Println("Failed to get manifest.\n", err) continue } if manifest.Attributes.ExtendedAssetUrls.EnhancedHls == "" { fmt.Println("Unavailable in ALAC.") continue } filename := fmt.Sprintf("%02d. %s.m4a", trackNum, forbiddenNames.ReplaceAllString(track.Attributes.Name, "_")) fmt.Println(filename) lrcFilename := fmt.Sprintf("%02d. %s.lrc", trackNum, forbiddenNames.ReplaceAllString(track.Attributes.Name, "_")) trackPath := filepath.Join(sanAlbumFolder, filename) var lrc string = "" if userToken != "your-media-user-token" && (config.EmbedLrc || config.SaveLrcFile) { ttml, err := getSongLyrics(track.ID, storefront, token, userToken) if err != nil { fmt.Println("Failed to get lyrics") } else { lrc, err = conventTTMLToLRC(ttml) if err != nil { fmt.Printf("Failed to parse lyrics: %s \n", err) } else { if config.SaveLrcFile { err := writeLyrics(sanAlbumFolder, lrcFilename, lrc) if err != nil { fmt.Printf("Failed to write lyrics") } if !config.EmbedLrc { lrc = "" } } } } } exists, err := fileExists(trackPath) if err != nil { fmt.Println("Failed to check if track exists.") } if exists { fmt.Println("Track already exists locally.") continue } if manually { fmt.Print("m3u8: ") reader := bufio.NewReader(os.Stdin) m3u8_url, err := reader.ReadString('\n') if err != nil { fmt.Println(err) } m3u8_url = strings.TrimSpace(m3u8_url) manifest.Attributes.ExtendedAssetUrls.EnhancedHls=m3u8_url } trackUrl, keys, err := extractMedia(manifest.Attributes.ExtendedAssetUrls.EnhancedHls) if err != nil { fmt.Println("Failed to extract info from manifest.\n", err) continue } info, err := extractSong(trackUrl) if err != nil { fmt.Println("Failed to extract track.", err) continue } samplesOk := true for samplesOk { for _, i := range info.samples { if int(i.descIndex) >= len(keys) { fmt.Println("Decryption size mismatch.") samplesOk = false } } break } if !samplesOk { continue } err = decryptSong(info, keys, meta, trackPath, trackNum, trackTotal) if err != nil { fmt.Println("Failed to decrypt track.\n", err) continue } tags := []string{ fmt.Sprintf("lyrics=%s", lrc), } if config.EmbedCover { tags = append(tags, fmt.Sprintf("cover=%s/cover.%s", sanAlbumFolder, config.CoverFormat)) } tagsString := strings.Join(tags, ":") cmd := exec.Command("MP4Box", "-itags", tagsString, trackPath) if err := cmd.Run(); err != nil { fmt.Printf("Embed failed: %v\n", err) continue } } } return err } func main() { err := loadConfig() if err != nil { fmt.Printf("load config failed: %v", err) return } token, err := getToken() if err != nil { fmt.Println("Failed to get token.") return } albumTotal := len(os.Args[1:]) for albumNum, url := range os.Args[1:] { fmt.Printf("Album %d of %d:\n", albumNum+1, albumTotal) var storefront, albumId string if strings.Contains(url, "/playlist/") { storefront, albumId = checkUrlPlaylist(url) } else { storefront, albumId = checkUrl(url) } if albumId == "" { fmt.Printf("Invalid URL: %s\n", url) continue } err := rip(albumId, token, storefront, config.MediaUserToken) if err != nil { fmt.Println("Album failed.") fmt.Println(err) } } } func conventTTMLToLRC(ttml string) (string, error) { parsedTTML := etree.NewDocument() err := parsedTTML.ReadFromString(ttml) if err != nil { return "", err } var lrcLines []string for _, item := range parsedTTML.FindElement("tt").FindElement("body").ChildElements() { for _, lyric := range item.ChildElements() { var h, m, s, ms int if lyric.SelectAttr("begin") == nil { return "", errors.New("no synchronised lyrics") } if strings.Contains(lyric.SelectAttr("begin").Value, ":") { _, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d:%d:%d.%d", &h, &m, &s, &ms) if err != nil { _, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d:%d.%d", &m, &s, &ms) h = 0 } } else { _, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d.%d", &s, &ms) h, m = 0, 0 } if err != nil { return "", err } var text string if lyric.SelectAttr("text") == nil { var textTmp []string for _, span := range lyric.Child { if _, ok := span.(*etree.CharData); ok { textTmp = append(textTmp, span.(*etree.CharData).Data) } else { textTmp = append(textTmp, span.(*etree.Element).Text()) } } text = strings.Join(textTmp, "") } else { text = lyric.SelectAttr("text").Value } m += h * 60 ms = ms / 10 lrcLines = append(lrcLines, fmt.Sprintf("[%02d:%02d.%02d]%s", m, s, ms, text)) } } return strings.Join(lrcLines, "\n"), nil } func extractMedia(b string) (string, []string, error) { masterUrl, err := url.Parse(b) if err != nil { return "", nil, err } resp, err := http.Get(b) if err != nil { return "", nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", nil, errors.New(resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { return "", nil, err } masterString := string(body) from, listType, err := m3u8.DecodeFrom(strings.NewReader(masterString), true) if err != nil || listType != m3u8.MASTER { return "", nil, errors.New("m3u8 not of master type") } master := from.(*m3u8.MasterPlaylist) var streamUrl *url.URL sort.Slice(master.Variants, func(i, j int) bool { return master.Variants[i].AverageBandwidth > master.Variants[j].AverageBandwidth }) for _, variant := range master.Variants { if variant.Codecs == "alac" { split := strings.Split(variant.Audio, "-") length := len(split) fmt.Printf("%s-bit / %s Hz\n", split[length-1], split[length-2]) streamUrlTemp, err := masterUrl.Parse(variant.URI) if err != nil { panic(err) } streamUrl = streamUrlTemp break } } if streamUrl == nil { return "", nil, errors.New("no alac codec found") } var keys []string keys = append(keys, prefetchKey) streamUrl.Path = strings.TrimSuffix(streamUrl.Path, ".m3u8") + "_m.mp4" regex := regexp.MustCompile(`"(skd?://[^"]*)"`) matches := regex.FindAllStringSubmatch(masterString, -1) for _, match := range matches { if strings.HasSuffix(match[1], "c23") || strings.HasSuffix(match[1], "c6") { keys = append(keys, match[1]) } } return streamUrl.String(), keys, nil } func extractSong(url string) (*SongInfo, error) { fmt.Println("Downloading...") track, err := http.Get(url) if err != nil { return nil, err } defer track.Body.Close() if track.StatusCode != http.StatusOK { return nil, errors.New(track.Status) } rawSong, err := ioutil.ReadAll(track.Body) if err != nil { return nil, err } fmt.Println("Downloaded.") f := bytes.NewReader(rawSong) trex, err := mp4.ExtractBoxWithPayload(f, nil, []mp4.BoxType{ mp4.BoxTypeMoov(), mp4.BoxTypeMvex(), mp4.BoxTypeTrex(), }) if err != nil || len(trex) != 1 { return nil, err } trexPay := trex[0].Payload.(*mp4.Trex) stbl, err := mp4.ExtractBox(f, nil, []mp4.BoxType{ mp4.BoxTypeMoov(), mp4.BoxTypeTrak(), mp4.BoxTypeMdia(), mp4.BoxTypeMinf(), mp4.BoxTypeStbl(), }) if err != nil || len(stbl) != 1 { return nil, err } enca, err := mp4.ExtractBoxWithPayload(f, stbl[0], []mp4.BoxType{ mp4.BoxTypeStsd(), mp4.BoxTypeEnca(), }) if err != nil { return nil, err } aalac, err := mp4.ExtractBoxWithPayload(f, &enca[0].Info, []mp4.BoxType{BoxTypeAlac()}) if err != nil || len(aalac) != 1 { return nil, err } extracted := &SongInfo{ r: f, alacParam: aalac[0].Payload.(*Alac), } moofs, err := mp4.ExtractBox(f, nil, []mp4.BoxType{ mp4.BoxTypeMoof(), }) if err != nil || len(moofs) <= 0 { return nil, err } mdats, err := mp4.ExtractBoxWithPayload(f, nil, []mp4.BoxType{ mp4.BoxTypeMdat(), }) if err != nil || len(mdats) != len(moofs) { return nil, err } for i, moof := range moofs { tfhd, err := mp4.ExtractBoxWithPayload(f, moof, []mp4.BoxType{ mp4.BoxTypeTraf(), mp4.BoxTypeTfhd(), }) if err != nil || len(tfhd) != 1 { return nil, err } tfhdPay := tfhd[0].Payload.(*mp4.Tfhd) index := tfhdPay.SampleDescriptionIndex if index != 0 { index-- } truns, err := mp4.ExtractBoxWithPayload(f, moof, []mp4.BoxType{ mp4.BoxTypeTraf(), mp4.BoxTypeTrun(), }) if err != nil || len(truns) <= 0 { return nil, err } mdat := mdats[i].Payload.(*mp4.Mdat).Data for _, t := range truns { for _, en := range t.Payload.(*mp4.Trun).Entries { info := SampleInfo{descIndex: index} switch { case t.Payload.CheckFlag(0x200): info.data = mdat[:en.SampleSize] mdat = mdat[en.SampleSize:] case tfhdPay.CheckFlag(0x10): info.data = mdat[:tfhdPay.DefaultSampleSize] mdat = mdat[tfhdPay.DefaultSampleSize:] default: info.data = mdat[:trexPay.DefaultSampleSize] mdat = mdat[trexPay.DefaultSampleSize:] } switch { case t.Payload.CheckFlag(0x100): info.duration = en.SampleDuration case tfhdPay.CheckFlag(0x8): info.duration = tfhdPay.DefaultSampleDuration default: info.duration = trexPay.DefaultSampleDuration } extracted.samples = append(extracted.samples, info) } } if len(mdat) != 0 { return nil, errors.New("offset mismatch") } } return extracted, nil } func init() { mp4.AddBoxDef((*Alac)(nil)) } func BoxTypeAlac() mp4.BoxType { return mp4.StrToBoxType("alac") } type Alac struct { mp4.FullBox `mp4:"extend"` FrameLength uint32 `mp4:"size=32"` CompatibleVersion uint8 `mp4:"size=8"` BitDepth uint8 `mp4:"size=8"` Pb uint8 `mp4:"size=8"` Mb uint8 `mp4:"size=8"` Kb uint8 `mp4:"size=8"` NumChannels uint8 `mp4:"size=8"` MaxRun uint16 `mp4:"size=16"` MaxFrameBytes uint32 `mp4:"size=32"` AvgBitRate uint32 `mp4:"size=32"` SampleRate uint32 `mp4:"size=32"` } func getInfoFromAdam(adamId string, token string, storefront string) (*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") 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(ApiResult) err = json.NewDecoder(do.Body).Decode(&obj) if err != nil { return nil, err } for _, d := range obj.Data { if d.ID == adamId { return &d, nil } } return nil, nil } func getToken() (string, error) { req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } regex := regexp.MustCompile(`/assets/index-legacy-[^/]+\.js`) indexJsUri := regex.FindString(string(body)) req, err = http.NewRequest("GET", "https://beta.music.apple.com"+indexJsUri, nil) if err != nil { return "", err } resp, err = http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err = io.ReadAll(resp.Body) if err != nil { return "", err } regex = regexp.MustCompile(`eyJh([^"]*)`) token := regex.FindString(string(body)) return token, nil } 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"` 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"` 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 iTunesLookup struct { Results map[string]SongResult `json:"results"` } type Meta struct { Context string `json:"@context"` Type string `json:"@type"` Name string `json:"name"` Description string `json:"description"` Tracks []struct { Type string `json:"@type"` Name string `json:"name"` Audio struct { Type string `json:"@type"` } `json:"audio"` Offers struct { Type string `json:"@type"` Category string `json:"category"` Price int `json:"price"` } `json:"offers"` Duration string `json:"duration"` } `json:"tracks"` Citation []interface{} `json:"citation"` WorkExample []struct { Type string `json:"@type"` Name string `json:"name"` URL string `json:"url"` Audio struct { Type string `json:"@type"` } `json:"audio"` Offers struct { Type string `json:"@type"` Category string `json:"category"` Price int `json:"price"` } `json:"offers"` Duration string `json:"duration"` } `json:"workExample"` Genre []string `json:"genre"` DatePublished time.Time `json:"datePublished"` ByArtist struct { Type string `json:"@type"` URL string `json:"url"` Name string `json:"name"` } `json:"byArtist"` } 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"` 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"` } `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"` } `json:"attributes"` } `json:"data"` } `json:"artists"` Tracks 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"` 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"` } `json:"relationships"` } `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"` 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"` } `json:"relationships"` } `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"` }