Skip to content

Commit 196766c

Browse files
committed
Version 1.6.14
Backported `session=` keyword usage to specify session used for API Calls from the async branch (#57) Fixed M3U(8) playlist saved by `--save-m3u` failing because of the omitted TrackDownloadTask
1 parent 8570ee8 commit 196766c

10 files changed

+115
-155
lines changed

README.md

+12-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
![Logo](https://github.com/greats3an/pyncm/raw/master/demos/_logo.png)
22

33
# PyNCM
4+
第三方网易云音乐 Python API 及个人音乐库离线转储工具
5+
46
**注意** : 异步使用,请移步 [`async` 分支](https://github.com/mos9527/pyncm/tree/async)
7+
58
# 安装
69
pip install pyncm
710
可选 (若不考虑使用CLI则请忽略)
@@ -88,11 +91,10 @@
8891
|-|-|
8992
|`PYNCM_DEBUG`|调试日志输出等级,`'CRITICAL', 'DEBUG', 'ERROR','FATAL','INFO','WARNING'` 之一|
9093
### 使用示例
91-
## 下载单曲
94+
## 转储单曲
9295
[![asciicast](https://asciinema.org/a/4PEC5977rTcm4hp9jLuPFYUM1.svg)](https://asciinema.org/a/4PEC5977rTcm4hp9jLuPFYUM1)
93-
## 使用 [UNM](https://github.com/UnblockNeteaseMusic/server) 下载灰色歌曲
94-
[![asciicast](https://asciinema.org/a/AX4cdzD7YcgQlTebAdCTKZQnb.svg)](https://asciinema.org/a/AX4cdzD7YcgQlTebAdCTKZQnb)
95-
其他功能详见
96+
97+
API使用详见
9698
- [Demo](https://github.com/mos9527/pyncm/tree/master/demos)
9799

98100
# API 使用示例
@@ -119,6 +121,12 @@ with session: # 进入该 Session, 在 `with` 内的 API 将由该 Session 完
119121
# 离开 Session. 此后 API 将继续由全局 Session 管理
120122
GetTrackComments(...)
121123
```
124+
125+
同时,你也可以在 API Call 中 指定 Session
126+
```python
127+
await GetTrackComments(..., session=session)
128+
```
129+
122130
详见 [Session 说明](https://github.com/mos9527/pyncm/blob/master/pyncm/__init__.py#L52)
123131
## API 说明
124132
大部分 API 函数已经详细注释,可读性较高。推荐参阅 [API 源码](https://github.com/mos9527/pyncm/tree/master/pyncm) 获得支持
@@ -140,6 +148,3 @@ GetTrackComments(...)
140148
[Android逆向——网易云音乐排行榜api(上)](https://juejin.im/post/6844903586879520775)
141149

142150
[Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
143-
144-
### 衍生项目
145-
[PyNCMd](https://github.com/mos9527/pyncmd)

pyncm/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"""
3535
__VERSION_MAJOR__ = 1
3636
__VERSION_MINOR__ = 6
37-
__VERSION_PATCH__ = 13
37+
__VERSION_PATCH__ = 14
3838

3939
__version__ = '%s.%s.%s' % (__VERSION_MAJOR__,__VERSION_MINOR__,__VERSION_PATCH__)
4040

pyncm/__main__.py

+31-38
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from time import sleep
1919
from os.path import join, exists
2020
from os import remove, makedirs
21+
from dataclasses import dataclass
2122

2223
from logging import exception, getLogger, basicConfig
2324
import sys, argparse, re , os
@@ -40,13 +41,6 @@
4041

4142
__desc__ = """PyNCM 网易云音乐下载工具 %s""" % __version__
4243

43-
# Key-Value classes
44-
class BaseKeyValueClass:
45-
def __init__(self, **kw) -> None:
46-
for k, v in kw.items():
47-
self.__setattr__(k, v)
48-
49-
5044
class TaskPoolExecutorThread(Thread):
5145
@staticmethod
5246
def tag_audio(track: TrackHelper, file: str, cover_img: str = ""):
@@ -173,13 +167,11 @@ def __init__(self, *a, max_workers=4, **k):
173167
self.max_workers = max_workers
174168

175169
def run(self):
176-
def execute(task: BaseKeyValueClass):
177-
if type(task) == MarkerTask:
178-
# Mark a finished task w/o execution
179-
self.finished_tasks += 1
180-
return
170+
def execute(task):
181171
if type(task) == TrackDownloadTask:
182172
try:
173+
if task.skip_download:
174+
return
183175
# Downloding source audio
184176
apiCall = track.GetTrackAudioV1 if not task.routine.args.use_download_api else track.GetTrackDownloadURLV1
185177
if task.routine.args.use_download_api: logger.warning("使用下载 API,可能消耗 VIP 下载额度!")
@@ -300,36 +292,35 @@ def result_exception(self,result_id,exception : Exception,desc=None):
300292
@property
301293
def has_exceptions(self):
302294
return len(self.exceptions) > 0
303-
304-
class BaseDownloadTask(BaseKeyValueClass):
305-
id: int
306-
url: str
307-
dest: str
308-
level : str
309-
310-
295+
@dataclass
296+
class BaseDownloadTask:
297+
id: int = 0
298+
url: str = ''
299+
dest: str = ''
300+
level : str = ''
301+
302+
@dataclass
311303
class LyricsDownloadTask(BaseDownloadTask):
312-
id: int
313-
dest: str
314-
lrc_blacklist: set
315-
304+
id: int = 0
305+
dest: str = ''
306+
lrc_blacklist: set = None
316307

317-
class TrackDownloadTask(BaseKeyValueClass):
318-
song: TrackHelper
319-
cover: BaseDownloadTask
320-
lyrics: BaseDownloadTask
321-
audio: BaseDownloadTask
308+
@dataclass
309+
class TrackDownloadTask:
310+
song: TrackHelper = None
311+
cover: BaseDownloadTask = None
312+
lyrics: BaseDownloadTask = None
313+
audio: BaseDownloadTask = None
322314

323-
index: int
324-
total: int
325-
lyrics_exclude: set
326-
save_as: str
327-
extension: str
315+
index: int = 0
316+
total: int = 0
317+
lyrics_exclude: set = None
318+
save_as: str = ''
319+
extension: str = ''
328320

329-
routine : Subroutine
321+
routine : Subroutine = None
330322

331-
class MarkerTask(BaseKeyValueClass):
332-
pass
323+
skip_download : bool = False
333324

334325
class Playlist(Subroutine):
335326
prefix = '歌单'
@@ -391,7 +382,9 @@ def forIds(self, ids):
391382
logger.warning(
392383
"单曲 #%d / %d - %s - %s 已存在,跳过"
393384
% (index + 1, len(dDetails), song.Title, song.AlbumName))
394-
self.put(MarkerTask())
385+
tSong.skip_download = True
386+
tSong.extension = FuzzyPathHelper(output_folder).get_extension(output_name)
387+
self.put(tSong)
395388
continue
396389
self.put(tSong)
397390

pyncm/apis/__init__.py

+26-53
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,25 @@ def _BaseWrapper(requestFunc):
6464
def apiWrapper(apiFunc):
6565
@wraps(apiFunc)
6666
def wrapper(*a, **k):
67+
# HACK: 'session=' keyword support
68+
session = k.get("session", GetCurrentSession())
69+
# HACK: For now,wrapped functions will not have access to the session object
70+
if 'session' in k: del k['session']
71+
6772
ret = apiFunc(*a, **k)
6873
url, payload = ret[:2]
6974
method = ret[-1] if ret[-1] in ["POST", "GET"] else "POST"
70-
logger.debug('TYPE=%s API=%s.%s %s url=%s deviceId=%s payload=%s' % (
75+
logger.debug('TYPE=%s API=%s.%s %s url=%s deviceId=%s payload=%s session=0x%x' % (
7176
requestFunc.__name__.split('Crypto')[0].upper(),
7277
apiFunc.__module__,
73-
apiFunc,
78+
apiFunc.__name__,
7479
method,
7580
url,
76-
GetCurrentSession().deviceId,
77-
payload)
81+
session.deviceId,
82+
payload,
83+
id(session))
7884
)
79-
rsp = requestFunc(url, payload, method)
85+
rsp = requestFunc(session, url, payload, method)
8086
try:
8187
payload = rsp.text if isinstance(rsp, Response) else rsp
8288
payload = payload.decode() if not isinstance(payload, str) else payload
@@ -101,30 +107,6 @@ def wrapper(*a, **k):
101107
return apiWrapper
102108

103109

104-
def LoginRequiredApi(func):
105-
"""API 需要事先登录"""
106-
@wraps(func)
107-
def wrapper(*a, **k):
108-
if not GetCurrentSession().login_info["success"]:
109-
raise LOGIN_REQUIRED
110-
return func(*a, **k)
111-
112-
return wrapper
113-
114-
115-
def UserIDBasedApi(func):
116-
"""API 第一参数为用户 ID,而该参数可留 0 而指代已登录的用户 ID"""
117-
@wraps(func)
118-
def wrapper(user_id=0, *a, **k):
119-
if user_id == 0 and GetCurrentSession().login_info["success"]:
120-
user_id = GetCurrentSession().uid
121-
elif user_id == 0:
122-
raise LOGIN_REQUIRED
123-
return func(user_id, *a, **k)
124-
125-
return wrapper
126-
127-
128110
def EapiEncipered(func):
129111
"""函数值有 Eapi 加密 - 解密并返回原文"""
130112
@wraps(func)
@@ -138,51 +120,42 @@ def wrapper(*a, **k):
138120
return wrapper
139121

140122
@_BaseWrapper
141-
def WeapiCryptoRequest(url, plain, method):
142-
"""Weapi - 适用于 网页端、小程序、手机端部分 APIs"""
143-
payload = json.dumps({**plain, "csrf_token": GetCurrentSession().csrf_token})
144-
return GetCurrentSession().request(
123+
def WeapiCryptoRequest(session, url, plain, method):
124+
"""Weapi - 适用于 网页端、小程序、手机端部分 APIs"""
125+
payload = json.dumps({**plain, "csrf_token": session.csrf_token})
126+
return session.request(
145127
method,
146128
url.replace("/api/", "/weapi/"),
147-
params={"csrf_token": GetCurrentSession().csrf_token},
129+
params={"csrf_token": session.csrf_token},
148130
data={**WeapiEncrypt(payload)},
149131
)
150132

151-
# 来自 https://github.com/Binaryify/NeteaseCloudMusicApi
152-
@_BaseWrapper
153-
def LapiCryptoRequest(url, plain, method):
154-
"""Linux API - 适用于Linux客户端部分APIs"""
155-
payload = {"method": method, "url": GetCurrentSession().HOST + url, "params": plain}
156-
payload = json.dumps(payload)
157-
return GetCurrentSession().request(
158-
method,
159-
"/api/linux/forward",
160-
headers={"User-Agent": GetCurrentSession().UA_LINUX_API},
161-
data={**LinuxApiEncrypt(payload)},
162-
)
163-
164133
# 来自 https://github.com/Binaryify/NeteaseCloudMusicApi
165134
@_BaseWrapper
166135
@EapiEncipered
167-
def EapiCryptoRequest(url, plain, method):
136+
def EapiCryptoRequest(session, url, plain, method):
168137
"""Eapi - 适用于新版客户端绝大部分API"""
169138
payload = {**plain, "header": json.dumps({
170-
**GetCurrentSession().eapi_config,
139+
**session.eapi_config,
171140
"requestId": str(randrange(20000000,30000000))
172141
})}
173142
digest = EapiEncrypt(urllib.parse.urlparse(url).path.replace("/eapi/", "/api/"), json.dumps(payload))
174-
request = GetCurrentSession().request(
143+
request = session.request(
175144
method,
176145
url,
177-
headers={"User-Agent": GetCurrentSession().UA_EAPI, "Referer": None},
146+
headers={"User-Agent": session.UA_EAPI, "Referer": ''},
178147
cookies={
179-
**GetCurrentSession().eapi_config
148+
**session.eapi_config
180149
},
181150
data={
182151
**digest
183152
},
184153
)
185-
return request.content
154+
payload = request.content
155+
try:
156+
return EapiDecrypt(payload).decode()
157+
except:
158+
return payload
186159

187160
from . import (
188161
artist,

pyncm/apis/cloud.py

+5-9
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
# -*- coding: utf-8 -*-
22
"""我的音乐云盘 - Cloud APIs"""
33
import json
4-
from . import WeapiCryptoRequest, LoginRequiredApi, EapiCryptoRequest, GetCurrentSession
4+
from . import WeapiCryptoRequest, EapiCryptoRequest, GetCurrentSession
55

66
BUCKET = "jd-musicrep-privatecloud-audio-public"
77

88

99
@WeapiCryptoRequest
10-
@LoginRequiredApi
1110
def GetCloudDriveInfo(limit=30, offset=0):
1211
"""PC端 - 获取个人云盘内容
1312
@@ -22,7 +21,6 @@ def GetCloudDriveInfo(limit=30, offset=0):
2221

2322

2423
@WeapiCryptoRequest
25-
@LoginRequiredApi
2624
def GetCloudDriveItemInfo(song_ids: list):
2725
"""PC端 - 获取个人云盘项目详情
2826
@@ -75,7 +73,7 @@ def GetNosToken(
7573

7674

7775
def SetUploadObject(
78-
stream, md5, fileSize, objectKey, token, offset=0, compete=True, bucket=BUCKET
76+
stream, md5, fileSize, objectKey, token, offset=0, compete=True, bucket=BUCKET, session=None
7977
):
8078
"""移动端 - 上传内容
8179
@@ -90,7 +88,7 @@ def SetUploadObject(
9088
Returns:
9189
dict
9290
"""
93-
r = GetCurrentSession().post(
91+
r = (session or GetCurrentSession()).post(
9492
"http://45.127.129.8/%s/" % bucket + objectKey.replace("/", "%2F"),
9593
data=stream,
9694
params={"version": "1.0", "offset": offset, "complete": str(compete).lower()},
@@ -180,9 +178,7 @@ def SetPublishCloudResource(songid):
180178
"songid": str(songid),
181179
}
182180

183-
184-
@LoginRequiredApi
185-
def SetRectifySongId(oldSongId, newSongId):
181+
def SetRectifySongId(oldSongId, newSongId, session=None):
186182
"""移动端 - 歌曲纠偏
187183
188184
Args:
@@ -193,7 +189,7 @@ def SetRectifySongId(oldSongId, newSongId):
193189
dict
194190
"""
195191
return (
196-
GetCurrentSession()
192+
(session or GetCurrentSession())
197193
.get(
198194
"/api/cloud/user/song/match",
199195
params={"songId": str(oldSongId), "adjustSongId": str(newSongId)},

0 commit comments

Comments
 (0)