From d331a9d10abdf27dc477a019fe9a9ef7199daea0 Mon Sep 17 00:00:00 2001 From: WorldObservationLog Date: Wed, 24 Apr 2024 01:02:04 +0800 Subject: [PATCH] feat: lrc lyrics download --- README.md | 9 +++ go.mod | 5 +- go.sum | 8 +++ main.go | 149 ++++++++++++++++++++++++++++++++++++++++++------ main_atmos.go | 147 ++++++++++++++++++++++++++++++++++++++++++----- main_select.go | 151 +++++++++++++++++++++++++++++++++++++++++++------ 6 files changed, 417 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 839548d..b660f2a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ 1. 调用外部MP4Box自动封装ec3为m4a 2. 更改目录结构为 歌手名\专辑名 ;Atmos下载文件则另外移动到AM-DL-Atmos downloads,并更改目录结构为 歌手名\专辑名 [Atmos] 3. 运行结束后显示总体完成情况 +4. 下载LRC歌词 @@ -24,3 +25,11 @@ Original script by Sorrow. Modified by me to include some fixes and improvements 10. For dolby atmos: `go run main_atmos.go https://music.apple.com/us/album/1989-taylors-version-deluxe/1713845538`. [中文教程-详见方法三](https://telegra.ph/Apple-Music-Alac高解析度无损音乐下载教程-04-02-2) + +## Downloading lyrics +1. Open [Apple Music](https://music.apple.com) and log in +2. Open the Developer tools, Click `Application -> Storage -> Cookies -> https://music.apple.com` +3. Find the cookie named `media-user-token` and copy its value +4. Create a file named `media-user-token.txt` in the project root directory +5. Paste the cookie value obtained in step 3 into the file and save it +6. Start the script as usual \ No newline at end of file diff --git a/go.mod b/go.mod index 919b0c3..e2febcb 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,7 @@ require ( github.com/grafov/m3u8 v0.11.1 ) -require github.com/google/uuid v1.1.2 // indirect +require ( + github.com/beevik/etree v1.3.0 // indirect + github.com/google/uuid v1.1.2 // indirect +) diff --git a/go.sum b/go.sum index dea339a..2d15e06 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,10 @@ github.com/abema/go-mp4 v0.7.2 h1:ugTC8gfEmjyaDKpXs3vi2QzgJbDu9B8m6UMMIpbYbGg= github.com/abema/go-mp4 v0.7.2/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= +github.com/beevik/etree v1.3.0 h1:hQTc+pylzIKDb23yYprodCWWTt+ojFfUZyzU09a/hmU= +github.com/beevik/etree v1.3.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -11,9 +14,12 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -21,6 +27,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go index 6de2694..5bb1e8b 100644 --- a/main.go +++ b/main.go @@ -16,11 +16,12 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "time" - "strconv" "github.com/abema/go-mp4" + "github.com/beevik/etree" "github.com/grafov/m3u8" ) @@ -717,7 +718,6 @@ func writeM4a(w *mp4.Writer, info *SongInfo, meta *AutoGenerated, data []byte, t return err } - // plID, err := strconv.ParseUint(album.ID, 10, 32) // if err != nil { // return err @@ -900,7 +900,7 @@ func decryptSong(info *SongInfo, keys []string, manifest *AutoGenerated, filenam 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 { @@ -910,7 +910,7 @@ func checkUrl(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-]+)(?:$|\?)`) matches := pat.FindAllStringSubmatch(url, -1) - + if matches == nil { return "", "" } else { @@ -918,8 +918,6 @@ func checkUrlPlaylist(url string) (string, string) { } } - - func getMeta(albumId string, token string, storefront string) (*AutoGenerated, error) { var mtype string var page int @@ -928,7 +926,7 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e } 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) + 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 } @@ -958,11 +956,11 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e return nil, err } if strings.Contains(albumId, "pl.") { - obj.Data[0].Attributes.ArtistName="Apple Music" + obj.Data[0].Attributes.ArtistName = "Apple Music" if len(obj.Data[0].Relationships.Tracks.Next) > 0 { - page=0 - for{ - page=page+100 + 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 { @@ -987,7 +985,7 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e for _, value := range obj2.Data { obj.Data[0].Relationships.Tracks.Data = append(obj.Data[0].Relationships.Tracks.Data, value) } - if len(obj2.Next)==0{ + if len(obj2.Next) == 0 { break } } @@ -996,6 +994,27 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e 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) + return obj.Data[0].Attributes.Ttml, nil +} + func writeCover(sanAlbumFolder, url string) error { covPath := filepath.Join(sanAlbumFolder, "cover.jpg") exists, err := fileExists(covPath) @@ -1032,9 +1051,21 @@ func writeCover(sanAlbumFolder, url string) error { return nil } -func rip(albumId string, token string, storefront string) error { - +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 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") @@ -1072,7 +1103,24 @@ func rip(albumId string, token string, storefront string) error { continue } filename := fmt.Sprintf("%02d. %s.m4a", trackNum, forbiddenNames.ReplaceAllString(track.Attributes.Name, "_")) + lrcFilename := fmt.Sprintf("%02d. %s.lrc", trackNum, forbiddenNames.ReplaceAllString(track.Attributes.Name, "_")) trackPath := filepath.Join(sanAlbumFolder, filename) + if userToken != "" { + 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 { + err := writeLyrics(sanAlbumFolder, lrcFilename, lrc) + if err != nil { + fmt.Printf("Failed to write lyrics") + } + } + } + } exists, err := fileExists(trackPath) if err != nil { fmt.Println("Failed to check if track exists.") @@ -1105,7 +1153,7 @@ func rip(albumId string, token string, storefront string) error { if !samplesOk { continue } - err = decryptSong(info, keys, meta, trackPath, trackNum, trackTotal) + // err = decryptSong(info, keys, meta, trackPath, trackNum, trackTotal) if err != nil { fmt.Println("Failed to decrypt track.\n", err) continue @@ -1116,6 +1164,13 @@ func rip(albumId string, token string, storefront string) error { } func main() { + var mediaUserToken string + if _, err := os.Stat("media-user-token.txt"); err == nil { + file, err := os.ReadFile("media-user-token.txt") + if err == nil && file != nil { + mediaUserToken = string(file) + } + } token, err := getToken() if err != nil { fmt.Println("Failed to get token.") @@ -1130,12 +1185,12 @@ func main() { } else { storefront, albumId = checkUrl(url) } - + if albumId == "" { fmt.Printf("Invalid URL: %s\n", url) continue } - err := rip(albumId, token, storefront) + err = rip(albumId, token, storefront, mediaUserToken) if err != nil { fmt.Println("Album failed.") fmt.Println(err) @@ -1144,6 +1199,48 @@ func main() { fmt.Printf("======= Completed %d/%d ###### %d errors!! =======\n", oktrackNum, trackTotalnum, trackTotalnum-oktrackNum) } +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 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", &m, &s, &ms) + } else { + _, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d.%d", &s, &ms) + m = 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 + } + lrcLines = append(lrcLines, fmt.Sprintf("[%02d:%02d.%03d]%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 { @@ -1777,4 +1874,20 @@ type AutoGeneratedTrack struct { } `json:"artists"` } `json:"relationships"` } `json:"data"` -} \ No newline at end of file +} + +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"` +} diff --git a/main_atmos.go b/main_atmos.go index 6c7a281..09229bf 100644 --- a/main_atmos.go +++ b/main_atmos.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/beevik/etree" "io" "io/ioutil" "math" @@ -17,9 +18,9 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "time" - "strconv" "github.com/abema/go-mp4" "github.com/grafov/m3u8" @@ -916,7 +917,7 @@ func checkUrl(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-]+)(?:$|\?)`) matches := pat.FindAllStringSubmatch(url, -1) - + if matches == nil { return "", "" } else { @@ -932,7 +933,7 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e } 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) + 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 } @@ -962,11 +963,11 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e return nil, err } if strings.Contains(albumId, "pl.") { - obj.Data[0].Attributes.ArtistName="Apple Music" + obj.Data[0].Attributes.ArtistName = "Apple Music" if len(obj.Data[0].Relationships.Tracks.Next) > 0 { - page=0 - for{ - page=page+100 + 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 { @@ -991,7 +992,7 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e for _, value := range obj2.Data { obj.Data[0].Relationships.Tracks.Data = append(obj.Data[0].Relationships.Tracks.Data, value) } - if len(obj2.Next)==0{ + if len(obj2.Next) == 0 { break } } @@ -1000,6 +1001,27 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e 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) + return obj.Data[0].Attributes.Ttml, nil +} + func writeCover(sanAlbumFolder, url string) error { covPath := filepath.Join(sanAlbumFolder, "cover.jpg") exists, err := fileExists(covPath) @@ -1036,7 +1058,21 @@ func writeCover(sanAlbumFolder, url string) error { return nil } -func rip(albumId string, token string, storefront string) error { +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 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") @@ -1075,6 +1111,7 @@ func rip(albumId string, token string, storefront string) error { } filename := fmt.Sprintf("%02d. %s.ec3", trackNum, forbiddenNames.ReplaceAllString(track.Attributes.Name, "_")) m4afilename := fmt.Sprintf("%02d. %s.m4a", trackNum, forbiddenNames.ReplaceAllString(track.Attributes.Name, "_")) + lrcFilename := fmt.Sprintf("%02d. %s.lrc", trackNum, forbiddenNames.ReplaceAllString(track.Attributes.Name, "_")) trackPath := filepath.Join(sanAlbumFolder, filename) m4atrackPath := filepath.Join(sanAlbumFolder, m4afilename) exists, err := fileExists(trackPath) @@ -1090,6 +1127,22 @@ func rip(albumId string, token string, storefront string) error { oktrackNum += 1 continue } + if userToken != "" { + 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 { + err := writeLyrics(sanAlbumFolder, lrcFilename, lrc) + if err != nil { + fmt.Printf("Failed to write lyrics") + } + } + } + } trackUrl, keys, err := extractMedia(manifest.Attributes.ExtendedAssetUrls.EnhancedHls) if err != nil { fmt.Println("Failed to extract info from manifest.\n", err) @@ -1133,9 +1186,9 @@ func rip(albumId string, token string, storefront string) error { fmt.Sprintf("UPC=%s", meta.Data[0].Attributes.Upc), fmt.Sprintf("track=%d/%d", trackNum, trackTotal), } - + tagsString := strings.Join(tags, ":") - cmd := exec.Command("MP4Box", "-add", trackPath,"-name",fmt.Sprintf("1=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.Name),"-itags",tagsString, "-brand", "mp42", "-ab", "dby1", m4atrackPath) + cmd := exec.Command("MP4Box", "-add", trackPath, "-name", fmt.Sprintf("1=%s", meta.Data[0].Relationships.Tracks.Data[index].Attributes.Name), "-itags", tagsString, "-brand", "mp42", "-ab", "dby1", m4atrackPath) fmt.Printf("Encapsulating %s into %s\n", filepath.Base(trackPath), filepath.Base(m4atrackPath)) if err := cmd.Run(); err != nil { fmt.Printf("Error encapsulating file: %v\n", err) @@ -1153,8 +1206,14 @@ func rip(albumId string, token string, storefront string) error { return err } - func main() { + var mediaUserToken string + if _, err := os.Stat("media-user-token.txt"); err == nil { + file, err := os.ReadFile("media-user-token.txt") + if err == nil && file != nil { + mediaUserToken = string(file) + } + } token, err := getToken() if err != nil { fmt.Println("Failed to get token.") @@ -1173,7 +1232,7 @@ func main() { fmt.Printf("Invalid URL: %s\n", url) continue } - err := rip(albumId, token, storefront) + err := rip(albumId, token, storefront, mediaUserToken) if err != nil { fmt.Println("Album failed.") fmt.Println(err) @@ -1182,6 +1241,48 @@ func main() { fmt.Printf("======= Completed %d/%d ###### %d errors!! =======\n", oktrackNum, trackTotalnum, trackTotalnum-oktrackNum) } +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 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", &m, &s, &ms) + } else { + _, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d.%d", &s, &ms) + m = 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 + } + lrcLines = append(lrcLines, fmt.Sprintf("[%02d:%02d.%03d]%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 { @@ -1290,7 +1391,7 @@ func extractSong(url string) (*SongInfo, error) { // } extracted := &SongInfo{ - r: f, + r: f, // alacParam: aalac[0].Payload.(*Alac), } @@ -1814,4 +1915,20 @@ type AutoGeneratedTrack struct { } `json:"artists"` } `json:"relationships"` } `json:"data"` -} \ No newline at end of file +} + +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"` +} diff --git a/main_select.go b/main_select.go index b3eccaa..edb9c2d 100644 --- a/main_select.go +++ b/main_select.go @@ -1,11 +1,13 @@ package main import ( + "bufio" "bytes" "encoding/binary" "encoding/json" "errors" "fmt" + "github.com/beevik/etree" "io" "io/ioutil" "math" @@ -16,10 +18,9 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "time" - "bufio" - "strconv" "github.com/abema/go-mp4" "github.com/grafov/m3u8" @@ -716,7 +717,6 @@ func writeM4a(w *mp4.Writer, info *SongInfo, meta *AutoGenerated, data []byte, t return err } - // plID, err := strconv.ParseUint(album.ID, 10, 32) // if err != nil { // return err @@ -899,7 +899,7 @@ func decryptSong(info *SongInfo, keys []string, manifest *AutoGenerated, filenam 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 { @@ -909,7 +909,7 @@ func checkUrl(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-]+)(?:$|\?)`) matches := pat.FindAllStringSubmatch(url, -1) - + if matches == nil { return "", "" } else { @@ -917,8 +917,6 @@ func checkUrlPlaylist(url string) (string, string) { } } - - func getMeta(albumId string, token string, storefront string) (*AutoGenerated, error) { var mtype string var page int @@ -927,7 +925,7 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e } 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) + 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 } @@ -957,11 +955,11 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e return nil, err } if strings.Contains(albumId, "pl.") { - obj.Data[0].Attributes.ArtistName="Apple Music" + obj.Data[0].Attributes.ArtistName = "Apple Music" if len(obj.Data[0].Relationships.Tracks.Next) > 0 { - page=0 - for{ - page=page+100 + 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 { @@ -986,7 +984,7 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e for _, value := range obj2.Data { obj.Data[0].Relationships.Tracks.Data = append(obj.Data[0].Relationships.Tracks.Data, value) } - if len(obj2.Next)==0{ + if len(obj2.Next) == 0 { break } } @@ -995,6 +993,27 @@ func getMeta(albumId string, token string, storefront string) (*AutoGenerated, e 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) + return obj.Data[0].Attributes.Ttml, nil +} + func writeCover(sanAlbumFolder, url string) error { covPath := filepath.Join(sanAlbumFolder, "cover.jpg") exists, err := fileExists(covPath) @@ -1030,6 +1049,21 @@ func writeCover(sanAlbumFolder, url string) error { } 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 { @@ -1039,8 +1073,7 @@ func isInArray(arr []int, target int) bool { return false } -func rip(albumId string, token string, storefront string) error { - +func rip(albumId string, token string, storefront string, userToken string) error { meta, err := getMeta(albumId, token, storefront) if err != nil { @@ -1106,7 +1139,24 @@ func rip(albumId string, token string, storefront string) error { continue } filename := fmt.Sprintf("%02d. %s.m4a", trackNum, forbiddenNames.ReplaceAllString(track.Attributes.Name, "_")) + lrcFilename := fmt.Sprintf("%02d. %s.lrc", trackNum, forbiddenNames.ReplaceAllString(track.Attributes.Name, "_")) trackPath := filepath.Join(sanAlbumFolder, filename) + if userToken != "" { + 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 { + err := writeLyrics(sanAlbumFolder, lrcFilename, lrc) + if err != nil { + fmt.Printf("Failed to write lyrics") + } + } + } + } exists, err := fileExists(trackPath) if err != nil { fmt.Println("Failed to check if track exists.") @@ -1149,6 +1199,13 @@ func rip(albumId string, token string, storefront string) error { } func main() { + var mediaUserToken string + if _, err := os.Stat("media-user-token.txt"); err == nil { + file, err := os.ReadFile("media-user-token.txt") + if err == nil && file != nil { + mediaUserToken = string(file) + } + } token, err := getToken() if err != nil { fmt.Println("Failed to get token.") @@ -1163,12 +1220,12 @@ func main() { } else { storefront, albumId = checkUrl(url) } - + if albumId == "" { fmt.Printf("Invalid URL: %s\n", url) continue } - err := rip(albumId, token, storefront) + err := rip(albumId, token, storefront, mediaUserToken) if err != nil { fmt.Println("Album failed.") fmt.Println(err) @@ -1176,6 +1233,48 @@ func main() { } } +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 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", &m, &s, &ms) + } else { + _, err = fmt.Sscanf(lyric.SelectAttr("begin").Value, "%d.%d", &s, &ms) + m = 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 + } + lrcLines = append(lrcLines, fmt.Sprintf("[%02d:%02d.%03d]%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 { @@ -1809,4 +1908,20 @@ type AutoGeneratedTrack struct { } `json:"artists"` } `json:"relationships"` } `json:"data"` -} \ No newline at end of file +} + +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"` +}