├── .github └── workflows │ ├── build.yml │ └── docker.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── cmd ├── album │ └── album.go ├── artist │ └── artist.go ├── config │ └── config.go ├── playlist │ └── playlist.go ├── root.go ├── search │ └── search.go ├── serve │ └── serve.go └── song │ └── song.go ├── go.mod ├── go.sum ├── internal ├── cli │ └── cli.go ├── server │ └── server.go └── settings │ └── settings.go ├── mxget.go └── pkg ├── api └── api.go ├── concurrency └── concurrency.go ├── cryptography ├── cryptography.go └── ecb.go ├── provider ├── baidu │ ├── album.go │ ├── artist.go │ ├── baidu.go │ ├── baidu_test.go │ ├── crypto.go │ ├── playlist.go │ ├── search.go │ └── song.go ├── kugou │ ├── album.go │ ├── artist.go │ ├── kugou.go │ ├── kugou_test.go │ ├── playlist.go │ ├── search.go │ └── song.go ├── kuwo │ ├── album.go │ ├── artist.go │ ├── kuwo.go │ ├── kuwo_test.go │ ├── playlist.go │ ├── search.go │ └── song.go ├── migu │ ├── album.go │ ├── artist.go │ ├── migu.go │ ├── migu_test.go │ ├── playlist.go │ ├── search.go │ └── song.go ├── netease │ ├── account.go │ ├── album.go │ ├── artist.go │ ├── crypto.go │ ├── netease.go │ ├── netease_test.go │ ├── playlist.go │ ├── search.go │ └── song.go ├── provider.go ├── tencent │ ├── album.go │ ├── artist.go │ ├── playlist.go │ ├── search.go │ ├── song.go │ ├── tencent.go │ └── tencent_test.go └── xiami │ ├── account.go │ ├── album.go │ ├── artist.go │ ├── crypto.go │ ├── playlist.go │ ├── search.go │ ├── song.go │ ├── xiami.go │ └── xiami_test.go ├── request └── request.go └── utils └── utils.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | jobs: 10 | goreleaser: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Unshallow 17 | run: git fetch --prune --unshallow 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v1 21 | with: 22 | go-version: 1.13.x 23 | 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@v1.2.1 26 | with: 27 | version: latest 28 | args: release --rm-dist --skip-publish 29 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout code 8 | uses: actions/checkout@v1 9 | 10 | - name: Publish to Registry 11 | uses: elgohr/Publish-Docker-Github-Action@master 12 | with: 13 | name: winterssy/mxget 14 | username: ${{ secrets.DOCKER_USERNAME }} 15 | password: ${{ secrets.DOCKER_PASSWORD }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Go template 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Dependency directories (remove the comment below to include it) 17 | # vendor/ 18 | 19 | .idea/ 20 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | ldflags: 8 | - -s -w 9 | goos: 10 | - linux 11 | - darwin 12 | - windows 13 | goarch: 14 | - 386 15 | - amd64 16 | archives: 17 | - replacements: 18 | 386: i386 19 | amd64: x86_64 20 | format_overrides: 21 | - goos: windows 22 | format: zip 23 | checksum: 24 | name_template: 'SHA256SUMS' 25 | changelog: 26 | skip: true 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang AS builder 2 | 3 | WORKDIR /build 4 | 5 | COPY . . 6 | 7 | RUN go mod tidy && \ 8 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ 9 | go build 10 | 11 | FROM alpine AS runtime 12 | 13 | COPY --from=builder /build/mxget /usr/local/bin/ 14 | 15 | CMD ["mxget", "serve"] 16 | 17 | EXPOSE 8080 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mxget 2 | 3 | 通过命令行在线搜索你喜欢的音乐,下载并试听。 4 | 5 | [![Actions Status](https://img.shields.io/github/workflow/status/winterssy/mxget/Build/master?logo=appveyor)](https://github.com/winterssy/mxget/actions) 6 | 7 | ## 支持的音乐平台 8 | 9 | | 音乐平台 | 平台标识 | 10 | | :-------------------------------------: | :-------------------------: | 11 | | **[网易云音乐](https://music.163.com)** | `netease` / `nc` | 12 | | **[QQ音乐](https://y.qq.com)** | `tencent` / `qq` | 13 | | **[咪咕音乐](http://music.migu.cn/v3)** | `migu` / `mg` | 14 | | **[酷狗音乐](http://www.kugou.com)** | `kugou` / `kg` | 15 | | **[酷我音乐](http://www.kuwo.cn/)** | `kuwo` / `kw` | 16 | | **[虾米音乐](https://www.xiami.com)** | `xiami` / `xm` | 17 | | **[千千音乐](http://music.taihe.com)** | `qianqian` / `baidu` / `bd` | 18 | 19 | ## 下载安装 20 | 21 | ```sh 22 | go get -u github.com/winterssy/mxget 23 | ``` 24 | 25 | ## 使用帮助 26 | 27 | ``` 28 | _____ ______ ___ ___ ________ _______ _________ 29 | |\ _ \ _ \ |\ \ / /|\ ____\|\ ___ \|\___ ___\ 30 | \ \ \\\__\ \ \ \ \ \/ / | \ \___|\ \ __/\|___ \ \_| 31 | \ \ \\|__| \ \ \ \ / / \ \ \ __\ \ \_|/__ \ \ \ 32 | \ \ \ \ \ \ / \/ \ \ \|\ \ \ \_|\ \ \ \ \ 33 | \ \__\ \ \__\/ /\ \ \ \_______\ \_______\ \ \__\ 34 | \|__| \|__/__/ /\ __\ \|_______|\|_______| \|__| 35 | |__|/ \|__| 36 | 37 | A simple tool that help you search and download your favorite music, 38 | please visit https://github.com/winterssy/mxget for more detail. 39 | 40 | Usage: 41 | mxget [command] 42 | 43 | Available Commands: 44 | album Fetch and download album's songs via its id 45 | artist Fetch and download artist's hot songs via its id 46 | config Specify the default behavior of mxget 47 | help Help about any command 48 | playlist Fetch and download playlist's songs via its id 49 | search Search songs from the specified music platform 50 | serve Run mxget as an API server 51 | song Fetch and download single song via its id 52 | 53 | Flags: 54 | -h, --help help for mxget 55 | --version version for mxget 56 | 57 | Use "mxget [command] --help" for more information about a command. 58 | ``` 59 | 60 | - 搜索歌曲 61 | 62 | ```sh 63 | $ mxget search --from nc -k Faded 64 | ``` 65 | 66 | - 下载歌曲 67 | 68 | ```sh 69 | $ mxget song --from nc --id 36990266 70 | ``` 71 | 72 | - 下载专辑 73 | 74 | ```sh 75 | $ mxget album --from nc --id 3406843 76 | ``` 77 | 78 | - 下载歌单 79 | 80 | ```sh 81 | $ mxget playlist --from nc --id 156934569 82 | ``` 83 | 84 | - 下载歌手热门歌曲 85 | 86 | ```sh 87 | $ mxget artist --from nc --id 1045123 88 | ``` 89 | 90 | - 自动更新音乐标签/下载歌词 91 | 92 | 如果你希望 `mxget` 为你自动更新音乐标签,可使用 `--tag` 指令,如: 93 | 94 | ```sh 95 | $ mxget song --from nc --id 36990266 --tag 96 | ``` 97 | 98 | 当使用 `--tag` 指令时,`mxget` 会同时将歌词内嵌到音乐文件中,一般而言你无须再额外下载歌词。如果你确实需要 `.lrc` 格式的歌词文件,可使用 `--lyric` 指令,如: 99 | 100 | ```sh 101 | $ mxget song --from nc --id 36990266 --lyric 102 | ``` 103 | 104 | - 设置默认下载目录 105 | 106 | 默认情况下,`mxget` 会下载音乐到当前目录下的 `downloads` 文件夹,如果你想要更改此行为,可以这样做: 107 | 108 | ```sh 109 | $ mxget config --dir 110 | ``` 111 | 112 | > `directory` 必须为绝对路径。 113 | 114 | - 设置默认音乐平台 115 | 116 | `mxget` 默认使用的音乐平台为网易云音乐,你可以通过以下命令更改: 117 | 118 | ```sh 119 | $ mxget config --from qq 120 | ``` 121 | 122 | 这样,如果你不通过 `--from` 指令指定音乐平台,`mxget` 便会使用默认值。 123 | 124 | 在上述命令中,你会经常用到 `--from` 以及 `--id` 这两个指令,它们分别表示音乐平台标识和音乐id。 125 | 126 | > 音乐id为音乐平台为对应资源分配的唯一id,当使用 `mxget` 进行搜索时,歌曲id会显示在每条结果的后面。你也可以通过各大音乐平台的网页版在线搜索相关资源,然后从结果详情页的URL中获取其音乐id。值得注意的是,酷狗音乐对应的歌曲id即为文件哈希 `hash` 。 127 | 128 | - 多任务下载 129 | 130 | `mxget` 支持多任务快速并发下载,你可以通过 `--limit` 参数指定同时下载的任务数,如不指定默认为CPU核心数。 131 | 132 | ```sh 133 | $ mxget playlist --from nc --id 156934569 --limit 16 134 | ``` 135 | 136 | > `mxget` 允许设置的最高并发数是32,但使用时建议不要超过16。 137 | 138 | ## 免责声明 139 | 140 | - 本项目仅供学习研究使用。 141 | - 本项目使用的接口如无特别说明均为官方接口,音乐版权归源音乐平台所有,侵删。 142 | 143 | ## License 144 | 145 | GPLv3。 146 | -------------------------------------------------------------------------------- /cmd/album/album.go: -------------------------------------------------------------------------------- 1 | package album 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/winterssy/glog" 9 | "github.com/winterssy/mxget/internal/cli" 10 | "github.com/winterssy/mxget/internal/settings" 11 | "github.com/winterssy/mxget/pkg/provider" 12 | "github.com/winterssy/mxget/pkg/utils" 13 | ) 14 | 15 | var ( 16 | albumId string 17 | from string 18 | ) 19 | 20 | var CmdAlbum = &cobra.Command{ 21 | Use: "album", 22 | Short: "Fetch and download album's songs via its id", 23 | } 24 | 25 | func Run(cmd *cobra.Command, args []string) { 26 | if albumId == "" { 27 | albumId = utils.Input("Album id") 28 | fmt.Println() 29 | } 30 | 31 | platform := settings.Cfg.Platform 32 | if from != "" { 33 | platform = from 34 | } 35 | 36 | client, err := provider.GetClient(platform) 37 | if err != nil { 38 | glog.Fatal(err) 39 | } 40 | 41 | glog.Infof("Fetch album [%s] from [%s]", albumId, provider.GetDesc(platform)) 42 | ctx := context.Background() 43 | album, err := client.GetAlbum(ctx, albumId) 44 | if err != nil { 45 | glog.Fatal(err) 46 | } 47 | 48 | cli.ConcurrentDownload(ctx, client, album.Name, album.Songs...) 49 | } 50 | 51 | func init() { 52 | CmdAlbum.Flags().StringVar(&albumId, "id", "", "album id") 53 | CmdAlbum.Flags().StringVar(&from, "from", "", "music platform") 54 | CmdAlbum.Flags().IntVar(&settings.Limit, "limit", 0, "concurrent download limit") 55 | CmdAlbum.Flags().BoolVar(&settings.Tag, "tag", false, "update music metadata") 56 | CmdAlbum.Flags().BoolVar(&settings.Lyric, "lyric", false, "download lyric") 57 | CmdAlbum.Flags().BoolVar(&settings.Force, "force", false, "overwrite already downloaded music") 58 | CmdAlbum.Run = Run 59 | } 60 | -------------------------------------------------------------------------------- /cmd/artist/artist.go: -------------------------------------------------------------------------------- 1 | package artist 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/winterssy/glog" 9 | "github.com/winterssy/mxget/internal/cli" 10 | "github.com/winterssy/mxget/internal/settings" 11 | "github.com/winterssy/mxget/pkg/provider" 12 | "github.com/winterssy/mxget/pkg/utils" 13 | ) 14 | 15 | var ( 16 | artistId string 17 | from string 18 | ) 19 | 20 | var CmdArtist = &cobra.Command{ 21 | Use: "artist", 22 | Short: "Fetch and download artist's hot songs via its id", 23 | } 24 | 25 | func Run(cmd *cobra.Command, args []string) { 26 | if artistId == "" { 27 | artistId = utils.Input("Artist id") 28 | fmt.Println() 29 | } 30 | 31 | platform := settings.Cfg.Platform 32 | if from != "" { 33 | platform = from 34 | } 35 | 36 | client, err := provider.GetClient(platform) 37 | if err != nil { 38 | glog.Fatal(err) 39 | } 40 | 41 | glog.Infof("Fetch artist [%s] from [%s]", artistId, provider.GetDesc(platform)) 42 | ctx := context.Background() 43 | artist, err := client.GetArtist(ctx, artistId) 44 | if err != nil { 45 | glog.Fatal(err) 46 | } 47 | 48 | cli.ConcurrentDownload(ctx, client, artist.Name, artist.Songs...) 49 | } 50 | 51 | func init() { 52 | CmdArtist.Flags().StringVar(&artistId, "id", "", "artist id") 53 | CmdArtist.Flags().StringVar(&from, "from", "", "music platform") 54 | CmdArtist.Flags().IntVar(&settings.Limit, "limit", 0, "concurrent download limit") 55 | CmdArtist.Flags().BoolVar(&settings.Tag, "tag", false, "update music metadata") 56 | CmdArtist.Flags().BoolVar(&settings.Lyric, "lyric", false, "download lyric") 57 | CmdArtist.Flags().BoolVar(&settings.Force, "force", false, "overwrite already downloaded music") 58 | CmdArtist.Run = Run 59 | } 60 | -------------------------------------------------------------------------------- /cmd/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/winterssy/glog" 10 | "github.com/winterssy/mxget/internal/settings" 11 | "github.com/winterssy/mxget/pkg/provider" 12 | ) 13 | 14 | var ( 15 | dir string 16 | from string 17 | show bool 18 | reset bool 19 | ) 20 | 21 | var CmdSet = &cobra.Command{ 22 | Use: "config", 23 | Short: "Specify the default behavior of mxget", 24 | } 25 | 26 | func Run(cmd *cobra.Command, args []string) { 27 | if cmd.Flags().NFlag() == 0 { 28 | _ = cmd.Help() 29 | return 30 | } 31 | 32 | if show { 33 | fmt.Print(fmt.Sprintf(` 34 | download dir -> %s 35 | music platform -> %s [%s] 36 | `, settings.Cfg.Dir, settings.Cfg.Platform, provider.GetDesc(settings.Cfg.Platform))) 37 | return 38 | } 39 | 40 | if reset { 41 | settings.Cfg.Reset() 42 | return 43 | } 44 | 45 | if dir != "" { 46 | dir = filepath.Clean(dir) 47 | if err := os.MkdirAll(dir, 0755); err != nil { 48 | glog.Fatalf("Can't make download dir: %v", err) 49 | } 50 | settings.Cfg.Dir = dir 51 | } 52 | if from != "" { 53 | if provider.GetDesc(from) == "unknown" { 54 | glog.Fatalf("Unexpected music platform: %q", from) 55 | } 56 | settings.Cfg.Platform = from 57 | } 58 | 59 | if dir != "" || from != "" { 60 | _ = settings.Cfg.Save() 61 | } 62 | } 63 | 64 | func init() { 65 | CmdSet.Flags().StringVar(&dir, "dir", "", "specify the default download directory") 66 | CmdSet.Flags().StringVar(&from, "from", "", "specify the default music platform") 67 | CmdSet.Flags().BoolVar(&show, "show", false, "show current settings") 68 | CmdSet.Flags().BoolVar(&reset, "reset", false, "reset default settings") 69 | CmdSet.Run = Run 70 | } 71 | -------------------------------------------------------------------------------- /cmd/playlist/playlist.go: -------------------------------------------------------------------------------- 1 | package playlist 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/winterssy/glog" 9 | "github.com/winterssy/mxget/internal/cli" 10 | "github.com/winterssy/mxget/internal/settings" 11 | "github.com/winterssy/mxget/pkg/provider" 12 | "github.com/winterssy/mxget/pkg/utils" 13 | ) 14 | 15 | var ( 16 | playlistId string 17 | from string 18 | ) 19 | 20 | var CmdPlaylist = &cobra.Command{ 21 | Use: "playlist", 22 | Short: "Fetch and download playlist's songs via its id", 23 | } 24 | 25 | func Run(cmd *cobra.Command, args []string) { 26 | if playlistId == "" { 27 | playlistId = utils.Input("Playlist id") 28 | fmt.Println() 29 | } 30 | 31 | platform := settings.Cfg.Platform 32 | if from != "" { 33 | platform = from 34 | } 35 | 36 | client, err := provider.GetClient(platform) 37 | if err != nil { 38 | glog.Fatal(err) 39 | } 40 | 41 | glog.Infof("Fetch playlist [%s] from [%s]", playlistId, provider.GetDesc(platform)) 42 | ctx := context.Background() 43 | playlist, err := client.GetPlaylist(ctx, playlistId) 44 | if err != nil { 45 | glog.Fatal(err) 46 | } 47 | 48 | cli.ConcurrentDownload(ctx, client, playlist.Name, playlist.Songs...) 49 | } 50 | 51 | func init() { 52 | CmdPlaylist.Flags().StringVar(&playlistId, "id", "", "playlist id") 53 | CmdPlaylist.Flags().StringVar(&from, "from", "", "music platform") 54 | CmdPlaylist.Flags().IntVar(&settings.Limit, "limit", 0, "concurrent download limit") 55 | CmdPlaylist.Flags().BoolVar(&settings.Tag, "tag", false, "update music metadata") 56 | CmdPlaylist.Flags().BoolVar(&settings.Lyric, "lyric", false, "download lyric") 57 | CmdPlaylist.Flags().BoolVar(&settings.Force, "force", false, "overwrite already downloaded music") 58 | CmdPlaylist.Run = Run 59 | } 60 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/winterssy/mxget/cmd/album" 6 | "github.com/winterssy/mxget/cmd/artist" 7 | "github.com/winterssy/mxget/cmd/config" 8 | "github.com/winterssy/mxget/cmd/playlist" 9 | "github.com/winterssy/mxget/cmd/search" 10 | "github.com/winterssy/mxget/cmd/serve" 11 | "github.com/winterssy/mxget/cmd/song" 12 | "github.com/winterssy/mxget/internal/settings" 13 | ) 14 | 15 | const ( 16 | Version = "1.0.0" 17 | ) 18 | 19 | var CmdRoot = &cobra.Command{ 20 | Use: "mxget", 21 | Short: "Show help for mxget commands", 22 | Long: ` 23 | _____ ______ ___ ___ ________ _______ _________ 24 | |\ _ \ _ \ |\ \ / /|\ ____\|\ ___ \|\___ ___\ 25 | \ \ \\\__\ \ \ \ \ \/ / | \ \___|\ \ __/\|___ \ \_| 26 | \ \ \\|__| \ \ \ \ / / \ \ \ __\ \ \_|/__ \ \ \ 27 | \ \ \ \ \ \ / \/ \ \ \|\ \ \ \_|\ \ \ \ \ 28 | \ \__\ \ \__\/ /\ \ \ \_______\ \_______\ \ \__\ 29 | \|__| \|__/__/ /\ __\ \|_______|\|_______| \|__| 30 | |__|/ \|__| 31 | 32 | A simple tool that help you search and download your favorite music, 33 | please visit https://github.com/winterssy/mxget for more detail. 34 | `, 35 | Version: Version, 36 | } 37 | 38 | func Execute() error { 39 | return CmdRoot.Execute() 40 | } 41 | 42 | func init() { 43 | cobra.OnInitialize(settings.Init) 44 | 45 | CmdRoot.AddCommand(search.CmdSearch) 46 | CmdRoot.AddCommand(song.CmdSong) 47 | CmdRoot.AddCommand(artist.CmdArtist) 48 | CmdRoot.AddCommand(album.CmdAlbum) 49 | CmdRoot.AddCommand(playlist.CmdPlaylist) 50 | CmdRoot.AddCommand(serve.CmdServe) 51 | CmdRoot.AddCommand(config.CmdSet) 52 | } 53 | -------------------------------------------------------------------------------- /cmd/search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/winterssy/glog" 10 | "github.com/winterssy/mxget/internal/settings" 11 | "github.com/winterssy/mxget/pkg/provider" 12 | "github.com/winterssy/mxget/pkg/utils" 13 | ) 14 | 15 | var ( 16 | keyword string 17 | from string 18 | ) 19 | 20 | var CmdSearch = &cobra.Command{ 21 | Use: "search", 22 | Short: "Search songs from the specified music platform", 23 | } 24 | 25 | func Run(cmd *cobra.Command, args []string) { 26 | if keyword == "" { 27 | keyword = utils.Input("Keyword") 28 | fmt.Println() 29 | } 30 | 31 | platform := settings.Cfg.Platform 32 | if from != "" { 33 | platform = from 34 | } 35 | 36 | client, err := provider.GetClient(platform) 37 | if err != nil { 38 | glog.Fatal(err) 39 | } 40 | 41 | fmt.Printf("Search %q from [%s]...\n\n", keyword, provider.GetDesc(platform)) 42 | result, err := client.SearchSongs(context.Background(), keyword) 43 | if err != nil { 44 | glog.Fatal(err) 45 | } 46 | 47 | var sb strings.Builder 48 | for i, s := range result { 49 | fmt.Fprintf(&sb, "[%02d] %s - %s - %s\n", i+1, s.Name, s.Artist, s.Id) 50 | } 51 | fmt.Println(sb.String()) 52 | 53 | if from != "" { 54 | fmt.Printf("Command: mxget song --from %s --id \n", from) 55 | } else { 56 | fmt.Println("Command: mxget song --id ") 57 | } 58 | } 59 | 60 | func init() { 61 | CmdSearch.Flags().StringVarP(&keyword, "keyword", "k", "", "search keyword") 62 | CmdSearch.Flags().StringVar(&from, "from", "", "music platform") 63 | CmdSearch.Run = Run 64 | } 65 | -------------------------------------------------------------------------------- /cmd/serve/serve.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/spf13/cobra" 8 | "github.com/winterssy/mxget/internal/server" 9 | ) 10 | 11 | var ( 12 | port int 13 | ) 14 | 15 | var CmdServe = &cobra.Command{ 16 | Use: "serve", 17 | Short: "Run mxget as an API server", 18 | } 19 | 20 | func Run(cmd *cobra.Command, args []string) { 21 | gin.SetMode(gin.ReleaseMode) 22 | app := gin.New() 23 | server.Init(app) 24 | app.Run(fmt.Sprintf(":%d", port)) 25 | } 26 | 27 | func init() { 28 | CmdServe.Flags().IntVar(&port, "port", 8080, "server listening port") 29 | CmdServe.Run = Run 30 | } 31 | -------------------------------------------------------------------------------- /cmd/song/song.go: -------------------------------------------------------------------------------- 1 | package song 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/winterssy/glog" 9 | "github.com/winterssy/mxget/internal/cli" 10 | "github.com/winterssy/mxget/internal/settings" 11 | "github.com/winterssy/mxget/pkg/provider" 12 | "github.com/winterssy/mxget/pkg/utils" 13 | ) 14 | 15 | var ( 16 | songId string 17 | from string 18 | ) 19 | 20 | var CmdSong = &cobra.Command{ 21 | Use: "song", 22 | Short: "Fetch and download single song via its id", 23 | } 24 | 25 | func Run(cmd *cobra.Command, args []string) { 26 | if songId == "" { 27 | songId = utils.Input("Song id") 28 | fmt.Println() 29 | } 30 | 31 | platform := settings.Cfg.Platform 32 | if from != "" { 33 | platform = from 34 | } 35 | 36 | client, err := provider.GetClient(platform) 37 | if err != nil { 38 | glog.Fatal(err) 39 | } 40 | 41 | glog.Infof("Fetch song [%s] from [%s]", songId, provider.GetDesc(platform)) 42 | ctx := context.Background() 43 | song, err := client.GetSong(ctx, songId) 44 | if err != nil { 45 | glog.Fatal(err) 46 | } 47 | 48 | cli.ConcurrentDownload(ctx, client, ".", song) 49 | } 50 | 51 | func init() { 52 | CmdSong.Flags().StringVar(&songId, "id", "", "song id") 53 | CmdSong.Flags().StringVar(&from, "from", "", "music platform") 54 | CmdSong.Flags().BoolVar(&settings.Tag, "tag", false, "update music metadata") 55 | CmdSong.Flags().BoolVar(&settings.Lyric, "lyric", false, "download lyric") 56 | CmdSong.Flags().BoolVar(&settings.Force, "force", false, "overwrite already downloaded music") 57 | CmdSong.Run = Run 58 | } 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/winterssy/mxget 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/bogem/id3v2 v1.1.1 7 | github.com/gin-gonic/gin v1.5.0 8 | github.com/go-playground/universal-translator v0.17.0 // indirect 9 | github.com/golang/protobuf v1.3.3 // indirect 10 | github.com/json-iterator/go v1.1.10 // indirect 11 | github.com/leodido/go-urn v1.2.0 // indirect 12 | github.com/mattn/go-isatty v0.0.12 // indirect 13 | github.com/spf13/cobra v0.0.5 14 | github.com/spf13/pflag v1.0.5 // indirect 15 | github.com/stretchr/testify v1.4.0 16 | github.com/winterssy/ghttp v0.0.0-20200904031839-b68c76d629d9 17 | github.com/winterssy/gjson v0.0.0-20200306020332-1f68efaec187 18 | github.com/winterssy/glog v0.0.0-20200305052031-8e145d5ae4ef 19 | golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect 20 | golang.org/x/text v0.3.3 21 | golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect 22 | gopkg.in/go-playground/validator.v9 v9.31.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 3 | github.com/bogem/id3v2 v1.1.1 h1:FnjS2vytMeEb39tOMG09uz852MaEccA2A3asRM3XxbE= 4 | github.com/bogem/id3v2 v1.1.1/go.mod h1:D1rDm80qF/ocBU+Ik8U4RKnwMq/oNkkB8vGcnrlMJmM= 5 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 6 | github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= 7 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 8 | github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 13 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 14 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 15 | github.com/gin-gonic/gin v1.5.0 h1:fi+bqFAx/oLK54somfCtEZs9HeH1LHVoEPUgARpTqyc= 16 | github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= 17 | github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= 18 | github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= 19 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 20 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 21 | github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= 22 | github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= 23 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 24 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 25 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 26 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 27 | github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= 28 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 29 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 30 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 31 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 32 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 33 | github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo= 34 | github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 35 | github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= 36 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 37 | github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= 38 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 39 | github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= 40 | github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= 41 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 42 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 43 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 44 | github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg= 45 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= 46 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 47 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 48 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 49 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 50 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 51 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 52 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 53 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 54 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= 55 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 56 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 57 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 58 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 62 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 63 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 64 | github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= 65 | github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= 66 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 67 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 68 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 69 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 70 | github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= 71 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 72 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 73 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 74 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 75 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 76 | github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= 77 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 78 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 79 | github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= 80 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 81 | github.com/winterssy/bufferpool v0.0.0-20200229012952-527e7777fcd3 h1:hNn8ALenX7lFAh2ZSlseyv+xzYOyaWpaTLMoU7xhtM4= 82 | github.com/winterssy/bufferpool v0.0.0-20200229012952-527e7777fcd3/go.mod h1:Ys+2xIWb2KNMf2jI1xTlebh5Fblja7LyxlhU2xWfbPU= 83 | github.com/winterssy/ghttp v0.0.0-20200310005252-b14cb4acc8a2 h1:H2ApAJMhFnR5D1BpWrvASUdYFsWncvNzprHVW8+j3K0= 84 | github.com/winterssy/ghttp v0.0.0-20200310005252-b14cb4acc8a2/go.mod h1:F5JtqLq8U0RO8uPNFzT1Tx4gMFNN2Ei49JLSYsoTBDA= 85 | github.com/winterssy/ghttp v0.0.0-20200904031839-b68c76d629d9 h1:NEV/9wN2wdA+NQ/8DsJdB7BeQPvxjjXCbax40hkMxto= 86 | github.com/winterssy/ghttp v0.0.0-20200904031839-b68c76d629d9/go.mod h1:F5JtqLq8U0RO8uPNFzT1Tx4gMFNN2Ei49JLSYsoTBDA= 87 | github.com/winterssy/gjson v0.0.0-20200306020332-1f68efaec187 h1:NuUeupnBUlIDdH4du0pa+XvbQFiUZ9Vk/UvkUBPxAJI= 88 | github.com/winterssy/gjson v0.0.0-20200306020332-1f68efaec187/go.mod h1:Hq+tE5rN41nrDvD925cOO3YVjwFixLcs/CDUcULGpKQ= 89 | github.com/winterssy/glog v0.0.0-20200305052031-8e145d5ae4ef h1:+hyXi0jM14nEFaEzgdTmYMH/Ze8a6o664wOFdXbhXrA= 90 | github.com/winterssy/glog v0.0.0-20200305052031-8e145d5ae4ef/go.mod h1:VD6ii1erDvLeFH2DZO7frqCbLWPFhNsVUon3S9twr/E= 91 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 92 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 93 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 94 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 95 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 96 | golang.org/x/net v0.0.0-20191009170851-d66e71096ffb/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 97 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= 98 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 99 | golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA= 100 | golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 101 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 102 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 103 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= 105 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 107 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4 h1:sfkvUWPNGwSV+8/fNqctR5lS2AqCSqYwXdrjCxp/dXo= 108 | golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 109 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 110 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 111 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 112 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 113 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= 114 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 115 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= 116 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 117 | golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s= 118 | golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 119 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 120 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 121 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 122 | gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= 123 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 124 | gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc= 125 | gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= 126 | gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M= 127 | gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= 128 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 129 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 130 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 131 | -------------------------------------------------------------------------------- /internal/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | 11 | "github.com/bogem/id3v2" 12 | "github.com/winterssy/ghttp" 13 | "github.com/winterssy/glog" 14 | "github.com/winterssy/mxget/internal/settings" 15 | "github.com/winterssy/mxget/pkg/api" 16 | "github.com/winterssy/mxget/pkg/concurrency" 17 | "github.com/winterssy/mxget/pkg/utils" 18 | ) 19 | 20 | func ConcurrentDownload(ctx context.Context, client api.Provider, savePath string, songs ...*api.Song) { 21 | savePath = filepath.Join(settings.Cfg.Dir, utils.TrimInvalidFilePathChars(savePath)) 22 | if err := os.MkdirAll(savePath, 0755); err != nil { 23 | glog.Fatal(err) 24 | } 25 | 26 | var limit int 27 | switch { 28 | case settings.Limit < 1: 29 | limit = runtime.NumCPU() 30 | case settings.Limit > 32: 31 | limit = 32 32 | default: 33 | limit = settings.Limit 34 | } 35 | 36 | c := concurrency.New(limit) 37 | for _, s := range songs { 38 | if ctx.Err() != nil { 39 | break 40 | } 41 | 42 | c.Add(1) 43 | go func(s *api.Song) { 44 | defer c.Done() 45 | songInfo := fmt.Sprintf("%s - %s", s.Artist, s.Name) 46 | if s.ListenURL == "" { 47 | glog.Errorf("Download [%s] failed: song unavailable", songInfo) 48 | return 49 | } 50 | 51 | filePath := filepath.Join(savePath, utils.TrimInvalidFilePathChars(songInfo)) 52 | glog.Infof("Start download [%s]", songInfo) 53 | mp3FilePath := filePath + ".mp3" 54 | if !settings.Force { 55 | _, err := os.Stat(mp3FilePath) 56 | if err == nil { 57 | glog.Infof("Song already downloaded: [%s]", songInfo) 58 | return 59 | } 60 | } 61 | 62 | req, _ := ghttp.NewRequest(ghttp.MethodGet, s.ListenURL) 63 | req.SetContext(ctx) 64 | resp, err := client.SendRequest(req) 65 | if err == nil { 66 | err = resp.SaveFile(mp3FilePath, 0664) 67 | } 68 | if err != nil { 69 | glog.Errorf("Download [%s] failed: %v", songInfo, err) 70 | _ = os.Remove(mp3FilePath) 71 | return 72 | } 73 | glog.Infof("Download [%s] complete", songInfo) 74 | 75 | if settings.Tag { 76 | glog.Infof("Update music metadata: [%s]", songInfo) 77 | writeTag(ctx, client, mp3FilePath, s) 78 | } 79 | 80 | if settings.Lyric && s.Lyric != "" { 81 | glog.Infof("Save lyric: [%s]", songInfo) 82 | lrcFilePath := filePath + ".lrc" 83 | saveLyric(lrcFilePath, s.Lyric) 84 | } 85 | }(s) 86 | } 87 | c.Wait() 88 | } 89 | 90 | func saveLyric(filePath string, lyric string) { 91 | err := ioutil.WriteFile(filePath, []byte(lyric), 0644) 92 | if err != nil { 93 | _ = os.Remove(filePath) 94 | } 95 | } 96 | 97 | func writeTag(ctx context.Context, client api.Provider, filePath string, song *api.Song) { 98 | tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) 99 | if err != nil { 100 | return 101 | } 102 | defer tag.Close() 103 | 104 | tag.SetDefaultEncoding(id3v2.EncodingUTF8) 105 | tag.SetTitle(song.Name) 106 | tag.SetArtist(song.Artist) 107 | tag.SetAlbum(song.Album) 108 | 109 | if song.Lyric != "" { 110 | uslt := id3v2.UnsynchronisedLyricsFrame{ 111 | Encoding: id3v2.EncodingUTF8, 112 | Language: "eng", 113 | ContentDescriptor: song.Name, 114 | Lyrics: song.Lyric, 115 | } 116 | tag.AddUnsynchronisedLyricsFrame(uslt) 117 | } 118 | 119 | if song.PicURL != "" { 120 | req, _ := ghttp.NewRequest(ghttp.MethodGet, song.PicURL) 121 | req.SetContext(ctx) 122 | resp, err := client.SendRequest(req) 123 | if err == nil { 124 | pic, err := resp.Content() 125 | if err == nil { 126 | picFrame := id3v2.PictureFrame{ 127 | Encoding: id3v2.EncodingUTF8, 128 | MimeType: "image/jpeg", 129 | PictureType: id3v2.PTFrontCover, 130 | Description: "Front cover", 131 | Picture: pic, 132 | } 133 | tag.AddAttachedPicture(picFrame) 134 | } 135 | } 136 | } 137 | 138 | _ = tag.Save() 139 | } 140 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/winterssy/mxget/pkg/provider" 8 | ) 9 | 10 | type Response struct { 11 | Code int `json:"code"` 12 | Msg string `json:"msg,omitempty"` 13 | Data interface{} `json:"data,omitempty"` 14 | } 15 | 16 | func searchSongs(c *gin.Context) { 17 | platform := c.Param("platform") 18 | client, err := provider.GetClient(platform) 19 | if err != nil { 20 | c.JSON(400, Response{ 21 | Code: 400, 22 | Msg: err.Error(), 23 | }) 24 | return 25 | } 26 | 27 | data, err := client.SearchSongs(context.Background(), c.Param("keyword")) 28 | if err != nil { 29 | c.JSON(500, Response{ 30 | Code: 500, 31 | Msg: err.Error(), 32 | }) 33 | return 34 | } 35 | 36 | c.JSON(200, Response{ 37 | Code: 200, 38 | Data: data, 39 | }) 40 | } 41 | 42 | func getSong(c *gin.Context) { 43 | platform := c.Param("platform") 44 | client, err := provider.GetClient(platform) 45 | if err != nil { 46 | c.JSON(400, Response{ 47 | Code: 400, 48 | Msg: err.Error(), 49 | }) 50 | return 51 | } 52 | 53 | data, err := client.GetSong(context.Background(), c.Param("id")) 54 | if err != nil { 55 | c.JSON(500, Response{ 56 | Code: 500, 57 | Msg: err.Error(), 58 | }) 59 | return 60 | } 61 | 62 | c.JSON(200, Response{ 63 | Code: 200, 64 | Data: data, 65 | }) 66 | } 67 | 68 | func getArtist(c *gin.Context) { 69 | platform := c.Param("platform") 70 | client, err := provider.GetClient(platform) 71 | if err != nil { 72 | c.JSON(400, Response{ 73 | Code: 400, 74 | Msg: err.Error(), 75 | }) 76 | return 77 | } 78 | 79 | data, err := client.GetArtist(context.Background(), c.Param("id")) 80 | if err != nil { 81 | c.JSON(500, Response{ 82 | Code: 500, 83 | Msg: err.Error(), 84 | }) 85 | return 86 | } 87 | 88 | c.JSON(200, Response{ 89 | Code: 200, 90 | Data: data, 91 | }) 92 | } 93 | 94 | func getAlbum(c *gin.Context) { 95 | platform := c.Param("platform") 96 | client, err := provider.GetClient(platform) 97 | if err != nil { 98 | c.JSON(400, Response{ 99 | Code: 400, 100 | Msg: err.Error(), 101 | }) 102 | return 103 | } 104 | 105 | data, err := client.GetAlbum(context.Background(), c.Param("id")) 106 | if err != nil { 107 | c.JSON(500, Response{ 108 | Code: 500, 109 | Msg: err.Error(), 110 | }) 111 | return 112 | } 113 | 114 | c.JSON(200, Response{ 115 | Code: 200, 116 | Data: data, 117 | }) 118 | } 119 | 120 | func getPlaylist(c *gin.Context) { 121 | platform := c.Param("platform") 122 | client, err := provider.GetClient(platform) 123 | if err != nil { 124 | c.JSON(400, Response{ 125 | Code: 400, 126 | Msg: err.Error(), 127 | }) 128 | return 129 | } 130 | 131 | data, err := client.GetPlaylist(context.Background(), c.Param("id")) 132 | if err != nil { 133 | c.JSON(500, Response{ 134 | Code: 500, 135 | Msg: err.Error(), 136 | }) 137 | return 138 | } 139 | 140 | c.JSON(200, Response{ 141 | Code: 200, 142 | Data: data, 143 | }) 144 | } 145 | 146 | func Init(router *gin.Engine) { 147 | r := router.Group("/api") 148 | 149 | r.GET("/:platform/search/:keyword", searchSongs) 150 | r.GET("/:platform/song/:id", getSong) 151 | r.GET("/:platform/artist/:id", getArtist) 152 | r.GET("/:platform/album/:id", getAlbum) 153 | r.GET("/:platform/playlist/:id", getPlaylist) 154 | } 155 | -------------------------------------------------------------------------------- /internal/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/winterssy/gjson" 10 | "github.com/winterssy/glog" 11 | "github.com/winterssy/mxget/pkg/provider" 12 | ) 13 | 14 | const ( 15 | dir = "./downloads" 16 | platform = "nc" 17 | ) 18 | 19 | var ( 20 | Cfg = &Config{} 21 | Limit int 22 | Tag bool 23 | Lyric bool 24 | Force bool 25 | ) 26 | 27 | type ( 28 | Config struct { 29 | Dir string `json:"dir"` 30 | Platform string `json:"platform"` 31 | 32 | // 预留字段,其它设置项 33 | others map[string]interface{} `json:"-"` 34 | filePath string `json:"-"` 35 | } 36 | ) 37 | 38 | func Init() { 39 | err := Cfg.setup() 40 | if err != nil { 41 | _ = Cfg.Save() 42 | glog.Fatalf("Initialize config failed, reset to defaults: %v", err) 43 | } 44 | } 45 | 46 | func (c *Config) setup() error { 47 | if c.setupConfigFile() != nil { 48 | return c.initConfigFile() 49 | } 50 | 51 | err := c.loadConfigFile() 52 | if err != nil { 53 | return err 54 | } 55 | 56 | err = c.check() 57 | if err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func (c *Config) setupConfigFile() error { 65 | var cfgDir string 66 | xdgDir := os.Getenv("XDG_CONFIG_HOME") 67 | if xdgDir == "" { 68 | home, err := os.UserHomeDir() 69 | if err != nil { 70 | cfgDir = "." 71 | } else { 72 | cfgDir = filepath.Join(home, ".config", "mxget") 73 | } 74 | } else { 75 | cfgDir = filepath.Join(xdgDir, "mxget") 76 | } 77 | 78 | if cfgDir == "." || os.MkdirAll(cfgDir, 0755) != nil { 79 | c.filePath = ".mxget.json" 80 | } else { 81 | c.filePath = filepath.Join(cfgDir, "mxget.json") 82 | } 83 | 84 | _, err := os.Stat(c.filePath) 85 | return err 86 | } 87 | 88 | func (c *Config) initConfigFile() error { 89 | c.Dir = dir 90 | c.Platform = platform 91 | return c.Save() 92 | } 93 | 94 | func (c *Config) loadConfigFile() error { 95 | b, err := ioutil.ReadFile(c.filePath) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | return gjson.Unmarshal(b, c) 101 | } 102 | 103 | func (c *Config) check() error { 104 | if provider.GetDesc(c.Platform) == "unknown" { 105 | rawPlatform := c.Platform 106 | c.Platform = platform 107 | return fmt.Errorf("unexpected music platform: %q", rawPlatform) 108 | } 109 | 110 | err := os.MkdirAll(c.Dir, 0755) 111 | if err != nil { 112 | c.Dir = dir 113 | return fmt.Errorf("cant't make download dir: %s", err.Error()) 114 | } 115 | 116 | return nil 117 | } 118 | 119 | func (c *Config) Save() error { 120 | b, err := gjson.MarshalIndent(c, "", "\t") 121 | if err != nil { 122 | return err 123 | } 124 | 125 | return ioutil.WriteFile(c.filePath, b, 0644) 126 | } 127 | 128 | func (c *Config) Reset() { 129 | _ = c.initConfigFile() 130 | } 131 | -------------------------------------------------------------------------------- /mxget.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/winterssy/mxget/cmd" 7 | ) 8 | 9 | func main() { 10 | if err := cmd.Execute(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/winterssy/ghttp" 7 | "github.com/winterssy/mxget/pkg/utils" 8 | ) 9 | 10 | type Song struct { 11 | Id string `json:"id"` 12 | Name string `json:"name"` 13 | Artist string `json:"artist"` 14 | Album string `json:"album"` 15 | PicURL string `json:"pic_url,omitempty"` 16 | Lyric string `json:"lyric,omitempty"` 17 | ListenURL string `json:"listen_url,omitempty"` 18 | } 19 | 20 | type Collection struct { 21 | Id string `json:"id"` 22 | Name string `json:"name"` 23 | PicURL string `json:"pic_url"` 24 | Songs []*Song `json:"songs"` 25 | } 26 | 27 | type Provider interface { 28 | SearchSongs(ctx context.Context, keyword string) ([]*Song, error) 29 | GetSong(ctx context.Context, songId string) (*Song, error) 30 | GetArtist(ctx context.Context, artistId string) (*Collection, error) 31 | GetAlbum(ctx context.Context, albumId string) (*Collection, error) 32 | GetPlaylist(ctx context.Context, playlistId string) (*Collection, error) 33 | SendRequest(req *ghttp.Request) (*ghttp.Response, error) 34 | } 35 | 36 | func (s *Song) String() string { 37 | return utils.PrettyJSON(s) 38 | } 39 | 40 | func (c *Collection) String() string { 41 | return utils.PrettyJSON(c) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/concurrency/concurrency.go: -------------------------------------------------------------------------------- 1 | package concurrency 2 | 3 | import "sync" 4 | 5 | // C for simple concurrency control 6 | type C struct { 7 | ch chan struct{} 8 | wg *sync.WaitGroup 9 | } 10 | 11 | // New is used to initial a concurrent control object 12 | func New(limit int) *C { 13 | return &C{ 14 | wg: &sync.WaitGroup{}, 15 | ch: make(chan struct{}, limit), 16 | } 17 | } 18 | 19 | // Add is used to add a task 20 | func (c *C) Add(n int) { 21 | c.wg.Add(n) 22 | for n > 0 { 23 | n-- 24 | c.ch <- struct{}{} 25 | } 26 | } 27 | 28 | // Done is used to accomplish a task 29 | func (c *C) Done() { 30 | c.wg.Done() 31 | <-c.ch 32 | } 33 | 34 | // Wait is used to wg for all tasks to be completed 35 | func (c *C) Wait() { 36 | c.wg.Wait() 37 | } 38 | -------------------------------------------------------------------------------- /pkg/cryptography/cryptography.go: -------------------------------------------------------------------------------- 1 | package cryptography 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "fmt" 8 | "math/big" 9 | ) 10 | 11 | func AESCBCEncrypt(plainText, key, iv []byte) []byte { 12 | block, _ := aes.NewCipher(key) 13 | plainText = pkcs7Padding(plainText, block.BlockSize()) 14 | blockMode := cipher.NewCBCEncrypter(block, iv) 15 | cipherText := make([]byte, len(plainText)) 16 | blockMode.CryptBlocks(cipherText, plainText) 17 | return cipherText 18 | } 19 | 20 | func AESCBCDecrypt(cipherText, key, iv []byte) []byte { 21 | block, _ := aes.NewCipher(key) 22 | blockMode := cipher.NewCBCDecrypter(block, iv) 23 | plainText := make([]byte, len(cipherText)) 24 | blockMode.CryptBlocks(plainText, cipherText) 25 | plainText = pkcs7UnPadding(plainText) 26 | return plainText 27 | } 28 | 29 | func AESECBEncrypt(plainText, key []byte) []byte { 30 | block, _ := aes.NewCipher(key) 31 | plainText = pkcs7Padding(plainText, block.BlockSize()) 32 | blockMode := NewECBEncrypter(block) 33 | cipherText := make([]byte, len(plainText)) 34 | blockMode.CryptBlocks(cipherText, plainText) 35 | return cipherText 36 | } 37 | 38 | func AESECBDecrypt(cipherText, key []byte) []byte { 39 | block, _ := aes.NewCipher(key) 40 | blockMode := NewECBDecrypter(block) 41 | plainText := make([]byte, len(cipherText)) 42 | blockMode.CryptBlocks(plainText, cipherText) 43 | plainText = pkcs7UnPadding(plainText) 44 | return plainText 45 | } 46 | 47 | func pkcs7Padding(src []byte, blockSize int) []byte { 48 | padding := blockSize - len(src)%blockSize 49 | paddingText := bytes.Repeat([]byte{byte(padding)}, padding) 50 | return append(src, paddingText...) 51 | } 52 | 53 | func pkcs7UnPadding(src []byte) []byte { 54 | n := len(src) 55 | unPadding := int(src[n-1]) 56 | return src[:n-unPadding] 57 | } 58 | 59 | func RSAEncrypt(origData []byte, modulus string, exponent int64) string { 60 | bigOrigData := big.NewInt(0).SetBytes(origData) 61 | bigModulus, _ := big.NewInt(0).SetString(modulus, 16) 62 | bigRs := bigOrigData.Exp(bigOrigData, big.NewInt(exponent), bigModulus) 63 | return fmt.Sprintf("%0256x", bigRs) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/cryptography/ecb.go: -------------------------------------------------------------------------------- 1 | package cryptography 2 | 3 | import "crypto/cipher" 4 | 5 | type ecb struct { 6 | b cipher.Block 7 | blockSize int 8 | } 9 | 10 | func newECB(b cipher.Block) *ecb { 11 | return &ecb{ 12 | b: b, 13 | blockSize: b.BlockSize(), 14 | } 15 | } 16 | 17 | type ecbEncrypter ecb 18 | 19 | func NewECBEncrypter(b cipher.Block) cipher.BlockMode { 20 | return (*ecbEncrypter)(newECB(b)) 21 | } 22 | 23 | func (x *ecbEncrypter) BlockSize() int { return x.blockSize } 24 | 25 | func (x *ecbEncrypter) CryptBlocks(dst, src []byte) { 26 | if len(src)%x.blockSize != 0 { 27 | panic("crypto/cipher: input not full blocks") 28 | } 29 | if len(dst) < len(src) { 30 | panic("crypto/cipher: output smaller than input") 31 | } 32 | for len(src) > 0 { 33 | x.b.Encrypt(dst, src[:x.blockSize]) 34 | src = src[x.blockSize:] 35 | dst = dst[x.blockSize:] 36 | } 37 | } 38 | 39 | type ecbDecrypter ecb 40 | 41 | func NewECBDecrypter(b cipher.Block) cipher.BlockMode { 42 | return (*ecbDecrypter)(newECB(b)) 43 | } 44 | 45 | func (x *ecbDecrypter) BlockSize() int { return x.blockSize } 46 | 47 | func (x *ecbDecrypter) CryptBlocks(dst, src []byte) { 48 | if len(src)%x.blockSize != 0 { 49 | panic("crypto/cipher: input not full blocks") 50 | } 51 | if len(dst) < len(src) { 52 | panic("crypto/cipher: output smaller than input") 53 | } 54 | for len(src) > 0 { 55 | x.b.Decrypt(dst, src[:x.blockSize]) 56 | src = src[x.blockSize:] 57 | dst = dst[x.blockSize:] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/provider/baidu/album.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetAlbum(ctx context.Context, albumId string) (*api.Collection, error) { 14 | resp, err := a.GetAlbumRaw(ctx, albumId) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | n := len(resp.SongList) 20 | if n == 0 { 21 | return nil, errors.New("get album: no data") 22 | } 23 | 24 | a.patchSongsURL(ctx, resp.SongList...) 25 | a.patchSongsLyric(ctx, resp.SongList...) 26 | songs := translate(resp.SongList...) 27 | return &api.Collection{ 28 | Id: resp.AlbumInfo.AlbumId, 29 | Name: strings.TrimSpace(resp.AlbumInfo.Title), 30 | PicURL: strings.SplitN(resp.AlbumInfo.PicBig, "@", 2)[0], 31 | Songs: songs, 32 | }, nil 33 | } 34 | 35 | // 获取专辑 36 | func (a *API) GetAlbumRaw(ctx context.Context, albumId string) (*AlbumResponse, error) { 37 | params := ghttp.Params{ 38 | "album_id": albumId, 39 | } 40 | 41 | resp := new(AlbumResponse) 42 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetAlbum) 43 | req.SetQuery(params) 44 | req.SetContext(ctx) 45 | r, err := a.SendRequest(req) 46 | if err == nil { 47 | err = r.JSON(resp) 48 | } 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | if resp.ErrorCode != 0 && resp.ErrorCode != 22000 { 54 | return nil, fmt.Errorf("get album: %s", resp.errorMessage()) 55 | } 56 | 57 | return resp, nil 58 | } 59 | -------------------------------------------------------------------------------- /pkg/provider/baidu/artist.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetArtist(ctx context.Context, tingUid string) (*api.Collection, error) { 14 | resp, err := a.GetArtistRaw(ctx, tingUid, 0, 50) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | n := len(resp.SongList) 20 | if n == 0 { 21 | return nil, errors.New("get artist: no data") 22 | } 23 | 24 | a.patchSongsURL(ctx, resp.SongList...) 25 | a.patchSongsLyric(ctx, resp.SongList...) 26 | songs := translate(resp.SongList...) 27 | return &api.Collection{ 28 | Id: resp.ArtistInfo.TingUid, 29 | Name: strings.TrimSpace(resp.ArtistInfo.Name), 30 | PicURL: strings.SplitN(resp.ArtistInfo.AvatarBig, "@", 2)[0], 31 | Songs: songs, 32 | }, nil 33 | } 34 | 35 | // 获取歌手 36 | func (a *API) GetArtistRaw(ctx context.Context, tingUid string, offset int, limits int) (*ArtistResponse, error) { 37 | params := ghttp.Params{ 38 | "tinguid": tingUid, 39 | "offset": offset, 40 | "limits": limits, 41 | } 42 | 43 | resp := new(ArtistResponse) 44 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtist) 45 | req.SetQuery(params) 46 | req.SetContext(ctx) 47 | r, err := a.SendRequest(req) 48 | if err == nil { 49 | err = r.JSON(resp) 50 | } 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | if resp.ErrorCode != 22000 { 56 | return nil, fmt.Errorf("get artist: %s", resp.errorMessage()) 57 | } 58 | 59 | return resp, nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/provider/baidu/baidu.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/winterssy/ghttp" 9 | "github.com/winterssy/mxget/pkg/api" 10 | "github.com/winterssy/mxget/pkg/concurrency" 11 | "github.com/winterssy/mxget/pkg/request" 12 | "github.com/winterssy/mxget/pkg/utils" 13 | ) 14 | 15 | const ( 16 | apiSearch = "http://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.search.merge&from=android&version=8.1.4.0&format=json&type=-1&isNew=1" 17 | apiGetSong = "http://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.song.getInfos&format=json&from=android&version=8.1.4.0" 18 | apiGetSongs = "http://music.taihe.com/data/music/fmlink" 19 | apiGetSongLyric = "http://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.song.lry&format=json&from=android&version=8.1.4.0" 20 | apiGetArtist = "http://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.artist.getSongList&from=android&version=8.1.4.0&format=json&order=2" 21 | apiGetAlbum = "http://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.album.getAlbumInfo&from=android&version=8.1.4.0&format=json" 22 | apiGetPlaylist = "http://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.ugcdiy.getBaseInfo&from=android&version=8.1.4.0" 23 | ) 24 | 25 | var ( 26 | std = New(request.DefaultClient) 27 | 28 | defaultHeaders = ghttp.Headers{ 29 | "Origin": "http://music.taihe.com", 30 | "Referer": "http://music.taihe.com", 31 | } 32 | ) 33 | 34 | type ( 35 | CommonResponse struct { 36 | ErrorCode int `json:"error_code,omitempty"` 37 | ErrorMessage string `json:"error_message,omitempty"` 38 | } 39 | 40 | Song struct { 41 | SongId string `json:"song_id"` 42 | Title string `json:"title"` 43 | TingUid string `json:"ting_uid"` 44 | Author string `json:"author"` 45 | AlbumId string `json:"album_id"` 46 | AlbumTitle string `json:"album_title"` 47 | PicBig string `json:"pic_big"` 48 | LrcLink string `json:"lrclink"` 49 | CopyType string `json:"copy_type"` 50 | URL string `json:"-"` 51 | Lyric string `json:"-"` 52 | } 53 | 54 | SearchSongsResponse struct { 55 | CommonResponse 56 | Result struct { 57 | SongInfo struct { 58 | SongList []*Song `json:"song_list"` 59 | } `json:"song_info"` 60 | } `json:"result"` 61 | } 62 | 63 | URL struct { 64 | ShowLink string `json:"show_link"` 65 | FileFormat string `json:"file_format"` 66 | FileBitrate int `json:"file_bitrate"` 67 | FileLink string `json:"file_link"` 68 | } 69 | 70 | SongResponse struct { 71 | CommonResponse 72 | SongInfo Song `json:"songinfo"` 73 | SongURL struct { 74 | URL []URL `json:"url"` 75 | } `json:"songurl"` 76 | } 77 | 78 | SongsResponse struct { 79 | ErrorCode int `json:"errorCode"` 80 | Data struct { 81 | SongList []*struct { 82 | SongId int `json:"songId"` 83 | SongName string `json:"songName"` 84 | ArtistId string `json:"artistId"` 85 | ArtistName string `json:"artistName"` 86 | AlbumId int `json:"albumId"` 87 | AlbumName string `json:"albumName"` 88 | SongPicBig string `json:"songPicBig"` 89 | LrcLink string `json:"lrcLink"` 90 | CopyType int `json:"copyType"` 91 | SongLink string `json:"songLink"` 92 | ShowLink string `json:"showLink"` 93 | Format string `json:"format"` 94 | Rate int `json:"rate"` 95 | } `json:"songList"` 96 | } `json:"data"` 97 | } 98 | 99 | SongLyricResponse struct { 100 | CommonResponse 101 | Title string `json:"title"` 102 | LrcContent string `json:"lrcContent"` 103 | } 104 | 105 | ArtistResponse struct { 106 | CommonResponse 107 | ArtistInfo struct { 108 | TingUid string `json:"ting_uid"` 109 | Name string `json:"name"` 110 | AvatarBig string `json:"avatar_big"` 111 | } `json:"artistinfo"` 112 | SongList []*Song `json:"songlist"` 113 | } 114 | 115 | // 百度的这个接口设计比较坑爹,无数据时albumInfo字段为数组类型,导致json反序列化失败 116 | AlbumResponse struct { 117 | CommonResponse 118 | AlbumInfo struct { 119 | AlbumId string `json:"album_id"` 120 | Title string `json:"title"` 121 | PicBig string `json:"pic_big"` 122 | } `json:"albumInfo"` 123 | SongList []*Song `json:"songlist"` 124 | } 125 | 126 | PlaylistResponse struct { 127 | CommonResponse 128 | Result struct { 129 | Info struct { 130 | ListId string `json:"list_id"` 131 | ListTitle string `json:"list_title"` 132 | ListPic string `json:"list_pic"` 133 | } `json:"info"` 134 | SongList []*Song `json:"songlist"` 135 | } `json:"result"` 136 | } 137 | 138 | API struct { 139 | Client *ghttp.Client 140 | } 141 | ) 142 | 143 | func New(client *ghttp.Client) *API { 144 | return &API{ 145 | Client: client, 146 | } 147 | } 148 | 149 | func Client() *API { 150 | return std 151 | } 152 | 153 | func (c *CommonResponse) errorMessage() string { 154 | if c.ErrorMessage == "" { 155 | return strconv.Itoa(c.ErrorCode) 156 | } 157 | return c.ErrorMessage 158 | } 159 | 160 | func (s *SearchSongsResponse) String() string { 161 | return utils.PrettyJSON(s) 162 | } 163 | 164 | func (s *SongResponse) String() string { 165 | return utils.PrettyJSON(s) 166 | } 167 | 168 | func (s *SongsResponse) String() string { 169 | return utils.PrettyJSON(s) 170 | } 171 | 172 | func (s *SongLyricResponse) String() string { 173 | return utils.PrettyJSON(s) 174 | } 175 | 176 | func (a *ArtistResponse) String() string { 177 | return utils.PrettyJSON(a) 178 | } 179 | 180 | func (a *AlbumResponse) String() string { 181 | return utils.PrettyJSON(a) 182 | } 183 | 184 | func (p *PlaylistResponse) String() string { 185 | return utils.PrettyJSON(p) 186 | } 187 | 188 | func (a *API) SendRequest(req *ghttp.Request) (*ghttp.Response, error) { 189 | req.SetHeaders(defaultHeaders) 190 | return a.Client.Do(req) 191 | } 192 | 193 | func songURL(urls []URL) string { 194 | for _, i := range urls { 195 | if i.FileFormat == "mp3" { 196 | return i.ShowLink 197 | } 198 | } 199 | return "" 200 | } 201 | 202 | func (a *API) patchSongsURL(ctx context.Context, songs ...*Song) { 203 | c := concurrency.New(32) 204 | for _, s := range songs { 205 | if ctx.Err() != nil { 206 | break 207 | } 208 | 209 | c.Add(1) 210 | go func(s *Song) { 211 | resp, err := a.GetSongRaw(ctx, s.SongId) 212 | if err == nil { 213 | s.URL = songURL(resp.SongURL.URL) 214 | if s.LrcLink == "" { 215 | s.LrcLink = resp.SongInfo.LrcLink 216 | } 217 | } 218 | c.Done() 219 | }(s) 220 | } 221 | c.Wait() 222 | } 223 | 224 | func (a *API) patchSongsLyric(ctx context.Context, songs ...*Song) { 225 | c := concurrency.New(32) 226 | for _, s := range songs { 227 | if ctx.Err() != nil { 228 | break 229 | } 230 | 231 | c.Add(1) 232 | go func(s *Song) { 233 | req, _ := ghttp.NewRequest(ghttp.MethodGet, s.LrcLink) 234 | req.SetContext(ctx) 235 | resp, err := a.SendRequest(req) 236 | if err == nil { 237 | lyric, _ := resp.Text() 238 | s.Lyric = lyric 239 | } 240 | c.Done() 241 | }(s) 242 | } 243 | c.Wait() 244 | } 245 | 246 | func translate(src ...*Song) []*api.Song { 247 | songs := make([]*api.Song, len(src)) 248 | for i, s := range src { 249 | songs[i] = &api.Song{ 250 | Id: s.SongId, 251 | Name: strings.TrimSpace(s.Title), 252 | Artist: strings.TrimSpace(strings.ReplaceAll(s.Author, ",", "/")), 253 | Album: strings.TrimSpace(s.AlbumTitle), 254 | PicURL: strings.SplitN(s.PicBig, "@", 2)[0], 255 | Lyric: s.Lyric, 256 | ListenURL: s.URL, 257 | } 258 | } 259 | return songs 260 | } 261 | -------------------------------------------------------------------------------- /pkg/provider/baidu/baidu_test.go: -------------------------------------------------------------------------------- 1 | package baidu_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/winterssy/mxget/pkg/provider/baidu" 10 | ) 11 | 12 | var ( 13 | client *baidu.API 14 | ctx context.Context 15 | ) 16 | 17 | func setup() { 18 | client = baidu.Client() 19 | ctx = context.Background() 20 | } 21 | 22 | func TestMain(m *testing.M) { 23 | setup() 24 | os.Exit(m.Run()) 25 | } 26 | 27 | func TestAPI_SearchSongs(t *testing.T) { 28 | result, err := client.SearchSongs(ctx, "五月天") 29 | if assert.NoError(t, err) { 30 | t.Log(result) 31 | } 32 | } 33 | 34 | func TestAPI_GetSong(t *testing.T) { 35 | song, err := client.GetSong(ctx, "1686649") 36 | if assert.NoError(t, err) { 37 | t.Log(song) 38 | } 39 | } 40 | 41 | func TestAPI_GetSongLyric(t *testing.T) { 42 | lyric, err := client.GetSongLyric(ctx, "1686649") 43 | if assert.NoError(t, err) { 44 | t.Log(lyric) 45 | } 46 | } 47 | 48 | func TestAPI_GetArtist(t *testing.T) { 49 | artist, err := client.GetArtist(ctx, "1557") 50 | if assert.NoError(t, err) { 51 | t.Log(artist) 52 | } 53 | } 54 | 55 | func TestAPI_GetAlbum(t *testing.T) { 56 | album, err := client.GetAlbum(ctx, "946499") 57 | if assert.NoError(t, err) { 58 | t.Log(album) 59 | } 60 | } 61 | 62 | func TestAPI_GetPlaylist(t *testing.T) { 63 | playlist, err := client.GetPlaylist(ctx, "566347665") 64 | if assert.NoError(t, err) { 65 | t.Log(playlist) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pkg/provider/baidu/crypto.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/base64" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/cryptography" 11 | ) 12 | 13 | const ( 14 | Input = "2012171402992850" 15 | IV = "2012061402992850" 16 | ) 17 | 18 | var ( 19 | key string 20 | ) 21 | 22 | func init() { 23 | hash := fmt.Sprintf("%X", md5.Sum([]byte(Input))) 24 | key = hash[len(hash)/2:] 25 | } 26 | 27 | func aesCBCEncrypt(songId string) ghttp.Params { 28 | params := ghttp.Params{ 29 | "songid": songId, 30 | "ts": time.Now().UnixNano() / 1e6, 31 | } 32 | 33 | e := base64.StdEncoding.EncodeToString(cryptography. 34 | AESCBCEncrypt([]byte(params.EncodeToURL(false)), []byte(key), []byte(IV))) 35 | params["e"] = e 36 | return params 37 | } 38 | 39 | func signPayload(params ghttp.Params) ghttp.Params { 40 | q := params.EncodeToURL(false) 41 | ts := time.Now().Unix() 42 | r := fmt.Sprintf("baidu_taihe_music_secret_key%d", ts) 43 | key := fmt.Sprintf("%x", md5.Sum([]byte(r)))[8:24] 44 | param := base64.StdEncoding.EncodeToString(cryptography.AESCBCEncrypt([]byte(q), []byte(key), []byte(key))) 45 | sign := fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("baidu_taihe_music%s%d", param, ts)))) 46 | return ghttp.Params{ 47 | "timestamp": ts, 48 | "param": param, 49 | "sign": sign, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/provider/baidu/playlist.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetPlaylist(ctx context.Context, playlistId string) (*api.Collection, error) { 14 | resp, err := a.GetPlaylistRaw(ctx, playlistId) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | n := len(resp.Result.SongList) 20 | if n == 0 { 21 | return nil, errors.New("get playlist: no data") 22 | } 23 | 24 | a.patchSongsURL(ctx, resp.Result.SongList...) 25 | a.patchSongsLyric(ctx, resp.Result.SongList...) 26 | songs := translate(resp.Result.SongList...) 27 | return &api.Collection{ 28 | Id: resp.Result.Info.ListId, 29 | Name: strings.TrimSpace(resp.Result.Info.ListTitle), 30 | PicURL: resp.Result.Info.ListPic, 31 | Songs: songs, 32 | }, nil 33 | } 34 | 35 | // 获取歌单 36 | func (a *API) GetPlaylistRaw(ctx context.Context, playlistId string) (*PlaylistResponse, error) { 37 | params := ghttp.Params{ 38 | "list_id": playlistId, 39 | "withcount": "1", 40 | "withsong": "1", 41 | } 42 | 43 | resp := new(PlaylistResponse) 44 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetPlaylist) 45 | req.SetQuery(params) 46 | req.SetContext(ctx) 47 | r, err := a.SendRequest(req) 48 | if err == nil { 49 | err = r.JSON(resp) 50 | } 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | if resp.ErrorCode != 22000 { 56 | return nil, fmt.Errorf("get playlist: %s", resp.errorMessage()) 57 | } 58 | 59 | return resp, nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/provider/baidu/search.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) SearchSongs(ctx context.Context, keyword string) ([]*api.Song, error) { 15 | resp, err := a.SearchSongsRaw(ctx, keyword, 1, 50) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | n := len(resp.Result.SongInfo.SongList) 21 | if n == 0 { 22 | return nil, errors.New("search songs: no data") 23 | } 24 | 25 | songs := make([]*api.Song, n) 26 | for i, s := range resp.Result.SongInfo.SongList { 27 | songs[i] = &api.Song{ 28 | Id: s.SongId, 29 | Name: strings.TrimSpace(s.Title), 30 | Artist: strings.TrimSpace(strings.ReplaceAll(s.Author, ",", "/")), 31 | Album: strings.TrimSpace(s.AlbumTitle), 32 | } 33 | } 34 | return songs, nil 35 | } 36 | 37 | // 搜索歌曲 38 | func (a *API) SearchSongsRaw(ctx context.Context, keyword string, page int, pageSize int) (*SearchSongsResponse, error) { 39 | params := ghttp.Params{ 40 | "query": keyword, 41 | "page_no": strconv.Itoa(page), 42 | "page_size": strconv.Itoa(pageSize), 43 | } 44 | 45 | resp := new(SearchSongsResponse) 46 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiSearch) 47 | req.SetQuery(params) 48 | req.SetContext(ctx) 49 | r, err := a.SendRequest(req) 50 | if err == nil { 51 | err = r.JSON(resp) 52 | } 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | if resp.ErrorCode != 22000 { 58 | return nil, fmt.Errorf("search songs: %s", resp.errorMessage()) 59 | } 60 | 61 | return resp, nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/provider/baidu/song.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/winterssy/ghttp" 9 | "github.com/winterssy/mxget/pkg/api" 10 | ) 11 | 12 | func (a *API) GetSong(ctx context.Context, songId string) (*api.Song, error) { 13 | resp, err := a.GetSongRaw(ctx, songId) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | resp.SongInfo.URL = songURL(resp.SongURL.URL) 19 | a.patchSongsLyric(ctx, &resp.SongInfo) 20 | songs := translate(&resp.SongInfo) 21 | return songs[0], nil 22 | } 23 | 24 | // 获取歌曲 25 | func (a *API) GetSongRaw(ctx context.Context, songId string) (*SongResponse, error) { 26 | resp := new(SongResponse) 27 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSong) 28 | req.SetQuery(aesCBCEncrypt(songId)) 29 | req.SetContext(ctx) 30 | r, err := a.SendRequest(req) 31 | if err == nil { 32 | err = r.JSON(resp) 33 | } 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | if resp.ErrorCode != 22000 { 39 | return nil, fmt.Errorf("get song: %s", resp.errorMessage()) 40 | } 41 | 42 | return resp, nil 43 | } 44 | 45 | // 批量获取歌曲,遗留接口,不推荐使用 46 | func (a *API) GetSongsRaw(ctx context.Context, songIds ...string) (*SongsResponse, error) { 47 | params := ghttp.Params{ 48 | "songIds": strings.Join(songIds, ","), 49 | } 50 | resp := new(SongsResponse) 51 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongs) 52 | req.SetQuery(params) 53 | req.SetContext(ctx) 54 | r, err := a.SendRequest(req) 55 | if err == nil { 56 | err = r.JSON(resp) 57 | } 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | if resp.ErrorCode != 22000 { 63 | return nil, fmt.Errorf("get songs: %d", resp.ErrorCode) 64 | } 65 | 66 | return resp, nil 67 | } 68 | 69 | func (a *API) GetSongLyric(ctx context.Context, songId string) (string, error) { 70 | resp, err := a.GetSongLyricRaw(ctx, songId) 71 | if err != nil { 72 | return "", err 73 | } 74 | 75 | return resp.LrcContent, nil 76 | } 77 | 78 | // 获取歌词 79 | func (a *API) GetSongLyricRaw(ctx context.Context, songId string) (*SongLyricResponse, error) { 80 | params := ghttp.Params{ 81 | "songid": songId, 82 | } 83 | 84 | resp := new(SongLyricResponse) 85 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongLyric) 86 | req.SetQuery(params) 87 | req.SetContext(ctx) 88 | r, err := a.SendRequest(req) 89 | if err == nil { 90 | err = r.JSON(resp) 91 | } 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | if resp.ErrorCode != 0 && resp.ErrorCode != 22000 { 97 | return nil, fmt.Errorf("get lyric: %s", resp.errorMessage()) 98 | } 99 | 100 | return resp, nil 101 | } 102 | -------------------------------------------------------------------------------- /pkg/provider/kugou/album.go: -------------------------------------------------------------------------------- 1 | package kugou 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetAlbum(ctx context.Context, albumId string) (*api.Collection, error) { 15 | albumInfo, err := a.GetAlbumInfoRaw(ctx, albumId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | albumSongs, err := a.GetAlbumSongsRaw(ctx, albumId, 1, -1) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | n := len(albumSongs.Data.Info) 26 | if n == 0 { 27 | return nil, errors.New("get album songs: no data") 28 | } 29 | 30 | a.patchSongInfo(ctx, albumSongs.Data.Info...) 31 | a.patchSongsInfo(ctx, albumSongs.Data.Info...) 32 | a.patchSongsLyric(ctx, albumSongs.Data.Info...) 33 | songs := translate(albumSongs.Data.Info...) 34 | return &api.Collection{ 35 | Id: strconv.Itoa(albumInfo.Data.AlbumId), 36 | Name: strings.TrimSpace(albumInfo.Data.AlbumName), 37 | PicURL: strings.ReplaceAll(albumInfo.Data.ImgURL, "{size}", "480"), 38 | Songs: songs, 39 | }, nil 40 | } 41 | 42 | // 获取专辑信息 43 | func (a *API) GetAlbumInfoRaw(ctx context.Context, albumId string) (*AlbumInfoResponse, error) { 44 | params := ghttp.Params{ 45 | "albumid": albumId, 46 | } 47 | 48 | resp := new(AlbumInfoResponse) 49 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetAlbumInfo) 50 | req.SetQuery(params) 51 | req.SetContext(ctx) 52 | r, err := a.SendRequest(req) 53 | if err == nil { 54 | err = r.JSON(resp) 55 | } 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | if resp.ErrCode != 0 { 61 | return nil, fmt.Errorf("get album info: %s", resp.errorMessage()) 62 | } 63 | 64 | return resp, nil 65 | } 66 | 67 | // 获取专辑歌曲,page: 页码;pageSize: 每页数量,-1获取全部 68 | func (a *API) GetAlbumSongsRaw(ctx context.Context, albumId string, page int, pageSize int) (*AlbumSongsResponse, error) { 69 | params := ghttp.Params{ 70 | "albumid": albumId, 71 | "page": page, 72 | "pagesize": pageSize, 73 | } 74 | 75 | resp := new(AlbumSongsResponse) 76 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetAlbumSongs) 77 | req.SetQuery(params) 78 | req.SetContext(ctx) 79 | r, err := a.SendRequest(req) 80 | if err == nil { 81 | err = r.JSON(resp) 82 | } 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | if resp.ErrCode != 0 { 88 | return nil, fmt.Errorf("get album songs: %s", resp.errorMessage()) 89 | } 90 | 91 | return resp, nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/provider/kugou/artist.go: -------------------------------------------------------------------------------- 1 | package kugou 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetArtist(ctx context.Context, singerId string) (*api.Collection, error) { 15 | artistInfo, err := a.GetArtistInfoRaw(ctx, singerId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | artistSongs, err := a.GetArtistSongsRaw(ctx, singerId, 1, 50) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | n := len(artistSongs.Data.Info) 26 | if n == 0 { 27 | return nil, errors.New("get artist songs: no data") 28 | } 29 | 30 | a.patchSongInfo(ctx, artistSongs.Data.Info...) 31 | a.patchSongsInfo(ctx, artistSongs.Data.Info...) 32 | a.patchSongsLyric(ctx, artistSongs.Data.Info...) 33 | songs := translate(artistSongs.Data.Info...) 34 | return &api.Collection{ 35 | Id: strconv.Itoa(artistInfo.Data.SingerId), 36 | Name: strings.TrimSpace(artistInfo.Data.SingerName), 37 | PicURL: strings.ReplaceAll(artistInfo.Data.ImgURL, "{size}", "480"), 38 | Songs: songs, 39 | }, nil 40 | } 41 | 42 | // 获取歌手信息 43 | func (a *API) GetArtistInfoRaw(ctx context.Context, singerId string) (*ArtistInfoResponse, error) { 44 | params := ghttp.Params{ 45 | "singerid": singerId, 46 | } 47 | 48 | resp := new(ArtistInfoResponse) 49 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtistInfo) 50 | req.SetQuery(params) 51 | req.SetContext(ctx) 52 | r, err := a.SendRequest(req) 53 | if err == nil { 54 | err = r.JSON(resp) 55 | } 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | if resp.ErrCode != 0 { 61 | return nil, fmt.Errorf("get artist info: %s", resp.errorMessage()) 62 | } 63 | 64 | return resp, nil 65 | } 66 | 67 | // 获取歌手歌曲,page: 页码;pageSize: 每页数量,-1获取全部 68 | func (a *API) GetArtistSongsRaw(ctx context.Context, singerId string, page int, pageSize int) (*ArtistSongsResponse, error) { 69 | params := ghttp.Params{ 70 | "singerid": singerId, 71 | "page": page, 72 | "pagesize": pageSize, 73 | } 74 | 75 | resp := new(ArtistSongsResponse) 76 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtistSongs) 77 | req.SetQuery(params) 78 | req.SetContext(ctx) 79 | r, err := a.SendRequest(req) 80 | if err == nil { 81 | err = r.JSON(resp) 82 | } 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | if resp.ErrCode != 0 { 88 | return nil, fmt.Errorf("get artist songs: %s", resp.errorMessage()) 89 | } 90 | 91 | return resp, nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/provider/kugou/kugou.go: -------------------------------------------------------------------------------- 1 | package kugou 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/winterssy/ghttp" 9 | "github.com/winterssy/mxget/pkg/api" 10 | "github.com/winterssy/mxget/pkg/concurrency" 11 | "github.com/winterssy/mxget/pkg/request" 12 | "github.com/winterssy/mxget/pkg/utils" 13 | ) 14 | 15 | const ( 16 | apiSearch = "http://mobilecdn.kugou.com/api/v3/search/song" 17 | apiGetSong = "http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo" 18 | apiGetSongURL = "http://trackercdn.kugou.com/i/v2/?pid=2&behavior=play&cmd=25" 19 | apiGetSongLyric = "http://m.kugou.com/app/i/krc.php?cmd=100&timelength=1" 20 | apiGetArtistInfo = "http://mobilecdn.kugou.com/api/v3/singer/info" 21 | apiGetArtistSongs = "http://mobilecdn.kugou.com/api/v3/singer/song" 22 | apiGetAlbumInfo = "http://mobilecdn.kugou.com/api/v3/album/info" 23 | apiGetAlbumSongs = "http://mobilecdn.kugou.com/api/v3/album/song" 24 | apiGetPlaylistInfo = "http://mobilecdn.kugou.com/api/v3/special/info" 25 | apiGetPlaylistSongs = "http://mobilecdn.kugou.com/api/v3/special/song" 26 | ) 27 | 28 | var ( 29 | std = New(request.DefaultClient) 30 | 31 | defaultHeaders = ghttp.Headers{ 32 | "Origin": "https://www.kugou.com", 33 | "Referer": "https://www.kugou.com", 34 | } 35 | ) 36 | 37 | type ( 38 | CommonResponse struct { 39 | Status int `json:"status"` 40 | Error string `json:"error,omitempty"` 41 | ErrCode int `json:"errcode"` 42 | } 43 | 44 | SearchSongsResponse struct { 45 | CommonResponse 46 | Data struct { 47 | Total int `json:"total"` 48 | Info []*struct { 49 | Hash string `json:"hash"` 50 | HQHash string `json:"320hash"` 51 | SQHash string `json:"sqhash"` 52 | SongName string `json:"songname"` 53 | SingerName string `json:"singername"` 54 | AlbumId string `json:"album_id"` 55 | AlbumName string `json:"album_name"` 56 | } `json:"info"` 57 | } `json:"data"` 58 | } 59 | 60 | Song struct { 61 | Hash string `json:"hash"` 62 | SongName string `json:"songName"` 63 | SingerId int `json:"singerId"` 64 | SingerName string `json:"singerName"` 65 | ChoricSinger string `json:"choricSinger"` 66 | FileName string `json:"fileName"` 67 | ExtName string `json:"extName"` 68 | AlbumId int `json:"albumid"` 69 | AlbumImg string `json:"album_img"` 70 | Extra struct { 71 | PQHash string `json:"128hash"` 72 | HQHash string `json:"320hash"` 73 | SQHash string `json:"sqhash"` 74 | } `json:"extra"` 75 | URL string `json:"url"` 76 | AlbumName string `json:"-"` 77 | Lyric string `json:"-"` 78 | } 79 | 80 | SongResponse struct { 81 | CommonResponse 82 | Song 83 | } 84 | 85 | SongURLResponse struct { 86 | Status int `json:"status"` 87 | BitRate int `json:"bitRate"` 88 | ExtName string `json:"extName"` 89 | URL []string `json:"url"` 90 | } 91 | 92 | ArtistInfo struct { 93 | SingerId int `json:"singerid"` 94 | SingerName string `json:"singername"` 95 | ImgURL string `json:"imgurl"` 96 | } 97 | 98 | ArtistInfoResponse struct { 99 | CommonResponse 100 | Data ArtistInfo `json:"data"` 101 | } 102 | 103 | ArtistSongsResponse struct { 104 | CommonResponse 105 | Data struct { 106 | Info []*Song `json:"info"` 107 | } `json:"data"` 108 | } 109 | 110 | AlbumInfo struct { 111 | AlbumId int `json:"albumid"` 112 | AlbumName string `json:"albumname"` 113 | ImgURL string `json:"imgurl"` 114 | } 115 | 116 | AlbumInfoResponse struct { 117 | CommonResponse 118 | Data AlbumInfo `json:"data"` 119 | } 120 | 121 | AlbumSongsResponse struct { 122 | CommonResponse 123 | Data struct { 124 | Info []*Song `json:"info"` 125 | } `json:"data"` 126 | } 127 | 128 | PlaylistInfo struct { 129 | SpecialId int `json:"specialid"` 130 | SpecialName string `json:"specialname"` 131 | ImgURL string `json:"imgurl"` 132 | } 133 | 134 | PlaylistInfoResponse struct { 135 | CommonResponse 136 | Data PlaylistInfo `json:"data"` 137 | } 138 | 139 | PlaylistSongsResponse struct { 140 | CommonResponse 141 | Data struct { 142 | Info []*Song `json:"info"` 143 | } `json:"data"` 144 | } 145 | 146 | API struct { 147 | Client *ghttp.Client 148 | } 149 | ) 150 | 151 | func New(client *ghttp.Client) *API { 152 | return &API{ 153 | Client: client, 154 | } 155 | } 156 | 157 | func Client() *API { 158 | return std 159 | } 160 | 161 | func (c *CommonResponse) errorMessage() string { 162 | if c.Error == "" { 163 | return strconv.Itoa(c.ErrCode) 164 | } 165 | return c.Error 166 | } 167 | 168 | func (s *SearchSongsResponse) String() string { 169 | return utils.PrettyJSON(s) 170 | } 171 | 172 | func (s *SongResponse) String() string { 173 | return utils.PrettyJSON(s) 174 | } 175 | 176 | func (s *SongURLResponse) String() string { 177 | return utils.PrettyJSON(s) 178 | } 179 | 180 | func (a *ArtistInfoResponse) String() string { 181 | return utils.PrettyJSON(a) 182 | } 183 | 184 | func (a *ArtistSongsResponse) String() string { 185 | return utils.PrettyJSON(a) 186 | } 187 | 188 | func (a *AlbumInfoResponse) String() string { 189 | return utils.PrettyJSON(a) 190 | } 191 | 192 | func (a *AlbumSongsResponse) String() string { 193 | return utils.PrettyJSON(a) 194 | } 195 | 196 | func (p *PlaylistInfoResponse) String() string { 197 | return utils.PrettyJSON(p) 198 | } 199 | 200 | func (p *PlaylistSongsResponse) String() string { 201 | return utils.PrettyJSON(p) 202 | } 203 | 204 | func (a *API) SendRequest(req *ghttp.Request) (*ghttp.Response, error) { 205 | req.SetHeaders(defaultHeaders) 206 | return a.Client.Do(req) 207 | } 208 | 209 | func (a *API) patchSongInfo(ctx context.Context, songs ...*Song) { 210 | c := concurrency.New(32) 211 | for _, s := range songs { 212 | if ctx.Err() != nil { 213 | break 214 | } 215 | 216 | c.Add(1) 217 | go func(s *Song) { 218 | resp, err := a.GetSongRaw(ctx, s.Hash) 219 | if err == nil { 220 | s.SongName = resp.SongName 221 | s.SingerId = resp.SingerId 222 | s.SingerName = resp.SingerName 223 | s.ChoricSinger = resp.ChoricSinger 224 | s.AlbumId = resp.AlbumId 225 | s.AlbumImg = resp.AlbumImg 226 | s.Extra = resp.Extra 227 | s.URL = resp.URL 228 | } 229 | c.Done() 230 | }(s) 231 | } 232 | c.Wait() 233 | } 234 | 235 | func (a *API) patchSongsInfo(ctx context.Context, songs ...*Song) { 236 | c := concurrency.New(32) 237 | for _, s := range songs { 238 | if ctx.Err() != nil { 239 | break 240 | } 241 | 242 | c.Add(1) 243 | go func(s *Song) { 244 | if s.AlbumId != 0 { 245 | resp, err := a.GetAlbumInfoRaw(ctx, strconv.Itoa(s.AlbumId)) 246 | if err == nil { 247 | s.AlbumName = resp.Data.AlbumName 248 | } 249 | } 250 | c.Done() 251 | }(s) 252 | } 253 | c.Wait() 254 | } 255 | 256 | func (a *API) patchSongsURL(ctx context.Context, songs ...*Song) { 257 | c := concurrency.New(32) 258 | for _, s := range songs { 259 | if ctx.Err() != nil { 260 | break 261 | } 262 | 263 | if s.URL != "" { 264 | continue 265 | } 266 | c.Add(1) 267 | go func(s *Song) { 268 | url, err := a.GetSongURL(ctx, s.Hash) 269 | if err == nil { 270 | s.URL = url 271 | } 272 | c.Done() 273 | }(s) 274 | } 275 | c.Wait() 276 | } 277 | 278 | func (a *API) patchSongsLyric(ctx context.Context, songs ...*Song) { 279 | c := concurrency.New(32) 280 | for _, s := range songs { 281 | if ctx.Err() != nil { 282 | break 283 | } 284 | 285 | c.Add(1) 286 | go func(s *Song) { 287 | lyric, err := a.GetSongLyric(ctx, s.Hash) 288 | if err == nil { 289 | s.Lyric = lyric 290 | } 291 | c.Done() 292 | }(s) 293 | } 294 | c.Wait() 295 | } 296 | 297 | func translate(src ...*Song) []*api.Song { 298 | songs := make([]*api.Song, len(src)) 299 | for i, s := range src { 300 | songs[i] = &api.Song{ 301 | Id: s.Hash, 302 | Name: strings.TrimSpace(s.SongName), 303 | Artist: strings.TrimSpace(strings.ReplaceAll(s.ChoricSinger, "、", "/")), 304 | Album: strings.TrimSpace(s.AlbumName), 305 | PicURL: strings.ReplaceAll(s.AlbumImg, "{size}", "480"), 306 | Lyric: s.Lyric, 307 | ListenURL: s.URL, 308 | } 309 | } 310 | return songs 311 | } 312 | -------------------------------------------------------------------------------- /pkg/provider/kugou/kugou_test.go: -------------------------------------------------------------------------------- 1 | package kugou_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/winterssy/mxget/pkg/provider/kugou" 10 | ) 11 | 12 | var ( 13 | client *kugou.API 14 | ctx context.Context 15 | ) 16 | 17 | func setup() { 18 | client = kugou.Client() 19 | ctx = context.Background() 20 | } 21 | 22 | func TestMain(m *testing.M) { 23 | setup() 24 | os.Exit(m.Run()) 25 | } 26 | 27 | func TestAPI_SearchSongs(t *testing.T) { 28 | result, err := client.SearchSongs(ctx, "五月天") 29 | if assert.NoError(t, err) { 30 | t.Log(result) 31 | } 32 | } 33 | 34 | func TestAPI_GetSong(t *testing.T) { 35 | song, err := client.GetSong(ctx, "1571941D82D63AD614E35EAD9DB6A6A2") 36 | if assert.NoError(t, err) { 37 | t.Log(song) 38 | } 39 | } 40 | 41 | func TestAPI_GetSongURL(t *testing.T) { 42 | url, err := client.GetSongURL(ctx, "1571941D82D63AD614E35EAD9DB6A6A2") 43 | if assert.NoError(t, err) { 44 | t.Log(url) 45 | } 46 | } 47 | 48 | func TestAPI_GetSongLyric(t *testing.T) { 49 | lyric, err := client.GetSongLyric(ctx, "1571941D82D63AD614E35EAD9DB6A6A2") 50 | if assert.NoError(t, err) { 51 | t.Log(lyric) 52 | } 53 | } 54 | 55 | func TestAPI_GetArtist(t *testing.T) { 56 | artist, err := client.GetArtist(ctx, "8965") 57 | if assert.NoError(t, err) { 58 | t.Log(artist) 59 | } 60 | } 61 | 62 | func TestAPI_GetAlbum(t *testing.T) { 63 | album, err := client.GetAlbum(ctx, "976965") 64 | if assert.NoError(t, err) { 65 | t.Log(album) 66 | } 67 | } 68 | 69 | func TestAPI_GetPlaylist(t *testing.T) { 70 | playlist, err := client.GetPlaylist(ctx, "610433") 71 | if assert.NoError(t, err) { 72 | t.Log(playlist) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/provider/kugou/playlist.go: -------------------------------------------------------------------------------- 1 | package kugou 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetPlaylist(ctx context.Context, specialId string) (*api.Collection, error) { 15 | playlistInfo, err := a.GetPlaylistInfoRaw(ctx, specialId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | playlistSongs, err := a.GetPlaylistSongsRaw(ctx, specialId, 1, -1) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | n := len(playlistSongs.Data.Info) 26 | if n == 0 { 27 | return nil, errors.New("get playlist songs: no data") 28 | } 29 | 30 | a.patchSongInfo(ctx, playlistSongs.Data.Info...) 31 | a.patchSongsInfo(ctx, playlistSongs.Data.Info...) 32 | a.patchSongsLyric(ctx, playlistSongs.Data.Info...) 33 | songs := translate(playlistSongs.Data.Info...) 34 | return &api.Collection{ 35 | Id: strconv.Itoa(playlistInfo.Data.SpecialId), 36 | Name: strings.TrimSpace(playlistInfo.Data.SpecialName), 37 | PicURL: strings.ReplaceAll(playlistInfo.Data.ImgURL, "{size}", "480"), 38 | Songs: songs, 39 | }, nil 40 | } 41 | 42 | // 获取歌单信息 43 | func (a *API) GetPlaylistInfoRaw(ctx context.Context, specialId string) (*PlaylistInfoResponse, error) { 44 | params := ghttp.Params{ 45 | "specialid": specialId, 46 | } 47 | 48 | resp := new(PlaylistInfoResponse) 49 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetPlaylistInfo) 50 | req.SetQuery(params) 51 | req.SetContext(ctx) 52 | r, err := a.SendRequest(req) 53 | if err == nil { 54 | err = r.JSON(resp) 55 | } 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | if resp.ErrCode != 0 { 61 | return nil, fmt.Errorf("get playlist info: %s", resp.errorMessage()) 62 | } 63 | 64 | return resp, nil 65 | } 66 | 67 | // 获取歌单歌曲,page: 页码;pageSize: 每页数量,-1获取全部 68 | func (a *API) GetPlaylistSongsRaw(ctx context.Context, specialId string, page int, pageSize int) (*PlaylistSongsResponse, error) { 69 | params := ghttp.Params{ 70 | "specialid": specialId, 71 | "page": page, 72 | "pagesize": pageSize, 73 | } 74 | 75 | resp := new(PlaylistSongsResponse) 76 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetPlaylistSongs) 77 | req.SetQuery(params) 78 | req.SetContext(ctx) 79 | r, err := a.SendRequest(req) 80 | if err == nil { 81 | err = r.JSON(resp) 82 | } 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | if resp.ErrCode != 0 { 88 | return nil, fmt.Errorf("get playlist songs: %s", resp.errorMessage()) 89 | } 90 | 91 | return resp, nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/provider/kugou/search.go: -------------------------------------------------------------------------------- 1 | package kugou 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) SearchSongs(ctx context.Context, keyword string) ([]*api.Song, error) { 15 | resp, err := a.SearchSongsRaw(ctx, keyword, 1, 50) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | n := len(resp.Data.Info) 21 | if n == 0 { 22 | return nil, errors.New("search songs: no data") 23 | } 24 | 25 | songs := make([]*api.Song, n) 26 | for i, s := range resp.Data.Info { 27 | songs[i] = &api.Song{ 28 | Id: s.Hash, 29 | Name: strings.TrimSpace(s.SongName), 30 | Artist: strings.TrimSpace(strings.ReplaceAll(s.SingerName, "、", "/")), 31 | Album: strings.TrimSpace(s.AlbumName), 32 | } 33 | } 34 | return songs, nil 35 | } 36 | 37 | // 搜索歌曲 38 | func (a *API) SearchSongsRaw(ctx context.Context, keyword string, page int, pageSize int) (*SearchSongsResponse, error) { 39 | params := ghttp.Params{ 40 | "keyword": keyword, 41 | "page": strconv.Itoa(page), 42 | "pagesize": strconv.Itoa(pageSize), 43 | } 44 | 45 | resp := new(SearchSongsResponse) 46 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiSearch) 47 | req.SetQuery(params) 48 | req.SetContext(ctx) 49 | r, err := a.SendRequest(req) 50 | if err == nil { 51 | err = r.JSON(resp) 52 | } 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | if resp.ErrCode != 0 { 58 | return nil, fmt.Errorf("search songs: %s", resp.errorMessage()) 59 | } 60 | 61 | return resp, nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/provider/kugou/song.go: -------------------------------------------------------------------------------- 1 | package kugou 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "errors" 7 | "fmt" 8 | "math/rand" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetSong(ctx context.Context, hash string) (*api.Song, error) { 15 | resp, err := a.GetSongRaw(ctx, hash) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | a.patchSongsInfo(ctx, &resp.Song) 21 | a.patchSongsLyric(ctx, &resp.Song) 22 | songs := translate(&resp.Song) 23 | return songs[0], nil 24 | } 25 | 26 | // 获取歌曲详情 27 | func (a *API) GetSongRaw(ctx context.Context, hash string) (*SongResponse, error) { 28 | params := ghttp.Params{ 29 | "hash": hash, 30 | } 31 | 32 | resp := new(SongResponse) 33 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSong) 34 | req.SetQuery(params) 35 | req.SetContext(ctx) 36 | r, err := a.SendRequest(req) 37 | if err == nil { 38 | err = r.JSON(resp) 39 | } 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | if resp.ErrCode != 0 { 45 | return nil, fmt.Errorf("get song: %s", resp.errorMessage()) 46 | } 47 | 48 | return resp, nil 49 | } 50 | 51 | func (a *API) GetSongURL(ctx context.Context, hash string) (string, error) { 52 | resp, err := a.GetSongURLRaw(ctx, hash) 53 | if err != nil { 54 | return "", err 55 | } 56 | if len(resp.URL) == 0 { 57 | return "", errors.New("get song url: no data") 58 | } 59 | 60 | return resp.URL[rand.Intn(len(resp.URL))], nil 61 | } 62 | 63 | // 获取歌曲播放地址 64 | func (a *API) GetSongURLRaw(ctx context.Context, hash string) (*SongURLResponse, error) { 65 | data := []byte(hash + "kgcloudv2") 66 | key := fmt.Sprintf("%x", md5.Sum(data)) 67 | 68 | params := ghttp.Params{ 69 | "hash": hash, 70 | "key": key, 71 | } 72 | 73 | resp := new(SongURLResponse) 74 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongURL) 75 | req.SetQuery(params) 76 | req.SetContext(ctx) 77 | r, err := a.SendRequest(req) 78 | if err == nil { 79 | err = r.JSON(resp) 80 | } 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | if resp.Status != 1 { 86 | if resp.Status == 2 { 87 | err = errors.New("get song url: copyright protection") 88 | } else { 89 | err = fmt.Errorf("get song url: %d", resp.Status) 90 | } 91 | return nil, err 92 | } 93 | 94 | return resp, nil 95 | } 96 | 97 | // 获取歌词 98 | func (a *API) GetSongLyric(ctx context.Context, hash string) (string, error) { 99 | params := ghttp.Params{ 100 | "hash": hash, 101 | } 102 | 103 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongLyric) 104 | req.SetQuery(params) 105 | req.SetContext(ctx) 106 | r, err := a.SendRequest(req) 107 | if err != nil { 108 | return "", err 109 | } 110 | return r.Text() 111 | } 112 | -------------------------------------------------------------------------------- /pkg/provider/kuwo/album.go: -------------------------------------------------------------------------------- 1 | package kuwo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetAlbum(ctx context.Context, albumId string) (*api.Collection, error) { 15 | resp, err := a.GetAlbumRaw(ctx, albumId, 1, 9999) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | n := len(resp.Data.MusicList) 21 | if n == 0 { 22 | return nil, errors.New("get album: no data") 23 | } 24 | 25 | a.patchSongsURL(ctx, songDefaultBR, resp.Data.MusicList...) 26 | a.patchSongsLyric(ctx, resp.Data.MusicList...) 27 | songs := translate(resp.Data.MusicList...) 28 | return &api.Collection{ 29 | Id: strconv.Itoa(resp.Data.AlbumId), 30 | Name: strings.TrimSpace(resp.Data.Album), 31 | PicURL: resp.Data.Pic, 32 | Songs: songs, 33 | }, nil 34 | } 35 | 36 | // 获取专辑,page: 页码; pageSize: 每页数量,如果要获取全部请设置为较大的值 37 | func (a *API) GetAlbumRaw(ctx context.Context, albumId string, page int, pageSize int) (*AlbumResponse, error) { 38 | params := ghttp.Params{ 39 | "albumId": albumId, 40 | "pn": strconv.Itoa(page), 41 | "rn": strconv.Itoa(pageSize), 42 | } 43 | resp := new(AlbumResponse) 44 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetAlbum) 45 | req.SetQuery(params) 46 | req.SetContext(ctx) 47 | r, err := a.SendRequest(req) 48 | if err == nil { 49 | err = r.JSON(resp) 50 | } 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | if resp.Code != 200 { 56 | return nil, fmt.Errorf("get album: %s", resp.errorMessage()) 57 | } 58 | 59 | return resp, nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/provider/kuwo/artist.go: -------------------------------------------------------------------------------- 1 | package kuwo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetArtist(ctx context.Context, artistId string) (*api.Collection, error) { 15 | artistInfo, err := a.GetArtistInfoRaw(ctx, artistId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | artistSongs, err := a.GetArtistSongsRaw(ctx, artistId, 1, 50) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | n := len(artistSongs.Data.List) 26 | if n == 0 { 27 | return nil, errors.New("get artist songs: no data") 28 | } 29 | 30 | a.patchSongsURL(ctx, songDefaultBR, artistSongs.Data.List...) 31 | a.patchSongsLyric(ctx, artistSongs.Data.List...) 32 | songs := translate(artistSongs.Data.List...) 33 | return &api.Collection{ 34 | Id: strconv.Itoa(artistInfo.Data.Id), 35 | Name: strings.TrimSpace(artistInfo.Data.Name), 36 | PicURL: artistInfo.Data.Pic300, 37 | Songs: songs, 38 | }, nil 39 | } 40 | 41 | // 获取歌手信息 42 | func (a *API) GetArtistInfoRaw(ctx context.Context, artistId string) (*ArtistInfoResponse, error) { 43 | params := ghttp.Params{ 44 | "artistid": artistId, 45 | } 46 | 47 | resp := new(ArtistInfoResponse) 48 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtistInfo) 49 | req.SetQuery(params) 50 | req.SetContext(ctx) 51 | r, err := a.SendRequest(req) 52 | if err == nil { 53 | err = r.JSON(resp) 54 | } 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | if resp.Code != 200 { 60 | return nil, fmt.Errorf("get artist info: %s", resp.errorMessage()) 61 | } 62 | 63 | return resp, nil 64 | } 65 | 66 | // 获取歌手歌曲,page: 页码; pageSize: 每页数量,如果要获取全部请设置为较大的值 67 | func (a *API) GetArtistSongsRaw(ctx context.Context, artistId string, page int, pageSize int) (*ArtistSongsResponse, error) { 68 | params := ghttp.Params{ 69 | "artistid": artistId, 70 | "pn": page, 71 | "rn": pageSize, 72 | } 73 | 74 | resp := new(ArtistSongsResponse) 75 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtistSongs) 76 | req.SetQuery(params) 77 | req.SetContext(ctx) 78 | r, err := a.SendRequest(req) 79 | if err == nil { 80 | err = r.JSON(resp) 81 | } 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | if resp.Code != 200 { 87 | return nil, fmt.Errorf("get artist songs: %s", resp.errorMessage()) 88 | } 89 | 90 | return resp, nil 91 | } 92 | -------------------------------------------------------------------------------- /pkg/provider/kuwo/kuwo.go: -------------------------------------------------------------------------------- 1 | package kuwo 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | "github.com/winterssy/mxget/pkg/concurrency" 12 | "github.com/winterssy/mxget/pkg/request" 13 | "github.com/winterssy/mxget/pkg/utils" 14 | ) 15 | 16 | const ( 17 | apiSearch = "http://www.kuwo.cn/api/www/search/searchMusicBykeyWord" 18 | apiGetSong = "http://www.kuwo.cn/api/www/music/musicInfo" 19 | apiGetSongURL = "http://www.kuwo.cn/url?format=mp3&response=url&type=convert_url3" 20 | apiGetSongLyric = "http://www.kuwo.cn/newh5/singles/songinfoandlrc" 21 | apiGetArtistInfo = "http://www.kuwo.cn/api/www/artist/artist" 22 | apiGetArtistSongs = "http://www.kuwo.cn/api/www/artist/artistMusic" 23 | apiGetAlbum = "http://www.kuwo.cn/api/www/album/albumInfo" 24 | apiGetPlaylist = "http://www.kuwo.cn/api/www/playlist/playListInfo" 25 | 26 | songDefaultBR = 128 27 | ) 28 | 29 | var ( 30 | std = New(request.DefaultClient) 31 | ) 32 | 33 | type ( 34 | CommonResponse struct { 35 | Code int `json:"code"` 36 | Msg string `json:"msg,omitempty"` 37 | } 38 | 39 | Song struct { 40 | RId int `json:"rid"` 41 | Name string `json:"name"` 42 | ArtistId int `json:"artistid"` 43 | Artist string `json:"artist"` 44 | AlbumId int `json:"albumid"` 45 | Album string `json:"album"` 46 | AlbumPic string `json:"albumpic"` 47 | Track int `json:"track"` 48 | IsListenFee bool `json:"isListenFee"` 49 | SongTimeMinutes string `json:"songTimeMinutes"` 50 | Lyric string `json:"-"` 51 | URL string `json:"-"` 52 | } 53 | 54 | SearchSongsResponse struct { 55 | CommonResponse 56 | Data struct { 57 | Total string `json:"total"` 58 | List []*Song `json:"list"` 59 | } `json:"data"` 60 | } 61 | 62 | SongResponse struct { 63 | CommonResponse 64 | Data Song `json:"data"` 65 | } 66 | 67 | SongURLResponse struct { 68 | CommonResponse 69 | URL string `json:"url"` 70 | } 71 | 72 | SongLyricResponse struct { 73 | Status int `json:"status"` 74 | Msg string `json:"msg,omitempty"` 75 | Data struct { 76 | LrcList []struct { 77 | Time string `json:"time"` 78 | LineLyric string `json:"lineLyric"` 79 | } `json:"lrclist"` 80 | } `json:"data"` 81 | } 82 | 83 | ArtistInfo struct { 84 | Id int `json:"id"` 85 | Name string `json:"name"` 86 | Pic300 string `json:"pic300"` 87 | } 88 | 89 | ArtistInfoResponse struct { 90 | CommonResponse 91 | Data ArtistInfo `json:"data"` 92 | } 93 | 94 | ArtistSongsResponse struct { 95 | CommonResponse 96 | Data struct { 97 | List []*Song `json:"list"` 98 | } `json:"data"` 99 | } 100 | 101 | AlbumResponse struct { 102 | CommonResponse 103 | Data struct { 104 | AlbumId int `json:"albumId"` 105 | Album string `json:"album"` 106 | Pic string `json:"pic"` 107 | MusicList []*Song `json:"musicList"` 108 | } `json:"data"` 109 | } 110 | 111 | PlaylistResponse struct { 112 | CommonResponse 113 | Data struct { 114 | Id int `json:"id"` 115 | Name string `json:"name"` 116 | Img700 string `json:"img700"` 117 | MusicList []*Song `json:"musicList"` 118 | } `json:"data"` 119 | } 120 | 121 | API struct { 122 | Client *ghttp.Client 123 | } 124 | ) 125 | 126 | func New(client *ghttp.Client) *API { 127 | return &API{ 128 | Client: client, 129 | } 130 | } 131 | 132 | func Client() *API { 133 | return std 134 | } 135 | 136 | func (c *CommonResponse) errorMessage() string { 137 | if c.Msg == "" { 138 | return strconv.Itoa(c.Code) 139 | } 140 | return c.Msg 141 | } 142 | 143 | func (s *SearchSongsResponse) String() string { 144 | return utils.PrettyJSON(s) 145 | } 146 | 147 | func (s *SongResponse) String() string { 148 | return utils.PrettyJSON(s) 149 | } 150 | 151 | func (s *SongURLResponse) String() string { 152 | return utils.PrettyJSON(s) 153 | } 154 | 155 | func (s *SongLyricResponse) String() string { 156 | return utils.PrettyJSON(s) 157 | } 158 | 159 | func (a *ArtistInfoResponse) String() string { 160 | return utils.PrettyJSON(a) 161 | } 162 | 163 | func (a *ArtistSongsResponse) String() string { 164 | return utils.PrettyJSON(a) 165 | } 166 | 167 | func (a *AlbumResponse) String() string { 168 | return utils.PrettyJSON(a) 169 | } 170 | 171 | func (p *PlaylistResponse) String() string { 172 | return utils.PrettyJSON(p) 173 | } 174 | 175 | func (a *API) SendRequest(req *ghttp.Request) (*ghttp.Response, error) { 176 | // csrf 必须跟 kw_token 保持一致 177 | csrf := "0" 178 | cookie, err := a.Client.Cookie(req.URL.String(), "kw_token") 179 | if err != nil { 180 | req.AddCookie(&http.Cookie{ 181 | Name: "kw_token", 182 | Value: csrf, 183 | }) 184 | } else { 185 | csrf = cookie.Value 186 | } 187 | 188 | headers := ghttp.Headers{ 189 | "Origin": "http://www.kuwo.cn", 190 | "Referer": "http://www.kuwo.cn", 191 | "csrf": csrf, 192 | } 193 | req.SetHeaders(headers) 194 | return a.Client.Do(req) 195 | } 196 | 197 | func (a *API) patchSongsURL(ctx context.Context, br int, songs ...*Song) { 198 | c := concurrency.New(32) 199 | for _, s := range songs { 200 | if ctx.Err() != nil { 201 | break 202 | } 203 | 204 | c.Add(1) 205 | go func(s *Song) { 206 | url, err := a.GetSongURL(ctx, s.RId, br) 207 | if err == nil { 208 | s.URL = url 209 | } 210 | c.Done() 211 | }(s) 212 | } 213 | c.Wait() 214 | } 215 | 216 | func (a *API) patchSongsLyric(ctx context.Context, songs ...*Song) { 217 | c := concurrency.New(32) 218 | for _, s := range songs { 219 | if ctx.Err() != nil { 220 | break 221 | } 222 | 223 | c.Add(1) 224 | go func(s *Song) { 225 | lyric, err := a.GetSongLyric(ctx, s.RId) 226 | if err == nil { 227 | s.Lyric = lyric 228 | } 229 | c.Done() 230 | }(s) 231 | } 232 | c.Wait() 233 | } 234 | 235 | func translate(src ...*Song) []*api.Song { 236 | songs := make([]*api.Song, len(src)) 237 | for i, s := range src { 238 | songs[i] = &api.Song{ 239 | Id: strconv.Itoa(s.RId), 240 | Name: strings.TrimSpace(s.Name), 241 | Artist: strings.TrimSpace(strings.ReplaceAll(s.Artist, "&", "/")), 242 | Album: strings.TrimSpace(s.Album), 243 | PicURL: s.AlbumPic, 244 | Lyric: s.Lyric, 245 | ListenURL: s.URL, 246 | } 247 | } 248 | return songs 249 | } 250 | -------------------------------------------------------------------------------- /pkg/provider/kuwo/kuwo_test.go: -------------------------------------------------------------------------------- 1 | package kuwo_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/winterssy/mxget/pkg/provider/kuwo" 10 | ) 11 | 12 | var ( 13 | client *kuwo.API 14 | ctx context.Context 15 | ) 16 | 17 | func setup() { 18 | client = kuwo.Client() 19 | ctx = context.Background() 20 | } 21 | 22 | func TestMain(m *testing.M) { 23 | setup() 24 | os.Exit(m.Run()) 25 | } 26 | 27 | func TestAPI_SearchSongs(t *testing.T) { 28 | result, err := client.SearchSongs(ctx, "周杰伦") 29 | if assert.NoError(t, err) { 30 | t.Log(result) 31 | } 32 | } 33 | 34 | func TestAPI_GetSong(t *testing.T) { 35 | song, err := client.GetSong(ctx, "76323299") 36 | if assert.NoError(t, err) { 37 | t.Log(song) 38 | } 39 | } 40 | 41 | func TestAPI_GetSongURL(t *testing.T) { 42 | url, err := client.GetSongURL(ctx, 76323299, 320) 43 | if assert.NoError(t, err) { 44 | t.Log(url) 45 | } 46 | } 47 | 48 | func TestAPI_GetSongLyric(t *testing.T) { 49 | lyric, err := client.GetSongLyric(ctx, 76323299) 50 | if assert.NoError(t, err) { 51 | t.Log(lyric) 52 | } 53 | } 54 | 55 | func TestAPI_GetArtist(t *testing.T) { 56 | artist, err := client.GetArtist(ctx, "336") 57 | if assert.NoError(t, err) { 58 | t.Log(artist) 59 | } 60 | } 61 | 62 | func TestAPI_GetAlbum(t *testing.T) { 63 | album, err := client.GetAlbum(ctx, "10685968") 64 | if assert.NoError(t, err) { 65 | t.Log(album) 66 | } 67 | } 68 | 69 | func TestAPI_GetPlaylist(t *testing.T) { 70 | playlist, err := client.GetPlaylist(ctx, "1085247459") 71 | if assert.NoError(t, err) { 72 | t.Log(playlist) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/provider/kuwo/playlist.go: -------------------------------------------------------------------------------- 1 | package kuwo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetPlaylist(ctx context.Context, playlistId string) (*api.Collection, error) { 15 | resp, err := a.GetPlaylistRaw(ctx, playlistId, 1, 9999) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | n := len(resp.Data.MusicList) 21 | if n == 0 { 22 | return nil, errors.New("get playlist: no data") 23 | } 24 | 25 | a.patchSongsURL(ctx, songDefaultBR, resp.Data.MusicList...) 26 | a.patchSongsLyric(ctx, resp.Data.MusicList...) 27 | songs := translate(resp.Data.MusicList...) 28 | return &api.Collection{ 29 | Id: strconv.Itoa(resp.Data.Id), 30 | Name: strings.TrimSpace(resp.Data.Name), 31 | PicURL: resp.Data.Img700, 32 | Songs: songs, 33 | }, nil 34 | } 35 | 36 | // 获取歌单,page: 页码; pageSize: 每页数量,如果要获取全部请设置为较大的值 37 | func (a *API) GetPlaylistRaw(ctx context.Context, playlistId string, page int, pageSize int) (*PlaylistResponse, error) { 38 | params := ghttp.Params{ 39 | "pid": playlistId, 40 | "pn": page, 41 | "rn": pageSize, 42 | } 43 | 44 | resp := new(PlaylistResponse) 45 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetPlaylist) 46 | req.SetQuery(params) 47 | req.SetContext(ctx) 48 | r, err := a.SendRequest(req) 49 | if err == nil { 50 | err = r.JSON(resp) 51 | } 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | if resp.Code != 200 { 57 | return nil, fmt.Errorf("get playlist: %s", resp.errorMessage()) 58 | } 59 | 60 | return resp, nil 61 | } 62 | -------------------------------------------------------------------------------- /pkg/provider/kuwo/search.go: -------------------------------------------------------------------------------- 1 | package kuwo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) SearchSongs(ctx context.Context, keyword string) ([]*api.Song, error) { 15 | resp, err := a.SearchSongsRaw(ctx, keyword, 1, 50) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | n := len(resp.Data.List) 21 | if n == 0 { 22 | return nil, errors.New("search songs: no data") 23 | } 24 | 25 | songs := make([]*api.Song, n) 26 | for i, s := range resp.Data.List { 27 | songs[i] = &api.Song{ 28 | Id: strconv.Itoa(s.RId), 29 | Name: strings.TrimSpace(s.Name), 30 | Artist: strings.TrimSpace(strings.ReplaceAll(s.Artist, "&", "/")), 31 | Album: strings.TrimSpace(s.Album), 32 | } 33 | } 34 | return songs, nil 35 | } 36 | 37 | // 搜索歌曲 38 | func (a *API) SearchSongsRaw(ctx context.Context, keyword string, page int, pageSize int) (*SearchSongsResponse, error) { 39 | params := ghttp.Params{ 40 | "key": keyword, 41 | "pn": page, 42 | "rn": pageSize, 43 | } 44 | 45 | resp := new(SearchSongsResponse) 46 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiSearch) 47 | req.SetQuery(params) 48 | req.SetContext(ctx) 49 | r, err := a.SendRequest(req) 50 | if err == nil { 51 | err = r.JSON(resp) 52 | } 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | if resp.Code != 200 { 58 | if resp.Code == -1 { 59 | err = errors.New("search songs: no data") 60 | } else { 61 | err = fmt.Errorf("search songs: %s", resp.errorMessage()) 62 | } 63 | return nil, err 64 | } 65 | 66 | return resp, nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/provider/kuwo/song.go: -------------------------------------------------------------------------------- 1 | package kuwo 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetSong(ctx context.Context, mid string) (*api.Song, error) { 14 | resp, err := a.GetSongRaw(ctx, mid) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | a.patchSongsURL(ctx, songDefaultBR, &resp.Data) 20 | a.patchSongsLyric(ctx, &resp.Data) 21 | songs := translate(&resp.Data) 22 | return songs[0], nil 23 | } 24 | 25 | // 获取歌曲详情 26 | func (a *API) GetSongRaw(ctx context.Context, mid string) (*SongResponse, error) { 27 | params := ghttp.Params{ 28 | "mid": mid, 29 | } 30 | 31 | resp := new(SongResponse) 32 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSong) 33 | req.SetQuery(params) 34 | req.SetContext(ctx) 35 | r, err := a.SendRequest(req) 36 | if err == nil { 37 | err = r.JSON(resp) 38 | } 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | if resp.Code != 200 { 44 | return nil, fmt.Errorf("get song: %s", resp.errorMessage()) 45 | } 46 | 47 | return resp, nil 48 | } 49 | 50 | func (a *API) GetSongURL(ctx context.Context, mid int, br int) (string, error) { 51 | resp, err := a.GetSongURLRaw(ctx, mid, br) 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | return resp.URL, nil 57 | } 58 | 59 | // 获取歌曲播放地址,br: 比特率,128/192/320 可选 60 | func (a *API) GetSongURLRaw(ctx context.Context, mid int, br int) (*SongURLResponse, error) { 61 | var _br int 62 | switch br { 63 | case 128, 192, 320: 64 | _br = br 65 | default: 66 | _br = 320 67 | } 68 | params := ghttp.Params{ 69 | "rid": mid, 70 | "br": fmt.Sprintf("%dkmp3", _br), 71 | } 72 | 73 | resp := new(SongURLResponse) 74 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongURL) 75 | req.SetQuery(params) 76 | req.SetContext(ctx) 77 | r, err := a.SendRequest(req) 78 | if err == nil { 79 | err = r.JSON(resp) 80 | } 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | if resp.Code != 200 { 86 | return nil, fmt.Errorf("get song url: %s", resp.errorMessage()) 87 | } 88 | 89 | return resp, nil 90 | } 91 | 92 | func (a *API) GetSongLyric(ctx context.Context, mid int) (string, error) { 93 | resp, err := a.GetSongLyricRaw(ctx, mid) 94 | if err != nil { 95 | return "", err 96 | } 97 | 98 | var sb strings.Builder 99 | for _, i := range resp.Data.LrcList { 100 | t, err := strconv.ParseFloat(i.Time, 64) 101 | if err != nil { 102 | return "", err 103 | } 104 | m := int(t / 60) 105 | s := int(t - float64(m*60)) 106 | ms := int((t - float64(m*60) - float64(s)) * 100) 107 | sb.WriteString(fmt.Sprintf("[%02d:%02d:%02d]%s\n", m, s, ms, i.LineLyric)) 108 | } 109 | 110 | return sb.String(), nil 111 | } 112 | 113 | // 获取歌词 114 | func (a *API) GetSongLyricRaw(ctx context.Context, mid int) (*SongLyricResponse, error) { 115 | params := ghttp.Params{ 116 | "musicId": mid, 117 | } 118 | 119 | resp := new(SongLyricResponse) 120 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongLyric) 121 | req.SetQuery(params) 122 | req.SetContext(ctx) 123 | r, err := a.SendRequest(req) 124 | if err == nil { 125 | err = r.JSON(resp) 126 | } 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | if resp.Status != 200 { 132 | return nil, fmt.Errorf("get song lyric: %s", resp.Msg) 133 | } 134 | 135 | return resp, nil 136 | } 137 | -------------------------------------------------------------------------------- /pkg/provider/migu/album.go: -------------------------------------------------------------------------------- 1 | package migu 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetAlbum(ctx context.Context, albumId string) (*api.Collection, error) { 14 | resp, err := a.GetAlbumRaw(ctx, albumId) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | if len(resp.Resource) == 0 || len(resp.Resource[0].SongItems) == 0 { 20 | return nil, errors.New("get album: no data") 21 | } 22 | 23 | a.patchSongsLyric(ctx, resp.Resource[0].SongItems...) 24 | songs := translate(resp.Resource[0].SongItems...) 25 | return &api.Collection{ 26 | Id: resp.Resource[0].AlbumId, 27 | Name: strings.TrimSpace(resp.Resource[0].Title), 28 | PicURL: picURL(resp.Resource[0].ImgItems), 29 | Songs: songs, 30 | }, nil 31 | } 32 | 33 | // 获取专辑 34 | func (a *API) GetAlbumRaw(ctx context.Context, albumId string) (*AlbumResponse, error) { 35 | params := ghttp.Params{ 36 | "resourceId": albumId, 37 | } 38 | 39 | resp := new(AlbumResponse) 40 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetAlbum) 41 | req.SetQuery(params) 42 | req.SetContext(ctx) 43 | r, err := a.SendRequest(req) 44 | if err == nil { 45 | err = r.JSON(resp) 46 | } 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | if resp.Code != "000000" { 52 | return nil, fmt.Errorf("get album: %s", resp.errorMessage()) 53 | } 54 | 55 | return resp, nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/provider/migu/artist.go: -------------------------------------------------------------------------------- 1 | package migu 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetArtist(ctx context.Context, singerId string) (*api.Collection, error) { 14 | artistInfo, err := a.GetArtistInfoRaw(ctx, singerId) 15 | if err != nil { 16 | return nil, err 17 | } 18 | if len(artistInfo.Resource) == 0 { 19 | return nil, errors.New("get artist info: no data") 20 | } 21 | 22 | artistSongs, err := a.GetArtistSongsRaw(ctx, singerId, 1, 50) 23 | if err != nil { 24 | return nil, err 25 | } 26 | if len(artistSongs.Data.ContentItemList) == 0 || 27 | len(artistSongs.Data.ContentItemList[0].ItemList) == 0 { 28 | return nil, errors.New("get artist songs: no data") 29 | } 30 | 31 | itemList := artistSongs.Data.ContentItemList[0].ItemList 32 | n := len(itemList) 33 | _songs := make([]*Song, n/2) 34 | for i, j := 0, 0; i < n; i, j = i+2, j+1 { 35 | _songs[j] = &itemList[i].Song 36 | } 37 | 38 | a.patchSongsLyric(ctx, _songs...) 39 | songs := translate(_songs...) 40 | return &api.Collection{ 41 | Id: artistInfo.Resource[0].SingerId, 42 | Name: strings.TrimSpace(artistInfo.Resource[0].Singer), 43 | PicURL: picURL(artistInfo.Resource[0].Imgs), 44 | Songs: songs, 45 | }, nil 46 | } 47 | 48 | // 获取歌手信息 49 | func (a *API) GetArtistInfoRaw(ctx context.Context, singerId string) (*ArtistInfoResponse, error) { 50 | params := ghttp.Params{ 51 | "resourceId": singerId, 52 | } 53 | 54 | resp := new(ArtistInfoResponse) 55 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtistInfo) 56 | req.SetQuery(params) 57 | req.SetContext(ctx) 58 | r, err := a.SendRequest(req) 59 | if err == nil { 60 | err = r.JSON(resp) 61 | } 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | if resp.Code != "000000" { 67 | return nil, fmt.Errorf("get artist info: %s", resp.errorMessage()) 68 | } 69 | 70 | return resp, nil 71 | } 72 | 73 | // 获取歌手歌曲,page: 页码;pageSize: 每页数量 74 | func (a *API) GetArtistSongsRaw(ctx context.Context, singerId string, page int, pageSize int) (*ArtistSongsResponse, error) { 75 | params := ghttp.Params{ 76 | "singerId": singerId, 77 | "pageNo": page, 78 | "pageSize": pageSize, 79 | } 80 | 81 | resp := new(ArtistSongsResponse) 82 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtistSongs) 83 | req.SetQuery(params) 84 | req.SetContext(ctx) 85 | r, err := a.SendRequest(req) 86 | if err == nil { 87 | err = r.JSON(resp) 88 | } 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if resp.Code != "000000" { 94 | return nil, fmt.Errorf("get artist songs: %s", resp.errorMessage()) 95 | } 96 | 97 | return resp, nil 98 | } 99 | -------------------------------------------------------------------------------- /pkg/provider/migu/migu.go: -------------------------------------------------------------------------------- 1 | package migu 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "unicode/utf8" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | "github.com/winterssy/mxget/pkg/concurrency" 12 | "github.com/winterssy/mxget/pkg/request" 13 | "github.com/winterssy/mxget/pkg/utils" 14 | "golang.org/x/text/encoding/simplifiedchinese" 15 | ) 16 | 17 | const ( 18 | apiSearchV1 = "https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1" 19 | apiSearchV2 = "http://m.music.migu.cn/migu/remoting/scr_search_tag?type=2" 20 | apiGetSongId = "http://music.migu.cn/v3/api/music/audioPlayer/songs?type=1" 21 | apiGetSong = "https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/querySongBySongId.do?contentId=0" 22 | apiGetSongURL = "https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/listen-url?copyrightId=0&netType=01&toneFlag=HQ" 23 | apiGetSongLyric = "http://music.migu.cn/v3/api/music/audioPlayer/getLyric" 24 | apiGetSongPic = "http://music.migu.cn/v3/api/music/audioPlayer/getSongPic" 25 | apiGetArtistInfo = "https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do?needSimple=01&resourceType=2002" 26 | apiGetArtistSongs = "https://app.c.nf.migu.cn/MIGUM3.0/v1.0/template/singerSongs/release?templateVersion=2" 27 | apiGetAlbum = "https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do?needSimple=01&resourceType=2003" 28 | apiGetPlaylist = "https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do?needSimple=01&resourceType=2021" 29 | 30 | songDefaultBR = 128 31 | ) 32 | 33 | var ( 34 | codeRate = map[int]string{ 35 | 64: "LQ", 36 | 128: "PQ", 37 | 320: "HQ", 38 | 999: "SQ", 39 | } 40 | 41 | std = New(request.DefaultClient) 42 | 43 | defaultHeaders = ghttp.Headers{ 44 | "channel": "0", 45 | "Origin": "http://music.migu.cn/v3", 46 | "Referer": "http://music.migu.cn/v3", 47 | } 48 | ) 49 | 50 | type ( 51 | CommonResponse struct { 52 | Code string `json:"code"` 53 | Info string `json:"info,omitempty"` 54 | } 55 | 56 | SearchSongsResult struct { 57 | ResourceType string `json:"resourceType"` 58 | ContentId string `json:"contentId"` 59 | CopyrightId string `json:"copyrightId"` 60 | Id string `json:"id"` 61 | Name string `json:"name"` 62 | Singers []struct { 63 | Id string `json:"id"` 64 | Name string `json:"name"` 65 | } `json:"singers"` 66 | Albums []struct { 67 | Id string `json:"id"` 68 | Name string `json:"name"` 69 | } `json:"albums"` 70 | } 71 | 72 | SearchSongsResponse struct { 73 | CommonResponse 74 | SongResultData struct { 75 | TotalCount string `json:"totalCount"` 76 | Result []*SearchSongsResult `json:"result"` 77 | } `json:"songResultData"` 78 | } 79 | 80 | ImgItem struct { 81 | ImgSizeType string `json:"imgSizeType"` 82 | Img string `json:"img"` 83 | } 84 | 85 | SongIdResponse struct { 86 | ReturnCode string `json:"returnCode"` 87 | Msg string `json:"msg,omitempty"` 88 | Items []struct { 89 | SongId string `json:"songId"` 90 | } `json:"items"` 91 | } 92 | 93 | Song struct { 94 | ResourceType string `json:"resourceType"` 95 | ContentId string `json:"contentId"` 96 | CopyrightId string `json:"copyrightId"` 97 | SongId string `json:"songId"` 98 | SongName string `json:"songName"` 99 | SingerId string `json:"singerId"` 100 | Singer string `json:"singer"` 101 | AlbumId string `json:"albumId"` 102 | Album string `json:"album"` 103 | AlbumImgs []ImgItem `json:"albumImgs"` 104 | LrcURL string `json:"lrcUrl"` 105 | PicURL string `json:"-"` 106 | Lyric string `json:"-"` 107 | URL string `json:"-"` 108 | } 109 | 110 | SongResponse struct { 111 | CommonResponse 112 | Resource []*Song `json:"resource"` 113 | } 114 | 115 | SongURLResponse struct { 116 | CommonResponse 117 | Data struct { 118 | URL string `json:"url"` 119 | } `json:"data"` 120 | } 121 | 122 | SongLyricResponse struct { 123 | ReturnCode string `json:"returnCode"` 124 | Msg string `json:"msg"` 125 | Lyric string `json:"lyric"` 126 | } 127 | 128 | SongPicResponse struct { 129 | ReturnCode string `json:"returnCode"` 130 | Msg string `json:"msg"` 131 | SmallPic string `json:"smallPic"` 132 | MediumPic string `json:"mediumPic"` 133 | LargePic string `json:"largePic"` 134 | } 135 | 136 | ArtistInfo struct { 137 | ResourceType string `json:"resourceType"` 138 | SingerId string `json:"singerId"` 139 | Singer string `json:"singer"` 140 | Imgs []ImgItem `json:"imgs"` 141 | } 142 | 143 | ArtistInfoResponse struct { 144 | CommonResponse 145 | Resource []ArtistInfo `json:"resource"` 146 | } 147 | 148 | ArtistSongsResponse struct { 149 | CommonResponse 150 | Data struct { 151 | ContentItemList []struct { 152 | ItemList []struct { 153 | Song Song `json:"song"` 154 | } `json:"itemList"` 155 | } `json:"contentItemList"` 156 | } `json:"data"` 157 | } 158 | 159 | Album struct { 160 | ResourceType string `json:"resourceType"` 161 | AlbumId string `json:"albumId"` 162 | Title string `json:"title"` 163 | ImgItems []ImgItem `json:"imgItems"` 164 | SongItems []*Song `json:"songItems"` 165 | } 166 | 167 | AlbumResponse struct { 168 | CommonResponse 169 | Resource []Album `json:"resource"` 170 | } 171 | 172 | Playlist struct { 173 | ResourceType string `json:"resourceType"` 174 | MusicListId string `json:"musicListId"` 175 | Title string `json:"title"` 176 | ImgItem struct { 177 | Img string `json:"img"` 178 | } `json:"imgItem"` 179 | SongItems []*Song `json:"songItems"` 180 | } 181 | 182 | PlaylistResponse struct { 183 | CommonResponse 184 | Resource []Playlist `json:"resource"` 185 | } 186 | 187 | API struct { 188 | Client *ghttp.Client 189 | } 190 | ) 191 | 192 | func New(client *ghttp.Client) *API { 193 | return &API{ 194 | Client: client, 195 | } 196 | } 197 | 198 | func Client() *API { 199 | return std 200 | } 201 | 202 | func (c *CommonResponse) errorMessage() string { 203 | if c.Info == "" { 204 | return c.Code 205 | } 206 | return c.Info 207 | } 208 | 209 | func (s *SearchSongsResponse) String() string { 210 | return utils.PrettyJSON(s) 211 | } 212 | 213 | func (s *SongIdResponse) String() string { 214 | return utils.PrettyJSON(s) 215 | } 216 | 217 | func (s *SongResponse) String() string { 218 | return utils.PrettyJSON(s) 219 | } 220 | 221 | func (s *SongURLResponse) String() string { 222 | return utils.PrettyJSON(s) 223 | } 224 | 225 | func (s *SongLyricResponse) String() string { 226 | return utils.PrettyJSON(s) 227 | } 228 | 229 | func (s *SongPicResponse) String() string { 230 | return utils.PrettyJSON(s) 231 | } 232 | 233 | func (a *ArtistInfoResponse) String() string { 234 | return utils.PrettyJSON(a) 235 | } 236 | 237 | func (a *ArtistSongsResponse) String() string { 238 | return utils.PrettyJSON(a) 239 | } 240 | 241 | func (a *AlbumResponse) String() string { 242 | return utils.PrettyJSON(a) 243 | } 244 | 245 | func (p *PlaylistResponse) String() string { 246 | return utils.PrettyJSON(p) 247 | } 248 | 249 | func (a *API) SendRequest(req *ghttp.Request) (*ghttp.Response, error) { 250 | req.SetHeaders(defaultHeaders) 251 | return a.Client.Do(req) 252 | } 253 | 254 | func (a *API) patchSongsInfo(ctx context.Context, songs ...*Song) { 255 | c := concurrency.New(32) 256 | for _, s := range songs { 257 | if ctx.Err() != nil { 258 | break 259 | } 260 | 261 | c.Add(1) 262 | go func(s *Song) { 263 | picURL, err := a.GetSongPic(ctx, s.SongId) 264 | if err == nil { 265 | if !strings.HasPrefix(picURL, "http:") { 266 | picURL = "http:" + picURL 267 | } 268 | s.PicURL = picURL 269 | } 270 | c.Done() 271 | }(s) 272 | } 273 | c.Wait() 274 | } 275 | 276 | func (a *API) patchSongsURL(ctx context.Context, songs ...*Song) { 277 | c := concurrency.New(32) 278 | for _, s := range songs { 279 | if ctx.Err() != nil { 280 | break 281 | } 282 | 283 | c.Add(1) 284 | go func(s *Song) { 285 | url, err := a.GetSongURL(ctx, s.ContentId, s.ResourceType) 286 | if err == nil { 287 | s.URL = url 288 | } 289 | c.Done() 290 | }(s) 291 | } 292 | c.Wait() 293 | } 294 | 295 | func (a *API) patchSongsLyric(ctx context.Context, songs ...*Song) { 296 | c := concurrency.New(32) 297 | for _, s := range songs { 298 | if ctx.Err() != nil { 299 | break 300 | } 301 | 302 | c.Add(1) 303 | go func(s *Song) { 304 | if s.LrcURL != "" { 305 | req, _ := ghttp.NewRequest(ghttp.MethodGet, s.LrcURL) 306 | req.SetContext(ctx) 307 | resp, err := a.SendRequest(req) 308 | if err == nil { 309 | lrcBytes, _ := resp.Content() 310 | if utf8.Valid(lrcBytes) { 311 | s.Lyric = utils.BytesToString(lrcBytes) 312 | } else { 313 | // GBK 编码 314 | lrcBytes, err = simplifiedchinese.GB18030.NewDecoder().Bytes(lrcBytes) 315 | if err == nil { 316 | s.Lyric = utils.BytesToString(lrcBytes) 317 | } 318 | } 319 | } 320 | } 321 | c.Done() 322 | }(s) 323 | } 324 | c.Wait() 325 | } 326 | 327 | func picURL(imgs []ImgItem) string { 328 | for _, i := range imgs { 329 | if i.ImgSizeType == "03" { 330 | return i.Img 331 | } 332 | } 333 | return "" 334 | } 335 | 336 | func generateSongURL(contentId string, br int) string { 337 | const ( 338 | tmpl = "https://app.pd.nf.migu.cn/MIGUM2.0/v1.0/content/sub/listenSong.do?contentId=%s©rightId=0&netType=01&resourceType=%s&toneFlag=%s&channel=0" 339 | ) 340 | 341 | var _br int 342 | switch br { 343 | case 64, 128, 320, 999: 344 | _br = br 345 | default: 346 | _br = 320 347 | } 348 | return fmt.Sprintf(tmpl, contentId, "E", codeRate[_br]) 349 | } 350 | 351 | func translate(src ...*Song) []*api.Song { 352 | songs := make([]*api.Song, len(src)) 353 | for i, s := range src { 354 | url := generateSongURL(s.ContentId, songDefaultBR) 355 | songs[i] = &api.Song{ 356 | Id: s.SongId, 357 | Name: strings.TrimSpace(s.SongName), 358 | Artist: strings.TrimSpace(strings.ReplaceAll(s.Singer, "|", "/")), 359 | Album: strings.TrimSpace(s.Album), 360 | PicURL: picURL(s.AlbumImgs), 361 | Lyric: s.Lyric, 362 | ListenURL: url, 363 | } 364 | } 365 | return songs 366 | } 367 | -------------------------------------------------------------------------------- /pkg/provider/migu/migu_test.go: -------------------------------------------------------------------------------- 1 | package migu_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/winterssy/mxget/pkg/provider/migu" 10 | ) 11 | 12 | var ( 13 | client *migu.API 14 | ctx context.Context 15 | ) 16 | 17 | func setup() { 18 | client = migu.Client() 19 | ctx = context.Background() 20 | } 21 | 22 | func TestMain(m *testing.M) { 23 | setup() 24 | os.Exit(m.Run()) 25 | } 26 | 27 | func TestAPI_SearchSongs(t *testing.T) { 28 | result, err := client.SearchSongs(ctx, "周杰伦") 29 | if assert.NoError(t, err) { 30 | t.Log(result) 31 | } 32 | } 33 | 34 | func TestAPI_GetSong(t *testing.T) { 35 | song, err := client.GetSong(ctx, "63273402938") 36 | if assert.NoError(t, err) { 37 | t.Log(song) 38 | } 39 | } 40 | 41 | func TestAPI_GetSongURL(t *testing.T) { 42 | resp, err := client.GetSongURL(ctx, "600908000002677565", "2") 43 | if assert.NoError(t, err) { 44 | t.Log(resp) 45 | } 46 | } 47 | 48 | func TestAPI_GetSongLyric(t *testing.T) { 49 | lyric, err := client.GetSongLyric(ctx, "63273402938") 50 | if assert.NoError(t, err) { 51 | t.Log(lyric) 52 | } 53 | } 54 | 55 | func TestAPI_GetSongPic(t *testing.T) { 56 | pic, err := client.GetSongPic(ctx, "1121439251") 57 | if assert.NoError(t, err) { 58 | t.Log(pic) 59 | } 60 | } 61 | 62 | func TestAPI_GetArtist(t *testing.T) { 63 | artist, err := client.GetArtist(ctx, "112") 64 | if assert.NoError(t, err) { 65 | t.Log(artist) 66 | } 67 | } 68 | 69 | func TestAPI_GetAlbum(t *testing.T) { 70 | album, err := client.GetAlbum(ctx, "1121438701") 71 | if assert.NoError(t, err) { 72 | t.Log(album) 73 | } 74 | } 75 | 76 | func TestAPI_GetPlaylist(t *testing.T) { 77 | playlist, err := client.GetPlaylist(ctx, "159248239") 78 | if assert.NoError(t, err) { 79 | t.Log(playlist) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pkg/provider/migu/playlist.go: -------------------------------------------------------------------------------- 1 | package migu 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetPlaylist(ctx context.Context, playlistId string) (*api.Collection, error) { 14 | resp, err := a.GetPlaylistRaw(ctx, playlistId) 15 | if err != nil { 16 | return nil, err 17 | } 18 | if len(resp.Resource) == 0 || len(resp.Resource[0].SongItems) == 0 { 19 | return nil, errors.New("get playlist: no data") 20 | } 21 | 22 | a.patchSongsLyric(ctx, resp.Resource[0].SongItems...) 23 | songs := translate(resp.Resource[0].SongItems...) 24 | return &api.Collection{ 25 | Id: resp.Resource[0].MusicListId, 26 | Name: strings.TrimSpace(resp.Resource[0].Title), 27 | PicURL: resp.Resource[0].ImgItem.Img, 28 | Songs: songs, 29 | }, nil 30 | } 31 | 32 | // 获取歌单 33 | func (a *API) GetPlaylistRaw(ctx context.Context, playlistId string) (*PlaylistResponse, error) { 34 | params := ghttp.Params{ 35 | "resourceId": playlistId, 36 | } 37 | 38 | resp := new(PlaylistResponse) 39 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetPlaylist) 40 | req.SetQuery(params) 41 | req.SetContext(ctx) 42 | r, err := a.SendRequest(req) 43 | if err == nil { 44 | err = r.JSON(resp) 45 | } 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | if resp.Code != "000000" { 51 | return nil, fmt.Errorf("get playlist: %s", resp.errorMessage()) 52 | } 53 | 54 | return resp, nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/provider/migu/search.go: -------------------------------------------------------------------------------- 1 | package migu 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/gjson" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) SearchSongs(ctx context.Context, keyword string) ([]*api.Song, error) { 15 | resp, err := a.SearchSongsRawV2(ctx, keyword, 1, 50) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | musics := resp.GetArray("musics") 21 | n := len(musics) 22 | if n == 0 { 23 | return nil, errors.New("search songs: no data") 24 | } 25 | 26 | songs := make([]*api.Song, n) 27 | for i, s := range musics { 28 | obj := s.ToObject() 29 | songs[i] = &api.Song{ 30 | Id: obj.GetString("id"), 31 | Name: obj.GetString("songName"), 32 | Artist: strings.ReplaceAll(obj.GetString("singerName"), ", ", "/"), 33 | Album: obj.GetString("albumName"), 34 | } 35 | } 36 | return songs, nil 37 | } 38 | 39 | // 搜索歌曲 40 | func (a *API) SearchSongsRawV1(ctx context.Context, keyword string, page int, pageSize int) (*SearchSongsResponse, error) { 41 | switchOption := map[string]int{ 42 | "song": 1, 43 | "album": 0, 44 | "singer": 0, 45 | "tagSong": 0, 46 | "mvSong": 0, 47 | "songlist": 0, 48 | "bestShow": 0, 49 | } 50 | enc, _ := gjson.Marshal(switchOption) 51 | params := ghttp.Params{ 52 | "searchSwitch": enc, 53 | "text": keyword, 54 | "pageNo": page, 55 | "pageSize": pageSize, 56 | } 57 | 58 | resp := new(SearchSongsResponse) 59 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiSearchV1) 60 | req.SetQuery(params) 61 | req.SetContext(ctx) 62 | r, err := a.SendRequest(req) 63 | if err == nil { 64 | err = r.JSON(resp) 65 | } 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | if resp.Code != "000000" { 71 | return nil, fmt.Errorf("search songs: %s", resp.errorMessage()) 72 | } 73 | 74 | return resp, nil 75 | } 76 | 77 | // 搜索歌曲 78 | func (a *API) SearchSongsRawV2(ctx context.Context, keyword string, page int, pageSize int) (ghttp.H, error) { 79 | params := ghttp.Params{ 80 | "keyword": keyword, 81 | "pgc": page, 82 | "rows": pageSize, 83 | } 84 | 85 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiSearchV2) 86 | req.SetQuery(params) 87 | req.SetContext(ctx) 88 | r, err := a.SendRequest(req) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | data, err := r.H() 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | if !data.GetBoolean("success") { 99 | return data, fmt.Errorf("search songs: %s", data.GetString("msg")) 100 | } 101 | 102 | return data, nil 103 | } 104 | -------------------------------------------------------------------------------- /pkg/provider/migu/song.go: -------------------------------------------------------------------------------- 1 | package migu 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | /* 14 | 注意! 15 | GetSongIdRaw, GetSongPicRaw, GetSongLyricRaw 为网页版API 16 | 这些API限流,并发请求经常503,不适用于批量获取 17 | */ 18 | 19 | func (a *API) GetSong(ctx context.Context, id string) (*api.Song, error) { 20 | /* 21 | 先判断id是版权id还是歌曲id,以减少1次API请求 22 | 测试表现版权id的长度是11位,以6开头并且可能包含字符,歌曲id为纯数字,长度不定 23 | 不确定是否会误判,待反馈 24 | */ 25 | var songId string 26 | var err error 27 | if len(id) > 10 && strings.HasPrefix(id, "6") { 28 | songId, err = a.GetSongId(ctx, id) 29 | if err != nil { 30 | return nil, err 31 | } 32 | } else { 33 | songId = id 34 | } 35 | 36 | resp, err := a.GetSongRaw(ctx, songId) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if len(resp.Resource) == 0 { 41 | return nil, errors.New("get song: no data") 42 | } 43 | 44 | _song := resp.Resource[0] 45 | 46 | // 单曲请求可调用网页版API获取歌词,不会出现乱码现象 47 | lyric, err := a.GetSongLyric(ctx, _song.CopyrightId) 48 | if err == nil { 49 | _song.Lyric = lyric 50 | } 51 | songs := translate(_song) 52 | return songs[0], nil 53 | } 54 | 55 | // 获取歌曲详情 56 | func (a *API) GetSongRaw(ctx context.Context, songId string) (*SongResponse, error) { 57 | params := ghttp.Params{ 58 | "songId": songId, 59 | } 60 | 61 | resp := new(SongResponse) 62 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSong) 63 | req.SetQuery(params) 64 | req.SetContext(ctx) 65 | r, err := a.SendRequest(req) 66 | if err == nil { 67 | err = r.JSON(resp) 68 | } 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | if resp.Code != "000000" { 74 | return nil, fmt.Errorf("get song: %s", resp.errorMessage()) 75 | } 76 | 77 | return resp, nil 78 | } 79 | 80 | func (a *API) GetSongId(ctx context.Context, copyrightId string) (string, error) { 81 | resp, err := a.GetSongIdRaw(ctx, copyrightId) 82 | if err != nil { 83 | return "", err 84 | } 85 | if len(resp.Items) == 0 || resp.Items[0].SongId == "" { 86 | return "", errors.New("get song id: no data") 87 | } 88 | 89 | return resp.Items[0].SongId, nil 90 | } 91 | 92 | // 根据版权id获取歌曲id 93 | func (a *API) GetSongIdRaw(ctx context.Context, copyrightId string) (*SongIdResponse, error) { 94 | params := ghttp.Params{ 95 | "copyrightId": copyrightId, 96 | } 97 | 98 | resp := new(SongIdResponse) 99 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongId) 100 | req.SetQuery(params) 101 | req.SetContext(ctx) 102 | r, err := a.SendRequest(req) 103 | if err == nil { 104 | err = r.JSON(resp) 105 | } 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | if resp.ReturnCode != "000000" { 111 | return nil, fmt.Errorf("get song id: %s", resp.Msg) 112 | } 113 | 114 | return resp, nil 115 | } 116 | 117 | func (a *API) GetSongURL(ctx context.Context, contentId, resourceType string) (string, error) { 118 | resp, err := a.GetSongURLRaw(ctx, contentId, resourceType) 119 | if err != nil { 120 | return "", err 121 | } 122 | 123 | return resp.Data.URL, nil 124 | } 125 | 126 | // 获取歌曲播放地址 127 | func (a *API) GetSongURLRaw(ctx context.Context, contentId, resourceType string) (*SongURLResponse, error) { 128 | params := ghttp.Params{ 129 | "contentId": contentId, 130 | "lowerQualityContentId": contentId, 131 | "resourceType": resourceType, 132 | } 133 | 134 | resp := new(SongURLResponse) 135 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongURL) 136 | req.SetQuery(params) 137 | req.SetContext(ctx) 138 | r, err := a.SendRequest(req) 139 | if err == nil { 140 | err = r.JSON(resp) 141 | } 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | if resp.Code != "000000" { 147 | return nil, fmt.Errorf("get song url: %s", resp.errorMessage()) 148 | } 149 | 150 | return resp, nil 151 | } 152 | 153 | func (a *API) GetSongPic(ctx context.Context, songId string) (string, error) { 154 | resp, err := a.GetSongPicRaw(ctx, songId) 155 | if err != nil { 156 | return "", err 157 | } 158 | return resp.LargePic, nil 159 | } 160 | 161 | // 获取歌曲专辑封面 162 | func (a *API) GetSongPicRaw(ctx context.Context, songId string) (*SongPicResponse, error) { 163 | params := ghttp.Params{ 164 | "songId": songId, 165 | } 166 | 167 | resp := new(SongPicResponse) 168 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongPic) 169 | req.SetQuery(params) 170 | req.SetContext(ctx) 171 | r, err := a.SendRequest(req) 172 | if err == nil { 173 | err = r.JSON(resp) 174 | } 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | if resp.ReturnCode != "000000" { 180 | return nil, fmt.Errorf("get song pic: %s", resp.Msg) 181 | } 182 | 183 | return resp, nil 184 | } 185 | 186 | func (a *API) GetSongLyric(ctx context.Context, copyrightId string) (string, error) { 187 | resp, err := a.GetSongLyricRaw(ctx, copyrightId) 188 | if err != nil { 189 | return "", err 190 | } 191 | return resp.Lyric, nil 192 | } 193 | 194 | // 获取歌词 195 | func (a *API) GetSongLyricRaw(ctx context.Context, copyrightId string) (*SongLyricResponse, error) { 196 | params := ghttp.Params{ 197 | "copyrightId": copyrightId, 198 | } 199 | 200 | resp := new(SongLyricResponse) 201 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongLyric) 202 | req.SetQuery(params) 203 | req.SetContext(ctx) 204 | r, err := a.SendRequest(req) 205 | if err == nil { 206 | err = r.JSON(resp) 207 | } 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | if resp.ReturnCode != "000000" { 213 | return nil, fmt.Errorf("get song lyric: %s", resp.Msg) 214 | } 215 | 216 | return resp, nil 217 | } 218 | -------------------------------------------------------------------------------- /pkg/provider/netease/account.go: -------------------------------------------------------------------------------- 1 | package netease 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "fmt" 8 | 9 | "github.com/winterssy/ghttp" 10 | ) 11 | 12 | // 邮箱登录 13 | func (a *API) EmailLoginRaw(ctx context.Context, email string, password string) (*LoginResponse, error) { 14 | passwordHash := md5.Sum([]byte(password)) 15 | password = hex.EncodeToString(passwordHash[:]) 16 | data := map[string]interface{}{ 17 | "username": email, 18 | "password": password, 19 | "rememberLogin": true, 20 | } 21 | 22 | resp := new(LoginResponse) 23 | req, _ := ghttp.NewRequest(ghttp.MethodPost, apiEmailLogin) 24 | req.SetForm(weapi(data)) 25 | req.SetContext(ctx) 26 | r, err := a.SendRequest(req) 27 | if err == nil { 28 | err = r.JSON(resp) 29 | } 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | if resp.Code != 200 { 35 | return nil, fmt.Errorf("email login: %s", resp.errorMessage()) 36 | } 37 | 38 | return resp, nil 39 | } 40 | 41 | // 手机登录 42 | func (a *API) CellphoneLoginRaw(ctx context.Context, countryCode int, phone int, password string) (*LoginResponse, error) { 43 | passwordHash := md5.Sum([]byte(password)) 44 | password = hex.EncodeToString(passwordHash[:]) 45 | data := map[string]interface{}{ 46 | "phone": phone, 47 | "countrycode": countryCode, 48 | "password": password, 49 | "rememberLogin": true, 50 | } 51 | 52 | resp := new(LoginResponse) 53 | req, _ := ghttp.NewRequest(ghttp.MethodPost, apiCellphoneLogin) 54 | req.SetForm(weapi(data)) 55 | req.SetContext(ctx) 56 | r, err := a.SendRequest(req) 57 | if err == nil { 58 | err = r.JSON(resp) 59 | } 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | if resp.Code != 200 { 65 | return nil, fmt.Errorf("cellphone login: %s", resp.errorMessage()) 66 | } 67 | 68 | return resp, nil 69 | } 70 | 71 | // 刷新登录状态 72 | func (a *API) RefreshLoginRaw(ctx context.Context) (*CommonResponse, error) { 73 | resp := new(CommonResponse) 74 | req, _ := ghttp.NewRequest(ghttp.MethodPost, apiRefreshLogin) 75 | req.SetForm(weapi(struct{}{})) 76 | req.SetContext(ctx) 77 | r, err := a.SendRequest(req) 78 | if err == nil { 79 | err = r.JSON(resp) 80 | } 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | if resp.Code != 200 { 86 | return nil, fmt.Errorf("refresh login: %s", resp.errorMessage()) 87 | } 88 | 89 | return resp, nil 90 | } 91 | 92 | // 退出登录 93 | func (a *API) LogoutRaw(ctx context.Context) (*CommonResponse, error) { 94 | resp := new(CommonResponse) 95 | req, _ := ghttp.NewRequest(ghttp.MethodPost, apiLogout) 96 | req.SetForm(weapi(struct{}{})) 97 | req.SetContext(ctx) 98 | r, err := a.SendRequest(req) 99 | if err == nil { 100 | err = r.JSON(resp) 101 | } 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | if resp.Code != 200 { 107 | return nil, fmt.Errorf("logout: %s", resp.errorMessage()) 108 | } 109 | 110 | return resp, nil 111 | } 112 | -------------------------------------------------------------------------------- /pkg/provider/netease/album.go: -------------------------------------------------------------------------------- 1 | package netease 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetAlbum(ctx context.Context, albumId string) (*api.Collection, error) { 15 | _albumId, err := strconv.Atoi(albumId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | resp, err := a.GetAlbumRaw(ctx, _albumId) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | n := len(resp.Songs) 26 | if n == 0 { 27 | return nil, errors.New("get album: no data") 28 | } 29 | 30 | a.patchSongsURL(ctx, songDefaultBR, resp.Songs...) 31 | a.patchSongsLyric(ctx, resp.Songs...) 32 | songs := translate(resp.Songs...) 33 | return &api.Collection{ 34 | Id: strconv.Itoa(resp.Album.Id), 35 | Name: strings.TrimSpace(resp.Album.Name), 36 | PicURL: resp.Album.PicURL, 37 | Songs: songs, 38 | }, nil 39 | } 40 | 41 | // 获取专辑 42 | func (a *API) GetAlbumRaw(ctx context.Context, albumId int) (*AlbumResponse, error) { 43 | resp := new(AlbumResponse) 44 | req, _ := ghttp.NewRequest(ghttp.MethodPost, fmt.Sprintf(apiGetAlbum, albumId)) 45 | req.SetForm(weapi(struct{}{})) 46 | req.SetContext(ctx) 47 | r, err := a.SendRequest(req) 48 | if err == nil { 49 | err = r.JSON(resp) 50 | } 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | if resp.Code != 200 { 56 | return nil, fmt.Errorf("get album: %s", resp.errorMessage()) 57 | } 58 | 59 | return resp, nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/provider/netease/artist.go: -------------------------------------------------------------------------------- 1 | package netease 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetArtist(ctx context.Context, artistId string) (*api.Collection, error) { 15 | _artistId, err := strconv.Atoi(artistId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | resp, err := a.GetArtistRaw(ctx, _artistId) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | n := len(resp.HotSongs) 26 | if n == 0 { 27 | return nil, errors.New("get artist: no data") 28 | } 29 | 30 | a.patchSongsURL(ctx, songDefaultBR, resp.HotSongs...) 31 | a.patchSongsLyric(ctx, resp.HotSongs...) 32 | songs := translate(resp.HotSongs...) 33 | return &api.Collection{ 34 | Id: strconv.Itoa(resp.Artist.Id), 35 | Name: strings.TrimSpace(resp.Artist.Name), 36 | PicURL: resp.Artist.PicURL, 37 | Songs: songs, 38 | }, nil 39 | } 40 | 41 | // 获取歌手 42 | func (a *API) GetArtistRaw(ctx context.Context, artistId int) (*ArtistResponse, error) { 43 | resp := new(ArtistResponse) 44 | req, _ := ghttp.NewRequest(ghttp.MethodPost, fmt.Sprintf(apiGetArtist, artistId)) 45 | req.SetForm(weapi(struct{}{})) 46 | req.SetContext(ctx) 47 | r, err := a.SendRequest(req) 48 | if err == nil { 49 | err = r.JSON(resp) 50 | } 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | if resp.Code != 200 { 56 | return nil, fmt.Errorf("get artist: %s", resp.errorMessage()) 57 | } 58 | 59 | return resp, nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/provider/netease/crypto.go: -------------------------------------------------------------------------------- 1 | package netease 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/base64" 6 | "encoding/hex" 7 | "fmt" 8 | "math/rand" 9 | "strings" 10 | "time" 11 | 12 | "github.com/winterssy/ghttp" 13 | "github.com/winterssy/gjson" 14 | "github.com/winterssy/mxget/pkg/cryptography" 15 | ) 16 | 17 | const ( 18 | Base62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 19 | PresetKey = "0CoJUm6Qyw8W8jud" 20 | IV = "0102030405060708" 21 | LinuxAPIKey = "rFgB&h#%2?^eDg:Q" 22 | EAPIKey = "e82ckenh8dichen8" 23 | DefaultRSAPublicKeyModulus = "e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7" 24 | DefaultRSAPublicKeyExponent = 0x10001 25 | ) 26 | 27 | func weapi(origData interface{}) ghttp.Form { 28 | plainText, _ := gjson.Marshal(origData) 29 | params := base64.StdEncoding.EncodeToString(cryptography.AESCBCEncrypt(plainText, []byte(PresetKey), []byte(IV))) 30 | secKey := CreateSecretKey(16, Base62) 31 | params = base64.StdEncoding.EncodeToString(cryptography.AESCBCEncrypt([]byte(params), secKey, []byte(IV))) 32 | return ghttp.Form{ 33 | "params": params, 34 | "encSecKey": cryptography.RSAEncrypt(BytesReverse(secKey), DefaultRSAPublicKeyModulus, DefaultRSAPublicKeyExponent), 35 | } 36 | } 37 | 38 | func linuxapi(origData interface{}) ghttp.Form { 39 | plainText, _ := gjson.Marshal(origData) 40 | return ghttp.Form{ 41 | "eparams": strings.ToUpper(hex.EncodeToString(cryptography.AESECBEncrypt(plainText, []byte(LinuxAPIKey)))), 42 | } 43 | } 44 | 45 | func eapi(url string, origData interface{}) ghttp.Form { 46 | plainText, _ := gjson.MarshalToString(origData) 47 | message := fmt.Sprintf("nobody%suse%smd5forencrypt", url, plainText) 48 | digest := fmt.Sprintf("%x", md5.Sum([]byte(message))) 49 | data := fmt.Sprintf("%s-36cd479b6b5-%s-36cd479b6b5-%s", url, plainText, digest) 50 | return ghttp.Form{ 51 | "params": strings.ToUpper(hex.EncodeToString(cryptography.AESECBEncrypt([]byte(data), []byte(EAPIKey)))), 52 | } 53 | } 54 | 55 | func CreateSecretKey(size int, charset string) []byte { 56 | secKey, n := make([]byte, size), len(charset) 57 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 58 | for i := range secKey { 59 | secKey[i] = charset[r.Intn(n)] 60 | } 61 | return secKey 62 | } 63 | 64 | func BytesReverse(b []byte) []byte { 65 | for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 { 66 | b[i], b[j] = b[j], b[i] 67 | } 68 | return b 69 | } 70 | -------------------------------------------------------------------------------- /pkg/provider/netease/netease.go: -------------------------------------------------------------------------------- 1 | package netease 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | "math/rand" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/winterssy/ghttp" 12 | "github.com/winterssy/mxget/pkg/api" 13 | "github.com/winterssy/mxget/pkg/concurrency" 14 | "github.com/winterssy/mxget/pkg/request" 15 | "github.com/winterssy/mxget/pkg/utils" 16 | ) 17 | 18 | const ( 19 | linuxAPI = "https://music.163.com/api/linux/forward" 20 | apiSearch = "https://music.163.com/weapi/search/get" 21 | apiGetSongs = "https://music.163.com/weapi/v3/song/detail" 22 | apiGetSongsURL = "https://music.163.com/weapi/song/enhance/player/url" 23 | apiGetArtist = "https://music.163.com/weapi/v1/artist/%d" 24 | apiGetAlbum = "https://music.163.com/weapi/v1/album/%d" 25 | apiGetPlaylist = "https://music.163.com/weapi/v3/playlist/detail" 26 | apiEmailLogin = "https://music.163.com/weapi/login" 27 | apiCellphoneLogin = "https://music.163.com/weapi/login/cellphone" 28 | apiRefreshLogin = "https://music.163.com/weapi/login/token/refresh" 29 | apiLogout = "https://music.163.com/weapi/logout" 30 | 31 | songRequestLimit = 1000 32 | songDefaultBR = 128 33 | ) 34 | 35 | var ( 36 | std = New(request.DefaultClient) 37 | 38 | defaultCookie = createCookie() 39 | 40 | defaultHeaders = ghttp.Headers{ 41 | "Origin": "https://music.163.com", 42 | "Referer": "https://music.163.com", 43 | } 44 | ) 45 | 46 | type ( 47 | CommonResponse struct { 48 | Code int `json:"code"` 49 | Msg string `json:"msg,omitempty"` 50 | } 51 | 52 | Song struct { 53 | Id int `json:"id"` 54 | Name string `json:"name"` 55 | Artists []Artist `json:"ar"` 56 | Album Album `json:"al"` 57 | Track int `json:"no"` 58 | Lyric string `json:"-"` 59 | URL string `json:"-"` 60 | } 61 | 62 | SearchSongsResponse struct { 63 | CommonResponse 64 | Result struct { 65 | Songs []*struct { 66 | Id int `json:"id"` 67 | Name string `json:"name"` 68 | Artists []struct { 69 | Id int `json:"id"` 70 | Name string `json:"name"` 71 | } `json:"artists"` 72 | Album struct { 73 | Id int `json:"id"` 74 | Name string `json:"name"` 75 | } `json:"album"` 76 | } `json:"songs"` 77 | SongCount int `json:"songCount"` 78 | } `json:"result"` 79 | } 80 | 81 | SongURL struct { 82 | Id int `json:"id"` 83 | URL string `json:"url"` 84 | BR int `json:"br"` 85 | Code int `json:"code"` 86 | } 87 | 88 | SongsResponse struct { 89 | CommonResponse 90 | Songs []*Song `json:"songs"` 91 | } 92 | 93 | SongURLResponse struct { 94 | CommonResponse 95 | Data []struct { 96 | Code int `json:"code"` 97 | Id int `json:"id"` 98 | BR int `json:"br"` 99 | URL string `json:"url"` 100 | } `json:"data"` 101 | } 102 | 103 | SongLyricResponse struct { 104 | CommonResponse 105 | Lrc struct { 106 | Lyric string `json:"lyric"` 107 | } `json:"lrc"` 108 | TLyric struct { 109 | Lyric string `json:"lyric"` 110 | } `json:"tlyric"` 111 | } 112 | 113 | Artist struct { 114 | Id int `json:"id"` 115 | Name string `json:"name"` 116 | PicURL string `json:"picUrl"` 117 | } 118 | 119 | ArtistResponse struct { 120 | CommonResponse 121 | Artist struct { 122 | Artist 123 | } `json:"artist"` 124 | HotSongs []*Song `json:"hotSongs"` 125 | } 126 | 127 | Album struct { 128 | Id int `json:"id"` 129 | Name string `json:"name"` 130 | PicURL string `json:"picUrl"` 131 | } 132 | 133 | AlbumResponse struct { 134 | CommonResponse 135 | Album Album `json:"album"` 136 | Songs []*Song `json:"songs"` 137 | } 138 | 139 | PlaylistResponse struct { 140 | CommonResponse 141 | Playlist struct { 142 | Id int `json:"id"` 143 | Name string `json:"name"` 144 | PicURL string `json:"coverImgUrl"` 145 | Tracks []*Song `json:"tracks"` 146 | TrackIds []struct { 147 | Id int `json:"id"` 148 | } `json:"trackIds"` 149 | Total int `json:"trackCount"` 150 | } `json:"playlist"` 151 | } 152 | 153 | LoginResponse struct { 154 | CommonResponse 155 | LoginType int `json:"loginType"` 156 | Account struct { 157 | Id int `json:"id"` 158 | UserName string `json:"userName"` 159 | } `json:"account"` 160 | } 161 | 162 | API struct { 163 | Client *ghttp.Client 164 | } 165 | ) 166 | 167 | func createCookie() *http.Cookie { 168 | b := make([]byte, 16) 169 | _, _ = rand.Read(b) 170 | return &http.Cookie{ 171 | Name: "_ntes_nuid", 172 | Value: hex.EncodeToString(b), 173 | } 174 | } 175 | 176 | func New(client *ghttp.Client) *API { 177 | return &API{ 178 | Client: client, 179 | } 180 | } 181 | 182 | func Client() *API { 183 | return std 184 | } 185 | 186 | func (c *CommonResponse) errorMessage() string { 187 | if c.Msg == "" { 188 | return strconv.Itoa(c.Code) 189 | } 190 | return c.Msg 191 | } 192 | 193 | func (s *SearchSongsResponse) String() string { 194 | return utils.PrettyJSON(s) 195 | } 196 | 197 | func (s *SongsResponse) String() string { 198 | return utils.PrettyJSON(s) 199 | } 200 | 201 | func (s *SongURLResponse) String() string { 202 | return utils.PrettyJSON(s) 203 | } 204 | 205 | func (s *SongLyricResponse) String() string { 206 | return utils.PrettyJSON(s) 207 | } 208 | 209 | func (a *ArtistResponse) String() string { 210 | return utils.PrettyJSON(a) 211 | } 212 | 213 | func (a *AlbumResponse) String() string { 214 | return utils.PrettyJSON(a) 215 | } 216 | 217 | func (p *PlaylistResponse) String() string { 218 | return utils.PrettyJSON(p) 219 | } 220 | 221 | func (e *LoginResponse) String() string { 222 | return utils.PrettyJSON(e) 223 | } 224 | 225 | func (a *API) SendRequest(req *ghttp.Request) (*ghttp.Response, error) { 226 | req.SetHeaders(defaultHeaders) 227 | 228 | // 如果已经登录,不需要额外设置cookies,cookie jar会自动管理 229 | _, err := a.Client.Cookie(req.URL.String(), "MUSIC_U") 230 | if err != nil { 231 | req.AddCookie(defaultCookie) 232 | } 233 | 234 | return a.Client.Do(req) 235 | } 236 | 237 | func (a *API) patchSongsURL(ctx context.Context, br int, songs ...*Song) { 238 | ids := make([]int, len(songs)) 239 | for i, s := range songs { 240 | ids[i] = s.Id 241 | } 242 | 243 | resp, err := a.GetSongsURLRaw(ctx, br, ids...) 244 | if err == nil && len(resp.Data) != 0 { 245 | m := make(map[int]string, len(resp.Data)) 246 | for _, i := range resp.Data { 247 | m[i.Id] = i.URL 248 | } 249 | for _, s := range songs { 250 | s.URL = m[s.Id] 251 | } 252 | } 253 | } 254 | 255 | func (a *API) patchSongsLyric(ctx context.Context, songs ...*Song) { 256 | c := concurrency.New(32) 257 | for _, s := range songs { 258 | if ctx.Err() != nil { 259 | break 260 | } 261 | 262 | c.Add(1) 263 | go func(s *Song) { 264 | lyric, err := a.GetSongLyric(ctx, s.Id) 265 | if err == nil { 266 | s.Lyric = lyric 267 | } 268 | c.Done() 269 | }(s) 270 | } 271 | c.Wait() 272 | } 273 | 274 | func translate(src ...*Song) []*api.Song { 275 | songs := make([]*api.Song, len(src)) 276 | for i, s := range src { 277 | artists := make([]string, len(s.Artists)) 278 | for j, a := range s.Artists { 279 | artists[j] = strings.TrimSpace(a.Name) 280 | } 281 | songs[i] = &api.Song{ 282 | Id: strconv.Itoa(s.Id), 283 | Name: strings.TrimSpace(s.Name), 284 | Artist: strings.Join(artists, "/"), 285 | Album: strings.TrimSpace(s.Album.Name), 286 | PicURL: s.Album.PicURL, 287 | Lyric: s.Lyric, 288 | ListenURL: s.URL, 289 | } 290 | } 291 | return songs 292 | } 293 | -------------------------------------------------------------------------------- /pkg/provider/netease/netease_test.go: -------------------------------------------------------------------------------- 1 | package netease_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/winterssy/mxget/pkg/provider/netease" 10 | ) 11 | 12 | var ( 13 | client *netease.API 14 | ctx context.Context 15 | ) 16 | 17 | func setup() { 18 | client = netease.Client() 19 | ctx = context.Background() 20 | } 21 | 22 | func TestMain(m *testing.M) { 23 | setup() 24 | os.Exit(m.Run()) 25 | } 26 | 27 | func TestAPI_SearchSongs(t *testing.T) { 28 | result, err := client.SearchSongs(ctx, "Alan Walker") 29 | if assert.NoError(t, err) { 30 | t.Log(result) 31 | } 32 | } 33 | 34 | func TestAPI_GetSong(t *testing.T) { 35 | song, err := client.GetSong(ctx, "444269135") 36 | if assert.NoError(t, err) { 37 | t.Log(song) 38 | } 39 | } 40 | 41 | func TestAPI_GetSongURL(t *testing.T) { 42 | url, err := client.GetSongURL(ctx, 444269135, 320) 43 | if assert.NoError(t, err) { 44 | t.Log(url) 45 | } 46 | } 47 | 48 | func TestAPI_GetSongLyric(t *testing.T) { 49 | lyric, err := client.GetSongLyric(ctx, 444269135) 50 | if assert.NoError(t, err) { 51 | t.Log(lyric) 52 | } 53 | } 54 | 55 | func TestAPI_GetArtist(t *testing.T) { 56 | artist, err := client.GetArtist(ctx, "1045123") 57 | if assert.NoError(t, err) { 58 | t.Log(artist) 59 | } 60 | } 61 | 62 | func TestAPI_GetAlbum(t *testing.T) { 63 | album, err := client.GetAlbum(ctx, "35023284") 64 | if assert.NoError(t, err) { 65 | t.Log(album) 66 | } 67 | } 68 | 69 | func TestAPI_GetPlaylist(t *testing.T) { 70 | playlist, err := client.GetPlaylist(ctx, "156934569") 71 | if assert.NoError(t, err) { 72 | t.Log(playlist) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/provider/netease/playlist.go: -------------------------------------------------------------------------------- 1 | package netease 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/winterssy/ghttp" 12 | "github.com/winterssy/mxget/pkg/api" 13 | "github.com/winterssy/mxget/pkg/utils" 14 | ) 15 | 16 | func (a *API) GetPlaylist(ctx context.Context, playlistId string) (*api.Collection, error) { 17 | _playlistId, err := strconv.Atoi(playlistId) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | resp, err := a.GetPlaylistRaw(ctx, _playlistId) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | n := resp.Playlist.Total 28 | if n == 0 { 29 | return nil, errors.New("get playlist: no data") 30 | } 31 | 32 | tracks := resp.Playlist.Tracks 33 | if n > songRequestLimit { 34 | extra := n - songRequestLimit 35 | trackIds := make([]int, extra) 36 | for i, j := songRequestLimit, 0; i < n; i, j = i+1, j+1 { 37 | trackIds[j] = resp.Playlist.TrackIds[i].Id 38 | } 39 | 40 | queue := make(chan []*Song) 41 | wg := new(sync.WaitGroup) 42 | for i := 0; i < extra; i += songRequestLimit { 43 | if ctx.Err() != nil { 44 | break 45 | } 46 | 47 | songIds := trackIds[i:utils.Min(i+songRequestLimit, extra)] 48 | wg.Add(1) 49 | go func() { 50 | resp, err := a.GetSongsRaw(ctx, songIds...) 51 | if err != nil { 52 | wg.Done() 53 | return 54 | } 55 | queue <- resp.Songs 56 | }() 57 | } 58 | 59 | go func() { 60 | for s := range queue { 61 | resp.Playlist.Tracks = append(tracks, s...) 62 | wg.Done() 63 | } 64 | }() 65 | wg.Wait() 66 | } 67 | 68 | a.patchSongsURL(ctx, songDefaultBR, tracks...) 69 | a.patchSongsLyric(ctx, tracks...) 70 | songs := translate(tracks...) 71 | return &api.Collection{ 72 | Id: strconv.Itoa(resp.Playlist.Id), 73 | Name: strings.TrimSpace(resp.Playlist.Name), 74 | PicURL: resp.Playlist.PicURL, 75 | Songs: songs, 76 | }, nil 77 | } 78 | 79 | // 获取歌单 80 | func (a *API) GetPlaylistRaw(ctx context.Context, playlistId int) (*PlaylistResponse, error) { 81 | data := map[string]int{ 82 | "id": playlistId, 83 | "n": 100000, 84 | } 85 | 86 | resp := new(PlaylistResponse) 87 | req, _ := ghttp.NewRequest(ghttp.MethodPost, apiGetPlaylist) 88 | req.SetForm(weapi(data)) 89 | req.SetContext(ctx) 90 | r, err := a.SendRequest(req) 91 | if err == nil { 92 | err = r.JSON(resp) 93 | } 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | if resp.Code != 200 { 99 | return nil, fmt.Errorf("get playlist: %s", resp.errorMessage()) 100 | } 101 | 102 | return resp, nil 103 | } 104 | -------------------------------------------------------------------------------- /pkg/provider/netease/search.go: -------------------------------------------------------------------------------- 1 | package netease 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) SearchSongs(ctx context.Context, keyword string) ([]*api.Song, error) { 15 | resp, err := a.SearchSongsRaw(ctx, keyword, 0, 50) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | n := len(resp.Result.Songs) 21 | if n == 0 { 22 | return nil, errors.New("search songs: no data") 23 | } 24 | 25 | songs := make([]*api.Song, n) 26 | for i, s := range resp.Result.Songs { 27 | artists := make([]string, len(s.Artists)) 28 | for j, a := range s.Artists { 29 | artists[j] = strings.TrimSpace(a.Name) 30 | } 31 | songs[i] = &api.Song{ 32 | Id: strconv.Itoa(s.Id), 33 | Name: strings.TrimSpace(s.Name), 34 | Artist: strings.Join(artists, "/"), 35 | Album: s.Album.Name, 36 | } 37 | } 38 | return songs, nil 39 | } 40 | 41 | // 搜索歌曲 42 | func (a *API) SearchSongsRaw(ctx context.Context, keyword string, offset int, limit int) (*SearchSongsResponse, error) { 43 | // type: 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 44 | // 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频 45 | data := map[string]interface{}{ 46 | "s": keyword, 47 | "type": 1, 48 | "offset": offset, 49 | "limit": limit, 50 | } 51 | 52 | resp := new(SearchSongsResponse) 53 | req, _ := ghttp.NewRequest(ghttp.MethodPost, apiSearch) 54 | req.SetForm(weapi(data)) 55 | req.SetContext(ctx) 56 | r, err := a.SendRequest(req) 57 | if err == nil { 58 | err = r.JSON(resp) 59 | } 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | if resp.Code != 200 { 65 | return nil, fmt.Errorf("search songs: %s", resp.errorMessage()) 66 | } 67 | 68 | return resp, nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/provider/netease/song.go: -------------------------------------------------------------------------------- 1 | package netease 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/gjson" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetSong(ctx context.Context, songId string) (*api.Song, error) { 15 | _songId, err := strconv.Atoi(songId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | resp, err := a.GetSongsRaw(ctx, _songId) 21 | if err != nil { 22 | return nil, err 23 | } 24 | if len(resp.Songs) == 0 { 25 | return nil, errors.New("get song: no data") 26 | } 27 | 28 | _song := resp.Songs[0] 29 | a.patchSongsURL(ctx, songDefaultBR, _song) 30 | a.patchSongsLyric(ctx, _song) 31 | songs := translate(_song) 32 | return songs[0], nil 33 | } 34 | 35 | // 批量获取歌曲详情,上限1000首 36 | func (a *API) GetSongsRaw(ctx context.Context, songIds ...int) (*SongsResponse, error) { 37 | n := len(songIds) 38 | if n > songRequestLimit { 39 | songIds = songIds[:songRequestLimit] 40 | n = songRequestLimit 41 | } 42 | 43 | c := make([]map[string]int, n) 44 | for i, id := range songIds { 45 | c[i] = map[string]int{"id": id} 46 | } 47 | enc, _ := gjson.MarshalToString(c) 48 | data := map[string]string{ 49 | "c": enc, 50 | } 51 | 52 | resp := new(SongsResponse) 53 | req, _ := ghttp.NewRequest(ghttp.MethodPost, apiGetSongs) 54 | req.SetForm(weapi(data)) 55 | req.SetContext(ctx) 56 | r, err := a.SendRequest(req) 57 | if err == nil { 58 | err = r.JSON(resp) 59 | } 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | if resp.Code != 200 { 65 | return nil, fmt.Errorf("get songs: %s", resp.errorMessage()) 66 | } 67 | 68 | return resp, nil 69 | } 70 | 71 | func (a *API) GetSongURL(ctx context.Context, id int, br int) (string, error) { 72 | resp, err := a.GetSongsURLRaw(ctx, br, id) 73 | if err != nil { 74 | return "", err 75 | } 76 | if len(resp.Data) == 0 { 77 | return "", errors.New("get song url: no data") 78 | } 79 | if resp.Data[0].Code != 200 { 80 | return "", errors.New("get song url: copyright protection") 81 | } 82 | 83 | return resp.Data[0].URL, nil 84 | } 85 | 86 | // 批量获取歌曲播放地址,br: 比特率,128/192/320/999 87 | func (a *API) GetSongsURLRaw(ctx context.Context, br int, songIds ...int) (*SongURLResponse, error) { 88 | var _br int 89 | switch br { 90 | case 128, 192, 320: 91 | _br = br 92 | default: 93 | _br = 999 94 | } 95 | 96 | enc, _ := gjson.MarshalToString(songIds) 97 | data := map[string]interface{}{ 98 | "br": _br * 1000, 99 | "ids": enc, 100 | } 101 | 102 | resp := new(SongURLResponse) 103 | req, _ := ghttp.NewRequest(ghttp.MethodPost, apiGetSongsURL) 104 | req.SetForm(weapi(data)) 105 | req.SetContext(ctx) 106 | r, err := a.SendRequest(req) 107 | if err == nil { 108 | err = r.JSON(resp) 109 | } 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | if resp.Code != 200 { 115 | return nil, fmt.Errorf("get songs url: %s", resp.errorMessage()) 116 | } 117 | 118 | return resp, nil 119 | } 120 | 121 | func (a *API) GetSongLyric(ctx context.Context, songId int) (string, error) { 122 | resp, err := a.GetSongLyricRaw(ctx, songId) 123 | if err != nil { 124 | return "", err 125 | } 126 | return resp.Lrc.Lyric, nil 127 | } 128 | 129 | // 获取歌词 130 | func (a *API) GetSongLyricRaw(ctx context.Context, songId int) (*SongLyricResponse, error) { 131 | data := map[string]interface{}{ 132 | "method": "POST", 133 | "url": "https://music.163.com/api/song/lyric?lv=-1&kv=-1&tv=-1", 134 | "params": map[string]int{ 135 | "id": songId, 136 | }, 137 | } 138 | resp := new(SongLyricResponse) 139 | req, _ := ghttp.NewRequest(ghttp.MethodPost, linuxAPI) 140 | req.SetForm(linuxapi(data)) 141 | req.SetContext(ctx) 142 | r, err := a.SendRequest(req) 143 | if err == nil { 144 | err = r.JSON(resp) 145 | } 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | if resp.Code != 200 { 151 | return nil, fmt.Errorf("get song lyric: %s", resp.errorMessage()) 152 | } 153 | 154 | return resp, nil 155 | } 156 | -------------------------------------------------------------------------------- /pkg/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/winterssy/mxget/pkg/api" 7 | "github.com/winterssy/mxget/pkg/provider/baidu" 8 | "github.com/winterssy/mxget/pkg/provider/kugou" 9 | "github.com/winterssy/mxget/pkg/provider/kuwo" 10 | "github.com/winterssy/mxget/pkg/provider/migu" 11 | "github.com/winterssy/mxget/pkg/provider/netease" 12 | "github.com/winterssy/mxget/pkg/provider/tencent" 13 | "github.com/winterssy/mxget/pkg/provider/xiami" 14 | ) 15 | 16 | func GetClient(platform string) (api.Provider, error) { 17 | switch platform { 18 | case "netease", "nc": 19 | return netease.Client(), nil 20 | case "tencent", "qq": 21 | return tencent.Client(), nil 22 | case "migu", "mg": 23 | return migu.Client(), nil 24 | case "kugou", "kg": 25 | return kugou.Client(), nil 26 | case "kuwo", "kw": 27 | return kuwo.Client(), nil 28 | case "xiami", "xm": 29 | return xiami.Client(), nil 30 | case "qianqian", "baidu", "bd": 31 | return baidu.Client(), nil 32 | default: 33 | return nil, errors.New("unexpected music platform") 34 | } 35 | } 36 | 37 | func GetDesc(platform string) string { 38 | switch platform { 39 | case "netease", "nc": 40 | return "netease cloud music" 41 | case "tencent", "qq": 42 | return "qq music" 43 | case "migu", "mg": 44 | return "migu music" 45 | case "kugou", "kg": 46 | return "kugou music" 47 | case "kuwo", "kw": 48 | return "kuwo music" 49 | case "xiami", "xm": 50 | return "xiami music" 51 | case "qianqian", "baidu", "bd": 52 | return "qianqian music" 53 | default: 54 | return "unknown" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/provider/tencent/album.go: -------------------------------------------------------------------------------- 1 | package tencent 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetAlbum(ctx context.Context, albumMid string) (*api.Collection, error) { 14 | resp, err := a.GetAlbumRaw(ctx, albumMid) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | n := len(resp.Data.GetSongInfo) 20 | if n == 0 { 21 | return nil, errors.New("get album: no data") 22 | } 23 | 24 | _songs := resp.Data.GetSongInfo 25 | a.patchSongsURLV1(ctx, _songs...) 26 | a.patchSongsLyric(ctx, _songs...) 27 | songs := translate(_songs...) 28 | return &api.Collection{ 29 | Id: resp.Data.GetAlbumInfo.FAlbumMid, 30 | Name: strings.TrimSpace(resp.Data.GetAlbumInfo.FAlbumName), 31 | PicURL: fmt.Sprintf(albumPicURLTmpl, resp.Data.GetAlbumInfo.FAlbumMid), 32 | Songs: songs, 33 | }, nil 34 | } 35 | 36 | // 获取专辑 37 | func (a *API) GetAlbumRaw(ctx context.Context, albumMid string) (*AlbumResponse, error) { 38 | params := ghttp.Params{ 39 | "albummid": albumMid, 40 | } 41 | 42 | resp := new(AlbumResponse) 43 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetAlbum) 44 | req.SetQuery(params) 45 | req.SetContext(ctx) 46 | r, err := a.SendRequest(req) 47 | if err == nil { 48 | err = r.JSON(resp) 49 | } 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | if resp.Code != 0 { 55 | return nil, fmt.Errorf("get album: %d", resp.Code) 56 | } 57 | 58 | return resp, nil 59 | } 60 | -------------------------------------------------------------------------------- /pkg/provider/tencent/artist.go: -------------------------------------------------------------------------------- 1 | package tencent 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetArtist(ctx context.Context, singerMid string) (*api.Collection, error) { 14 | resp, err := a.GetArtistRaw(ctx, singerMid, 0, 50) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | n := len(resp.Data.List) 20 | if n == 0 { 21 | return nil, errors.New("get artist: no data") 22 | } 23 | 24 | _songs := make([]*Song, n) 25 | for i, v := range resp.Data.List { 26 | _songs[i] = v.MusicData 27 | } 28 | 29 | a.patchSongsURLV1(ctx, _songs...) 30 | a.patchSongsLyric(ctx, _songs...) 31 | songs := translate(_songs...) 32 | return &api.Collection{ 33 | Id: resp.Data.SingerMid, 34 | Name: strings.TrimSpace(resp.Data.SingerName), 35 | PicURL: fmt.Sprintf(artistPicURLTmpl, resp.Data.SingerMid), 36 | Songs: songs, 37 | }, nil 38 | } 39 | 40 | // 获取歌手 41 | func (a *API) GetArtistRaw(ctx context.Context, singerMid string, page int, pageSize int) (*ArtistResponse, error) { 42 | params := ghttp.Params{ 43 | "singermid": singerMid, 44 | "begin": page, 45 | "num": pageSize, 46 | } 47 | 48 | resp := new(ArtistResponse) 49 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtist) 50 | req.SetQuery(params) 51 | req.SetContext(ctx) 52 | r, err := a.SendRequest(req) 53 | if err == nil { 54 | err = r.JSON(resp) 55 | } 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | if resp.Code != 0 { 61 | return nil, fmt.Errorf("get artist: %d", resp.Code) 62 | } 63 | 64 | return resp, nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/provider/tencent/playlist.go: -------------------------------------------------------------------------------- 1 | package tencent 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetPlaylist(ctx context.Context, playlistId string) (*api.Collection, error) { 14 | resp, err := a.GetPlaylistRaw(ctx, playlistId) 15 | if err != nil { 16 | return nil, err 17 | } 18 | if len(resp.Data.CDList) == 0 || len(resp.Data.CDList[0].SongList) == 0 { 19 | return nil, errors.New("get playlist: no data") 20 | } 21 | 22 | playlist := resp.Data.CDList[0] 23 | if playlist.PicURL == "" { 24 | playlist.PicURL = playlist.Logo 25 | } 26 | _songs := playlist.SongList 27 | a.patchSongsURLV1(ctx, _songs...) 28 | a.patchSongsLyric(ctx, _songs...) 29 | songs := translate(_songs...) 30 | return &api.Collection{ 31 | Id: playlist.DissTid, 32 | Name: strings.TrimSpace(playlist.DissName), 33 | PicURL: playlist.PicURL, 34 | Songs: songs, 35 | }, nil 36 | } 37 | 38 | // 获取歌单 39 | func (a *API) GetPlaylistRaw(ctx context.Context, playlistId string) (*PlaylistResponse, error) { 40 | params := ghttp.Params{ 41 | "id": playlistId, 42 | } 43 | 44 | resp := new(PlaylistResponse) 45 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetPlaylist) 46 | req.SetQuery(params) 47 | req.SetContext(ctx) 48 | r, err := a.SendRequest(req) 49 | if err == nil { 50 | err = r.JSON(resp) 51 | } 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | if resp.Code != 0 { 57 | return nil, fmt.Errorf("get playlist: %d", resp.Code) 58 | } 59 | 60 | return resp, nil 61 | } 62 | -------------------------------------------------------------------------------- /pkg/provider/tencent/search.go: -------------------------------------------------------------------------------- 1 | package tencent 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) SearchSongs(ctx context.Context, keyword string) ([]*api.Song, error) { 15 | resp, err := a.SearchSongsRaw(ctx, keyword, 1, 50) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | n := len(resp.Data.Song.List) 21 | if n == 0 { 22 | return nil, errors.New("search songs: no data") 23 | } 24 | 25 | songs := make([]*api.Song, n) 26 | for i, s := range resp.Data.Song.List { 27 | artists := make([]string, len(s.Singer)) 28 | for j, a := range s.Singer { 29 | artists[j] = strings.TrimSpace(a.Name) 30 | } 31 | songs[i] = &api.Song{ 32 | Id: s.Mid, 33 | Name: strings.TrimSpace(s.Title), 34 | Artist: strings.Join(artists, "/"), 35 | Album: strings.TrimSpace(s.Album.Name), 36 | } 37 | } 38 | return songs, nil 39 | } 40 | 41 | // 搜索歌曲 42 | func (a *API) SearchSongsRaw(ctx context.Context, keyword string, page int, pageSize int) (*SearchSongsResponse, error) { 43 | params := ghttp.Params{ 44 | "w": keyword, 45 | "p": strconv.Itoa(page), 46 | "n": strconv.Itoa(pageSize), 47 | } 48 | 49 | resp := new(SearchSongsResponse) 50 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiSearch) 51 | req.SetQuery(params) 52 | req.SetContext(ctx) 53 | r, err := a.SendRequest(req) 54 | if err == nil { 55 | err = r.JSON(resp) 56 | } 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | if resp.Code != 0 { 62 | return nil, fmt.Errorf("search songs: %d", resp.Code) 63 | } 64 | 65 | return resp, nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/provider/tencent/song.go: -------------------------------------------------------------------------------- 1 | package tencent 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "math/rand" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/gjson" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetSong(ctx context.Context, songMid string) (*api.Song, error) { 15 | resp, err := a.GetSongRaw(ctx, songMid) 16 | if err != nil { 17 | return nil, err 18 | } 19 | if len(resp.Data) == 0 { 20 | return nil, errors.New("get song: no data") 21 | } 22 | 23 | _song := resp.Data[0] 24 | a.patchSongsURLV1(ctx, _song) 25 | a.patchSongsLyric(ctx, _song) 26 | songs := translate(_song) 27 | return songs[0], nil 28 | } 29 | 30 | // 获取歌曲详情 31 | func (a *API) GetSongRaw(ctx context.Context, songMid string) (*SongResponse, error) { 32 | params := ghttp.Params{ 33 | "songmid": songMid, 34 | } 35 | 36 | resp := new(SongResponse) 37 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSong) 38 | req.SetQuery(params) 39 | req.SetContext(ctx) 40 | r, err := a.SendRequest(req) 41 | if err == nil { 42 | err = r.JSON(resp) 43 | } 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | if resp.Code != 0 { 49 | return nil, fmt.Errorf("get song: %d", resp.Code) 50 | } 51 | 52 | return resp, nil 53 | } 54 | 55 | func (a *API) GetSongURLV1(ctx context.Context, songMid string, mediaMid string) (string, error) { 56 | const ( 57 | tmpl = "http://mobileoc.music.tc.qq.com/%s?guid=0&uin=0&vkey=%s" 58 | ) 59 | 60 | resp, err := a.GetSongURLV1Raw(ctx, songMid, mediaMid) 61 | if err != nil { 62 | return "", err 63 | } 64 | if len(resp.Data.Items) == 0 { 65 | return "", errors.New("get song url: no data") 66 | } 67 | 68 | item := resp.Data.Items[0] 69 | if item.SubCode != 0 { 70 | return "", fmt.Errorf("get song url: %d", item.SubCode) 71 | } 72 | 73 | return fmt.Sprintf(tmpl, item.FileName, item.Vkey), nil 74 | } 75 | 76 | // 获取歌曲播放地址 77 | func (a *API) GetSongURLV1Raw(ctx context.Context, songMid string, mediaMid string) (*SongURLResponseV1, error) { 78 | params := ghttp.Params{ 79 | "songmid": songMid, 80 | "filename": "M500" + mediaMid + ".mp3", 81 | } 82 | 83 | resp := new(SongURLResponseV1) 84 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongURLV1) 85 | req.SetQuery(params) 86 | req.SetContext(ctx) 87 | r, err := a.SendRequest(req) 88 | if err == nil { 89 | err = r.JSON(resp) 90 | } 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | if resp.Code != 0 { 96 | return nil, fmt.Errorf("get song url: %s", resp.ErrInfo) 97 | } 98 | 99 | return resp, nil 100 | } 101 | 102 | func (a *API) GetSongURLV2(ctx context.Context, songMid string) (string, error) { 103 | resp, err := a.GetSongsURLV2Raw(ctx, songMid) 104 | if err != nil { 105 | return "", err 106 | } 107 | if len(resp.Req0.Data.MidURLInfo) == 0 { 108 | return "", errors.New("get song url: no data") 109 | } 110 | 111 | n := len(resp.Req0.Data.Sip) 112 | if n == 0 { 113 | return "", errors.New("get song url: no sip") 114 | } 115 | 116 | // 随机获取一个sip 117 | sip := resp.Req0.Data.Sip[rand.Intn(n)] 118 | urlInfo := resp.Req0.Data.MidURLInfo[0] 119 | if urlInfo.PURL == "" { 120 | return "", errors.New("get song url: copyright protection") 121 | } 122 | 123 | return sip + urlInfo.PURL, nil 124 | } 125 | 126 | // 批量获取歌曲播放地址 127 | func (a *API) GetSongsURLV2Raw(ctx context.Context, songMids ...string) (*SongURLResponseV2, error) { 128 | if len(songMids) > songURLRequestLimit { 129 | songMids = songMids[:songURLRequestLimit] 130 | } 131 | 132 | param := map[string]interface{}{ 133 | "guid": "9386820628", 134 | "loginflag": 1, 135 | "songmid": songMids, 136 | "uin": "1152921504838414858", 137 | "platform": "20", 138 | } 139 | req0 := map[string]interface{}{ 140 | "module": "vkey.GetVkeyServer", 141 | "method": "CgiGetVkey", 142 | "param": param, 143 | } 144 | data := map[string]interface{}{ 145 | "req0": req0, 146 | } 147 | 148 | enc, _ := gjson.MarshalToString(data) 149 | params := ghttp.Params{ 150 | "data": enc, 151 | } 152 | resp := new(SongURLResponseV2) 153 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongsURLV2) 154 | req.SetQuery(params) 155 | req.SetContext(ctx) 156 | r, err := a.SendRequest(req) 157 | if err == nil { 158 | err = r.JSON(resp) 159 | } 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | if resp.Code != 0 { 165 | return nil, fmt.Errorf("get song url: %d", resp.Code) 166 | } 167 | 168 | return resp, nil 169 | } 170 | 171 | func (a *API) GetSongLyric(ctx context.Context, songMid string) (string, error) { 172 | resp, err := a.GetSongLyricRaw(ctx, songMid) 173 | if err != nil { 174 | return "", err 175 | } 176 | 177 | // lyric, err := base64.StdEncoding.DecodeString(resp.Lyric) 178 | // if err != nil { 179 | // return "", err 180 | // } 181 | 182 | return resp.Lyric, nil 183 | } 184 | 185 | // 获取歌词 186 | func (a *API) GetSongLyricRaw(ctx context.Context, songMid string) (*SongLyricResponse, error) { 187 | params := ghttp.Params{ 188 | "songmid": songMid, 189 | } 190 | 191 | resp := new(SongLyricResponse) 192 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongLyric) 193 | req.SetQuery(params) 194 | req.SetContext(ctx) 195 | r, err := a.SendRequest(req) 196 | if err == nil { 197 | err = r.JSON(resp) 198 | } 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | if resp.Code != 0 { 204 | return nil, fmt.Errorf("get song lyric: %d", resp.Code) 205 | } 206 | 207 | return resp, nil 208 | } 209 | -------------------------------------------------------------------------------- /pkg/provider/tencent/tencent.go: -------------------------------------------------------------------------------- 1 | package tencent 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | "github.com/winterssy/mxget/pkg/concurrency" 13 | "github.com/winterssy/mxget/pkg/request" 14 | "github.com/winterssy/mxget/pkg/utils" 15 | ) 16 | 17 | const ( 18 | apiSearch = "https://c.y.qq.com/soso/fcgi-bin/client_search_cp?format=json&platform=yqq&new_json=1" 19 | apiGetSong = "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?format=json&platform=yqq" 20 | apiGetSongURLV1 = "http://c.y.qq.com/base/fcgi-bin/fcg_music_express_mobile3.fcg?format=json&platform=yqq&needNewCode=0&cid=205361747&uin=0&guid=0" 21 | apiGetSongsURLV2 = "https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&platform=yqq" 22 | apiGetSongLyric = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?format=json&platform=yqq&nobase64=1" 23 | apiGetArtist = "https://c.y.qq.com/v8/fcg-bin/fcg_v8_singer_track_cp.fcg?format=json&platform=yqq&newsong=1&order=listen" 24 | apiGetAlbum = "https://c.y.qq.com/v8/fcg-bin/fcg_v8_album_detail_cp.fcg?format=json&platform=yqq&newsong=1" 25 | apiGetPlaylist = "https://c.y.qq.com/v8/fcg-bin/fcg_v8_playlist_cp.fcg?format=json&platform=yqq&newsong=1" 26 | 27 | artistPicURLTmpl = "https://y.gtimg.cn/music/photo_new/T001R800x800M000%s.jpg" 28 | albumPicURLTmpl = "https://y.gtimg.cn/music/photo_new/T002R800x800M000%s.jpg" 29 | 30 | songURLRequestLimit = 300 31 | ) 32 | 33 | var ( 34 | std = New(request.DefaultClient) 35 | 36 | defaultHeaders = ghttp.Headers{ 37 | "Origin": "https://c.y.qq.com", 38 | "Referer": "https://c.y.qq.com", 39 | } 40 | ) 41 | 42 | type ( 43 | CommonResponse struct { 44 | Code int `json:"code"` 45 | } 46 | 47 | Song struct { 48 | Mid string `json:"mid"` 49 | Title string `json:"title"` 50 | Singer []Singer `json:"singer"` 51 | Album Album `json:"album"` 52 | Track int `json:"index_album"` 53 | Action struct { 54 | Switch int `json:"switch"` 55 | } `json:"action"` 56 | File struct { 57 | MediaMid string `json:"media_mid"` 58 | } `json:"file"` 59 | Lyric string `json:"-"` 60 | URL string `json:"-"` 61 | } 62 | 63 | SearchSongsResponse struct { 64 | CommonResponse 65 | Data struct { 66 | Song struct { 67 | TotalNum int `json:"totalnum"` 68 | List []*Song `json:"list"` 69 | } `json:"song"` 70 | } `json:"data"` 71 | } 72 | 73 | SongResponse struct { 74 | CommonResponse 75 | Data []*Song `json:"data"` 76 | } 77 | 78 | SongURLResponseV1 struct { 79 | Code int `json:"code"` 80 | Cid int `json:"cid"` 81 | ErrInfo string `json:"errinfo,omitempty"` 82 | Data struct { 83 | Expiration int `json:"expiration"` 84 | Items []struct { 85 | SubCode int `json:"subcode"` 86 | SongMid string `json:"songmid"` 87 | FileName string `json:"filename"` 88 | Vkey string `json:"vkey"` 89 | } `json:"items"` 90 | } `json:"data"` 91 | } 92 | 93 | SongURLResponseV2 struct { 94 | CommonResponse 95 | Req0 struct { 96 | Code int `json:"code"` 97 | Data struct { 98 | MidURLInfo []struct { 99 | FileName string `json:"filename"` 100 | PURL string `json:"purl"` 101 | SongMid string `json:"songmid"` 102 | Vkey string `json:"vkey"` 103 | } `json:"midurlinfo"` 104 | Sip []string `json:"sip"` 105 | TestFile2g string `json:"testfile2g"` 106 | } `json:"data"` 107 | } `json:"req0"` 108 | } 109 | 110 | SongLyricResponse struct { 111 | CommonResponse 112 | Lyric string `json:"lyric"` 113 | Trans string `json:"trans"` 114 | } 115 | 116 | Singer struct { 117 | Mid string `json:"mid"` 118 | Name string `json:"name"` 119 | } 120 | 121 | ArtistResponse struct { 122 | CommonResponse 123 | Data struct { 124 | SingerMid string `json:"singer_mid"` 125 | SingerName string `json:"singer_name"` 126 | List []struct { 127 | MusicData *Song `json:"musicData"` 128 | } `json:"list"` 129 | } `json:"data"` 130 | } 131 | 132 | Album struct { 133 | Mid string `json:"mid"` 134 | Name string `json:"name"` 135 | } 136 | 137 | AlbumResponse struct { 138 | CommonResponse 139 | Data struct { 140 | GetAlbumInfo struct { 141 | FAlbumMid string `json:"Falbum_mid"` 142 | FAlbumName string `json:"Falbum_name"` 143 | } `json:"getAlbumInfo"` 144 | GetSongInfo []*Song `json:"getSongInfo"` 145 | } `json:"data"` 146 | } 147 | 148 | PlaylistResponse struct { 149 | CommonResponse 150 | Data struct { 151 | CDList []struct { 152 | DissTid string `json:"disstid"` 153 | DissName string `json:"dissname"` 154 | Logo string `json:"logo"` 155 | PicURL string `json:"dir_pic_url2"` 156 | SongList []*Song `json:"songlist"` 157 | } `json:"cdlist"` 158 | } `json:"data"` 159 | } 160 | 161 | API struct { 162 | Client *ghttp.Client 163 | } 164 | ) 165 | 166 | func New(client *ghttp.Client) *API { 167 | return &API{ 168 | Client: client, 169 | } 170 | } 171 | 172 | func Client() *API { 173 | return std 174 | } 175 | 176 | func (s *SearchSongsResponse) String() string { 177 | return utils.PrettyJSON(s) 178 | } 179 | 180 | func (s *SongResponse) String() string { 181 | return utils.PrettyJSON(s) 182 | } 183 | 184 | func (s *SongURLResponseV2) String() string { 185 | return utils.PrettyJSON(s) 186 | } 187 | 188 | func (s *SongLyricResponse) String() string { 189 | return utils.PrettyJSON(s) 190 | } 191 | 192 | func (a *ArtistResponse) String() string { 193 | return utils.PrettyJSON(a) 194 | } 195 | 196 | func (a *AlbumResponse) String() string { 197 | return utils.PrettyJSON(a) 198 | } 199 | 200 | func (p *PlaylistResponse) String() string { 201 | return utils.PrettyJSON(p) 202 | } 203 | 204 | func (a *API) SendRequest(req *ghttp.Request) (*ghttp.Response, error) { 205 | req.SetHeaders(defaultHeaders) 206 | return a.Client.Do(req) 207 | } 208 | 209 | func (a *API) patchSongsURLV1(ctx context.Context, songs ...*Song) { 210 | c := concurrency.New(32) 211 | for _, s := range songs { 212 | if ctx.Err() != nil { 213 | break 214 | } 215 | 216 | c.Add(1) 217 | go func(s *Song) { 218 | url, err := a.GetSongURLV1(ctx, s.Mid, s.File.MediaMid) 219 | if err == nil { 220 | s.URL = url 221 | } 222 | c.Done() 223 | }(s) 224 | } 225 | c.Wait() 226 | } 227 | 228 | func (a *API) patchSongsURLV2(ctx context.Context, songs ...*Song) { 229 | n := len(songs) 230 | songMids := make([]string, n) 231 | for i, s := range songs { 232 | songMids[i] = s.Mid 233 | } 234 | 235 | type result struct { 236 | resp *SongURLResponseV2 237 | err error 238 | } 239 | 240 | urlMap := make(map[string]string, n) 241 | queue := make(chan *result) 242 | wg := new(sync.WaitGroup) 243 | 244 | // url长度限制,每次请求的歌曲数不能太多,分批获取 245 | for i := 0; i < n; i += songURLRequestLimit { 246 | if ctx.Err() != nil { 247 | break 248 | } 249 | 250 | ids := songMids[i:utils.Min(i+songURLRequestLimit, n)] 251 | wg.Add(1) 252 | go func() { 253 | resp, err := a.GetSongsURLV2Raw(ctx, ids...) 254 | queue <- &result{ 255 | resp: resp, 256 | err: err, 257 | } 258 | }() 259 | } 260 | go func() { 261 | for r := range queue { 262 | if r.err == nil { 263 | n := len(r.resp.Req0.Data.Sip) 264 | if n > 0 { 265 | // 随机获取一个sip 266 | sip := r.resp.Req0.Data.Sip[rand.Intn(n)] 267 | for _, i := range r.resp.Req0.Data.MidURLInfo { 268 | if i.PURL != "" { 269 | urlMap[i.SongMid] = sip + i.PURL 270 | } 271 | } 272 | } 273 | } 274 | wg.Done() 275 | } 276 | }() 277 | wg.Wait() 278 | 279 | for _, s := range songs { 280 | s.URL = urlMap[s.Mid] 281 | } 282 | } 283 | 284 | func (a *API) patchSongsLyric(ctx context.Context, songs ...*Song) { 285 | c := concurrency.New(32) 286 | for _, s := range songs { 287 | if ctx.Err() != nil { 288 | break 289 | } 290 | 291 | c.Add(1) 292 | go func(s *Song) { 293 | lyric, err := a.GetSongLyric(ctx, s.Mid) 294 | if err == nil { 295 | s.Lyric = lyric 296 | } 297 | c.Done() 298 | }(s) 299 | } 300 | c.Wait() 301 | } 302 | 303 | func translate(src ...*Song) []*api.Song { 304 | songs := make([]*api.Song, len(src)) 305 | for i, s := range src { 306 | artists := make([]string, len(s.Singer)) 307 | for j, a := range s.Singer { 308 | artists[j] = strings.TrimSpace(a.Name) 309 | } 310 | songs[i] = &api.Song{ 311 | Id: s.Mid, 312 | Name: strings.TrimSpace(s.Title), 313 | Artist: strings.Join(artists, "/"), 314 | Album: strings.TrimSpace(s.Album.Name), 315 | PicURL: fmt.Sprintf(albumPicURLTmpl, s.Album.Mid), 316 | Lyric: s.Lyric, 317 | ListenURL: s.URL, 318 | } 319 | } 320 | return songs 321 | } 322 | -------------------------------------------------------------------------------- /pkg/provider/tencent/tencent_test.go: -------------------------------------------------------------------------------- 1 | package tencent_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/winterssy/mxget/pkg/provider/tencent" 10 | ) 11 | 12 | var ( 13 | client *tencent.API 14 | ctx context.Context 15 | ) 16 | 17 | func setup() { 18 | client = tencent.Client() 19 | ctx = context.Background() 20 | } 21 | 22 | func TestMain(m *testing.M) { 23 | setup() 24 | os.Exit(m.Run()) 25 | } 26 | 27 | func TestAPI_SearchSongs(t *testing.T) { 28 | result, err := client.SearchSongs(ctx, "Alan Walker") 29 | if assert.NoError(t, err) { 30 | t.Log(result) 31 | } 32 | } 33 | 34 | func TestAPI_GetSong(t *testing.T) { 35 | song, err := client.GetSong(ctx, "0015xqSa3G50mK") 36 | if assert.NoError(t, err) { 37 | t.Log(song) 38 | } 39 | } 40 | 41 | func TestAPI_GetSongURLV1(t *testing.T) { 42 | url, err := client.GetSongURLV1(ctx, "0015xqSa3G50mK", "0015xqSa3G50mK") 43 | if assert.NoError(t, err) { 44 | t.Log(url) 45 | } 46 | } 47 | 48 | func TestAPI_GetSongURLV2(t *testing.T) { 49 | url, err := client.GetSongURLV2(ctx, "0015xqSa3G50mK") 50 | if assert.NoError(t, err) { 51 | t.Log(url) 52 | } 53 | } 54 | 55 | func TestAPI_GetSongLyric(t *testing.T) { 56 | lyric, err := client.GetSongLyric(ctx, "002Zkt5S2z8JZx") 57 | if assert.NoError(t, err) { 58 | t.Log(lyric) 59 | } 60 | } 61 | 62 | func TestAPI_GetArtist(t *testing.T) { 63 | artist, err := client.GetArtist(ctx, "000Sp0Bz4JXH0o") 64 | if assert.NoError(t, err) { 65 | t.Log(artist) 66 | } 67 | } 68 | 69 | func TestAPI_GetAlbum(t *testing.T) { 70 | album, err := client.GetAlbum(ctx, "002fRO0N4FftzY") 71 | if assert.NoError(t, err) { 72 | t.Log(album) 73 | } 74 | } 75 | 76 | func TestAPI_GetPlaylist(t *testing.T) { 77 | playlist, err := client.GetPlaylist(ctx, "5474239760") 78 | if assert.NoError(t, err) { 79 | t.Log(playlist) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pkg/provider/xiami/account.go: -------------------------------------------------------------------------------- 1 | package xiami 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "fmt" 8 | 9 | "github.com/winterssy/ghttp" 10 | ) 11 | 12 | // 登录接口,account 可为邮箱/手机号码 13 | func (a *API) LoginRaw(ctx context.Context, account string, password string) (*LoginResponse, error) { 14 | token, err := a.getToken(apiLogin) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | passwordHash := md5.Sum([]byte(password)) 20 | password = hex.EncodeToString(passwordHash[:]) 21 | model := map[string]string{ 22 | "account": account, 23 | "password": password, 24 | } 25 | params := signPayload(token, model) 26 | 27 | resp := new(LoginResponse) 28 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiLogin) 29 | req.SetQuery(params) 30 | req.SetContext(ctx) 31 | r, err := a.SendRequest(req) 32 | if err == nil { 33 | err = r.JSON(resp) 34 | } 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | err = resp.check() 40 | if err != nil { 41 | return nil, fmt.Errorf("login: %s", err.Error()) 42 | } 43 | 44 | return resp, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/provider/xiami/album.go: -------------------------------------------------------------------------------- 1 | package xiami 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetAlbum(ctx context.Context, albumId string) (*api.Collection, error) { 15 | resp, err := a.GetAlbumRaw(ctx, albumId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | _songs := resp.Data.Data.AlbumDetail.Songs 21 | n := len(_songs) 22 | if n == 0 { 23 | return nil, errors.New("get album: no data") 24 | } 25 | 26 | a.patchSongsLyric(ctx, _songs...) 27 | songs := translate(_songs...) 28 | return &api.Collection{ 29 | Id: resp.Data.Data.AlbumDetail.AlbumId, 30 | Name: strings.TrimSpace(resp.Data.Data.AlbumDetail.AlbumName), 31 | PicURL: resp.Data.Data.AlbumDetail.AlbumLogo, 32 | Songs: songs, 33 | }, nil 34 | } 35 | 36 | // 获取专辑 37 | func (a *API) GetAlbumRaw(ctx context.Context, albumId string) (*AlbumResponse, error) { 38 | token, err := a.getToken(apiGetAlbum) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | model := make(map[string]string) 44 | _, err = strconv.Atoi(albumId) 45 | if err != nil { 46 | model["albumStringId"] = albumId 47 | } else { 48 | model["albumId"] = albumId 49 | } 50 | params := signPayload(token, model) 51 | 52 | resp := new(AlbumResponse) 53 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetAlbum) 54 | req.SetQuery(params) 55 | req.SetContext(ctx) 56 | r, err := a.SendRequest(req) 57 | if err == nil { 58 | err = r.JSON(resp) 59 | } 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | err = resp.check() 65 | if err != nil { 66 | return nil, fmt.Errorf("get album: %s", err.Error()) 67 | } 68 | 69 | return resp, nil 70 | } 71 | -------------------------------------------------------------------------------- /pkg/provider/xiami/artist.go: -------------------------------------------------------------------------------- 1 | package xiami 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/winterssy/ghttp" 11 | "github.com/winterssy/mxget/pkg/api" 12 | ) 13 | 14 | func (a *API) GetArtist(ctx context.Context, artistId string) (*api.Collection, error) { 15 | artistInfo, err := a.GetArtistInfoRaw(ctx, artistId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | artistSongs, err := a.GetArtistSongsRaw(ctx, artistId, 1, 50) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | _songs := artistSongs.Data.Data.Songs 26 | n := len(_songs) 27 | if n == 0 { 28 | return nil, errors.New("get artist songs: no data") 29 | } 30 | 31 | a.patchSongsLyric(ctx, _songs...) 32 | songs := translate(_songs...) 33 | return &api.Collection{ 34 | Id: artistInfo.Data.Data.ArtistDetailVO.ArtistId, 35 | Name: strings.TrimSpace(artistInfo.Data.Data.ArtistDetailVO.ArtistName), 36 | PicURL: artistInfo.Data.Data.ArtistDetailVO.ArtistLogo, 37 | Songs: songs, 38 | }, nil 39 | } 40 | 41 | // 获取歌手信息 42 | func (a *API) GetArtistInfoRaw(ctx context.Context, artistId string) (*ArtistInfoResponse, error) { 43 | token, err := a.getToken(apiGetArtistInfo) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | model := make(map[string]string) 49 | _, err = strconv.Atoi(artistId) 50 | if err != nil { 51 | model["artistStringId"] = artistId 52 | } else { 53 | model["artistId"] = artistId 54 | } 55 | params := signPayload(token, model) 56 | 57 | resp := new(ArtistInfoResponse) 58 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtistInfo) 59 | req.SetQuery(params) 60 | req.SetContext(ctx) 61 | r, err := a.SendRequest(req) 62 | if err == nil { 63 | err = r.JSON(resp) 64 | } 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | err = resp.check() 70 | if err != nil { 71 | return nil, fmt.Errorf("get artist info: %s", err.Error()) 72 | } 73 | 74 | return resp, nil 75 | } 76 | 77 | // 获取歌手歌曲 78 | func (a *API) GetArtistSongsRaw(ctx context.Context, artistId string, page int, pageSize int) (*ArtistSongsResponse, error) { 79 | token, err := a.getToken(apiGetArtistSongs) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | model := map[string]interface{}{ 85 | "pagingVO": map[string]int{ 86 | "page": page, 87 | "pageSize": pageSize, 88 | }, 89 | } 90 | _, err = strconv.Atoi(artistId) 91 | if err != nil { 92 | model["artistStringId"] = artistId 93 | } else { 94 | model["artistId"] = artistId 95 | } 96 | params := signPayload(token, model) 97 | 98 | resp := new(ArtistSongsResponse) 99 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetArtistSongs) 100 | req.SetQuery(params) 101 | req.SetContext(ctx) 102 | r, err := a.SendRequest(req) 103 | if err == nil { 104 | err = r.JSON(resp) 105 | } 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | err = resp.check() 111 | if err != nil { 112 | return nil, fmt.Errorf("get artist songs: %s", err.Error()) 113 | } 114 | 115 | return resp, nil 116 | } 117 | -------------------------------------------------------------------------------- /pkg/provider/xiami/crypto.go: -------------------------------------------------------------------------------- 1 | package xiami 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/winterssy/ghttp" 9 | "github.com/winterssy/gjson" 10 | ) 11 | 12 | const ( 13 | APPKey = "23649156" 14 | ) 15 | 16 | var ( 17 | reqHeader = map[string]interface{}{ 18 | "appId": 200, 19 | "platformId": "h5", 20 | } 21 | ) 22 | 23 | func signPayload(token string, model interface{}) ghttp.Params { 24 | payload := map[string]interface{}{ 25 | "header": reqHeader, 26 | "model": model, 27 | } 28 | requestStr, _ := gjson.MarshalToString(payload) 29 | data := map[string]string{ 30 | "requestStr": requestStr, 31 | } 32 | dataStr, _ := gjson.MarshalToString(data) 33 | 34 | t := time.Now().UnixNano() / (1e6) 35 | signStr := fmt.Sprintf("%s&%d&%s&%s", token, t, APPKey, dataStr) 36 | sign := fmt.Sprintf("%x", md5.Sum([]byte(signStr))) 37 | 38 | return ghttp.Params{ 39 | "t": t, 40 | "sign": sign, 41 | "data": dataStr, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/provider/xiami/playlist.go: -------------------------------------------------------------------------------- 1 | package xiami 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/winterssy/ghttp" 12 | "github.com/winterssy/mxget/pkg/api" 13 | "github.com/winterssy/mxget/pkg/utils" 14 | ) 15 | 16 | func (a *API) GetPlaylist(ctx context.Context, playlistId string) (*api.Collection, error) { 17 | resp, err := a.GetPlaylistDetailRaw(ctx, playlistId, 1, songRequestLimit) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | n, _ := strconv.Atoi(resp.Data.Data.CollectDetail.SongCount) 23 | if n == 0 { 24 | return nil, errors.New("get playlist: no data") 25 | } 26 | 27 | _songs := resp.Data.Data.CollectDetail.Songs 28 | if n > songRequestLimit { 29 | allSongs := resp.Data.Data.CollectDetail.AllSongs 30 | queue := make(chan []*Song) 31 | wg := new(sync.WaitGroup) 32 | for i := songRequestLimit; i < n; i += songRequestLimit { 33 | if ctx.Err() != nil { 34 | break 35 | } 36 | 37 | songIds := allSongs[i:utils.Min(i+songRequestLimit, n)] 38 | wg.Add(1) 39 | go func() { 40 | resp, err := a.GetSongsRaw(ctx, songIds...) 41 | if err != nil { 42 | wg.Done() 43 | return 44 | } 45 | queue <- resp.Data.Data.Songs 46 | }() 47 | } 48 | 49 | go func() { 50 | for s := range queue { 51 | _songs = append(_songs, s...) 52 | wg.Done() 53 | } 54 | }() 55 | wg.Wait() 56 | } 57 | 58 | a.patchSongsLyric(ctx, _songs...) 59 | songs := translate(_songs...) 60 | return &api.Collection{ 61 | Id: resp.Data.Data.CollectDetail.ListId, 62 | Name: strings.TrimSpace(resp.Data.Data.CollectDetail.CollectName), 63 | PicURL: resp.Data.Data.CollectDetail.CollectLogo, 64 | Songs: songs, 65 | }, nil 66 | } 67 | 68 | // 获取歌单详情,包含歌单信息跟歌曲 69 | func (a *API) GetPlaylistDetailRaw(ctx context.Context, playlistId string, page int, pageSize int) (*PlaylistDetailResponse, error) { 70 | token, err := a.getToken(apiGetPlaylistDetail) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | model := map[string]interface{}{ 76 | "listId": playlistId, 77 | "pagingVO": map[string]int{ 78 | "page": page, 79 | "pageSize": pageSize, 80 | }, 81 | } 82 | params := signPayload(token, model) 83 | 84 | resp := new(PlaylistDetailResponse) 85 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetPlaylistDetail) 86 | req.SetQuery(params) 87 | req.SetContext(ctx) 88 | r, err := a.SendRequest(req) 89 | if err == nil { 90 | err = r.JSON(resp) 91 | } 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | err = resp.check() 97 | if err != nil { 98 | return nil, fmt.Errorf("get playlist detail: %s", err.Error()) 99 | } 100 | 101 | return resp, nil 102 | } 103 | 104 | // 获取歌单歌曲,不包含歌单信息 105 | func (a *API) GetPlaylistSongsRaw(ctx context.Context, playlistId string, page int, pageSize int) (*PlaylistSongsResponse, error) { 106 | token, err := a.getToken(apiGetPlaylistSongs) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | model := map[string]interface{}{ 112 | "listId": playlistId, 113 | "pagingVO": map[string]int{ 114 | "page": page, 115 | "pageSize": pageSize, 116 | }, 117 | } 118 | params := signPayload(token, model) 119 | 120 | resp := new(PlaylistSongsResponse) 121 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetPlaylistSongs) 122 | req.SetQuery(params) 123 | req.SetContext(ctx) 124 | r, err := a.SendRequest(req) 125 | if err == nil { 126 | err = r.JSON(resp) 127 | } 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | err = resp.check() 133 | if err != nil { 134 | return nil, fmt.Errorf("get playlist songs: %s", err.Error()) 135 | } 136 | 137 | return resp, nil 138 | } 139 | -------------------------------------------------------------------------------- /pkg/provider/xiami/search.go: -------------------------------------------------------------------------------- 1 | package xiami 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) SearchSongs(ctx context.Context, keyword string) ([]*api.Song, error) { 14 | resp, err := a.SearchSongsRaw(ctx, keyword, 1, 50) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | n := len(resp.Data.Data.Songs) 20 | if n == 0 { 21 | return nil, errors.New("search songs: no data") 22 | } 23 | 24 | songs := make([]*api.Song, n) 25 | for i, s := range resp.Data.Data.Songs { 26 | songs[i] = &api.Song{ 27 | Id: s.SongId, 28 | Name: strings.TrimSpace(s.SongName), 29 | Artist: strings.TrimSpace(strings.ReplaceAll(s.Singers, " / ", "/")), 30 | Album: strings.TrimSpace(s.AlbumName), 31 | } 32 | } 33 | return songs, nil 34 | } 35 | 36 | // 搜索歌曲 37 | func (a *API) SearchSongsRaw(ctx context.Context, keyword string, page int, pageSize int) (*SearchSongsResponse, error) { 38 | token, err := a.getToken(apiSearch) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | model := map[string]interface{}{ 44 | "key": keyword, 45 | "pagingVO": map[string]int{ 46 | "page": page, 47 | "pageSize": pageSize, 48 | }, 49 | } 50 | params := signPayload(token, model) 51 | 52 | resp := new(SearchSongsResponse) 53 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiSearch) 54 | req.SetQuery(params) 55 | req.SetContext(ctx) 56 | r, err := a.SendRequest(req) 57 | if err == nil { 58 | err = r.JSON(resp) 59 | } 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | err = resp.check() 65 | if err != nil { 66 | return nil, fmt.Errorf("search songs: %s", err.Error()) 67 | } 68 | 69 | return resp, nil 70 | } 71 | -------------------------------------------------------------------------------- /pkg/provider/xiami/song.go: -------------------------------------------------------------------------------- 1 | package xiami 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | ) 12 | 13 | func (a *API) GetSong(ctx context.Context, songId string) (*api.Song, error) { 14 | resp, err := a.GetSongDetailRaw(ctx, songId) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | _song := &resp.Data.Data.SongDetail 20 | a.patchSongsLyric(ctx, _song) 21 | songs := translate(_song) 22 | return songs[0], nil 23 | } 24 | 25 | // 获取歌曲详情 26 | func (a *API) GetSongDetailRaw(ctx context.Context, songId string) (*SongDetailResponse, error) { 27 | token, err := a.getToken(apiGetSongDetail) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | model := make(map[string]string) 33 | _, err = strconv.Atoi(songId) 34 | if err != nil { 35 | model["songStringId"] = songId 36 | } else { 37 | model["songId"] = songId 38 | } 39 | params := signPayload(token, model) 40 | 41 | resp := new(SongDetailResponse) 42 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongDetail) 43 | req.SetQuery(params) 44 | req.SetContext(ctx) 45 | r, err := a.SendRequest(req) 46 | if err == nil { 47 | err = r.JSON(resp) 48 | } 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | err = resp.check() 54 | if err != nil { 55 | return nil, fmt.Errorf("get song detail: %s", err.Error()) 56 | } 57 | 58 | return resp, nil 59 | } 60 | 61 | func (a *API) GetSongLyric(ctx context.Context, songId string) (string, error) { 62 | resp, err := a.GetSongLyricRaw(ctx, songId) 63 | if err != nil { 64 | return "", err 65 | } 66 | 67 | for _, i := range resp.Data.Data.Lyrics { 68 | if i.FlagOfficial == "1" && i.Type == "2" { 69 | return i.Content, nil 70 | } 71 | } 72 | 73 | return "", errors.New("official lyric not present") 74 | } 75 | 76 | // 获取歌词 77 | func (a *API) GetSongLyricRaw(ctx context.Context, songId string) (*SongLyricResponse, error) { 78 | token, err := a.getToken(apiGetSongLyric) 79 | if err != nil { 80 | panic(err) 81 | } 82 | 83 | model := make(map[string]string) 84 | _, err = strconv.Atoi(songId) 85 | if err != nil { 86 | model["songStringId"] = songId 87 | } else { 88 | model["songId"] = songId 89 | } 90 | params := signPayload(token, model) 91 | 92 | resp := new(SongLyricResponse) 93 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongLyric) 94 | req.SetQuery(params) 95 | req.SetContext(ctx) 96 | r, err := a.SendRequest(req) 97 | if err == nil { 98 | err = r.JSON(resp) 99 | } 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | err = resp.check() 105 | if err != nil { 106 | return nil, fmt.Errorf("get song lyric: %s", err.Error()) 107 | } 108 | 109 | return resp, nil 110 | } 111 | 112 | // 批量获取歌曲,上限200首 113 | func (a *API) GetSongsRaw(ctx context.Context, songIds ...string) (*SongsResponse, error) { 114 | token, err := a.getToken(apiGetSongs) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | if len(songIds) > songRequestLimit { 120 | songIds = songIds[:songRequestLimit] 121 | } 122 | model := map[string][]string{ 123 | "songIds": songIds, 124 | } 125 | params := signPayload(token, model) 126 | 127 | resp := new(SongsResponse) 128 | req, _ := ghttp.NewRequest(ghttp.MethodGet, apiGetSongs) 129 | req.SetQuery(params) 130 | req.SetContext(ctx) 131 | r, err := a.SendRequest(req) 132 | if err == nil { 133 | err = r.JSON(resp) 134 | } 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | err = resp.check() 140 | if err != nil { 141 | return nil, fmt.Errorf("get songs: %s", err.Error()) 142 | } 143 | 144 | return resp, nil 145 | } 146 | -------------------------------------------------------------------------------- /pkg/provider/xiami/xiami.go: -------------------------------------------------------------------------------- 1 | package xiami 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/winterssy/ghttp" 10 | "github.com/winterssy/mxget/pkg/api" 11 | "github.com/winterssy/mxget/pkg/concurrency" 12 | "github.com/winterssy/mxget/pkg/request" 13 | "github.com/winterssy/mxget/pkg/utils" 14 | ) 15 | 16 | const ( 17 | apiSearch = "https://acs.m.xiami.com/h5/mtop.alimusic.search.searchservice.searchsongs/1.0/?appKey=23649156" 18 | apiGetSongDetail = "https://acs.m.xiami.com/h5/mtop.alimusic.music.songservice.getsongdetail/1.0/?appKey=23649156" 19 | apiGetSongLyric = "https://acs.m.xiami.com/h5/mtop.alimusic.music.lyricservice.getsonglyrics/1.0/?appKey=23649156" 20 | apiGetSongs = "https://acs.m.xiami.com/h5/mtop.alimusic.music.songservice.getsongs/1.0/?appKey=23649156" 21 | apiGetArtistInfo = "https://acs.m.xiami.com/h5/mtop.alimusic.music.artistservice.getartistdetail/1.0/?appKey=23649156" 22 | apiGetArtistSongs = "https://acs.m.xiami.com/h5/mtop.alimusic.music.songservice.getartistsongs/1.0/?appKey=23649156" 23 | apiGetAlbum = "https://acs.m.xiami.com/h5/mtop.alimusic.music.albumservice.getalbumdetail/1.0/?appKey=23649156" 24 | apiGetPlaylistDetail = "https://h5api.m.xiami.com/h5/mtop.alimusic.music.list.collectservice.getcollectdetail/1.0/?appKey=23649156" 25 | apiGetPlaylistSongs = "https://h5api.m.xiami.com/h5/mtop.alimusic.music.list.collectservice.getcollectsongs/1.0/?appKey=23649156" 26 | apiLogin = "https://h5api.m.xiami.com/h5/mtop.alimusic.xuser.facade.xiamiuserservice.login/1.0/?appKey=23649156" 27 | 28 | songRequestLimit = 200 29 | ) 30 | 31 | var ( 32 | std = New(request.DefaultClient) 33 | 34 | defaultHeaders = ghttp.Headers{ 35 | "Origin": "https://h.xiami.com", 36 | "Referer": "https://h.xiami.com", 37 | } 38 | ) 39 | 40 | type ( 41 | CommonResponse struct { 42 | API string `json:"api"` 43 | Ret []string `json:"ret"` 44 | } 45 | 46 | PagingVO struct { 47 | Count string `json:"count"` 48 | Page string `json:"page"` 49 | PageSize string `json:"pageSize"` 50 | Pages string `json:"pages"` 51 | } 52 | 53 | ListenFile struct { 54 | Expire string `json:"expire,omitempty"` 55 | FileSize string `json:"fileSize"` 56 | Format string `json:"format"` 57 | ListenFile string `json:"listenFile,omitempty"` 58 | Quality string `json:"quality"` 59 | URL string `json:"url,omitempty"` 60 | } 61 | 62 | Song struct { 63 | Album 64 | SongId string `json:"songId"` 65 | SongStringId string `json:"songStringId"` 66 | SongName string `json:"songName"` 67 | Singers string `json:"singers"` 68 | SingerVOs []Artist `json:"singerVOs"` 69 | ListenFile string `json:"listenFile,omitempty"` 70 | ListenFiles []ListenFile `json:"listenFiles"` 71 | Lyric string `json:"-"` 72 | } 73 | 74 | SearchSongsResponse struct { 75 | CommonResponse 76 | Data struct { 77 | Data struct { 78 | PagingVO PagingVO `json:"pagingVO"` 79 | Songs []*Song `json:"songs"` 80 | } `json:"data"` 81 | } `json:"data"` 82 | } 83 | 84 | SongDetailResponse struct { 85 | CommonResponse 86 | Data struct { 87 | Data struct { 88 | SongDetail Song `json:"songDetail"` 89 | } `json:"data"` 90 | } `json:"data"` 91 | } 92 | 93 | SongLyricResponse struct { 94 | CommonResponse 95 | Data struct { 96 | Data struct { 97 | Lyrics []struct { 98 | Content string `json:"content"` 99 | FlagOfficial string `json:"flagOfficial"` 100 | LyricURL string `json:"lyricUrl"` 101 | Type string `json:"type"` 102 | } `json:"lyrics"` 103 | } `json:"data"` 104 | } `json:"data"` 105 | } 106 | 107 | SongsResponse struct { 108 | CommonResponse 109 | Data struct { 110 | Data struct { 111 | Songs []*Song `json:"songs"` 112 | } `json:"data"` 113 | } `json:"data"` 114 | } 115 | 116 | Artist struct { 117 | ArtistId string `json:"artistId"` 118 | ArtistStringId string `json:"artistStringId"` 119 | ArtistName string `json:"artistName"` 120 | ArtistLogo string `json:"artistLogo"` 121 | } 122 | 123 | ArtistInfoResponse struct { 124 | CommonResponse 125 | Data struct { 126 | Data struct { 127 | ArtistDetailVO Artist `json:"artistDetailVO"` 128 | } `json:"data"` 129 | } `json:"data"` 130 | } 131 | 132 | ArtistSongsResponse struct { 133 | CommonResponse 134 | Data struct { 135 | Data struct { 136 | PagingVO PagingVO `json:"pagingVO"` 137 | Songs []*Song `json:"songs"` 138 | } `json:"data"` 139 | } `json:"data"` 140 | } 141 | 142 | Album struct { 143 | AlbumId string `json:"albumId"` 144 | AlbumStringId string `json:"albumStringId"` 145 | AlbumName string `json:"albumName"` 146 | AlbumLogo string `json:"albumLogo"` 147 | } 148 | 149 | AlbumResponse struct { 150 | CommonResponse 151 | Data struct { 152 | Data struct { 153 | AlbumDetail struct { 154 | Album 155 | Songs []*Song `json:"songs"` 156 | } `json:"albumDetail"` 157 | } `json:"data"` 158 | } `json:"data"` 159 | } 160 | 161 | PlaylistDetailResponse struct { 162 | CommonResponse 163 | Data struct { 164 | Data struct { 165 | CollectDetail struct { 166 | ListId string `json:"listId"` 167 | CollectName string `json:"collectName"` 168 | CollectLogo string `json:"collectLogo"` 169 | SongCount string `json:"songCount"` 170 | AllSongs []string `json:"allSongs"` 171 | Songs []*Song `json:"songs"` 172 | PagingVO PagingVO `json:"pagingVO"` 173 | } `json:"collectDetail"` 174 | } `json:"data"` 175 | } `json:"data"` 176 | } 177 | 178 | PlaylistSongsResponse struct { 179 | CommonResponse 180 | Data struct { 181 | Data struct { 182 | Songs []*Song `json:"songs"` 183 | PagingVO PagingVO `json:"pagingVO"` 184 | } `json:"data"` 185 | } `json:"data"` 186 | } 187 | 188 | LoginResponse struct { 189 | CommonResponse 190 | Data struct { 191 | Data struct { 192 | AccessToken string `json:"accessToken"` 193 | Expires string `json:"expires"` 194 | NickName string `json:"nickName"` 195 | RefreshExpires string `json:"refreshExpires"` 196 | RefreshToken string `json:"refreshToken"` 197 | UserId string `json:"userId"` 198 | } `json:"data"` 199 | } `json:"data"` 200 | } 201 | 202 | API struct { 203 | Client *ghttp.Client 204 | } 205 | ) 206 | 207 | func New(client *ghttp.Client) *API { 208 | return &API{ 209 | Client: client, 210 | } 211 | } 212 | 213 | func Client() *API { 214 | return std 215 | } 216 | 217 | func (c *CommonResponse) check() error { 218 | for _, s := range c.Ret { 219 | if strings.HasPrefix(s, "FAIL") { 220 | return errors.New(s) 221 | } 222 | } 223 | return nil 224 | } 225 | 226 | func (s *SearchSongsResponse) String() string { 227 | return utils.PrettyJSON(s) 228 | } 229 | 230 | func (s *SongDetailResponse) String() string { 231 | return utils.PrettyJSON(s) 232 | } 233 | 234 | func (s *SongsResponse) String() string { 235 | return utils.PrettyJSON(s) 236 | } 237 | 238 | func (a *ArtistInfoResponse) String() string { 239 | return utils.PrettyJSON(a) 240 | } 241 | 242 | func (a *ArtistSongsResponse) String() string { 243 | return utils.PrettyJSON(a) 244 | } 245 | 246 | func (a *AlbumResponse) String() string { 247 | return utils.PrettyJSON(a) 248 | } 249 | 250 | func (p *PlaylistDetailResponse) String() string { 251 | return utils.PrettyJSON(p) 252 | } 253 | 254 | func (p *PlaylistSongsResponse) String() string { 255 | return utils.PrettyJSON(p) 256 | } 257 | 258 | func (l *LoginResponse) String() string { 259 | return utils.PrettyJSON(l) 260 | } 261 | 262 | func (a *API) SendRequest(req *ghttp.Request) (*ghttp.Response, error) { 263 | req.SetHeaders(defaultHeaders) 264 | return a.Client.Do(req) 265 | } 266 | 267 | func (a *API) getToken(url string) (string, error) { 268 | const XiaMiToken = "_m_h5_tk" 269 | token, err := a.Client.Cookie(url, XiaMiToken) 270 | if err != nil { 271 | // 如果在cookie jar中没有找到对应cookie,发送预请求获取 272 | req, _ := ghttp.NewRequest(ghttp.MethodGet, url) 273 | var resp *ghttp.Response 274 | resp, err = a.SendRequest(req) 275 | if err == nil { 276 | token, err = resp.Cookie(XiaMiToken) 277 | } 278 | } 279 | 280 | if err != nil { 281 | return "", fmt.Errorf("can't get token: %s", err.Error()) 282 | } 283 | 284 | return strings.Split(token.Value, "_")[0], nil 285 | } 286 | 287 | func (a *API) patchSongsLyric(ctx context.Context, songs ...*Song) { 288 | c := concurrency.New(32) 289 | for _, s := range songs { 290 | if ctx.Err() != nil { 291 | break 292 | } 293 | 294 | c.Add(1) 295 | go func(s *Song) { 296 | lyric, err := a.GetSongLyric(ctx, s.SongId) 297 | if err == nil { 298 | s.Lyric = lyric 299 | } 300 | c.Done() 301 | }(s) 302 | } 303 | c.Wait() 304 | } 305 | 306 | func songURL(listenFiles []ListenFile) string { 307 | for _, i := range listenFiles { 308 | if i.Quality == "l" { 309 | return i.URL + i.ListenFile 310 | } 311 | } 312 | return "" 313 | } 314 | 315 | func translate(src ...*Song) []*api.Song { 316 | songs := make([]*api.Song, len(src)) 317 | for i, s := range src { 318 | url := songURL(s.ListenFiles) 319 | songs[i] = &api.Song{ 320 | Id: s.SongId, 321 | Name: strings.TrimSpace(s.SongName), 322 | Artist: strings.TrimSpace(strings.ReplaceAll(s.Singers, " / ", "/")), 323 | Album: strings.TrimSpace(s.AlbumName), 324 | PicURL: s.AlbumLogo, 325 | Lyric: s.Lyric, 326 | ListenURL: url, 327 | } 328 | } 329 | return songs 330 | } 331 | -------------------------------------------------------------------------------- /pkg/provider/xiami/xiami_test.go: -------------------------------------------------------------------------------- 1 | package xiami_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/winterssy/mxget/pkg/provider/xiami" 10 | ) 11 | 12 | var ( 13 | client *xiami.API 14 | ctx context.Context 15 | ) 16 | 17 | func setup() { 18 | client = xiami.Client() 19 | ctx = context.Background() 20 | } 21 | 22 | func TestMain(m *testing.M) { 23 | setup() 24 | os.Exit(m.Run()) 25 | } 26 | 27 | func TestAPI_SearchSongs(t *testing.T) { 28 | result, err := client.SearchSongs(ctx, "五月天") 29 | if assert.NoError(t, err) { 30 | t.Log(result) 31 | } 32 | } 33 | 34 | func TestAPI_GetSong(t *testing.T) { 35 | song, err := client.GetSong(ctx, "xMPr7Lbbb28") 36 | if assert.NoError(t, err) { 37 | t.Log(song) 38 | } 39 | } 40 | 41 | func TestAPI_GetSongLyric(t *testing.T) { 42 | lyric, err := client.GetSongLyric(ctx, "xMPr7Lbbb28") 43 | if assert.NoError(t, err) { 44 | t.Log(lyric) 45 | } 46 | } 47 | 48 | func TestAPI_GetArtist(t *testing.T) { 49 | artist, err := client.GetArtist(ctx, "3110") 50 | if assert.NoError(t, err) { 51 | t.Log(artist) 52 | } 53 | } 54 | 55 | func TestAPI_GetAlbum(t *testing.T) { 56 | album, err := client.GetAlbum(ctx, "nmTM4c70144") 57 | if assert.NoError(t, err) { 58 | t.Log(album) 59 | } 60 | } 61 | 62 | func TestAPI_GetPlaylist(t *testing.T) { 63 | playlist, err := client.GetPlaylist(ctx, "8007523") 64 | if assert.NoError(t, err) { 65 | t.Log(playlist) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pkg/request/request.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "github.com/winterssy/ghttp" 5 | ) 6 | 7 | const ( 8 | defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36" 9 | ) 10 | 11 | var ( 12 | DefaultClient *ghttp.Client 13 | ) 14 | 15 | func init() { 16 | DefaultClient = ghttp.New() 17 | DefaultClient.RegisterBeforeRequestCallbacks( 18 | ghttp.WithUserAgent(defaultUserAgent), 19 | ghttp.EnableRetry(), 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | "unsafe" 10 | 11 | "github.com/winterssy/gjson" 12 | ) 13 | 14 | var ( 15 | re = regexp.MustCompile(`[\\/:*?"<>|]`) 16 | ) 17 | 18 | func TrimInvalidFilePathChars(path string) string { 19 | return strings.TrimSpace(re.ReplaceAllString(path, " ")) 20 | } 21 | 22 | func BytesToString(b []byte) string { 23 | return *(*string)(unsafe.Pointer(&b)) 24 | } 25 | 26 | func PrettyJSON(v interface{}) string { 27 | s, err := gjson.EncodeToString(v, func(enc *gjson.Encoder) { 28 | enc.SetIndent("", "\t") 29 | enc.SetEscapeHTML(false) 30 | }) 31 | if err != nil { 32 | return "{}" 33 | } 34 | return s 35 | } 36 | 37 | func Min(a, b int) int { 38 | if a < b { 39 | return a 40 | } 41 | return b 42 | } 43 | 44 | func Max(a, b int) int { 45 | if a > b { 46 | return a 47 | } 48 | return b 49 | } 50 | 51 | func Input(prompt string) string { 52 | reader := bufio.NewReader(os.Stdin) 53 | fmt.Printf("%s: ", prompt) 54 | text, _ := reader.ReadString('\n') 55 | input := strings.TrimSpace(text) 56 | if input == "" { 57 | return Input(prompt) 58 | } 59 | return input 60 | } 61 | --------------------------------------------------------------------------------