File size: 7,013 Bytes
00a912e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
import ncmApi from '@neteasecloudmusicapienhanced/api'
import type { Playlist } from '@music-together/shared'
import type { GetUserInfoResult } from './authProvider.js'
import { logger } from '../utils/logger.js'

interface NeteaseVipInfoApi {
  vip_info_v2(params: { uid: number; cookie: string; timestamp: number }): Promise<{
    body?: { data?: Record<string, unknown> }
  }>
}

const NETEASE_VIP_LEVEL_NAMES: Record<number, string> = {
  1: '壹',
  2: '贰',
  3: '叁',
  4: '肆',
  5: '伍',
  6: '陆',
  7: '柒',
}

export function formatNeteaseVipLabel(vipType: number, vipLevel?: number): string | undefined {
  if (vipType <= 0) return undefined
  const tier = vipType >= 2 ? 'SVIP' : 'VIP'
  const level = vipLevel ? NETEASE_VIP_LEVEL_NAMES[vipLevel] : undefined
  return level ? `${tier}·${level}` : tier
}

export function parseNeteaseMembership(
  profileVipType: number,
  vipData: Record<string, unknown> | undefined,
  now = Date.now(),
): { vipType: 0 | 1 | 2; vipLabel?: string; vipLevel?: number } {
  const redplusValue = vipData?.redplus ?? vipData?.redPlus
  const redplus =
    redplusValue && typeof redplusValue === 'object' ? (redplusValue as Record<string, unknown>) : undefined
  const expireTime = Number(redplus?.expireTime ?? redplus?.expire_time ?? 0)
  const isSvip =
    Boolean(redplus) &&
    Number(redplus?.vipCode ?? redplus?.vip_code ?? 0) > 0 &&
    (expireTime > now || redplus?.isVip === true || Number(redplus?.vipStatus ?? 0) === 1)
  const rawVipLevel = Number(vipData?.redVipLevel ?? vipData?.red_vip_level ?? 0)
  const vipType = isSvip ? 2 : profileVipType > 0 ? 1 : 0
  const vipLevel = vipType > 0 && Number.isInteger(rawVipLevel) && rawVipLevel > 0 ? rawVipLevel : undefined

  return {
    vipType,
    vipLabel: formatNeteaseVipLabel(vipType, vipLevel),
    vipLevel,
  }
}

/**
 * 网易云音乐认证服务
 * 封装 @neteasecloudmusicapienhanced/api 实现 QR 登录和 Cookie 验证
 * 实现 AuthProvider 接口
 */

// ---------------------------------------------------------------------------
// QR Code 登录
// ---------------------------------------------------------------------------

/**
 * 生成网易云音乐扫码登录二维码
 */
export async function generateQrCode(): Promise<{ key: string; qrimg: string } | null> {
  try {
    const keyRes = await ncmApi.login_qr_key({ timestamp: Date.now() })
    const key = keyRes?.body?.data?.unikey
    if (!key) {
      logger.error('Netease QR: failed to get unikey', keyRes?.body)
      return null
    }

    const qrRes = await ncmApi.login_qr_create({ key, qrimg: true, timestamp: Date.now() })
    const qrimg = qrRes?.body?.data?.qrimg
    if (!qrimg) {
      logger.error('Netease QR: failed to generate QR image', qrRes?.body)
      return null
    }

    logger.info('网易云音乐登录二维码已生成')
    return { key, qrimg }
  } catch (err) {
    logger.error('Netease QR generation failed', err)
    return null
  }
}

/**
 * 检查网易云扫码状态
 * 状态码:800=过期, 801=等待扫码, 802=已扫码待确认, 803=登录成功
 */
export async function checkQrStatus(key: string): Promise<{
  status: number
  message: string
  cookie?: string
}> {
  try {
    const res = await ncmApi.login_qr_check({ key, timestamp: Date.now() })
    const code = res?.body?.code ?? 800
    const cookie = res?.body?.cookie

    const messages: Record<number, string> = {
      800: '二维码已过期,请重新获取',
      801: '等待扫码',
      802: '已扫码,等待确认',
      803: '登录成功',
    }

    return {
      status: code,
      message: messages[code] ?? `未知状态 (${code})`,
      cookie: code === 803 ? cookie : undefined,
    }
  } catch (err) {
    logger.error('Netease QR check failed', err)
    return { status: 800, message: '检查状态失败' }
  }
}

// ---------------------------------------------------------------------------
// Cookie 验证 & 用户信息
// ---------------------------------------------------------------------------

/**
 * 验证 Cookie 并获取用户信息
 * 区分「Cookie 已过期」和「临时故障」,以便调用方决定是否移除 Cookie
 */
export async function getUserInfo(cookie: string): Promise<GetUserInfoResult> {
  try {
    const res = await ncmApi.login_status({ cookie, timestamp: Date.now() })
    const profile = res?.body?.data?.profile

    if (!profile) {
      logger.warn('Netease cookie validation: no profile in response', { responseData: res?.body?.data })
      return { ok: false, reason: 'expired' }
    }

    const profileVipType = Number(profile.vipType ?? 0)
    let vipData: Record<string, unknown> | undefined
    try {
      const vipInfoApi = ncmApi as unknown as NeteaseVipInfoApi
      const vipRes = await vipInfoApi.vip_info_v2({ uid: Number(profile.userId ?? 0), cookie, timestamp: Date.now() })
      vipData = vipRes?.body?.data
    } catch (err) {
      // Membership detail failure must not invalidate an otherwise valid login.
      logger.warn('Netease SVIP status check failed', { err })
    }
    const { vipType, vipLabel, vipLevel } = parseNeteaseMembership(profileVipType, vipData)

    return {
      ok: true,
      data: {
        nickname: profile.nickname || 'Unknown',
        vipType,
        vipLabel,
        vipLevel,
        userId: profile.userId ?? 0,
      },
    }
  } catch (err) {
    logger.error('Netease getUserInfo failed (transient error)', err)
    return { ok: false, reason: 'error' }
  }
}

// ---------------------------------------------------------------------------
// 用户歌单
// ---------------------------------------------------------------------------

/**
 * 获取用户网易云歌单列表
 */
export async function getUserPlaylists(cookie: string): Promise<Playlist[]> {
  try {
    const result = await getUserInfo(cookie)
    if (!result.ok) {
      logger.warn(`Cannot fetch playlists: cookie ${result.reason}`)
      return []
    }

    const userInfo = result.data

    const res = await ncmApi.user_playlist({
      uid: userInfo.userId,
      limit: 50,
      offset: 0,
      cookie,
      timestamp: Date.now(),
    })

    const playlists = res?.body?.playlist
    if (!Array.isArray(playlists)) {
      logger.warn('Netease user_playlist: unexpected response', { code: res?.body?.code })
      return []
    }

    const mapped: Playlist[] = playlists.map((p) => ({
      id: String(p.id),
      name: String(p.name || ''),
      cover: String(p.coverImgUrl || ''),
      trackCount: Number(p.trackCount ?? 0),
      source: 'netease' as const,
      creator: String(p.creator?.nickname || ''),
      description: String(p.description || ''),
    }))

    logger.info(`已获取网易云用户“${userInfo.nickname}”的 ${mapped.length} 个歌单`, {
      platform: 'netease',
      nickname: userInfo.nickname,
      count: mapped.length,
    })
    return mapped
  } catch (err) {
    logger.error('Netease getUserPlaylists failed', err)
    return []
  }
}