Skip to content

Commit

Permalink
Version 1.6.14
Browse files Browse the repository at this point in the history
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
  • Loading branch information
mos9527 committed Mar 6, 2024
1 parent 8570ee8 commit 196766c
Show file tree
Hide file tree
Showing 10 changed files with 115 additions and 155 deletions.
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
![Logo](https://github.com/greats3an/pyncm/raw/master/demos/_logo.png)

# PyNCM
第三方网易云音乐 Python API 及个人音乐库离线转储工具

**注意** : 异步使用,请移步 [`async` 分支](https://github.com/mos9527/pyncm/tree/async)

# 安装
pip install pyncm
可选 (若不考虑使用CLI则请忽略)
Expand Down Expand Up @@ -88,11 +91,10 @@
|-|-|
|`PYNCM_DEBUG`|调试日志输出等级,`'CRITICAL', 'DEBUG', 'ERROR','FATAL','INFO','WARNING'` 之一|
### 使用示例
## 下载单曲
## 转储单曲
[![asciicast](https://asciinema.org/a/4PEC5977rTcm4hp9jLuPFYUM1.svg)](https://asciinema.org/a/4PEC5977rTcm4hp9jLuPFYUM1)
## 使用 [UNM](https://github.com/UnblockNeteaseMusic/server) 下载灰色歌曲
[![asciicast](https://asciinema.org/a/AX4cdzD7YcgQlTebAdCTKZQnb.svg)](https://asciinema.org/a/AX4cdzD7YcgQlTebAdCTKZQnb)
其他功能详见

API使用详见
- [Demo](https://github.com/mos9527/pyncm/tree/master/demos)

# API 使用示例
Expand All @@ -119,6 +121,12 @@ with session: # 进入该 Session, 在 `with` 内的 API 将由该 Session 完
# 离开 Session. 此后 API 将继续由全局 Session 管理
GetTrackComments(...)
```

同时,你也可以在 API Call 中 指定 Session
```python
await GetTrackComments(..., session=session)
```

详见 [Session 说明](https://github.com/mos9527/pyncm/blob/master/pyncm/__init__.py#L52)
## API 说明
大部分 API 函数已经详细注释,可读性较高。推荐参阅 [API 源码](https://github.com/mos9527/pyncm/tree/master/pyncm) 获得支持
Expand All @@ -140,6 +148,3 @@ GetTrackComments(...)
[Android逆向——网易云音乐排行榜api(上)](https://juejin.im/post/6844903586879520775)

[Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)

### 衍生项目
[PyNCMd](https://github.com/mos9527/pyncmd)
2 changes: 1 addition & 1 deletion pyncm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"""
__VERSION_MAJOR__ = 1
__VERSION_MINOR__ = 6
__VERSION_PATCH__ = 13
__VERSION_PATCH__ = 14

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

Expand Down
69 changes: 31 additions & 38 deletions pyncm/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from time import sleep
from os.path import join, exists
from os import remove, makedirs
from dataclasses import dataclass

from logging import exception, getLogger, basicConfig
import sys, argparse, re , os
Expand All @@ -40,13 +41,6 @@

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

# Key-Value classes
class BaseKeyValueClass:
def __init__(self, **kw) -> None:
for k, v in kw.items():
self.__setattr__(k, v)


class TaskPoolExecutorThread(Thread):
@staticmethod
def tag_audio(track: TrackHelper, file: str, cover_img: str = ""):
Expand Down Expand Up @@ -173,13 +167,11 @@ def __init__(self, *a, max_workers=4, **k):
self.max_workers = max_workers

def run(self):
def execute(task: BaseKeyValueClass):
if type(task) == MarkerTask:
# Mark a finished task w/o execution
self.finished_tasks += 1
return
def execute(task):
if type(task) == TrackDownloadTask:
try:
if task.skip_download:
return
# Downloding source audio
apiCall = track.GetTrackAudioV1 if not task.routine.args.use_download_api else track.GetTrackDownloadURLV1
if task.routine.args.use_download_api: logger.warning("使用下载 API,可能消耗 VIP 下载额度!")
Expand Down Expand Up @@ -300,36 +292,35 @@ def result_exception(self,result_id,exception : Exception,desc=None):
@property
def has_exceptions(self):
return len(self.exceptions) > 0
class BaseDownloadTask(BaseKeyValueClass):
id: int
url: str
dest: str
level : str


@dataclass
class BaseDownloadTask:
id: int = 0
url: str = ''
dest: str = ''
level : str = ''

@dataclass
class LyricsDownloadTask(BaseDownloadTask):
id: int
dest: str
lrc_blacklist: set

id: int = 0
dest: str = ''
lrc_blacklist: set = None

class TrackDownloadTask(BaseKeyValueClass):
song: TrackHelper
cover: BaseDownloadTask
lyrics: BaseDownloadTask
audio: BaseDownloadTask
@dataclass
class TrackDownloadTask:
song: TrackHelper = None
cover: BaseDownloadTask = None
lyrics: BaseDownloadTask = None
audio: BaseDownloadTask = None

index: int
total: int
lyrics_exclude: set
save_as: str
extension: str
index: int = 0
total: int = 0
lyrics_exclude: set = None
save_as: str = ''
extension: str = ''

routine : Subroutine
routine : Subroutine = None

class MarkerTask(BaseKeyValueClass):
pass
skip_download : bool = False

class Playlist(Subroutine):
prefix = '歌单'
Expand Down Expand Up @@ -391,7 +382,9 @@ def forIds(self, ids):
logger.warning(
"单曲 #%d / %d - %s - %s 已存在,跳过"
% (index + 1, len(dDetails), song.Title, song.AlbumName))
self.put(MarkerTask())
tSong.skip_download = True
tSong.extension = FuzzyPathHelper(output_folder).get_extension(output_name)
self.put(tSong)
continue
self.put(tSong)

Expand Down
79 changes: 26 additions & 53 deletions pyncm/apis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,25 @@ def _BaseWrapper(requestFunc):
def apiWrapper(apiFunc):
@wraps(apiFunc)
def wrapper(*a, **k):
# HACK: 'session=' keyword support
session = k.get("session", GetCurrentSession())
# HACK: For now,wrapped functions will not have access to the session object
if 'session' in k: del k['session']

ret = apiFunc(*a, **k)
url, payload = ret[:2]
method = ret[-1] if ret[-1] in ["POST", "GET"] else "POST"
logger.debug('TYPE=%s API=%s.%s %s url=%s deviceId=%s payload=%s' % (
logger.debug('TYPE=%s API=%s.%s %s url=%s deviceId=%s payload=%s session=0x%x' % (
requestFunc.__name__.split('Crypto')[0].upper(),
apiFunc.__module__,
apiFunc,
apiFunc.__name__,
method,
url,
GetCurrentSession().deviceId,
payload)
session.deviceId,
payload,
id(session))
)
rsp = requestFunc(url, payload, method)
rsp = requestFunc(session, url, payload, method)
try:
payload = rsp.text if isinstance(rsp, Response) else rsp
payload = payload.decode() if not isinstance(payload, str) else payload
Expand All @@ -101,30 +107,6 @@ def wrapper(*a, **k):
return apiWrapper


def LoginRequiredApi(func):
"""API 需要事先登录"""
@wraps(func)
def wrapper(*a, **k):
if not GetCurrentSession().login_info["success"]:
raise LOGIN_REQUIRED
return func(*a, **k)

return wrapper


def UserIDBasedApi(func):
"""API 第一参数为用户 ID,而该参数可留 0 而指代已登录的用户 ID"""
@wraps(func)
def wrapper(user_id=0, *a, **k):
if user_id == 0 and GetCurrentSession().login_info["success"]:
user_id = GetCurrentSession().uid
elif user_id == 0:
raise LOGIN_REQUIRED
return func(user_id, *a, **k)

return wrapper


def EapiEncipered(func):
"""函数值有 Eapi 加密 - 解密并返回原文"""
@wraps(func)
Expand All @@ -138,51 +120,42 @@ def wrapper(*a, **k):
return wrapper

@_BaseWrapper
def WeapiCryptoRequest(url, plain, method):
"""Weapi - 适用于 网页端、小程序、手机端部分 APIs"""
payload = json.dumps({**plain, "csrf_token": GetCurrentSession().csrf_token})
return GetCurrentSession().request(
def WeapiCryptoRequest(session, url, plain, method):
"""Weapi - 适用于 网页端、小程序、手机端部分 APIs"""
payload = json.dumps({**plain, "csrf_token": session.csrf_token})
return session.request(
method,
url.replace("/api/", "/weapi/"),
params={"csrf_token": GetCurrentSession().csrf_token},
params={"csrf_token": session.csrf_token},
data={**WeapiEncrypt(payload)},
)

# 来自 https://github.com/Binaryify/NeteaseCloudMusicApi
@_BaseWrapper
def LapiCryptoRequest(url, plain, method):
"""Linux API - 适用于Linux客户端部分APIs"""
payload = {"method": method, "url": GetCurrentSession().HOST + url, "params": plain}
payload = json.dumps(payload)
return GetCurrentSession().request(
method,
"/api/linux/forward",
headers={"User-Agent": GetCurrentSession().UA_LINUX_API},
data={**LinuxApiEncrypt(payload)},
)

# 来自 https://github.com/Binaryify/NeteaseCloudMusicApi
@_BaseWrapper
@EapiEncipered
def EapiCryptoRequest(url, plain, method):
def EapiCryptoRequest(session, url, plain, method):
"""Eapi - 适用于新版客户端绝大部分API"""
payload = {**plain, "header": json.dumps({
**GetCurrentSession().eapi_config,
**session.eapi_config,
"requestId": str(randrange(20000000,30000000))
})}
digest = EapiEncrypt(urllib.parse.urlparse(url).path.replace("/eapi/", "/api/"), json.dumps(payload))
request = GetCurrentSession().request(
request = session.request(
method,
url,
headers={"User-Agent": GetCurrentSession().UA_EAPI, "Referer": None},
headers={"User-Agent": session.UA_EAPI, "Referer": ''},
cookies={
**GetCurrentSession().eapi_config
**session.eapi_config
},
data={
**digest
},
)
return request.content
payload = request.content
try:
return EapiDecrypt(payload).decode()
except:
return payload

from . import (
artist,
Expand Down
14 changes: 5 additions & 9 deletions pyncm/apis/cloud.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
# -*- coding: utf-8 -*-
"""我的音乐云盘 - Cloud APIs"""
import json
from . import WeapiCryptoRequest, LoginRequiredApi, EapiCryptoRequest, GetCurrentSession
from . import WeapiCryptoRequest, EapiCryptoRequest, GetCurrentSession

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


@WeapiCryptoRequest
@LoginRequiredApi
def GetCloudDriveInfo(limit=30, offset=0):
"""PC端 - 获取个人云盘内容
Expand All @@ -22,7 +21,6 @@ def GetCloudDriveInfo(limit=30, offset=0):


@WeapiCryptoRequest
@LoginRequiredApi
def GetCloudDriveItemInfo(song_ids: list):
"""PC端 - 获取个人云盘项目详情
Expand Down Expand Up @@ -75,7 +73,7 @@ def GetNosToken(


def SetUploadObject(
stream, md5, fileSize, objectKey, token, offset=0, compete=True, bucket=BUCKET
stream, md5, fileSize, objectKey, token, offset=0, compete=True, bucket=BUCKET, session=None
):
"""移动端 - 上传内容
Expand All @@ -90,7 +88,7 @@ def SetUploadObject(
Returns:
dict
"""
r = GetCurrentSession().post(
r = (session or GetCurrentSession()).post(
"http://45.127.129.8/%s/" % bucket + objectKey.replace("/", "%2F"),
data=stream,
params={"version": "1.0", "offset": offset, "complete": str(compete).lower()},
Expand Down Expand Up @@ -180,9 +178,7 @@ def SetPublishCloudResource(songid):
"songid": str(songid),
}


@LoginRequiredApi
def SetRectifySongId(oldSongId, newSongId):
def SetRectifySongId(oldSongId, newSongId, session=None):
"""移动端 - 歌曲纠偏
Args:
Expand All @@ -193,7 +189,7 @@ def SetRectifySongId(oldSongId, newSongId):
dict
"""
return (
GetCurrentSession()
(session or GetCurrentSession())
.get(
"/api/cloud/user/song/match",
params={"songId": str(oldSongId), "adjustSongId": str(newSongId)},
Expand Down
Loading

0 comments on commit 196766c

Please sign in to comment.