598 lines
14 KiB
Go
598 lines
14 KiB
Go
// Package netease implements a playlist.Provider for NetEase Cloud Music.
|
|
package netease
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
"github.com/bjarneo/cliamp/resolve"
|
|
)
|
|
|
|
var (
|
|
_ playlist.Provider = (*Provider)(nil)
|
|
_ provider.Searcher = (*Provider)(nil)
|
|
)
|
|
|
|
const (
|
|
defaultAPIBase = "https://music.163.com"
|
|
probeURL = "https://music.163.com/#/playlist?id=3778678"
|
|
apiTimeout = 15 * time.Second
|
|
// neteaseCodeOK is the application-level success code in NetEase API
|
|
// responses. It happens to share the value of HTTP 200 but is a distinct
|
|
// field, so it gets its own constant rather than comparing to http.StatusOK.
|
|
neteaseCodeOK = 200
|
|
)
|
|
|
|
// ErrNotAuthenticated is returned when browser cookies do not contain a
|
|
// signed-in NetEase Cloud Music account.
|
|
var ErrNotAuthenticated = errors.New("netease: browser session is not signed in")
|
|
|
|
// Config holds settings for the NetEase provider.
|
|
type Config struct {
|
|
Enabled bool
|
|
CookiesFrom string
|
|
UserID string
|
|
}
|
|
|
|
// IsSet reports whether the provider should be exposed.
|
|
func (c Config) IsSet() bool { return c.Enabled }
|
|
|
|
// Account describes the signed-in NetEase account visible through cookies.
|
|
type Account struct {
|
|
UserID string
|
|
Nickname string
|
|
VIPType int
|
|
}
|
|
|
|
type chartPlaylist struct {
|
|
id string
|
|
name string
|
|
}
|
|
|
|
var charts = []chartPlaylist{
|
|
{id: "3778678", name: "Hot Songs"},
|
|
{id: "3779629", name: "New Songs"},
|
|
{id: "19723756", name: "Rising Songs"},
|
|
{id: "2884035", name: "Original Songs"},
|
|
}
|
|
|
|
// Provider implements playlist.Provider and provider.Searcher.
|
|
type Provider struct {
|
|
apiBase string
|
|
httpClient *http.Client
|
|
cookiesFrom string
|
|
userID string
|
|
|
|
mu sync.Mutex
|
|
cookieHeader string
|
|
playlists []playlist.PlaylistInfo
|
|
account *Account
|
|
}
|
|
|
|
// NewFromConfig returns a provider, or nil when NetEase is not enabled.
|
|
// Sets resolve's yt-dlp cookies as a side effect when CookiesFrom is non-empty
|
|
// so URL resolution uses the same signed-in browser session.
|
|
func NewFromConfig(cfg Config) *Provider {
|
|
if !cfg.Enabled {
|
|
return nil
|
|
}
|
|
cfg.CookiesFrom = strings.TrimSpace(cfg.CookiesFrom)
|
|
if cfg.CookiesFrom != "" {
|
|
resolve.SetYTDLCookiesFrom(cfg.CookiesFrom)
|
|
}
|
|
return New(cfg)
|
|
}
|
|
|
|
// New creates a NetEase provider.
|
|
func New(cfg Config) *Provider {
|
|
return &Provider{
|
|
apiBase: defaultAPIBase,
|
|
httpClient: &http.Client{Timeout: apiTimeout},
|
|
cookiesFrom: strings.TrimSpace(cfg.CookiesFrom),
|
|
userID: strings.TrimSpace(cfg.UserID),
|
|
}
|
|
}
|
|
|
|
func newWithBase(cfg Config, base string) *Provider {
|
|
p := New(cfg)
|
|
p.apiBase = strings.TrimRight(base, "/")
|
|
return p
|
|
}
|
|
|
|
func (p *Provider) Name() string { return "NetEase Cloud Music" }
|
|
|
|
// Refresh clears cached account and playlist state.
|
|
func (p *Provider) Refresh() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.cookieHeader = ""
|
|
p.playlists = nil
|
|
p.account = nil
|
|
}
|
|
|
|
// CheckLogin verifies that the given browser has a signed-in NetEase account.
|
|
func CheckLogin(ctx context.Context, browser string) (Account, error) {
|
|
p := New(Config{Enabled: true, CookiesFrom: browser})
|
|
return p.Account(ctx)
|
|
}
|
|
|
|
// Account returns the signed-in account from browser cookies.
|
|
func (p *Provider) Account(ctx context.Context) (Account, error) {
|
|
p.mu.Lock()
|
|
if p.account != nil {
|
|
acc := *p.account
|
|
p.mu.Unlock()
|
|
return acc, nil
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
var resp accountResponse
|
|
if err := p.apiGet(ctx, "/api/nuser/account/get", nil, &resp); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if resp.Code != neteaseCodeOK {
|
|
return Account{}, fmt.Errorf("netease: account request failed with code %d", resp.Code)
|
|
}
|
|
uid := resp.Account.ID
|
|
if uid == 0 {
|
|
uid = resp.Profile.UserID
|
|
}
|
|
if uid == 0 {
|
|
return Account{}, ErrNotAuthenticated
|
|
}
|
|
acc := Account{
|
|
UserID: strconv.FormatInt(uid, 10),
|
|
Nickname: resp.Profile.Nickname,
|
|
VIPType: firstNonZero(resp.Profile.VIPType, resp.Account.VIPType),
|
|
}
|
|
|
|
p.mu.Lock()
|
|
p.account = &acc
|
|
if p.userID == "" {
|
|
p.userID = acc.UserID
|
|
}
|
|
p.mu.Unlock()
|
|
return acc, nil
|
|
}
|
|
|
|
// Playlists returns account playlists followed by public chart playlists.
|
|
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
|
|
p.mu.Lock()
|
|
if p.playlists != nil {
|
|
out := append([]playlist.PlaylistInfo(nil), p.playlists...)
|
|
p.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
userID := p.userID
|
|
p.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
|
|
defer cancel()
|
|
|
|
var infos []playlist.PlaylistInfo
|
|
if userID == "" && p.cookiesFrom != "" {
|
|
acc, err := p.Account(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
userID = acc.UserID
|
|
}
|
|
if userID != "" {
|
|
userLists, err := p.userPlaylists(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
infos = append(infos, userLists...)
|
|
}
|
|
infos = append(infos, chartPlaylists()...)
|
|
|
|
p.mu.Lock()
|
|
p.playlists = append([]playlist.PlaylistInfo(nil), infos...)
|
|
p.mu.Unlock()
|
|
return infos, nil
|
|
}
|
|
|
|
// Tracks returns tracks for a user playlist or built-in chart playlist.
|
|
func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) {
|
|
id, err := cleanPlaylistID(playlistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
|
|
defer cancel()
|
|
|
|
params := url.Values{"id": {id}}
|
|
var resp playlistDetailResponse
|
|
if err := p.apiGet(ctx, "/api/playlist/detail", params, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.Code != neteaseCodeOK {
|
|
return nil, fmt.Errorf("netease: playlist detail failed with code %d", resp.Code)
|
|
}
|
|
return songsToTracks(resp.Result.Tracks), nil
|
|
}
|
|
|
|
// SearchTracks searches NetEase songs.
|
|
func (p *Provider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
|
|
q := strings.TrimSpace(query)
|
|
if q == "" {
|
|
return nil, nil
|
|
}
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
params := url.Values{
|
|
"s": {q},
|
|
"type": {"1"},
|
|
"offset": {"0"},
|
|
"limit": {strconv.Itoa(limit)},
|
|
}
|
|
var resp searchResponse
|
|
if err := p.apiGet(ctx, "/api/search/get/web", params, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.Code != neteaseCodeOK {
|
|
return nil, fmt.Errorf("netease: search failed with code %d", resp.Code)
|
|
}
|
|
return songsToTracks(resp.Result.Songs), nil
|
|
}
|
|
|
|
func (p *Provider) userPlaylists(ctx context.Context, userID string) ([]playlist.PlaylistInfo, error) {
|
|
uid, err := strconv.ParseInt(strings.TrimSpace(userID), 10, 64)
|
|
if err != nil || uid <= 0 {
|
|
return nil, fmt.Errorf("netease: invalid user_id %q", userID)
|
|
}
|
|
const pageSize = 100
|
|
var out []playlist.PlaylistInfo
|
|
for offset := 0; ; offset += pageSize {
|
|
params := url.Values{
|
|
"uid": {strconv.FormatInt(uid, 10)},
|
|
"limit": {strconv.Itoa(pageSize)},
|
|
"offset": {strconv.Itoa(offset)},
|
|
}
|
|
var resp userPlaylistsResponse
|
|
if err := p.apiGet(ctx, "/api/user/playlist", params, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.Code != neteaseCodeOK {
|
|
return nil, fmt.Errorf("netease: playlist request failed with code %d", resp.Code)
|
|
}
|
|
for _, item := range resp.Playlist {
|
|
section := "Saved Playlists"
|
|
if item.UserID == uid {
|
|
section = "My Playlists"
|
|
}
|
|
name := strings.TrimSpace(item.Name)
|
|
if item.SpecialType == 5 {
|
|
name = "Liked Songs"
|
|
section = "My Playlists"
|
|
}
|
|
if name == "" {
|
|
name = "Untitled Playlist"
|
|
}
|
|
out = append(out, playlist.PlaylistInfo{
|
|
ID: "user:" + strconv.FormatInt(item.ID, 10),
|
|
Name: name,
|
|
TrackCount: item.TrackCount,
|
|
Section: section,
|
|
})
|
|
}
|
|
if len(resp.Playlist) < pageSize {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func chartPlaylists() []playlist.PlaylistInfo {
|
|
out := make([]playlist.PlaylistInfo, 0, len(charts))
|
|
for _, chart := range charts {
|
|
out = append(out, playlist.PlaylistInfo{
|
|
ID: "chart:" + chart.id,
|
|
Name: chart.name,
|
|
Section: "Charts",
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cleanPlaylistID(id string) (string, error) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return "", fmt.Errorf("netease: empty playlist id")
|
|
}
|
|
if v, ok := strings.CutPrefix(id, "user:"); ok {
|
|
id = v
|
|
} else if v, ok := strings.CutPrefix(id, "chart:"); ok {
|
|
id = v
|
|
}
|
|
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
|
return "", fmt.Errorf("netease: invalid playlist id %q", id)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func songsToTracks(songs []song) []playlist.Track {
|
|
tracks := make([]playlist.Track, 0, len(songs))
|
|
for _, s := range songs {
|
|
if s.ID == 0 {
|
|
continue
|
|
}
|
|
tracks = append(tracks, playlist.Track{
|
|
Path: songURL(s.ID),
|
|
Title: s.Name,
|
|
Artist: joinArtists(s.Artists),
|
|
Album: s.Album.Name,
|
|
TrackNumber: s.TrackNumber,
|
|
Stream: true,
|
|
DurationSecs: millisToSeconds(s.DurationMS),
|
|
ProviderMeta: map[string]string{provider.MetaNetEaseID: strconv.FormatInt(s.ID, 10)},
|
|
})
|
|
}
|
|
return tracks
|
|
}
|
|
|
|
func songURL(id int64) string {
|
|
return "https://music.163.com/#/song?id=" + strconv.FormatInt(id, 10)
|
|
}
|
|
|
|
func joinArtists(artists []artist) string {
|
|
if len(artists) == 0 {
|
|
return ""
|
|
}
|
|
names := make([]string, 0, len(artists))
|
|
for _, a := range artists {
|
|
if name := strings.TrimSpace(a.Name); name != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func millisToSeconds(ms int) int {
|
|
if ms <= 0 {
|
|
return 0
|
|
}
|
|
return (ms + 999) / 1000
|
|
}
|
|
|
|
func firstNonZero(values ...int) int {
|
|
for _, v := range values {
|
|
if v != 0 {
|
|
return v
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (p *Provider) apiGet(ctx context.Context, path string, params url.Values, out any) error {
|
|
endpoint, err := url.Parse(p.apiBase + path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if params != nil {
|
|
endpoint.RawQuery = params.Encode()
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
req.Header.Set("Referer", p.apiBase+"/")
|
|
if header, err := p.ensureCookieHeader(ctx); err != nil {
|
|
return err
|
|
} else if header != "" {
|
|
req.Header.Set("Cookie", header)
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("netease: http status %s", resp.Status)
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return fmt.Errorf("netease: decode response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Provider) ensureCookieHeader(ctx context.Context) (string, error) {
|
|
if p.cookiesFrom == "" {
|
|
return "", nil
|
|
}
|
|
p.mu.Lock()
|
|
if p.cookieHeader != "" {
|
|
header := p.cookieHeader
|
|
p.mu.Unlock()
|
|
return header, nil
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
header, err := extractBrowserCookieHeader(ctx, p.cookiesFrom)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
p.mu.Lock()
|
|
p.cookieHeader = header
|
|
p.mu.Unlock()
|
|
return header, nil
|
|
}
|
|
|
|
func extractBrowserCookieHeader(ctx context.Context, browser string) (string, error) {
|
|
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
|
return "", fmt.Errorf("yt-dlp not found. Install with: %s", ytDLPInstallHint())
|
|
}
|
|
tmp, err := os.CreateTemp("", "cliamp-netease-cookies-*.txt")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path := tmp.Name()
|
|
tmp.Close()
|
|
os.Remove(path)
|
|
defer os.Remove(path)
|
|
|
|
cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(cmdCtx, "yt-dlp",
|
|
"--cookies-from-browser", browser,
|
|
"--cookies", path,
|
|
"--flat-playlist",
|
|
"--playlist-end", "1",
|
|
"--socket-timeout", "15",
|
|
"--print", "title",
|
|
probeURL,
|
|
)
|
|
var stderr strings.Builder
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg != "" {
|
|
return "", fmt.Errorf("netease: load browser cookies: %s: %w", msg, err)
|
|
}
|
|
return "", fmt.Errorf("netease: load browser cookies: %w", err)
|
|
}
|
|
header, err := cookieHeaderFromNetscapeFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if header == "" {
|
|
return "", fmt.Errorf("netease: no NetEase cookies found in browser session")
|
|
}
|
|
return header, nil
|
|
}
|
|
|
|
func ytDLPInstallHint() string {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return "brew install yt-dlp"
|
|
case "linux":
|
|
if _, err := exec.LookPath("apt-get"); err == nil {
|
|
return "sudo apt install yt-dlp"
|
|
}
|
|
if _, err := exec.LookPath("pacman"); err == nil {
|
|
return "sudo pacman -S yt-dlp"
|
|
}
|
|
return "pip install yt-dlp"
|
|
case "windows":
|
|
return "winget install yt-dlp"
|
|
default:
|
|
return "pip install yt-dlp"
|
|
}
|
|
}
|
|
|
|
func cookieHeaderFromNetscapeFile(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
seen := map[string]bool{}
|
|
var pairs []string
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "# Netscape") || strings.HasPrefix(line, "# This file") {
|
|
continue
|
|
}
|
|
fields := strings.Split(line, "\t")
|
|
if len(fields) < 7 {
|
|
continue
|
|
}
|
|
domain := strings.TrimPrefix(fields[0], "#HttpOnly_")
|
|
if !isNetEaseCookieDomain(domain) {
|
|
continue
|
|
}
|
|
name := fields[5]
|
|
value := fields[6]
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
pairs = append(pairs, name+"="+value)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
return strings.Join(pairs, "; "), nil
|
|
}
|
|
|
|
func isNetEaseCookieDomain(domain string) bool {
|
|
domain = strings.TrimPrefix(strings.ToLower(domain), ".")
|
|
return domain == "163.com" || domain == "music.163.com" || strings.HasSuffix(domain, ".music.163.com")
|
|
}
|
|
|
|
type accountResponse struct {
|
|
Code int `json:"code"`
|
|
Account struct {
|
|
ID int64 `json:"id"`
|
|
VIPType int `json:"vipType"`
|
|
} `json:"account"`
|
|
Profile struct {
|
|
UserID int64 `json:"userId"`
|
|
Nickname string `json:"nickname"`
|
|
VIPType int `json:"vipType"`
|
|
} `json:"profile"`
|
|
}
|
|
|
|
type userPlaylistsResponse struct {
|
|
Code int `json:"code"`
|
|
Playlist []playlistItem `json:"playlist"`
|
|
}
|
|
|
|
type playlistItem struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
UserID int64 `json:"userId"`
|
|
TrackCount int `json:"trackCount"`
|
|
SpecialType int `json:"specialType"`
|
|
}
|
|
|
|
type playlistDetailResponse struct {
|
|
Code int `json:"code"`
|
|
Result struct {
|
|
Tracks []song `json:"tracks"`
|
|
} `json:"result"`
|
|
}
|
|
|
|
type searchResponse struct {
|
|
Code int `json:"code"`
|
|
Result struct {
|
|
Songs []song `json:"songs"`
|
|
} `json:"result"`
|
|
}
|
|
|
|
type song struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
DurationMS int `json:"duration"`
|
|
TrackNumber int `json:"no"`
|
|
Artists []artist `json:"artists"`
|
|
Album album `json:"album"`
|
|
}
|
|
|
|
type artist struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type album struct {
|
|
Name string `json:"name"`
|
|
}
|