├── .github
├── FUNDING.yml
└── workflows
│ └── pypi-publish.yml
├── .gitignore
├── .prettierignore
├── LICENSE
├── README.md
├── nonebot_plugin_multincm
├── __init__.py
├── config.py
├── const.py
├── data_source
│ ├── __init__.py
│ ├── album.py
│ ├── base.py
│ ├── playlist.py
│ ├── program.py
│ ├── radio.py
│ ├── raw
│ │ ├── __init__.py
│ │ ├── login.py
│ │ ├── models.py
│ │ └── request.py
│ └── song.py
├── interaction
│ ├── __init__.py
│ ├── cache.py
│ ├── commands
│ │ ├── __init__.py
│ │ ├── direct.py
│ │ ├── lyric.py
│ │ ├── resolve.py
│ │ ├── search.py
│ │ └── upload.py
│ ├── message
│ │ ├── __init__.py
│ │ ├── common.py
│ │ ├── song_card.py
│ │ └── song_file.py
│ └── resolver.py
├── render
│ ├── __init__.py
│ ├── card_list.py
│ ├── lyrics.py
│ ├── templates
│ │ ├── base.html.jinja
│ │ ├── card_list.html.jinja
│ │ ├── lyrics.html.jinja
│ │ └── track_card.html.jinja
│ └── utils.py
└── utils
│ ├── __init__.py
│ ├── base.py
│ └── lrc_parser.py
├── pdm.lock
└── pyproject.toml
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | custom:
2 | - https://afdian.net/a/lgc2333/
3 | - https://blog.lgc2333.top/donate
4 |
--------------------------------------------------------------------------------
/.github/workflows/pypi-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish Python 🐍 distributions 📦 to PyPI
2 |
3 | on:
4 | release:
5 | types: [published]
6 | workflow_dispatch:
7 |
8 | jobs:
9 | build-n-publish:
10 | name: Use PDM to Build and publish Python 🐍 distributions 📦 to PyPI
11 | runs-on: ubuntu-latest
12 |
13 | permissions:
14 | # IMPORTANT: this permission is mandatory for trusted publishing
15 | id-token: write
16 |
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@master
20 | with:
21 | submodules: true
22 |
23 | - name: Setup PDM
24 | uses: pdm-project/setup-pdm@v3
25 |
26 | - name: Build and Publish distribution 📦 to PyPI
27 | run: pdm publish
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/python
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python
3 |
4 | ### Python ###
5 | # Byte-compiled / optimized / DLL files
6 | __pycache__/
7 | *.py[cod]
8 | *$py.class
9 |
10 | # C extensions
11 | *.so
12 |
13 | # Distribution / packaging
14 | .Python
15 | build/
16 | develop-eggs/
17 | dist/
18 | downloads/
19 | eggs/
20 | .eggs/
21 | lib/
22 | lib64/
23 | parts/
24 | sdist/
25 | var/
26 | wheels/
27 | share/python-wheels/
28 | *.egg-info/
29 | .installed.cfg
30 | *.egg
31 | MANIFEST
32 |
33 | # PyInstaller
34 | # Usually these files are written by a python script from a template
35 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
36 | *.manifest
37 | *.spec
38 |
39 | # Installer logs
40 | pip-log.txt
41 | pip-delete-this-directory.txt
42 |
43 | # Unit test / coverage reports
44 | htmlcov/
45 | .tox/
46 | .nox/
47 | .coverage
48 | .coverage.*
49 | .cache
50 | nosetests.xml
51 | coverage.xml
52 | *.cover
53 | *.py,cover
54 | .hypothesis/
55 | .pytest_cache/
56 | cover/
57 |
58 | # Translations
59 | *.mo
60 | *.pot
61 |
62 | # Django stuff:
63 | *.log
64 | local_settings.py
65 | db.sqlite3
66 | db.sqlite3-journal
67 |
68 | # Flask stuff:
69 | instance/
70 | .webassets-cache
71 |
72 | # Scrapy stuff:
73 | .scrapy
74 |
75 | # Sphinx documentation
76 | docs/_build/
77 |
78 | # PyBuilder
79 | .pybuilder/
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | # For a library or package, you might want to ignore these files since the code is
91 | # intended to run in multiple environments; otherwise, check them in:
92 | # .python-version
93 |
94 | # pipenv
95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
98 | # install all needed dependencies.
99 | #Pipfile.lock
100 |
101 | # poetry
102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
103 | # This is especially recommended for binary packages to ensure reproducibility, and is more
104 | # commonly ignored for libraries.
105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
106 | poetry.lock
107 |
108 | # pdm
109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
110 | # pdm.lock
111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
112 | # in version control.
113 | # https://pdm.fming.dev/#use-with-ide
114 | .pdm.toml
115 | .pdm-python
116 |
117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
118 | __pypackages__/
119 |
120 | # Celery stuff
121 | celerybeat-schedule
122 | celerybeat.pid
123 |
124 | # SageMath parsed files
125 | *.sage.py
126 |
127 | # Environments
128 | .env
129 | .venv
130 | env/
131 | venv/
132 | ENV/
133 | env.bak/
134 | venv.bak/
135 |
136 | # Spyder project settings
137 | .spyderproject
138 | .spyproject
139 |
140 | # Rope project settings
141 | .ropeproject
142 |
143 | # mkdocs documentation
144 | /site
145 |
146 | # mypy
147 | .mypy_cache/
148 | .dmypy.json
149 | dmypy.json
150 |
151 | # Pyre type checker
152 | .pyre/
153 |
154 | # pytype static type analyzer
155 | .pytype/
156 |
157 | # Cython debug symbols
158 | cython_debug/
159 |
160 | # PyCharm
161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
163 | # and can be added to the global gitignore or merged into this file. For a more nuclear
164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
165 | #.idea/
166 |
167 | ### Python Patch ###
168 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
169 | # poetry.toml
170 |
171 | # End of https://www.toptal.com/developers/gitignore/api/python
172 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | venv/
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 LgCookie
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
50 |
51 | ## 📖 介绍
52 |
53 | 一个网易云多选点歌插件(也可以设置成单选,看下面),可以翻页,可以登录网易云账号点 vip 歌曲听(插件发送的是自定义音乐卡片),没了
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 | [推荐] 使用 nb-cli 安装
79 | 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
80 |
81 | ```bash
82 | nb plugin install nonebot-plugin-multincm
83 | ```
84 |
85 |
86 |
87 |
88 | 使用包管理器安装
89 | 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
90 |
91 |
92 | pip
93 |
94 | ```bash
95 | pip install nonebot-plugin-multincm
96 | ```
97 |
98 |
99 |
100 | pdm
101 |
102 | ```bash
103 | pdm add nonebot-plugin-multincm
104 | ```
105 |
106 |
107 |
108 | poetry
109 |
110 | ```bash
111 | poetry add nonebot-plugin-multincm
112 | ```
113 |
114 |
115 |
116 | conda
117 |
118 | ```bash
119 | conda install nonebot-plugin-multincm
120 | ```
121 |
122 |
123 |
124 | 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分的 `plugins` 项里追加写入
125 |
126 | ```toml
127 | [tool.nonebot]
128 | plugins = [
129 | # ...
130 | "nonebot_plugin_multincm"
131 | ]
132 | ```
133 |
134 |
135 |
136 | ## ⚙️ 配置
137 |
138 | 如果你安装了 [nonebot-plugin-ncm](https://github.com/kitUIN/nonebot-plugin-ncm) 或者其他使用到 pyncm 的插件并且全局 Session 已登录,本插件会与它们共用全局 Session,就可以不用填下面的账号密码了
139 |
140 | 登录相关配置填写说明:
141 |
142 | - **\[推荐\]** 如果想要使用 二维码 登录,则 **所有** 登录相关配置项都 **不要填写**
143 | - 如果想要使用 短信验证码 登录
144 | - **必填** `NCM_PHONE`
145 | - 选填 `NCM_CTCODE`(默认为 `86`)
146 | - 如果想要使用 手机号与密码 登录
147 | - **必填** `NCM_PHONE`
148 | - **必填** `NCM_PASSWORD` 或 `NCM_PASSWORD_HASH` 其中一个
149 | - 选填 `NCM_CTCODE`(默认为 `86`)
150 | - 如果想要使用 邮箱与密码 登录
151 | - **必填** `NCM_EMAIL`
152 | - **必填** `NCM_PASSWORD` 或 `NCM_PASSWORD_HASH` 其中一个
153 | - 如果只想要使用 游客 登录
154 | - **必填** `NCM_ANONYMOUS=True`
155 |
156 | 在 nonebot2 项目的 `.env` 文件中添加下表中的必填配置
157 |
158 | | 配置项 | 必填 | 默认值 | 说明 |
159 | | :--------------------------------: | :--: | :----------: | :-------------------------------------------------------------------------------------------------------: |
160 | | **登录相关** | | | |
161 | | `NCM_CTCODE` | 否 | `86` | 手机号登录用,登录手机区号 |
162 | | `NCM_PHONE` | 否 | 无 | 手机号登录用,登录手机号 |
163 | | `NCM_EMAIL` | 否 | 无 | 邮箱登录用,登录邮箱 |
164 | | `NCM_PASSWORD` | 否 | 无 | 帐号明文密码,邮箱登录时为邮箱密码 |
165 | | `NCM_PASSWORD_HASH` | 否 | 无 | 帐号密码 MD5 哈希,邮箱登录时为邮箱密码 |
166 | | `NCM_ANONYMOUS` | 否 | `False` | 是否强制游客登录 |
167 | | **UI 相关** | | | |
168 | | `NCM_LIST_LIMIT` | 否 | `20` | 歌曲列表每页的最大数量 |
169 | | `NCM_LIST_FONT` | 否 | 无 | 渲染歌曲列表使用的字体 |
170 | | `NCM_LRC_EMPTY_LINE` | 否 | `-` | 填充歌词空行的字符 |
171 | | **行为相关** | | | |
172 | | `NCM_AUTO_RESOLVE` | 否 | `False` | 当用户发送音乐链接时,是否自动解析并发送音乐卡片 |
173 | | `NCM_RESOLVE_COOL_DOWN` | 否 | `30` | 自动解析同一链接的冷却时间(单位秒) |
174 | | `NCM_RESOLVE_PLAYABLE_CARD` | 否 | `False` | 开启自动解析时,是否解析可播放的卡片 |
175 | | `NCM_ILLEGAL_CMD_FINISH` | 否 | `False` | 当用户在点歌时输入了非法指令,是否直接退出点歌 |
176 | | `NCM_ILLEGAL_CMD_LIMIT` | 否 | `3` | 当未启用 `NCM_ILLEGAL_CMD_FINISH` 时,用户在点歌时输入了多少次非法指令后直接退出点歌,填 `0` 以禁用此功能 |
177 | | `NCM_DELETE_MSG` | 否 | `True` | 是否在退出点歌模式后自动撤回歌曲列表与操作提示信息 |
178 | | `NCM_DELETE_MSG_DELAY` | 否 | `[0.5, 2.0]` | 自动撤回消息间隔时间(单位秒) |
179 | | `NCM_SEND_MEDIA` | 否 | `True` | 是否发送歌曲,如关闭将始终提示使用命令获取播放链接 |
180 | | `NCM_SEND_AS_CARD` | 否 | `True` | 在支持的平台下,发送歌曲卡片(目前支持 `OneBot V11` 与 `Kritor`) |
181 | | `NCM_SEND_AS_FILE` | 否 | `False` | 当无法发送卡片或卡片发送失败时,会回退到使用语音发送,启用此配置项将会换成回退到发送歌曲文件 |
182 | | **其他配置** | | | |
183 | | `NCM_MSG_CACHE_TIME` | 否 | `43200` | 缓存 用户最近一次操作 的时长(秒) |
184 | | `NCM_MSG_CACHE_SIZE` | 否 | `1024` | 缓存所有 用户最近一次操作 的总计数量 |
185 | | `NCM_RESOLVE_COOL_DOWN_CACHE_SIZE` | 否 | `1024` | 缓存 歌曲解析的冷却时间 的总计数量 |
186 | | `NCM_CARD_SIGN_URL` | 否 | `None` | 音卡签名地址(与 LLOneBot 或 NapCat 共用),填写此 URL 后将会把音卡的签名工作交给本插件 |
187 | | `NCM_CARD_SIGN_TIMEOUT` | 否 | `5` | 请求音卡签名地址的超时时间 |
188 | | `NCM_OB_V11_LOCAL_MODE` | 否 | `False` | 在 OneBot V11 适配器下,是否下载歌曲后使用本地文件路径上传歌曲 |
189 | | `NCM_FFMPEG_EXECUTABLE` | 否 | `ffmpeg` | FFmpeg 可执行文件路径,已经加进环境变量可以不用配置,在 OneBot V11 适配器下发送语音需要使用 |
190 |
191 | ## 🎉 使用
192 |
193 | ### 指令
194 |
195 | #### 搜索指令
196 |
197 | - 点歌 [歌曲名 / 音乐 ID]
198 | - 介绍:搜索歌曲。当输入音乐 ID 时会直接发送对应音乐
199 | - 别名:`网易云`、`wyy`、`网易点歌`、`wydg`、`wysong`
200 | - 网易声音 [声音名 / 节目 ID]
201 | - 介绍:搜索声音。当输入声音 ID 时会直接发送对应声音
202 | - 别名:`wysy`、`wyprog`
203 | - 网易电台 [电台名 / 电台 ID]
204 | - 介绍:搜索电台。当输入电台 ID 时会直接发送对应电台
205 | - 别名:`wydt`、`wydj`
206 | - 网易歌单 [歌单名 / 歌单 ID]
207 | - 介绍:搜索歌单。当输入歌单 ID 时会直接发送对应歌单
208 | - 别名:`wygd`、`wypli`
209 | - 网易专辑 [专辑名 / 专辑 ID]
210 | - 介绍:搜索专辑。当输入专辑 ID 时会直接发送对应专辑
211 | - 别名:`wyzj`、`wyal`
212 |
213 | #### 操作指令
214 |
215 | - 解析 [回复 音乐卡片 / 链接]
216 | - 介绍:获取该音乐信息并发送,也可以解析歌单等
217 | - 别名:`resolve`、`parse`、`get`
218 | - 直链 [回复 音乐卡片 / 链接]
219 | - 介绍:获取该音乐的下载链接
220 | - 别名:`direct`
221 | - 上传 [回复 音乐卡片 / 链接]
222 | - 介绍:下载该音乐并上传到群文件
223 | - 别名:`upload`
224 | - 歌词 [回复 音乐卡片 / 链接]
225 | - 介绍:获取该音乐的歌词,以图片形式发送
226 | - 别名:`lrc`、`lyric`、`lyrics`
227 |
228 | ### Tip
229 |
230 | - 当启用 `NCM_AUTO_RESOLVE` 时,Bot 会自动解析你发送的网易云歌曲或电台节目链接
231 | - 点击 Bot 发送的音乐卡片会跳转到官网歌曲页
232 | - 使用需要回复音乐卡片的指令时,如果没有回复,会自动使用你触发发送的最近一个音乐卡片的信息
233 |
234 | ## 🤔 Q & A
235 |
236 | ### Q: 我可以把插件变成单选点歌吗?
237 |
238 | A: 可以,把配置项 `NCM_LIST_LIMIT` 设置为 `1` 即可。因为插件在检测到搜索结果仅有一个时,会将它直接发送出来。我们在这里利用了这个特性。
239 |
240 | ## 📞 联系
241 |
242 | QQ:3076823485
243 | Telegram:[@lgc2333](https://t.me/lgc2333)
244 | 吹水群:[1105946125](https://jq.qq.com/?_wv=1027&k=Z3n1MpEp)
245 | 邮箱:
246 |
247 | ## 💡 鸣谢
248 |
249 | ### [mos9527/pyncm](https://github.com/mos9527/pyncm)
250 |
251 | 项目使用的网易云 API 调用库
252 |
253 | ### [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
254 |
255 | 项目一些相关 API 来源
256 |
257 | ## 💰 赞助
258 |
259 | **[赞助我](https://blog.lgc2333.top/donate)**
260 |
261 | 感谢大家的赞助!你们的赞助将是我继续创作的动力!
262 |
263 | ## 📝 更新日志
264 |
265 | ### 1.2.6
266 |
267 | - [#43](https://github.com/lgc-NB2Dev/nonebot-plugin-multincm/pull/43)
268 |
269 | ### 1.2.5
270 |
271 | - 修复小问题
272 |
273 | ### 1.2.4
274 |
275 | - 修复二维码登录出现错误登录状态不会报错退出登录的问题
276 | - 登录协程不会阻塞应用启动了
277 |
278 | ### 1.2.3
279 |
280 | - 修复文件后缀名的获取
281 |
282 | ### 1.2.2
283 |
284 | - 迁移到 `localstore`
285 |
286 | ### 1.2.1
287 |
288 | - 修改了歌词图片的样式,现在罗马音会显示在日语歌词上方
289 |
290 | ### 1.2.0
291 |
292 | - 支持更多登录方式
293 |
294 | ### 1.1.5
295 |
296 | - 修复选择未缓存的某列表项时总是会选择到项目所在页第一项的 Bug
297 |
298 | ### 1.1.4
299 |
300 | - 修复一个歌词合并的小问题
301 |
302 | ### 1.1.3
303 |
304 | - 修复过长歌词多行不居中的问题
305 |
306 | ### 1.1.2
307 |
308 | - 修复 OneBot V11 的音乐卡片无法发送的问题
309 |
310 | ### 1.1.1
311 |
312 | - 修复图文消息 cover 发不出的问题
313 |
314 | ### 1.1.0
315 |
316 | - 换用 alconna 构建卡片消息
317 | - 修复手动使用指令解析也有冷却的问题
318 |
319 | ### 1.0.0
320 |
321 | 项目重构
322 |
323 | - 支持多平台
324 | 目前多平台发歌逻辑还不是很完善,如果有建议欢迎提出
325 | - UI 大改
326 | - 支持电台与专辑的搜索与解析
327 | - 自动解析对同一歌曲有冷却了,防多 Bot 刷屏
328 | - 配置项变动
329 | - 增加配置项 `NCM_RESOLVE_COOL_DOWN`、`NCM_RESOLVE_COOL_DOWN_CACHE_SIZE`
330 | 按需更改,可防止多 Bot 刷屏
331 | - 增加配置项 `NCM_SEND_MEDIA`、`NCM_SEND_AS_CARD`、`NCM_SEND_AS_FILE`
332 | 控制插件发送音乐的方式,现在不止支持卡片了
333 | - 增加配置项 `NCM_CARD_SIGN_URL`、`NCM_CARD_SIGN_TIMEOUT`
334 | 可以把音卡的签名工作交给插件而不是协议端,自行寻找音卡签名服务填写于此
335 | - 增加配置项 `NCM_FFMPEG_EXECUTABLE`
336 | 发送语音时可以将 silk 的编码工作交给插件而不是协议端
337 | - 重命名配置项 `NCM_DOWNLOAD_LOCALLY` -> `NCM_OB_V11_LOCAL_MODE`
338 | - 移除配置项 `NCM_MAX_NAME_LEN`、`NCM_MAX_ARTIST_LEN`、`NCM_USE_PLAYWRIGHT`
339 | 现始终使用 `playwright` 进行图片渲染
340 |
341 |
342 | 点击展开 / 收起 v0 版本更新日志
343 |
344 | ### 0.5.0
345 |
346 | - 适配 Pydantic V1 & V2
347 | - 支持歌单的解析
348 | - 点歌指令可以回复一条文本消息作为搜索内容了
349 | - resolve [#14](https://github.com/lgc-NB2Dev/nonebot-plugin-multincm/issues/14)
350 | - 弃用 Pillow
351 | - 重构部分代码
352 |
353 | ### 0.4.4
354 |
355 | - 添加配置项 `NCM_ILLEGAL_CMD_LIMIT`
356 |
357 | ### 0.4.3
358 |
359 | - 可以退出搜索模式了
360 |
361 | ### 0.4.2
362 |
363 | - resolve [#13](https://github.com/lgc-NB2Dev/nonebot-plugin-multincm/issues/13)
364 |
365 | ### 0.4.1
366 |
367 | - 支持了 `163cn.tv` 短链(Thanks to [@XieXiLin2](https://github.com/XieXiLin2))
368 | - 修复当 `NCM_RESOLVE_PLAYABLE_CARD` 为 `False` 时,Bot 依然会回复的问题
369 | - 部分代码优化
370 |
371 | ### 0.4.0
372 |
373 | - 项目部分重构
374 | - 删除 `链接` 指令,新增 `直链`、`上传` 指令
375 | - 将卡片点击后跳转的地址改为官网歌曲页,代替 `链接` 指令,同时删除了发送过音乐卡片的缓存机制
376 | - 添加配置项 `NCM_RESOLVE_PLAYABLE_CARD`、`NCM_UPLOAD_FOLDER_NAME`
377 |
378 | ### 0.3.9
379 |
380 | - 让 `htmlrender` 成为真正的可选依赖
381 | - 把配置项 `NCM_MSG_CACHE_TIME` 的默认值改为 `43200`(12 小时)
382 |
383 | ### 0.3.8
384 |
385 | - 修改及统一表格背景色
386 |
387 | ### 0.3.7
388 |
389 | - 添加配置项 `NCM_DELETE_LIST_MSG` 和 `NCM_DELETE_LIST_MSG_DELAY`([#5](https://github.com/lgc-NB2Dev/nonebot-plugin-multincm/issues/5))
390 |
391 | ### 0.3.6
392 |
393 | - 支持使用 `nonebot-plugin-htmlrender` (`playwright`) 渲染歌曲列表与歌词图片(默认不启用,如要启用需要自行安装 `nonebot-plugin-multincm[playwright]`)
394 | - 添加配置项 `NCM_USE_PLAYWRIGHT` 与 `NCM_LRC_EMPTY_LINE`
395 |
396 | ### 0.3.5
397 |
398 | - 🎉 NoneBot 2.0 🚀
399 |
400 | ### 0.3.4
401 |
402 | - 修复分割线下会显示歌词翻译的问题
403 |
404 | ### 0.3.3
405 |
406 | - 新增配置项 `NCM_ILLEGAL_CMD_FINISH`
407 | - 在未启用 `NCM_ILLEGAL_CMD_FINISH` 时输入错误指令将会提示用户退出点歌
408 |
409 | ### 0.3.2
410 |
411 | - 新增配置项 `NCM_MSG_CACHE_TIME`、`NCM_AUTO_RESOLVE`
412 | - 调整登录流程到 `driver.on_startup` 中
413 |
414 | ### 0.3.1
415 |
416 | - 修复电台相关 bug
417 |
418 | ### 0.3.0
419 |
420 | - 支持电台节目的解析与点播
421 |
422 | ### 0.2.5
423 |
424 | - `解析`、`歌词`、`链接` 指令可以直接根据 Bot 发送的上个音乐卡片作出回应了
425 | - 歌词解析会合并多行空行和去掉首尾空行了
426 | - 现在插件会定时清理自身内存中的缓存了
427 |
428 | ### 0.2.4
429 |
430 | - 修复一个歌词解析 bug
431 |
432 | ### 0.2.3
433 |
434 | - 微调歌曲列表排版
435 | - 微调插件帮助文本
436 |
437 | ### 0.2.2
438 |
439 | - 修复搜歌 `KeyError`
440 |
441 | ### 0.2.1
442 |
443 | - 删除歌词尾部的空行与多余分割线
444 |
445 | ### 0.2.0
446 |
447 | - 新增了三个指令 `解析`、`歌词`、`链接`
448 | - 点歌指令支持直接输入音乐 ID
449 |
450 |
451 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/__init__.py:
--------------------------------------------------------------------------------
1 | # ruff: noqa: E402
2 |
3 | import asyncio
4 |
5 | from nonebot import get_driver
6 | from nonebot.plugin import PluginMetadata, inherit_supported_adapters, require
7 |
8 | require("nonebot_plugin_alconna")
9 | require("nonebot_plugin_waiter")
10 | require("nonebot_plugin_localstore")
11 | require("nonebot_plugin_htmlrender")
12 |
13 | from . import interaction as interaction
14 | from .config import ConfigModel, config
15 | from .data_source import login, registered_searcher
16 | from .interaction import load_commands
17 |
18 | driver = get_driver()
19 |
20 |
21 | @driver.on_startup
22 | async def _():
23 | asyncio.create_task(login())
24 |
25 |
26 | load_commands()
27 |
28 | search_commands_help = "\n".join(
29 | [
30 | f"▶ {cmds[0]} [{(c := s.child_calling)}名 / {c} ID]\n"
31 | f" ▷ 介绍:搜索{c}。当输入{c} ID 时会直接发送对应{c}\n"
32 | f" ▷ 别名:{'、'.join(f'`{x}`' for x in cmds[1:])}"
33 | for s, cmds in registered_searcher.items()
34 | ],
35 | )
36 | auto_resolve_tip = (
37 | "▶ Bot 会自动解析你发送的网易云链接\n" if config.ncm_auto_resolve else ""
38 | )
39 |
40 | __version__ = "1.2.6"
41 | __plugin_meta__ = PluginMetadata(
42 | name="MultiNCM",
43 | description="网易云多选点歌",
44 | usage=(
45 | "搜索指令:\n"
46 | f"{search_commands_help}"
47 | " \n"
48 | "操作指令:\n"
49 | "▶ 解析 [回复 音乐卡片 / 链接]\n"
50 | " ▷ 介绍:获取该音乐信息并发送,也可以解析歌单等\n"
51 | " ▷ 别名:`resolve`、`parse`、`get`\n"
52 | "▶ 直链 [回复 音乐卡片 / 链接]\n"
53 | " ▷ 介绍:获取该音乐的下载链接\n"
54 | " ▷ 别名:`direct`\n"
55 | "▶ 上传 [回复 音乐卡片 / 链接]\n"
56 | " ▷ 介绍:下载该音乐并上传到群文件\n"
57 | " ▷ 别名:`upload`\n"
58 | "▶ 歌词 [回复 音乐卡片 / 链接]\n"
59 | " ▷ 介绍:获取该音乐的歌词,以图片形式发送\n"
60 | " ▷ 别名:`lrc`、`lyric`、`lyrics`\n"
61 | " \n"
62 | "Tip:\n"
63 | f"{auto_resolve_tip}"
64 | "▶ 点击 Bot 发送的音乐卡片会跳转到官网歌曲页\n"
65 | "▶ 使用需要回复音乐卡片的指令时,如果没有回复,会自动使用你触发发送的最近一个音乐卡片的信息"
66 | ),
67 | homepage="https://github.com/lgc-NB2Dev/nonebot-plugin-multincm",
68 | type="application",
69 | config=ConfigModel,
70 | supported_adapters=inherit_supported_adapters(
71 | "nonebot_plugin_alconna",
72 | "nonebot_plugin_waiter",
73 | ),
74 | extra={"License": "MIT", "Author": "LgCookie"},
75 | )
76 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/config.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated, Optional
2 |
3 | from cookit.pyd import get_model_with_config
4 | from nonebot import get_plugin_config
5 | from nonebot.compat import PYDANTIC_V2
6 | from pydantic import AnyHttpUrl, BaseModel
7 |
8 | BaseConfigModel = (
9 | get_model_with_config({"coerce_numbers_to_str": True}) if PYDANTIC_V2 else BaseModel
10 | )
11 |
12 |
13 | class ConfigModel(BaseConfigModel):
14 | # login
15 | ncm_ctcode: int = 86
16 | ncm_phone: Optional[str] = None
17 | ncm_email: Optional[str] = None
18 | ncm_password: Optional[str] = None
19 | ncm_password_hash: Optional[str] = None
20 | ncm_anonymous: bool = False
21 |
22 | # ui
23 | ncm_list_limit: int = 20
24 | ncm_list_font: Optional[str] = None
25 | ncm_lrc_empty_line: Optional[str] = "-"
26 |
27 | # behavior
28 | ncm_auto_resolve: bool = False
29 | ncm_resolve_cool_down: int = 30
30 | ncm_resolve_playable_card: bool = False
31 | ncm_illegal_cmd_finish: bool = False
32 | ncm_illegal_cmd_limit: int = 3
33 | ncm_delete_msg: bool = True
34 | ncm_delete_msg_delay: tuple[float, float] = (0.5, 2.0)
35 | ncm_send_media: bool = True
36 | ncm_send_as_card: bool = True
37 | ncm_send_as_file: bool = False
38 |
39 | # other
40 | ncm_msg_cache_size: int = 1024
41 | ncm_msg_cache_time: int = 43200
42 | ncm_resolve_cool_down_cache_size: int = 1024
43 | ncm_card_sign_url: Optional[Annotated[str, AnyHttpUrl]] = None
44 | ncm_card_sign_timeout: int = 5
45 | ncm_ob_v11_local_mode: bool = False
46 | ncm_ffmpeg_executable: str = "ffmpeg"
47 |
48 |
49 | config = get_plugin_config(ConfigModel)
50 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/const.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from cookit.nonebot.localstore import ensure_localstore_path_config
4 | from nonebot_plugin_localstore import get_plugin_data_dir
5 |
6 | ensure_localstore_path_config()
7 |
8 | DATA_DIR = get_plugin_data_dir()
9 | SONG_CACHE_DIR = DATA_DIR / "song_cache"
10 |
11 | URL_REGEX = r"music\.163\.com/(.*?)(?P[a-zA-Z]+)(/?\?id=|/)(?P[0-9]+)&?"
12 | SHORT_URL_BASE = "https://163cn.tv"
13 | SHORT_URL_REGEX = r"163cn\.tv/(?P[a-zA-Z0-9]+)"
14 |
15 | SESSION_FILE_NAME = "session.cache"
16 | SESSION_FILE_PATH = DATA_DIR / SESSION_FILE_NAME
17 |
18 |
19 | def migrate_old_data():
20 | old_data_dir = Path.cwd() / "data" / "multincm"
21 | if not old_data_dir.exists():
22 | return
23 |
24 | import shutil
25 |
26 | from nonebot import logger
27 |
28 | old_session_file_path = old_data_dir / SESSION_FILE_NAME
29 | if old_session_file_path.exists():
30 | shutil.move(old_session_file_path, SESSION_FILE_PATH)
31 | logger.info("已迁移旧登录态文件")
32 |
33 | shutil.rmtree(old_data_dir)
34 | logger.info("已删除旧缓存目录")
35 |
36 |
37 | migrate_old_data()
38 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/__init__.py:
--------------------------------------------------------------------------------
1 | from .album import (
2 | Album as Album,
3 | AlbumListPage as AlbumListPage,
4 | AlbumSearcher as AlbumSearcher,
5 | )
6 | from .base import (
7 | BasePlaylist as BasePlaylist,
8 | BaseResolvable as BaseResolvable,
9 | BaseSearcher as BaseSearcher,
10 | BaseSong as BaseSong,
11 | BaseSongList as BaseSongList,
12 | BaseSongListPage as BaseSongListPage,
13 | GeneralGetPageReturn as GeneralGetPageReturn,
14 | GeneralPlaylist as GeneralPlaylist,
15 | GeneralPlaylistInfo as GeneralPlaylistInfo,
16 | GeneralSearcher as GeneralSearcher,
17 | GeneralSong as GeneralSong,
18 | GeneralSongInfo as GeneralSongInfo,
19 | GeneralSongList as GeneralSongList,
20 | GeneralSongListPage as GeneralSongListPage,
21 | GeneralSongOrList as GeneralSongOrList,
22 | GeneralSongOrPlaylist as GeneralSongOrPlaylist,
23 | ListPageCard as ListPageCard,
24 | ResolvableFromID as ResolvableFromID,
25 | SongInfo as SongInfo,
26 | SongListInnerResp as SongListInnerResp,
27 | registered_playlist as registered_playlist,
28 | registered_resolvable as registered_resolvable,
29 | registered_searcher as registered_searcher,
30 | registered_song as registered_song,
31 | resolve_from_link_params as resolve_from_link_params,
32 | )
33 | from .playlist import (
34 | Playlist as Playlist,
35 | PlaylistListPage as PlaylistListPage,
36 | PlaylistSearcher as PlaylistSearcher,
37 | )
38 | from .program import (
39 | Program as Program,
40 | ProgramListPage as ProgramListPage,
41 | ProgramSearcher as ProgramSearcher,
42 | )
43 | from .radio import (
44 | Radio as Radio,
45 | RadioListPage as RadioListPage,
46 | RadioSearcher as RadioSearcher,
47 | )
48 | from .raw import * # noqa: F403
49 | from .song import (
50 | Song as Song,
51 | SongListPage as SongListPage,
52 | SongSearcher as SongSearcher,
53 | )
54 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/album.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Iterable
2 | from contextlib import suppress
3 | from typing import Generic, Optional, TypeVar
4 | from typing_extensions import Self, override
5 |
6 | from ..utils import calc_min_max_index, format_artists, get_thumb_url
7 | from .base import (
8 | BasePlaylist,
9 | BaseSearcher,
10 | BaseSongList,
11 | BaseSongListPage,
12 | ListPageCard,
13 | PlaylistInfo,
14 | playlist,
15 | searcher,
16 | )
17 | from .raw import get_album_info, md, search_album
18 | from .song import Song, SongListPage
19 |
20 | _TSongList = TypeVar("_TSongList", bound=BaseSongList)
21 |
22 |
23 | class AlbumListPage(BaseSongListPage[md.Album, _TSongList], Generic[_TSongList]):
24 | @override
25 | @classmethod
26 | async def transform_resp_to_list_card(cls, resp: md.Album) -> ListPageCard:
27 | return ListPageCard(
28 | cover=get_thumb_url(resp.pic_url),
29 | title=resp.name,
30 | extras=[format_artists(resp.artists)],
31 | small_extras=[f"歌曲数 {resp.size}"],
32 | )
33 |
34 |
35 | @playlist
36 | class Album(BasePlaylist[md.AlbumInfo, list[md.Song], md.Song, Song]):
37 | calling = "专辑"
38 | child_calling = Song.calling
39 | link_types = ("album",)
40 |
41 | @property
42 | @override
43 | def id(self) -> int:
44 | return self.info.album.id
45 |
46 | @classmethod
47 | @override
48 | async def from_id(cls, arg_id: int) -> Self:
49 | resp = await get_album_info(arg_id)
50 | return cls(resp)
51 |
52 | @override
53 | async def _extract_resp_content(self, resp: list[md.Song]) -> list[md.Song]:
54 | return resp
55 |
56 | @override
57 | async def _extract_total_count(self, resp: list[md.Song]) -> int:
58 | return self.info.album.size
59 |
60 | @override
61 | async def _do_get_page(self, page: int) -> list[md.Song]:
62 | min_index, max_index = calc_min_max_index(page)
63 | return self.info.songs[min_index:max_index]
64 |
65 | @override
66 | async def _build_selection(self, resp: md.Song) -> Song:
67 | return Song(info=resp)
68 |
69 | @override
70 | async def _build_list_page(self, resp: Iterable[md.Song]) -> SongListPage[Self]:
71 | return SongListPage(resp, self)
72 |
73 | @override
74 | async def get_name(self) -> str:
75 | return self.info.album.name
76 |
77 | @override
78 | async def get_creators(self) -> list[str]:
79 | return [x.name for x in self.info.album.artists]
80 |
81 | @override
82 | async def get_cover_url(self) -> str:
83 | return self.info.album.pic_url
84 |
85 | @override
86 | @classmethod
87 | async def format_description(cls, info: PlaylistInfo) -> str:
88 | if not cls.is_info_from_cls(info):
89 | raise TypeError("Info is not from this class")
90 | base_desc = await super().format_description(info)
91 | self = info.father
92 | return f"{base_desc}\n歌曲数 {self.info.album.size}"
93 |
94 |
95 | @searcher
96 | class AlbumSearcher(BaseSearcher[md.AlbumSearchResult, md.Album, Album]):
97 | child_calling = Album.calling
98 | commands = ("网易专辑", "wyzj", "wyal")
99 |
100 | @override
101 | @staticmethod
102 | async def search_from_id(arg_id: int) -> Optional[Album]:
103 | with suppress(Exception):
104 | return await Album.from_id(arg_id)
105 | return None
106 |
107 | @override
108 | async def _extract_resp_content(
109 | self,
110 | resp: md.AlbumSearchResult,
111 | ) -> Optional[list[md.Album]]:
112 | return resp.albums
113 |
114 | @override
115 | async def _extract_total_count(self, resp: md.AlbumSearchResult) -> int:
116 | return resp.album_count
117 |
118 | @override
119 | async def _do_get_page(self, page: int) -> md.AlbumSearchResult:
120 | return await search_album(self.keyword, page=page)
121 |
122 | @override
123 | async def _build_selection(self, resp: md.Album) -> Album:
124 | return await Album.from_id(resp.id)
125 |
126 | @override
127 | async def _build_list_page(self, resp: Iterable[md.Album]) -> AlbumListPage[Self]:
128 | return AlbumListPage(resp, self)
129 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/base.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from abc import ABC, abstractmethod
3 | from collections.abc import Iterable
4 | from contextlib import suppress
5 | from dataclasses import dataclass, field
6 | from typing import Any, ClassVar, Generic, Optional, TypeVar, Union
7 | from typing_extensions import Self, TypeAlias, TypeGuard, override
8 |
9 | from yarl import URL
10 |
11 | from ..config import config
12 | from ..utils import (
13 | NCMLrcGroupLine,
14 | build_item_link,
15 | calc_max_page,
16 | calc_min_index,
17 | calc_page_number,
18 | format_alias,
19 | format_time,
20 | )
21 | from .raw import md
22 |
23 | SongListInnerResp: TypeAlias = Union[
24 | md.Song,
25 | md.ProgramBaseInfo,
26 | md.BasePlaylist,
27 | md.RadioBaseInfo,
28 | md.Album,
29 | ]
30 |
31 | _TRawInfo = TypeVar("_TRawInfo")
32 | _TRawResp = TypeVar("_TRawResp")
33 | _TRawRespInner = TypeVar("_TRawRespInner", bound=SongListInnerResp)
34 | _TSong = TypeVar("_TSong", bound="BaseSong")
35 | _TSongList = TypeVar("_TSongList", bound="BaseSongList")
36 | _TPlaylist = TypeVar("_TPlaylist", bound="BasePlaylist")
37 | _TSearcher = TypeVar("_TSearcher", bound="BaseSearcher")
38 | _TSongOrList = TypeVar("_TSongOrList", bound=Union["BaseSong", "BaseSongList"])
39 | _TResolvable = TypeVar("_TResolvable", bound="BaseResolvable")
40 |
41 |
42 | registered_resolvable: dict[str, type["BaseResolvable"]] = {}
43 | registered_song: set[type["BaseSong"]] = set()
44 | registered_playlist: set[type["BasePlaylist"]] = set()
45 | registered_searcher: dict[type["BaseSearcher"], tuple[str, ...]] = {}
46 |
47 |
48 | class ResolvableFromID(ABC):
49 | link_types: ClassVar[tuple[str, ...]]
50 |
51 | @property
52 | @abstractmethod
53 | def id(self) -> int: ...
54 |
55 | @classmethod
56 | @abstractmethod
57 | async def from_id(cls, arg_id: int) -> Self: ...
58 |
59 | async def get_url(self) -> str:
60 | if not self.link_types:
61 | raise ValueError("No link types found")
62 | return build_item_link(self.link_types[0], self.id)
63 |
64 |
65 | def link_resolvable(cls: type[_TResolvable]):
66 | if n := next((x for x in cls.link_types if x in registered_resolvable), None):
67 | raise ValueError(f"Duplicate link type: {n}")
68 | registered_resolvable.update(dict.fromkeys(cls.link_types, cls))
69 | return cls
70 |
71 |
72 | def song(cls: type[_TSong]):
73 | registered_song.add(cls)
74 | return link_resolvable(cls)
75 |
76 |
77 | def playlist(cls: type[_TPlaylist]):
78 | registered_playlist.add(cls)
79 | return link_resolvable(cls)
80 |
81 |
82 | def searcher(cls: type[_TSearcher]):
83 | registered_searcher[cls] = cls.commands
84 | return cls
85 |
86 |
87 | async def resolve_from_link_params(
88 | link_type: str,
89 | link_id: int,
90 | ) -> "GeneralSongOrPlaylist":
91 | item_class = registered_resolvable.get(link_type)
92 | if not item_class:
93 | raise ValueError(f"Non-resolvable link type: {link_type}")
94 | return await item_class.from_id(link_id)
95 |
96 |
97 | @dataclass
98 | class SongInfo(Generic[_TSong]):
99 | father: _TSong
100 | name: str
101 | alias: Optional[list[str]]
102 | artists: list[str]
103 | duration: int
104 | url: str
105 | cover_url: str
106 | playable_url: str
107 |
108 | @property
109 | def id(self) -> int:
110 | return self.father.id
111 |
112 | @property
113 | def display_artists(self) -> str:
114 | return "、".join(self.artists)
115 |
116 | @property
117 | def display_name(self) -> str:
118 | return format_alias(self.name, self.alias)
119 |
120 | @property
121 | def display_duration(self) -> str:
122 | return format_time(self.duration)
123 |
124 | @property
125 | def file_suffix(self) -> Optional[str]:
126 | return URL(self.playable_url).suffix.removeprefix(".") or None
127 |
128 | @property
129 | def display_filename(self) -> str:
130 | return (
131 | f"{self.display_name} - {self.display_artists}.{self.file_suffix or 'mp3'}"
132 | )
133 |
134 | @property
135 | def download_filename(self) -> str:
136 | return f"{type(self.father).__name__}_{self.id}.{self.file_suffix or 'mp3'}"
137 |
138 | async def get_description(self) -> str:
139 | return await self.father.format_description(self)
140 |
141 |
142 | class BaseSong(ResolvableFromID, ABC, Generic[_TRawResp]):
143 | calling: ClassVar[str]
144 |
145 | def __init__(self, info: _TRawResp) -> None:
146 | self.info: _TRawResp = info
147 |
148 | def __str__(self) -> str:
149 | return f"{type(self).__name__}(id={self.id})"
150 |
151 | def __eq__(self, value: object, /) -> bool:
152 | return isinstance(value, type(self)) and value.id == self.id
153 |
154 | @property
155 | @abstractmethod
156 | @override
157 | def id(self) -> int: ...
158 |
159 | @classmethod
160 | @abstractmethod
161 | @override
162 | async def from_id(cls, arg_id: int) -> Self: ...
163 |
164 | @abstractmethod
165 | async def get_name(self) -> str: ...
166 |
167 | @abstractmethod
168 | async def get_alias(self) -> Optional[list[str]]: ...
169 |
170 | @abstractmethod
171 | async def get_artists(self) -> list[str]: ...
172 |
173 | @abstractmethod
174 | async def get_duration(self) -> int: ...
175 |
176 | @abstractmethod
177 | async def get_cover_url(self) -> str: ...
178 |
179 | @abstractmethod
180 | async def get_playable_url(self) -> str: ...
181 |
182 | @abstractmethod
183 | async def get_lyrics(self) -> Optional[list[NCMLrcGroupLine]]: ...
184 |
185 | async def get_info(self) -> SongInfo:
186 | (
187 | (name, alias, artists, duration, url, cover_url),
188 | (playable_url,),
189 | ) = await asyncio.gather( # treat type checker
190 | asyncio.gather(
191 | self.get_name(),
192 | self.get_alias(),
193 | self.get_artists(),
194 | self.get_duration(),
195 | self.get_url(),
196 | self.get_cover_url(),
197 | ),
198 | asyncio.gather(
199 | self.get_playable_url(),
200 | ),
201 | )
202 | return SongInfo(
203 | father=self,
204 | name=name,
205 | alias=alias,
206 | artists=artists,
207 | duration=duration,
208 | url=url,
209 | cover_url=cover_url,
210 | playable_url=playable_url,
211 | )
212 |
213 | @classmethod
214 | def is_info_from_cls(cls, info: SongInfo) -> TypeGuard[SongInfo[Self]]:
215 | return isinstance(info.father, cls)
216 |
217 | @classmethod
218 | async def format_description(cls, info: SongInfo) -> str:
219 | # if not cls.is_info_from_cls(info):
220 | # raise TypeError("Info is not from this class")
221 | alias = format_alias("", info.alias) if info.alias else ""
222 | return f"{info.name}{alias}\nBy:{info.display_artists}\n时长 {info.display_duration}"
223 |
224 |
225 | @dataclass
226 | class ListPageCard:
227 | cover: str
228 | title: str
229 | alias: str = ""
230 | extras: list[str] = field(default_factory=list)
231 | small_extras: list[str] = field(default_factory=list)
232 |
233 |
234 | @dataclass
235 | class BaseSongListPage(Generic[_TRawRespInner, _TSongList]):
236 | content: Iterable[_TRawRespInner]
237 | father: _TSongList
238 |
239 | @override
240 | def __str__(self) -> str:
241 | return f"{type(self).__name__}(father={self.father})"
242 |
243 | @classmethod
244 | @abstractmethod
245 | async def transform_resp_to_list_card(
246 | cls,
247 | resp: _TRawRespInner,
248 | ) -> ListPageCard: ...
249 |
250 | async def transform_to_list_cards(self) -> list[ListPageCard]:
251 | return await asyncio.gather(
252 | *[self.transform_resp_to_list_card(resp) for resp in self.content],
253 | )
254 |
255 |
256 | class BaseSongList(ABC, Generic[_TRawResp, _TRawRespInner, _TSongOrList]):
257 | child_calling: ClassVar[str]
258 |
259 | def __init__(self) -> None:
260 | self.current_page: int = 1
261 | self._total_count: Optional[int] = None
262 | self._cache: dict[int, _TRawRespInner] = {}
263 |
264 | def __str__(self) -> str:
265 | return (
266 | f"{type(self).__name__}"
267 | f"(current_page={self.current_page}, total_count={self._total_count})"
268 | )
269 |
270 | @abstractmethod
271 | def __eq__(self, value: object, /) -> bool: ...
272 |
273 | @property
274 | def total_count(self) -> int:
275 | if self._total_count is None:
276 | raise ValueError("Total count not set, please call get_page first")
277 | return self._total_count
278 |
279 | @property
280 | def max_page(self) -> int:
281 | return calc_max_page(self.total_count)
282 |
283 | @property
284 | def is_first_page(self) -> bool:
285 | return self.current_page == 1
286 |
287 | @property
288 | def is_last_page(self) -> bool:
289 | return self.current_page == self.max_page
290 |
291 | @abstractmethod
292 | async def _extract_resp_content(
293 | self,
294 | resp: _TRawResp,
295 | ) -> Optional[list[_TRawRespInner]]: ...
296 |
297 | @abstractmethod
298 | async def _extract_total_count(self, resp: _TRawResp) -> int: ...
299 |
300 | @abstractmethod
301 | async def _do_get_page(self, page: int) -> _TRawResp: ...
302 |
303 | @abstractmethod
304 | async def _build_selection(self, resp: _TRawRespInner) -> _TSongOrList: ...
305 |
306 | @abstractmethod
307 | async def _build_list_page(
308 | self,
309 | resp: Iterable[_TRawRespInner],
310 | ) -> BaseSongListPage[_TRawRespInner, Self]: ...
311 |
312 | def _update_cache(self, page: int, data: list[_TRawRespInner]):
313 | min_index = calc_min_index(page)
314 | self._cache.update({min_index + i: item for i, item in enumerate(data)})
315 |
316 | def page_valid(self, page: int) -> bool:
317 | return 1 <= page <= self.max_page
318 |
319 | def index_valid(self, index: int) -> bool:
320 | return 0 <= index < self.total_count
321 |
322 | async def get_page(
323 | self,
324 | page: Optional[int] = None,
325 | ) -> Union[BaseSongListPage[_TRawRespInner, Self], _TSongOrList, None]:
326 | if page is None:
327 | page = self.current_page
328 | if not ((not self._total_count) or self.page_valid(page)):
329 | raise ValueError("Page out of range")
330 |
331 | min_index = calc_min_index(page)
332 | max_index = min_index + config.ncm_list_limit
333 | index_range = range(min_index, max_index + 1)
334 | if all(page in self._cache for page in index_range):
335 | return await self._build_list_page(
336 | self._cache[page] for page in index_range
337 | )
338 |
339 | resp = await self._do_get_page(page)
340 | content = await self._extract_resp_content(resp)
341 | self._total_count = await self._extract_total_count(resp)
342 | self.current_page = page
343 | if content is None:
344 | return None
345 | if len(content) == 1:
346 | return await self._build_selection(content[0])
347 |
348 | self._cache.update({min_index + i: item for i, item in enumerate(content)})
349 | return await self._build_list_page(content)
350 |
351 | async def select(self, index: int) -> _TSongOrList:
352 | page_num = calc_page_number(index)
353 | if index in self._cache:
354 | content = self._cache[index]
355 | elif not (1 <= page_num <= self.max_page):
356 | raise ValueError("Index out of range")
357 | else:
358 | resp = await self._extract_resp_content(await self._do_get_page(page_num))
359 | if resp is None:
360 | raise ValueError("Empty response, index may out of range")
361 | self._update_cache(page_num, resp)
362 | min_index = calc_min_index(page_num)
363 | content = resp[index - min_index]
364 | return await self._build_selection(content)
365 |
366 |
367 | @dataclass
368 | class PlaylistInfo(Generic[_TPlaylist]):
369 | father: _TPlaylist
370 | name: str
371 | creators: list[str]
372 | url: str
373 | cover_url: str
374 |
375 | @property
376 | def id(self) -> int:
377 | return self.father.id
378 |
379 | @property
380 | def display_creators(self) -> str:
381 | return "、".join(self.creators)
382 |
383 | async def get_description(self) -> str:
384 | return await self.father.format_description(self)
385 |
386 |
387 | class BasePlaylist(
388 | ResolvableFromID,
389 | BaseSongList[_TRawResp, _TRawRespInner, _TSongOrList],
390 | Generic[_TRawInfo, _TRawResp, _TRawRespInner, _TSongOrList],
391 | ):
392 | calling: ClassVar[str]
393 |
394 | @override
395 | def __init__(self, info: _TRawInfo) -> None:
396 | super().__init__()
397 | self.info: _TRawInfo = info
398 |
399 | @override
400 | def __str__(self) -> str:
401 | return f"{super().__str__()[:-1]}, id={self.id})"
402 |
403 | @override
404 | def __eq__(self, value: object, /) -> bool:
405 | return isinstance(value, type(self)) and value.id == self.id
406 |
407 | @property
408 | @abstractmethod
409 | @override
410 | def id(self) -> int: ...
411 |
412 | @classmethod
413 | @abstractmethod
414 | @override
415 | async def from_id(cls, arg_id: int) -> Self: ...
416 |
417 | @abstractmethod
418 | async def get_name(self) -> str: ...
419 |
420 | @abstractmethod
421 | async def get_creators(self) -> list[str]: ...
422 |
423 | @abstractmethod
424 | async def get_cover_url(self) -> str: ...
425 |
426 | async def get_info(self) -> PlaylistInfo:
427 | name, creators, url, cover_url = await asyncio.gather(
428 | self.get_name(),
429 | self.get_creators(),
430 | self.get_url(),
431 | self.get_cover_url(),
432 | )
433 | return PlaylistInfo(
434 | father=self,
435 | name=name,
436 | creators=creators,
437 | url=url,
438 | cover_url=cover_url,
439 | )
440 |
441 | @classmethod
442 | def is_info_from_cls(
443 | cls,
444 | info: PlaylistInfo,
445 | ) -> TypeGuard[PlaylistInfo[Self]]:
446 | return isinstance(info.father, cls)
447 |
448 | @classmethod
449 | async def format_description(cls, info: PlaylistInfo) -> str:
450 | # if not cls.is_info_from_cls(info):
451 | # raise TypeError("Info is not from this class")
452 | return f"{info.father.calling}:{info.name}\nBy: {info.display_creators}"
453 |
454 |
455 | class BaseSearcher(BaseSongList[_TRawResp, _TRawRespInner, _TSongOrList]):
456 | commands: tuple[str, ...]
457 |
458 | @override
459 | def __init__(self, keyword: str) -> None:
460 | super().__init__()
461 | self.keyword: str = keyword
462 |
463 | @override
464 | def __str__(self) -> str:
465 | return f"{super().__str__()[:-1]}, keyword={self.keyword})"
466 |
467 | @override
468 | def __eq__(self, value: object, /) -> bool:
469 | return isinstance(value, type(self)) and value.keyword == self.keyword
470 |
471 | @staticmethod
472 | @abstractmethod
473 | async def search_from_id(arg_id: int) -> Optional[_TSongOrList]: ...
474 |
475 | @override
476 | async def get_page(
477 | self,
478 | page: Optional[int] = None,
479 | ) -> Union[BaseSongListPage[_TRawRespInner, Self], _TSongOrList, None]:
480 | if self.keyword.isdigit():
481 | with suppress(Exception):
482 | if song := await self.search_from_id(int(self.keyword)):
483 | return song
484 | return await super().get_page(page)
485 |
486 |
487 | BaseResolvable: TypeAlias = Union[BaseSong, BasePlaylist]
488 |
489 | GeneralSong: TypeAlias = BaseSong[Any]
490 | GeneralSongOrList: TypeAlias = Union[
491 | GeneralSong,
492 | BaseSongList[Any, SongListInnerResp, "GeneralSongOrList"],
493 | ]
494 | GeneralSongList: TypeAlias = BaseSongList[Any, SongListInnerResp, GeneralSongOrList]
495 | GeneralPlaylist: TypeAlias = BasePlaylist[Any, Any, SongListInnerResp, GeneralSong]
496 | GeneralSearcher: TypeAlias = BaseSearcher[Any, SongListInnerResp, GeneralSongOrList]
497 | GeneralSongListPage: TypeAlias = BaseSongListPage[SongListInnerResp, GeneralSongList]
498 | GeneralSongOrPlaylist: TypeAlias = Union[GeneralSong, GeneralPlaylist]
499 | # GeneralResolvable: TypeAlias = GeneralSongOrPlaylist
500 |
501 | GeneralGetPageReturn: TypeAlias = Union[
502 | BaseSongListPage[SongListInnerResp, GeneralSongList],
503 | GeneralSongOrList,
504 | None,
505 | ]
506 | GeneralSongInfo: TypeAlias = SongInfo[GeneralSong]
507 | GeneralPlaylistInfo: TypeAlias = PlaylistInfo[GeneralPlaylist]
508 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/playlist.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Iterable
2 | from contextlib import suppress
3 | from typing import Generic, Optional, TypeVar
4 | from typing_extensions import Self, override
5 |
6 | from ..utils import calc_min_max_index, cut_string, get_thumb_url
7 | from .base import (
8 | BasePlaylist,
9 | BaseSearcher,
10 | BaseSongList,
11 | BaseSongListPage,
12 | ListPageCard,
13 | PlaylistInfo,
14 | playlist,
15 | searcher,
16 | )
17 | from .raw import get_playlist_info, get_track_info, md, search_playlist
18 | from .song import Song, SongListPage
19 |
20 | _TSongList = TypeVar("_TSongList", bound=BaseSongList)
21 |
22 |
23 | class PlaylistListPage(
24 | BaseSongListPage[md.BasePlaylist, _TSongList],
25 | Generic[_TSongList],
26 | ):
27 | @override
28 | @classmethod
29 | async def transform_resp_to_list_card(cls, resp: md.BasePlaylist) -> ListPageCard:
30 | return ListPageCard(
31 | cover=get_thumb_url(resp.cover_img_url),
32 | title=resp.name,
33 | extras=[resp.creator.nickname],
34 | small_extras=[
35 | f"歌曲数 {resp.track_count} | "
36 | f"播放 {resp.play_count} | 收藏 {resp.book_count}",
37 | ],
38 | )
39 |
40 |
41 | @playlist
42 | class Playlist(BasePlaylist[md.Playlist, list[md.Song], md.Song, Song]):
43 | calling = "歌单"
44 | child_calling = Song.calling
45 | link_types = ("playlist",)
46 |
47 | @property
48 | @override
49 | def id(self) -> int:
50 | return self.info.id
51 |
52 | @classmethod
53 | @override
54 | async def from_id(cls, arg_id: int) -> Self:
55 | resp = await get_playlist_info(arg_id)
56 | return cls(resp)
57 |
58 | @override
59 | async def _extract_resp_content(self, resp: list[md.Song]) -> list[md.Song]:
60 | return resp
61 |
62 | @override
63 | async def _extract_total_count(self, resp: list[md.Song]) -> int:
64 | return self.info.track_count
65 |
66 | @override
67 | async def _do_get_page(self, page: int) -> list[md.Song]:
68 | min_index, max_index = calc_min_max_index(page)
69 | track_ids = [x.id for x in self.info.track_ids[min_index:max_index]]
70 | return await get_track_info(track_ids)
71 |
72 | @override
73 | async def _build_selection(self, resp: md.Song) -> Song:
74 | return Song(info=resp)
75 |
76 | @override
77 | async def _build_list_page(self, resp: Iterable[md.Song]) -> SongListPage[Self]:
78 | return SongListPage(resp, self)
79 |
80 | @override
81 | async def get_name(self) -> str:
82 | return self.info.name
83 |
84 | @override
85 | async def get_creators(self) -> list[str]:
86 | return [self.info.creator.nickname]
87 |
88 | @override
89 | async def get_cover_url(self) -> str:
90 | return self.info.cover_img_url
91 |
92 | @override
93 | @classmethod
94 | async def format_description(cls, info: PlaylistInfo) -> str:
95 | if not cls.is_info_from_cls(info):
96 | raise TypeError("Info is not from this class")
97 | base_desc = await super().format_description(info)
98 | self = info.father
99 | lst_desc = f"\n{cut_string(d)}" if (d := self.info.description) else ""
100 | return (
101 | f"{base_desc}\n"
102 | f"播放 {self.info.play_count} | "
103 | f"收藏 {self.info.book_count} | "
104 | f"评论 {self.info.comment_count} | "
105 | f"分享 {self.info.share_count}"
106 | f"{lst_desc}"
107 | )
108 |
109 |
110 | @searcher
111 | class PlaylistSearcher(
112 | BaseSearcher[md.PlaylistSearchResult, md.BasePlaylist, Playlist],
113 | ):
114 | child_calling = Playlist.calling
115 | commands = ("网易歌单", "wygd", "wypli")
116 |
117 | @override
118 | @staticmethod
119 | async def search_from_id(arg_id: int) -> Optional[Playlist]:
120 | with suppress(Exception):
121 | return await Playlist.from_id(arg_id)
122 | return None
123 |
124 | @override
125 | async def _extract_resp_content(
126 | self,
127 | resp: md.PlaylistSearchResult,
128 | ) -> Optional[list[md.BasePlaylist]]:
129 | return resp.playlists
130 |
131 | @override
132 | async def _extract_total_count(self, resp: md.PlaylistSearchResult) -> int:
133 | return resp.playlist_count
134 |
135 | @override
136 | async def _do_get_page(self, page: int) -> md.PlaylistSearchResult:
137 | return await search_playlist(self.keyword, page=page)
138 |
139 | @override
140 | async def _build_selection(self, resp: md.BasePlaylist) -> Playlist:
141 | return await Playlist.from_id(resp.id)
142 |
143 | @override
144 | async def _build_list_page(
145 | self,
146 | resp: Iterable[md.BasePlaylist],
147 | ) -> PlaylistListPage[Self]:
148 | return PlaylistListPage(resp, self)
149 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/program.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Iterable
2 | from typing import Generic, Optional, TypeVar
3 | from typing_extensions import Self, override
4 |
5 | from ..utils import cut_string, format_time, get_thumb_url
6 | from .base import (
7 | BaseSearcher,
8 | BaseSong,
9 | BaseSongList,
10 | BaseSongListPage,
11 | ListPageCard,
12 | SongInfo,
13 | searcher,
14 | song,
15 | )
16 | from .raw import get_program_info, get_track_audio, md, search_program
17 |
18 | _TSongList = TypeVar("_TSongList", bound=BaseSongList)
19 |
20 |
21 | class ProgramListPage(
22 | BaseSongListPage[md.ProgramBaseInfo, _TSongList],
23 | Generic[_TSongList],
24 | ):
25 | @override
26 | @classmethod
27 | async def transform_resp_to_list_card(
28 | cls,
29 | resp: md.ProgramBaseInfo,
30 | ) -> ListPageCard:
31 | return ListPageCard(
32 | cover=get_thumb_url(resp.cover_url),
33 | title=resp.name,
34 | extras=[resp.radio.name],
35 | small_extras=[
36 | (
37 | f"{format_time(resp.duration)} | "
38 | f"播放 {resp.listener_count} | 点赞 {resp.liked_count}"
39 | ),
40 | ],
41 | )
42 |
43 |
44 | @song
45 | class Program(BaseSong[md.ProgramBaseInfo]):
46 | calling = "声音"
47 | link_types = ("program", "dj")
48 |
49 | @property
50 | @override
51 | def id(self) -> int:
52 | return self.info.id
53 |
54 | @classmethod
55 | @override
56 | async def from_id(cls, arg_id: int) -> Self:
57 | info = await get_program_info(arg_id)
58 | if not info:
59 | raise ValueError("Voice not found")
60 | return cls(info)
61 |
62 | @override
63 | async def get_name(self) -> str:
64 | return self.info.name
65 |
66 | @override
67 | async def get_alias(self) -> Optional[list[str]]:
68 | return None
69 |
70 | @override
71 | async def get_artists(self) -> list[str]:
72 | return [self.info.radio.name]
73 |
74 | @override
75 | async def get_duration(self) -> int:
76 | return self.info.duration
77 |
78 | @override
79 | async def get_cover_url(self) -> str:
80 | return self.info.cover_url
81 |
82 | @override
83 | async def get_playable_url(self) -> str:
84 | song_id = self.info.main_track_id
85 | info = (await get_track_audio([song_id]))[0]
86 | return info.url
87 |
88 | @override
89 | async def get_lyrics(self) -> None:
90 | return None
91 |
92 | @override
93 | @classmethod
94 | async def format_description(cls, info: SongInfo) -> str:
95 | if not cls.is_info_from_cls(info):
96 | raise TypeError("Info is not from this class")
97 | self = info.father
98 | p_desc = f"\n{cut_string(d)}" if (d := self.info.description) else ""
99 | return (
100 | f"{cls.calling}:{self.info.name}\n"
101 | f"电台:{self.info.radio.name}\n"
102 | f"台主:{self.info.dj.nickname}\n"
103 | f"时长 {info.display_duration} | "
104 | f"播放 {self.info.listener_count} | "
105 | f"点赞 {self.info.liked_count} | "
106 | f"评论 {self.info.comment_count} | "
107 | f"分享 {self.info.share_count}"
108 | f"{p_desc}"
109 | )
110 |
111 |
112 | @searcher
113 | class ProgramSearcher(
114 | BaseSearcher[md.ProgramSearchResult, md.ProgramBaseInfo, Program],
115 | ):
116 | child_calling = Program.calling
117 | commands = ("网易声音", "wysy", "wyprog")
118 |
119 | @staticmethod
120 | @override
121 | async def search_from_id(arg_id: int) -> Optional[Program]:
122 | try:
123 | return await Program.from_id(arg_id)
124 | except ValueError:
125 | return None
126 |
127 | @override
128 | async def _extract_resp_content(
129 | self,
130 | resp: md.ProgramSearchResult,
131 | ) -> Optional[list[md.ProgramBaseInfo]]:
132 | return [x.base_info for x in resp.resources] if resp.resources else None
133 |
134 | @override
135 | async def _extract_total_count(self, resp: md.ProgramSearchResult) -> int:
136 | return resp.total_count
137 |
138 | @override
139 | async def _do_get_page(self, page: int) -> md.ProgramSearchResult:
140 | return await search_program(self.keyword, page=page)
141 |
142 | @override
143 | async def _build_selection(self, resp: md.ProgramBaseInfo) -> Program:
144 | return Program(info=resp)
145 |
146 | @override
147 | async def _build_list_page(
148 | self,
149 | resp: Iterable[md.ProgramBaseInfo],
150 | ) -> ProgramListPage[Self]:
151 | return ProgramListPage(resp, self)
152 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/radio.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Iterable
2 | from contextlib import suppress
3 | from typing import Generic, Optional, TypeVar
4 | from typing_extensions import Self, override
5 |
6 | from ..utils import cut_string, get_thumb_url
7 | from .base import (
8 | BasePlaylist,
9 | BaseSearcher,
10 | BaseSongList,
11 | BaseSongListPage,
12 | ListPageCard,
13 | PlaylistInfo,
14 | playlist,
15 | searcher,
16 | )
17 | from .program import Program, ProgramListPage
18 | from .raw import get_radio_info, get_radio_programs, md, search_radio
19 |
20 | _TSongList = TypeVar("_TSongList", bound=BaseSongList)
21 |
22 |
23 | class RadioListPage(
24 | BaseSongListPage[md.RadioBaseInfo, _TSongList],
25 | Generic[_TSongList],
26 | ):
27 | @override
28 | @classmethod
29 | async def transform_resp_to_list_card(
30 | cls,
31 | resp: md.RadioBaseInfo,
32 | ) -> ListPageCard:
33 | return ListPageCard(
34 | cover=get_thumb_url(resp.pic_url),
35 | title=resp.name,
36 | extras=[resp.dj.nickname],
37 | small_extras=[
38 | f"节目数 {resp.program_count} | "
39 | f"播放 {resp.play_count} | 收藏 {resp.sub_count}",
40 | ],
41 | )
42 |
43 |
44 | @playlist
45 | class Radio(BasePlaylist[md.Radio, md.RadioProgramList, md.ProgramBaseInfo, Program]):
46 | calling = "电台"
47 | child_calling = Program.calling
48 | link_types = ("radio",)
49 |
50 | @property
51 | @override
52 | def id(self) -> int:
53 | return self.info.id
54 |
55 | @classmethod
56 | @override
57 | async def from_id(cls, arg_id: int) -> Self:
58 | resp = await get_radio_info(arg_id)
59 | return cls(resp)
60 |
61 | @override
62 | async def _extract_resp_content(
63 | self,
64 | resp: md.RadioProgramList,
65 | ) -> list[md.ProgramBaseInfo]:
66 | return resp.programs
67 |
68 | @override
69 | async def _extract_total_count(self, resp: md.RadioProgramList) -> int:
70 | return resp.count
71 |
72 | @override
73 | async def _do_get_page(self, page: int) -> md.RadioProgramList:
74 | return await get_radio_programs(self.id, page)
75 |
76 | @override
77 | async def _build_selection(self, resp: md.ProgramBaseInfo) -> Program:
78 | return Program(info=resp)
79 |
80 | @override
81 | async def _build_list_page(
82 | self,
83 | resp: Iterable[md.ProgramBaseInfo],
84 | ) -> ProgramListPage[Self]:
85 | return ProgramListPage(resp, self)
86 |
87 | @override
88 | async def get_name(self) -> str:
89 | return self.info.name
90 |
91 | @override
92 | async def get_creators(self) -> list[str]:
93 | return [self.info.dj.nickname]
94 |
95 | @override
96 | async def get_cover_url(self) -> str:
97 | return self.info.pic_url
98 |
99 | @override
100 | @classmethod
101 | async def format_description(cls, info: PlaylistInfo) -> str:
102 | if not cls.is_info_from_cls(info):
103 | raise TypeError("Info is not from this class")
104 | base_desc = await super().format_description(info)
105 | self = info.father
106 | sec_category = f"/{c}" if (c := self.info.second_category) else ""
107 | lst_desc = f"\n{cut_string(d)}" if (d := self.info.desc) else ""
108 | return (
109 | f"{base_desc}\n"
110 | f"分类:{self.info.category}{sec_category}\n"
111 | f"播放 {self.info.play_count} | "
112 | f"收藏 {self.info.sub_count} | "
113 | f"点赞 {self.info.liked_count} | "
114 | f"评论 {self.info.comment_count} | "
115 | f"分享 {self.info.share_count}"
116 | f"{lst_desc}"
117 | )
118 |
119 |
120 | @searcher
121 | class RadioSearcher(BaseSearcher[md.RadioSearchResult, md.RadioBaseInfo, Radio]):
122 | child_calling = Radio.calling
123 | commands = ("网易电台", "wydt", "wydj")
124 |
125 | @override
126 | @staticmethod
127 | async def search_from_id(arg_id: int) -> Optional[Radio]:
128 | with suppress(Exception):
129 | return await Radio.from_id(arg_id)
130 | return None
131 |
132 | @override
133 | async def _extract_resp_content(
134 | self,
135 | resp: md.RadioSearchResult,
136 | ) -> Optional[list[md.RadioBaseInfo]]:
137 | return [x.base_info for x in resp.resources] if resp.resources else None
138 |
139 | @override
140 | async def _extract_total_count(self, resp: md.RadioSearchResult) -> int:
141 | return resp.total_count
142 |
143 | @override
144 | async def _do_get_page(self, page: int) -> md.RadioSearchResult:
145 | return await search_radio(self.keyword, page=page)
146 |
147 | @override
148 | async def _build_selection(self, resp: md.RadioBaseInfo) -> Radio:
149 | return await Radio.from_id(resp.id)
150 |
151 | @override
152 | async def _build_list_page(
153 | self,
154 | resp: Iterable[md.RadioBaseInfo],
155 | ) -> RadioListPage[Self]:
156 | return RadioListPage(resp, self)
157 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/raw/__init__.py:
--------------------------------------------------------------------------------
1 | from . import models
2 | from .login import login as login
3 | from .request import (
4 | get_album_info as get_album_info,
5 | get_playlist_info as get_playlist_info,
6 | get_program_info as get_program_info,
7 | get_radio_info as get_radio_info,
8 | get_radio_programs as get_radio_programs,
9 | get_search_result as get_search_result,
10 | get_track_audio as get_track_audio,
11 | get_track_info as get_track_info,
12 | get_track_lrc as get_track_lrc,
13 | ncm_request as ncm_request,
14 | search_album as search_album,
15 | search_playlist as search_playlist,
16 | search_program as search_program,
17 | search_radio as search_radio,
18 | search_song as search_song,
19 | )
20 |
21 | md = models
22 | del models
23 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/raw/login.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import time
3 | from pathlib import Path
4 | from typing import Any, Optional
5 |
6 | import anyio
7 | import qrcode
8 | from cookit.loguru import warning_suppress
9 | from nonebot import logger
10 | from nonebot.utils import run_sync
11 | from pyncm import (
12 | DumpSessionAsString,
13 | GetCurrentSession,
14 | LoadSessionFromString,
15 | SetCurrentSession,
16 | )
17 | from pyncm.apis.login import (
18 | GetCurrentLoginStatus,
19 | LoginFailedException,
20 | LoginQrcodeCheck,
21 | LoginQrcodeUnikey,
22 | LoginViaAnonymousAccount,
23 | LoginViaCellphone,
24 | LoginViaEmail,
25 | SetSendRegisterVerifcationCodeViaCellphone,
26 | WriteLoginInfo,
27 | )
28 |
29 | from ...config import config
30 | from ...const import SESSION_FILE_PATH
31 | from .request import NCMResponseError, ncm_request
32 |
33 |
34 | async def sms_login(phone: str, country_code: int = 86):
35 | timeout = 60
36 |
37 | while True:
38 | await ncm_request(
39 | SetSendRegisterVerifcationCodeViaCellphone,
40 | phone,
41 | country_code,
42 | )
43 | last_send_time = time.time()
44 | logger.success(
45 | f"已发送验证码到 +{country_code} {'*' * (len(phone) - 3)}{phone[-3:]}",
46 | )
47 |
48 | while True:
49 | captcha = input("> 请输入验证码,留空直接回车代表重发: ").strip()
50 | if not captcha:
51 | if (time_passed := (time.time() - last_send_time)) >= timeout:
52 | break
53 | logger.warning(f"请等待 {timeout - time_passed:.0f} 秒后再重发")
54 | continue
55 |
56 | try:
57 | await ncm_request(
58 | LoginViaCellphone,
59 | phone=phone,
60 | ctcode=country_code,
61 | captcha=captcha,
62 | )
63 | except LoginFailedException as e:
64 | data: dict[str, Any] = e.args[0]
65 | if data.get("code") != 503:
66 | raise
67 | logger.error("验证码错误,请重新输入")
68 | else:
69 | return
70 |
71 |
72 | async def phone_login(
73 | phone: str,
74 | password: str = "",
75 | password_hash: str = "",
76 | country_code: int = 86,
77 | ):
78 | await run_sync(LoginViaCellphone)(
79 | ctcode=country_code,
80 | phone=phone,
81 | password=password,
82 | passwordHash=password_hash,
83 | )
84 |
85 |
86 | async def email_login(
87 | email: str,
88 | password: str = "",
89 | password_hash: str = "",
90 | ):
91 | await run_sync(LoginViaEmail)(
92 | email=email,
93 | password=password,
94 | passwordHash=password_hash,
95 | )
96 |
97 |
98 | async def qrcode_login():
99 | async def wait_scan(uni_key: str) -> bool:
100 | last_status: Optional[int] = None
101 | while True:
102 | await asyncio.sleep(2)
103 | try:
104 | await ncm_request(LoginQrcodeCheck, uni_key)
105 | except NCMResponseError as e:
106 | code = e.code
107 | if code != last_status:
108 | last_status = code
109 | extra_tip = (
110 | f" (用户:{e.data.get('nickname')})" if code == 802 else ""
111 | )
112 | logger.info(f"当前二维码状态:[{code}] {e.message}{extra_tip}")
113 | if code == 800:
114 | return False # 二维码过期
115 | if code == 803:
116 | return True # 授权成功
117 | if code and (code >= 1000):
118 | raise
119 |
120 | while True:
121 | uni_key: str = (await ncm_request(LoginQrcodeUnikey))["unikey"]
122 |
123 | url = f"https://music.163.com/login?codekey={uni_key}"
124 | qr = qrcode.QRCode()
125 | qr.add_data(url)
126 |
127 | logger.info("请使用网易云音乐 APP 扫描下方二维码完成登录")
128 | qr.print_ascii()
129 |
130 | qr_img_filename = "multincm-qrcode.png"
131 | qr_img_path = Path.cwd() / qr_img_filename
132 | with warning_suppress("Failed to save qrcode image"):
133 | qr.make_image().save(
134 | str(qr_img_path), # type: ignore
135 | )
136 | logger.info(
137 | f"二维码图片已保存至 Bot 根目录的 {qr_img_filename} 文件"
138 | f",如终端中二维码无法扫描可使用",
139 | )
140 |
141 | logger.info("或使用下方 URL 生成二维码扫描登录:")
142 | logger.info(url)
143 |
144 | try:
145 | scan_res = await wait_scan(uni_key)
146 | finally:
147 | with warning_suppress("Failed to delete qrcode image"):
148 | qr_img_path.unlink(missing_ok=True)
149 | if scan_res:
150 | return
151 |
152 |
153 | async def anonymous_login():
154 | await ncm_request(LoginViaAnonymousAccount)
155 |
156 |
157 | async def validate_login():
158 | with warning_suppress("Failed to get login status"):
159 | ret = await ncm_request(GetCurrentLoginStatus)
160 | ok = bool(ret.get("account"))
161 | if ok:
162 | WriteLoginInfo(ret, GetCurrentSession())
163 | return ok
164 | return False
165 |
166 |
167 | async def do_login(anonymous: bool = False):
168 | using_cached_session = False
169 |
170 | if anonymous:
171 | logger.info("使用游客身份登录")
172 | await anonymous_login()
173 |
174 | elif using_cached_session := SESSION_FILE_PATH.exists():
175 | logger.info(f"使用缓存登录态 ({SESSION_FILE_PATH})")
176 | SetCurrentSession(
177 | LoadSessionFromString(
178 | (await anyio.Path(SESSION_FILE_PATH).read_text(encoding="u8")),
179 | ),
180 | )
181 |
182 | elif config.ncm_phone:
183 | if config.ncm_password or config.ncm_password_hash:
184 | logger.info("使用手机号与密码登录")
185 | await phone_login(
186 | config.ncm_phone,
187 | config.ncm_password or "",
188 | config.ncm_password_hash or "",
189 | )
190 | else:
191 | logger.info("使用短信验证登录")
192 | await sms_login(config.ncm_phone)
193 |
194 | elif (has_password := bool(config.ncm_password or config.ncm_password_hash)) and (
195 | config.ncm_email
196 | ):
197 | logger.info("使用邮箱与密码登录")
198 | await email_login(
199 | config.ncm_email,
200 | config.ncm_password or "",
201 | config.ncm_password_hash or "",
202 | )
203 |
204 | else:
205 | if config.ncm_email and (not has_password):
206 | logger.warning("配置文件中提供了邮箱,但是通过邮箱登录需要提供密码")
207 | logger.info("使用二维码登录")
208 | await qrcode_login()
209 |
210 | if not (await validate_login()) and using_cached_session:
211 | SESSION_FILE_PATH.unlink()
212 | logger.warning("恢复缓存会话失败,尝试使用正常流程登录")
213 | await do_login()
214 | return
215 |
216 | session_exists = GetCurrentSession()
217 | if anonymous:
218 | logger.success("游客登录成功")
219 | else:
220 | if not using_cached_session:
221 | SESSION_FILE_PATH.write_text(
222 | DumpSessionAsString(session_exists),
223 | "u8",
224 | )
225 | logger.success(
226 | f"登录成功,欢迎您,{session_exists.nickname} [{session_exists.uid}]",
227 | )
228 |
229 |
230 | async def login():
231 | # if "nonebot-plugin-ncm" in get_available_plugin_names():
232 | # logger.info("nonebot-plugin-ncm 已安装,本插件将依赖其全局 Session")
233 | # require("nonebot-plugin-ncm")
234 | # return
235 |
236 | if GetCurrentSession().logged_in:
237 | logger.info("检测到当前全局 Session 已登录,插件将跳过登录步骤")
238 | return
239 |
240 | if not config.ncm_anonymous:
241 | with warning_suppress("登录失败,回落到游客登录"):
242 | await do_login()
243 | return
244 |
245 | with warning_suppress("登录失败"):
246 | await do_login(anonymous=True)
247 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/raw/models.py:
--------------------------------------------------------------------------------
1 | from typing import Literal, Optional
2 | from typing_extensions import TypeAlias
3 |
4 | from cookit.pyd import CamelAliasModel
5 | from pydantic import Field
6 |
7 | BrLevelType: TypeAlias = Literal[
8 | "hires",
9 | "lossless",
10 | "exhigh",
11 | "higher",
12 | "standard",
13 | "none",
14 | ]
15 |
16 |
17 | class Artist(CamelAliasModel):
18 | id: int
19 | name: str
20 | tns: Optional[list[str]] = None
21 | alias: Optional[list[str]] = None
22 |
23 |
24 | class BaseAlbum(CamelAliasModel):
25 | id: int
26 | name: str
27 | pic_url: str
28 |
29 |
30 | class Album(BaseAlbum):
31 | size: int
32 | artists: list[Artist]
33 |
34 |
35 | class Privilege(CamelAliasModel):
36 | id: int
37 | pl: int
38 | # plLevel: BrLevel
39 |
40 |
41 | class Song(CamelAliasModel):
42 | name: str
43 | id: int
44 | ar: list[Artist]
45 | alias: list[str] = Field(..., alias="alia")
46 | pop: int
47 | al: BaseAlbum
48 | dt: int
49 | """歌曲时长,单位 ms"""
50 | tns: Optional[list[str]] = None
51 | privilege: Optional[Privilege] = None
52 |
53 |
54 | class QcReminder(CamelAliasModel):
55 | qc_reminder_part: str
56 | """纠正内容部分"""
57 | high_light: bool
58 | """部分是否高亮(可点击纠正)"""
59 |
60 |
61 | class SearchQcReminder(CamelAliasModel):
62 | qc_reminders: list[QcReminder]
63 | qc_reminder_channel: str
64 |
65 |
66 | class SongSearchResult(CamelAliasModel):
67 | search_qc_reminder: Optional[SearchQcReminder] = None
68 | """搜索纠正"""
69 | song_count: int
70 | songs: Optional[list[Song]] = None
71 |
72 |
73 | class TrackAudio(CamelAliasModel):
74 | id: int
75 | url: str
76 | br: int
77 | size: int
78 | md5: str
79 | level: Optional[str] = None
80 | encode_type: Optional[str] = None
81 | time: int
82 |
83 |
84 | class User(CamelAliasModel):
85 | id: int
86 | user_id: int = Field(..., alias="userid")
87 | nickname: str
88 |
89 |
90 | class Lyric(CamelAliasModel):
91 | version: int
92 | lyric: str
93 |
94 |
95 | class LyricData(CamelAliasModel):
96 | trans_user: Optional[User] = None
97 | lyric_user: Optional[User] = None
98 | lrc: Optional[Lyric] = None
99 | trans_lrc: Optional[Lyric] = Field(None, alias="tlyric")
100 | roma_lrc: Optional[Lyric] = Field(None, alias="romalrc")
101 |
102 |
103 | class DJ(CamelAliasModel):
104 | user_id: int
105 | nickname: str
106 | avatar_url: str
107 | gender: int
108 | signature: str
109 | background_url: str
110 |
111 |
112 | class BaseRadio(CamelAliasModel):
113 | id: int
114 | name: str
115 | pic_url: str
116 | desc: str
117 | sub_count: int
118 | program_count: int
119 | play_count: int
120 | category_id: int
121 | second_category_id: Optional[int] = None
122 | category: str
123 | second_category: Optional[str] = None
124 | last_program_id: int
125 |
126 |
127 | class RadioBaseInfo(BaseRadio):
128 | dj: DJ
129 |
130 |
131 | class Radio(RadioBaseInfo):
132 | share_count: int
133 | liked_count: int
134 | comment_count: int
135 |
136 |
137 | class ProgramBaseInfo(CamelAliasModel):
138 | id: int
139 | main_track_id: int
140 | name: str
141 | cover_url: str
142 | description: str
143 | dj: DJ
144 | radio: BaseRadio
145 | duration: int
146 | listener_count: int
147 | share_count: int
148 | liked_count: int
149 | comment_count: int
150 | comment_thread_id: str
151 |
152 |
153 | class ProgramResource(CamelAliasModel):
154 | base_info: ProgramBaseInfo
155 |
156 |
157 | class ProgramSearchResult(CamelAliasModel):
158 | resources: Optional[list[ProgramResource]] = None
159 | total_count: int
160 | search_qc_reminder: Optional[SearchQcReminder] = None
161 |
162 |
163 | class TrackId(CamelAliasModel):
164 | id: int
165 |
166 |
167 | class PlaylistCreator(CamelAliasModel):
168 | user_id: int
169 | nickname: str
170 |
171 |
172 | class BasePlaylist(CamelAliasModel):
173 | id: int
174 | name: str
175 | cover_img_url: str
176 | creator: PlaylistCreator
177 | track_count: int
178 | play_count: int
179 | book_count: int
180 | description: Optional[str] = None
181 |
182 |
183 | class Playlist(BasePlaylist):
184 | # tracks: List[Song]
185 | track_ids: list[TrackId]
186 | book_count: int = Field(alias="subscribedCount")
187 | share_count: int
188 | comment_count: int
189 |
190 |
191 | class PlaylistSearchResult(CamelAliasModel):
192 | playlists: Optional[list[BasePlaylist]] = None
193 | playlist_count: int
194 | search_qc_reminder: Optional[SearchQcReminder] = None
195 |
196 |
197 | class RadioResource(CamelAliasModel):
198 | base_info: RadioBaseInfo
199 |
200 |
201 | class RadioSearchResult(CamelAliasModel):
202 | resources: Optional[list[RadioResource]] = None
203 | total_count: int
204 | search_qc_reminder: Optional[SearchQcReminder] = None
205 |
206 |
207 | class RadioProgramList(CamelAliasModel):
208 | count: int
209 | programs: list[ProgramBaseInfo]
210 |
211 |
212 | class AlbumSearchResult(CamelAliasModel):
213 | albums: Optional[list[Album]] = None
214 | album_count: int
215 |
216 |
217 | class AlbumInfo(CamelAliasModel):
218 | album: Album
219 | songs: list[Song]
220 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/raw/request.py:
--------------------------------------------------------------------------------
1 | from functools import partial
2 | from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast, overload
3 | from typing_extensions import ParamSpec
4 |
5 | from nonebot.utils import run_sync
6 | from pydantic import BaseModel
7 | from pyncm.apis import EapiCryptoRequest, WeapiCryptoRequest, cloudsearch as search
8 | from pyncm.apis.album import GetAlbumInfo
9 | from pyncm.apis.cloudsearch import GetSearchResult
10 | from pyncm.apis.playlist import GetPlaylistInfo
11 | from pyncm.apis.track import GetTrackAudio, GetTrackDetail, GetTrackLyrics
12 |
13 | from ...config import config
14 | from ...utils import calc_min_index, debug
15 | from .models import (
16 | AlbumInfo,
17 | AlbumSearchResult,
18 | LyricData,
19 | Playlist,
20 | PlaylistSearchResult,
21 | Privilege,
22 | ProgramBaseInfo,
23 | ProgramSearchResult,
24 | Radio,
25 | RadioProgramList,
26 | RadioSearchResult,
27 | Song,
28 | SongSearchResult,
29 | TrackAudio,
30 | )
31 |
32 | TModel = TypeVar("TModel", bound=BaseModel)
33 | P = ParamSpec("P")
34 |
35 |
36 | class NCMResponseError(Exception):
37 | def __init__(self, name: str, data: dict[str, Any]):
38 | self.name = name
39 | self.data = data
40 |
41 | @property
42 | def code(self) -> Optional[int]:
43 | return self.data.get("code")
44 |
45 | @property
46 | def message(self) -> Optional[str]:
47 | return self.data.get("message")
48 |
49 | def __str__(self):
50 | return f"{self.name} failed: [{self.code}] {self.message}"
51 |
52 |
53 | async def ncm_request(
54 | api: Callable[P, Any],
55 | *args: P.args,
56 | **kwargs: P.kwargs,
57 | ) -> dict[str, Any]:
58 | ret = await run_sync(api)(*args, **kwargs)
59 | if debug.enabled:
60 | debug.write(ret, f"{api.__name__}_{{time}}.json")
61 | if ret.get("code", 200) != 200:
62 | raise NCMResponseError(api.__name__, ret)
63 | return ret
64 |
65 |
66 | @overload
67 | async def get_search_result(
68 | keyword: str,
69 | return_model: type[TModel],
70 | page: int = 1,
71 | search_type: int = search.SONG,
72 | **kwargs,
73 | ) -> TModel: ...
74 |
75 |
76 | @overload
77 | async def get_search_result(
78 | keyword: str,
79 | return_model: Literal[None] = None,
80 | page: int = 1,
81 | search_type: int = search.SONG,
82 | **kwargs,
83 | ) -> dict[str, Any]: ...
84 |
85 |
86 | async def get_search_result(
87 | keyword: str,
88 | return_model: Optional[type[TModel]] = None,
89 | page: int = 1,
90 | search_type: int = search.SONG,
91 | **kwargs,
92 | ) -> Union[dict[str, Any], TModel]:
93 | offset = calc_min_index(page)
94 | res = await ncm_request(
95 | GetSearchResult,
96 | keyword=keyword,
97 | limit=config.ncm_list_limit,
98 | offset=offset,
99 | stype=search_type,
100 | **kwargs,
101 | )
102 | result = res["result"]
103 | if return_model:
104 | return return_model(**result)
105 | return result
106 |
107 |
108 | search_song = partial(
109 | get_search_result,
110 | search_type=search.SONG,
111 | return_model=SongSearchResult,
112 | )
113 | search_playlist = partial(
114 | get_search_result,
115 | search_type=search.PLAYLIST,
116 | return_model=PlaylistSearchResult,
117 | )
118 | search_album = partial(
119 | get_search_result,
120 | search_type=search.ALBUM,
121 | return_model=AlbumSearchResult,
122 | )
123 |
124 |
125 | async def search_radio(keyword: str, page: int = 1):
126 | offset = calc_min_index(page)
127 |
128 | @EapiCryptoRequest # type: ignore
129 | def SearchRadio(): # noqa: N802
130 | return (
131 | "/eapi/search/voicelist/get",
132 | {
133 | "keyword": keyword,
134 | "scene": "normal",
135 | "limit": config.ncm_list_limit,
136 | "offset": offset or 0,
137 | },
138 | )
139 |
140 | res = await ncm_request(SearchRadio)
141 | return RadioSearchResult(**res["data"])
142 |
143 |
144 | async def search_program(keyword: str, page: int = 1):
145 | offset = calc_min_index(page)
146 |
147 | @WeapiCryptoRequest # type: ignore
148 | def SearchVoice(): # noqa: N802
149 | return (
150 | "/api/search/voice/get",
151 | {
152 | "keyword": keyword,
153 | "scene": "normal",
154 | "limit": config.ncm_list_limit,
155 | "offset": offset or 0,
156 | },
157 | )
158 |
159 | res = await ncm_request(SearchVoice)
160 | return ProgramSearchResult(**res["data"])
161 |
162 |
163 | async def get_track_audio(
164 | song_ids: list[int],
165 | bit_rate: int = 999999,
166 | **kwargs,
167 | ) -> list[TrackAudio]:
168 | res = await ncm_request(GetTrackAudio, song_ids, bitrate=bit_rate, **kwargs)
169 | return [TrackAudio(**x) for x in cast("list[dict]", res["data"])]
170 |
171 |
172 | async def get_track_info(ids: list[int], **kwargs) -> list[Song]:
173 | res = await ncm_request(GetTrackDetail, ids, **kwargs)
174 | privileges = {y.id: y for y in [Privilege(**x) for x in res["privileges"]]}
175 | return [
176 | Song(
177 | **x,
178 | privilege=(
179 | privileges[song_id]
180 | if (song_id := x["id"]) in privileges
181 | else Privilege(id=song_id, pl=128000) # , plLevel="standard")
182 | ),
183 | )
184 | for x in res["songs"]
185 | ]
186 |
187 |
188 | async def get_track_lrc(song_id: int):
189 | res = await ncm_request(GetTrackLyrics, str(song_id))
190 | return LyricData(**res)
191 |
192 |
193 | async def get_radio_info(radio_id: int):
194 | @WeapiCryptoRequest # type: ignore
195 | def GetRadioInfo(): # noqa: N802
196 | return ("/api/djradio/v2/get", {"id": radio_id})
197 |
198 | res = await ncm_request(GetRadioInfo)
199 | return Radio(**res["data"])
200 |
201 |
202 | async def get_radio_programs(radio_id: int, page: int = 1):
203 | @WeapiCryptoRequest # type: ignore
204 | def GetRadioPrograms(): # noqa: N802
205 | offset = calc_min_index(page)
206 | return (
207 | "/weapi/dj/program/byradio",
208 | {"radioId": radio_id, "limit": config.ncm_list_limit, "offset": offset},
209 | )
210 |
211 | res = await ncm_request(GetRadioPrograms)
212 | return RadioProgramList(**res)
213 |
214 |
215 | async def get_program_info(program_id: int):
216 | @WeapiCryptoRequest # type: ignore
217 | def GetProgramDetail(): # noqa: N802
218 | return ("/api/dj/program/detail", {"id": program_id})
219 |
220 | res = await ncm_request(GetProgramDetail)
221 | return ProgramBaseInfo(**res["program"])
222 |
223 |
224 | async def get_playlist_info(playlist_id: int):
225 | res = await ncm_request(GetPlaylistInfo, playlist_id)
226 | return Playlist(**res["playlist"])
227 |
228 |
229 | async def get_album_info(album_id: int):
230 | res = await ncm_request(GetAlbumInfo, str(album_id))
231 | return AlbumInfo(**res)
232 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/data_source/song.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Iterable
2 | from typing import Generic, Optional, TypeVar
3 | from typing_extensions import Self, override
4 |
5 | from ..utils import (
6 | format_artists,
7 | format_time,
8 | get_thumb_url,
9 | merge_alias,
10 | normalize_lrc,
11 | )
12 | from .base import (
13 | BaseSearcher,
14 | BaseSong,
15 | BaseSongList,
16 | BaseSongListPage,
17 | ListPageCard,
18 | searcher,
19 | song,
20 | )
21 | from .raw import get_track_audio, get_track_info, get_track_lrc, md, search_song
22 |
23 | _TSongList = TypeVar("_TSongList", bound=BaseSongList)
24 |
25 |
26 | class SongListPage(BaseSongListPage[md.Song, _TSongList], Generic[_TSongList]):
27 | @override
28 | @classmethod
29 | async def transform_resp_to_list_card(cls, resp: md.Song) -> ListPageCard:
30 | return ListPageCard(
31 | cover=get_thumb_url(resp.al.pic_url),
32 | title=resp.name,
33 | alias=";".join(merge_alias(resp)),
34 | extras=[format_artists(resp.ar)],
35 | small_extras=[f"{format_time(resp.dt)} | 热度 {resp.pop}"],
36 | )
37 |
38 |
39 | @song
40 | class Song(BaseSong[md.Song]):
41 | calling = "歌曲"
42 | link_types = ("song", "url")
43 |
44 | @property
45 | @override
46 | def id(self) -> int:
47 | return self.info.id
48 |
49 | @classmethod
50 | @override
51 | async def from_id(cls, arg_id: int) -> Self:
52 | info = (await get_track_info([arg_id]))[0]
53 | if not info:
54 | raise ValueError("Song not found")
55 | return cls(info)
56 |
57 | @override
58 | async def get_name(self) -> str:
59 | return self.info.name
60 |
61 | @override
62 | async def get_alias(self) -> list[str]:
63 | return merge_alias(self.info)
64 |
65 | @override
66 | async def get_artists(self) -> list[str]:
67 | return [x.name for x in self.info.ar]
68 |
69 | @override
70 | async def get_duration(self) -> int:
71 | return self.info.dt
72 |
73 | @override
74 | async def get_cover_url(self) -> str:
75 | return self.info.al.pic_url
76 |
77 | @override
78 | async def get_playable_url(self) -> str:
79 | info = (await get_track_audio([self.info.id]))[0]
80 | return info.url
81 |
82 | @override
83 | async def get_lyrics(self) -> Optional[list[list[str]]]:
84 | return normalize_lrc(await get_track_lrc(self.info.id))
85 |
86 |
87 | @searcher
88 | class SongSearcher(BaseSearcher[md.SongSearchResult, md.Song, Song]):
89 | child_calling = Song.calling
90 | commands = ("点歌", "网易云", "wyy", "网易点歌", "wydg", "wysong")
91 |
92 | @staticmethod
93 | @override
94 | async def search_from_id(arg_id: int) -> Optional[Song]:
95 | try:
96 | return await Song.from_id(arg_id)
97 | except ValueError:
98 | return None
99 |
100 | @override
101 | async def _extract_resp_content(
102 | self,
103 | resp: md.SongSearchResult,
104 | ) -> Optional[list[md.Song]]:
105 | return resp.songs
106 |
107 | @override
108 | async def _extract_total_count(self, resp: md.SongSearchResult) -> int:
109 | return resp.song_count
110 |
111 | @override
112 | async def _do_get_page(self, page: int) -> md.SongSearchResult:
113 | return await search_song(self.keyword, page=page)
114 |
115 | @override
116 | async def _build_selection(self, resp: md.Song) -> Song:
117 | return Song(info=resp)
118 |
119 | @override
120 | async def _build_list_page(
121 | self,
122 | resp: Iterable[md.Song],
123 | ) -> SongListPage[Self]:
124 | return SongListPage(resp, self)
125 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/__init__.py:
--------------------------------------------------------------------------------
1 | from .commands import load_commands as load_commands
2 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/cache.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from dataclasses import dataclass
3 | from typing import Generic, Optional, TypeVar, Union
4 | from typing_extensions import Self, TypeAlias, override
5 |
6 | from cachetools import TTLCache
7 | from cookit.loguru import logged_suppress
8 | from nonebot.adapters import Event as BaseEvent
9 | from nonebot.matcher import current_event
10 |
11 | from ..config import config
12 | from ..data_source import BasePlaylist, BaseSong, ResolvableFromID
13 |
14 | CacheableItemType: TypeAlias = Union[BaseSong, BasePlaylist]
15 | CacheItemType: TypeAlias = "IDCache"
16 |
17 | TC = TypeVar("TC", bound=CacheableItemType)
18 | TID = TypeVar("TID", bound=ResolvableFromID)
19 |
20 |
21 | class BaseCache(ABC, Generic[TC]):
22 | @classmethod
23 | @abstractmethod
24 | async def build(cls, item: TC) -> Self: ...
25 |
26 | @abstractmethod
27 | async def restore(self) -> TC: ...
28 |
29 |
30 | @dataclass
31 | class IDCache(BaseCache, Generic[TID]):
32 | id: int
33 | original: type[TID]
34 |
35 | @override
36 | @classmethod
37 | async def build(cls, item: TID) -> Self:
38 | return cls(id=item.id, original=type(item))
39 |
40 | @override
41 | async def restore(self) -> TID:
42 | return await self.original.from_id(self.id)
43 |
44 |
45 | cache: TTLCache[str, CacheItemType] = TTLCache(
46 | config.ncm_msg_cache_size,
47 | config.ncm_msg_cache_time,
48 | )
49 |
50 |
51 | async def set_cache(item: CacheableItemType, event: Optional[BaseEvent] = None):
52 | if not event:
53 | event = current_event.get()
54 | session = event.get_session_id()
55 | with logged_suppress(f"Failed to set cache for session {session}"):
56 | cache[session] = await IDCache.build(item)
57 |
58 |
59 | async def get_cache(
60 | event: Optional[BaseEvent] = None,
61 | expected_type: Optional[
62 | Union[type[ResolvableFromID], tuple[type[ResolvableFromID], ...]]
63 | ] = None,
64 | ) -> Optional[CacheableItemType]:
65 | if not event:
66 | event = current_event.get()
67 |
68 | session = event.get_session_id()
69 | cache_item = cache.get(session)
70 | if (not cache_item) or (
71 | expected_type
72 | and (
73 | (not isinstance(cache_item, IDCache))
74 | or (not issubclass(cache_item.original, expected_type))
75 | )
76 | ):
77 | return None
78 |
79 | with logged_suppress(f"Failed to get cache for session {session}"):
80 | return await cache_item.restore()
81 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/commands/__init__.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | from pathlib import Path
3 |
4 |
5 | def load_commands():
6 | for module in Path(__file__).parent.iterdir():
7 | if module.name.startswith("_"):
8 | continue
9 |
10 | module = importlib.import_module(f".{module.stem}", __package__)
11 | assert module
12 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/commands/direct.py:
--------------------------------------------------------------------------------
1 | from nonebot import logger, on_command
2 | from nonebot.matcher import Matcher
3 |
4 | from ..resolver import ResolvedSong
5 |
6 | matcher_direct = on_command("直链", aliases={"direct"})
7 |
8 |
9 | @matcher_direct.handle()
10 | async def _(matcher: Matcher, song: ResolvedSong):
11 | try:
12 | url = await song.get_playable_url()
13 | except Exception:
14 | logger.exception(f"Failed to get playable url for {song}")
15 | await matcher.finish("获取直链失败,请检查后台输出")
16 | await matcher.send(url)
17 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/commands/lyric.py:
--------------------------------------------------------------------------------
1 | from nonebot import logger, on_command
2 | from nonebot.matcher import Matcher
3 | from nonebot_plugin_alconna.uniseg import UniMessage
4 |
5 | from ...render import render_lyrics
6 | from ..resolver import ResolvedSong
7 |
8 | matcher_lyric = on_command("歌词", aliases={"lrc", "lyric", "lyrics"})
9 |
10 |
11 | @matcher_lyric.handle()
12 | async def _(matcher: Matcher, song: ResolvedSong):
13 | try:
14 | lrc = await song.get_lyrics()
15 | except Exception:
16 | logger.exception(f"Failed to get lyric for {song}")
17 | await matcher.finish("获取歌词失败,请检查后台输出")
18 |
19 | if not lrc:
20 | await matcher.finish("该歌曲没有歌词")
21 |
22 | try:
23 | img = await render_lyrics(groups=lrc)
24 | except Exception:
25 | logger.exception(f"Failed to render lyrics for {song}")
26 | await matcher.finish("渲染歌词失败,请检查后台输出")
27 | await UniMessage.image(raw=img).finish()
28 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/commands/resolve.py:
--------------------------------------------------------------------------------
1 | from typing import Any, cast
2 |
3 | from nonebot import on_command, on_regex
4 | from nonebot.matcher import Matcher
5 |
6 | from ...config import config
7 | from ...const import SHORT_URL_REGEX, URL_REGEX
8 | from ...data_source import BaseSongList
9 | from ..cache import set_cache
10 | from ..message import construct_info_msg
11 | from ..resolver import IsAutoResolve, ResolvedItem
12 | from .search import handle_song_or_list
13 |
14 |
15 | async def resolve_handler(
16 | matcher: Matcher,
17 | result: ResolvedItem,
18 | is_auto_resolve: IsAutoResolve,
19 | ):
20 | result_it: Any = cast(Any, result) # fuck that annoying weak type annotation
21 | if is_auto_resolve and isinstance(result, BaseSongList):
22 | await (await construct_info_msg(result_it, tip_command=True)).send()
23 | else:
24 | await handle_song_or_list(result_it, matcher, send_init_info=True)
25 | await set_cache(result_it)
26 |
27 |
28 | def __register_resolve_matchers():
29 | matcher = on_command("解析", aliases={"resolve", "parse", "get"})
30 | matcher.handle()(resolve_handler)
31 | if config.ncm_auto_resolve:
32 | reg_matcher = on_regex(URL_REGEX)
33 | reg_matcher.handle()(resolve_handler)
34 | reg_short_matcher = on_regex(SHORT_URL_REGEX)
35 | reg_short_matcher.handle()(resolve_handler)
36 |
37 |
38 | __register_resolve_matchers()
39 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/commands/search.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from typing import Optional, cast
3 |
4 | from cookit.loguru import warning_suppress
5 | from cookit.nonebot.alconna import RecallContext
6 | from nonebot import logger, on_command
7 | from nonebot.adapters import Message as BaseMessage
8 | from nonebot.matcher import Matcher, current_matcher
9 | from nonebot.params import ArgPlainText, CommandArg, EventMessage
10 | from nonebot.typing import T_State
11 | from nonebot_plugin_alconna.uniseg import Reply, UniMessage, UniMsg
12 | from nonebot_plugin_waiter import prompt
13 |
14 | from ...config import config
15 | from ...data_source import (
16 | BasePlaylist,
17 | BaseSearcher,
18 | BaseSong,
19 | BaseSongListPage,
20 | GeneralGetPageReturn,
21 | GeneralSearcher,
22 | GeneralSongList,
23 | GeneralSongListPage,
24 | GeneralSongOrList,
25 | registered_searcher,
26 | )
27 | from ...render import render_list_resp
28 | from ..message import construct_info_msg, send_song
29 |
30 | KEY_SEARCHER = "searcher"
31 | KEY_KEYWORD = "keyword"
32 |
33 | EXIT_COMMAND = (
34 | "退出", "tc", "取消", "qx", "quit", "q", "exit", "e", "cancel", "c", "0",
35 | ) # fmt: skip
36 | PREVIOUS_COMMAND = ("上一页", "syy", "previous", "p")
37 | NEXT_COMMAND = ("下一页", "xyy", "next", "n")
38 | JUMP_PAGE_PREFIX = ("page", "p", "跳页", "页")
39 |
40 |
41 | async def handle_song_or_list(
42 | result: GeneralSongOrList,
43 | matcher: Optional[Matcher] = None,
44 | send_init_info: bool = False,
45 | ):
46 | if not matcher:
47 | matcher = current_matcher.get()
48 |
49 | recall = RecallContext(delay=config.ncm_delete_msg_delay)
50 |
51 | async def handle(result: GeneralSongOrList) -> GeneralSongList:
52 | if isinstance(result, BaseSong):
53 | await send_song(result)
54 | await matcher.finish()
55 | return result
56 |
57 | async def select(
58 | song_list: GeneralSongList,
59 | result: GeneralSongListPage,
60 | ) -> GeneralSongList:
61 | try:
62 | await recall.send(UniMessage.image(raw=await render_list_resp(result)))
63 | except Exception:
64 | logger.exception(f"Failed to render page image for {result}")
65 | await matcher.finish("图片渲染失败,请检查后台输出")
66 |
67 | illegal_counter = 0
68 |
69 | async def tip_illegal(message: str):
70 | nonlocal illegal_counter
71 | illegal_counter += 1
72 | if config.ncm_illegal_cmd_limit and (
73 | illegal_counter >= config.ncm_illegal_cmd_limit
74 | ):
75 | await matcher.finish("非法指令次数过多,已自动退出选择")
76 | await recall.send(message)
77 |
78 | while True:
79 | msg = await prompt("")
80 | if msg is None:
81 | await matcher.finish("等待超时,已退出选择")
82 | msg = msg.extract_plain_text().strip().lower()
83 |
84 | if msg in EXIT_COMMAND:
85 | await matcher.finish("已退出选择")
86 |
87 | if msg in PREVIOUS_COMMAND:
88 | if song_list.is_first_page:
89 | await tip_illegal("已经是第一页了")
90 | continue
91 | song_list.current_page -= 1
92 | return song_list
93 |
94 | if msg in NEXT_COMMAND:
95 | if song_list.is_last_page:
96 | await tip_illegal("已经是最后一页了")
97 | continue
98 | song_list.current_page += 1
99 | return song_list
100 |
101 | if prefix := next((p for p in JUMP_PAGE_PREFIX if msg.startswith(p)), None):
102 | msg = msg[len(prefix) :].strip()
103 | if not (msg.isdigit() and song_list.page_valid(p := int(msg))):
104 | await tip_illegal("页码输入有误,请重新输入")
105 | continue
106 | song_list.current_page = p
107 | return song_list
108 |
109 | if msg.isdigit():
110 | if not song_list.index_valid(index := int(msg) - 1):
111 | await tip_illegal("序号输入有误,请重新输入")
112 | continue
113 | try:
114 | resp = await song_list.select(index)
115 | except Exception:
116 | logger.exception(
117 | f"Error when selecting index {index} from {song_list}",
118 | )
119 | await matcher.finish("搜索出错,请检查后台输出")
120 | return await handle(resp)
121 |
122 | if config.ncm_illegal_cmd_finish:
123 | await matcher.finish("非正确指令,已退出选择")
124 | await tip_illegal(
125 | "非正确指令,请重新输入\nTip: 你可以发送 `退出` 来退出点歌模式",
126 | )
127 |
128 | async def handle_page(
129 | song_list: Optional[GeneralSongList],
130 | result: GeneralGetPageReturn,
131 | ) -> GeneralSongList:
132 | if result is None:
133 | await matcher.finish("没有搜索到任何内容")
134 |
135 | if isinstance(result, BaseSongListPage):
136 | assert song_list
137 | return await select(song_list, result)
138 |
139 | return await handle(result)
140 |
141 | async def send_info(song_list: GeneralSongList):
142 | if not isinstance(song_list, BasePlaylist):
143 | return
144 | with warning_suppress(f"Failed to construct info for {song_list}"):
145 | msg = await construct_info_msg(song_list, tip_command=False)
146 | with warning_suppress(f"Failed to send info for {song_list}"):
147 | await recall.send(msg)
148 |
149 | async def main():
150 | song_list = await handle_page(None, result)
151 | if send_init_info:
152 | await send_info(song_list)
153 |
154 | while True:
155 | try:
156 | get_page_result = await song_list.get_page()
157 | except Exception:
158 | logger.exception(f"Error when using {song_list} to search")
159 | await matcher.finish("搜索出错,请检查后台输出")
160 |
161 | new_list = await handle_page(song_list, get_page_result)
162 | if new_list != song_list:
163 | song_list = new_list
164 | await send_info(song_list)
165 |
166 | try:
167 | await main()
168 | finally:
169 | if config.ncm_delete_msg:
170 | asyncio.create_task(recall.recall())
171 |
172 |
173 | async def search_handler_0(
174 | matcher: Matcher,
175 | uni_msg: UniMsg,
176 | arg: BaseMessage = CommandArg(),
177 | ):
178 | arg_ok = arg.extract_plain_text().strip()
179 | if (
180 | (not arg_ok)
181 | and (Reply in uni_msg)
182 | and isinstance((r_raw := uni_msg[Reply, 0].msg), BaseMessage)
183 | and (r_raw.extract_plain_text().strip())
184 | ):
185 | arg = r_raw
186 | arg_ok = True
187 | if arg_ok:
188 | matcher.set_arg(KEY_KEYWORD, arg)
189 | else:
190 | await matcher.pause("请发送你要搜索的内容,或发送 0 退出搜索")
191 |
192 |
193 | async def search_handler_1(matcher: Matcher, message: BaseMessage = EventMessage()):
194 | if matcher.get_arg(KEY_KEYWORD):
195 | return
196 | arg_str = message.extract_plain_text().strip()
197 | if arg_str in EXIT_COMMAND:
198 | await matcher.finish("已退出搜索")
199 | if not arg_str:
200 | await matcher.finish("输入无效,退出搜索")
201 | matcher.set_arg(KEY_KEYWORD, message)
202 |
203 |
204 | async def search_handler_2(
205 | matcher: Matcher,
206 | state: T_State,
207 | keyword: str = ArgPlainText(KEY_KEYWORD),
208 | ):
209 | keyword = keyword.strip()
210 | searcher_type = cast(type[GeneralSearcher], state[KEY_SEARCHER])
211 | searcher: GeneralSongList = searcher_type(keyword)
212 | await handle_song_or_list(searcher, matcher)
213 |
214 |
215 | def __register_searcher_matchers():
216 | def do_reg(searcher: type[BaseSearcher], commands: tuple[str, ...]):
217 | priv_cmd, *rest_cmds = commands
218 | matcher = on_command(
219 | priv_cmd,
220 | aliases=set(rest_cmds),
221 | state={KEY_SEARCHER: searcher},
222 | )
223 | matcher.handle()(search_handler_0)
224 | matcher.handle()(search_handler_1)
225 | matcher.handle()(search_handler_2)
226 |
227 | for k, v in registered_searcher.items():
228 | do_reg(k, v)
229 |
230 |
231 | __register_searcher_matchers()
232 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/commands/upload.py:
--------------------------------------------------------------------------------
1 | from nonebot import logger, on_command
2 | from nonebot.matcher import Matcher
3 |
4 | from ..message import send_song_media
5 | from ..resolver import ResolvedSong
6 |
7 |
8 | async def upload_handler_0(matcher: Matcher, song: ResolvedSong):
9 | try:
10 | await send_song_media(song, as_file=True)
11 | except Exception:
12 | logger.exception(f"Failed to upload {song} as file")
13 | await matcher.finish("上传失败,请检查后台输出")
14 |
15 |
16 | def __register_upload_matcher():
17 | matcher_lyric = on_command("上传", aliases={"upload"})
18 | matcher_lyric.handle()(upload_handler_0)
19 |
20 |
21 | __register_upload_matcher()
22 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/message/__init__.py:
--------------------------------------------------------------------------------
1 | from .common import (
2 | construct_info_msg as construct_info_msg,
3 | send_song as send_song,
4 | )
5 | from .song_card import (
6 | send_song_card_msg as send_song_card_msg,
7 | )
8 | from .song_file import (
9 | download_song as download_song,
10 | send_song_media as send_song_media,
11 | )
12 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/message/common.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 |
3 | from cookit.loguru import warning_suppress
4 | from nonebot_plugin_alconna.uniseg import UniMessage
5 |
6 | from ...config import config
7 | from ...data_source import BasePlaylist, BaseSong
8 | from ...utils import is_song_card_supported
9 | from ..cache import set_cache
10 | from .song_card import send_song_card_msg
11 | from .song_file import send_song_media
12 |
13 | SONG_TIP = "\n使用指令 `direct` 获取播放链接"
14 | PLAYLIST_TIP = "\n使用指令 `resolve` 选择内容播放"
15 |
16 |
17 | async def construct_info_msg(
18 | it: Union[BaseSong, BasePlaylist],
19 | tip_command: bool = True,
20 | ) -> UniMessage:
21 | tip = (
22 | next(
23 | v
24 | for k, v in {BaseSong: SONG_TIP, BasePlaylist: PLAYLIST_TIP}.items()
25 | if isinstance(it, k)
26 | )
27 | if tip_command
28 | else ""
29 | )
30 | info = await it.get_info()
31 | desc = await info.get_description()
32 | return UniMessage.image(url=info.cover_url) + f"{desc}\n{info.url}{tip}"
33 |
34 |
35 | async def send_song(song: BaseSong):
36 | async def send():
37 | if config.ncm_send_as_card and is_song_card_supported():
38 | with warning_suppress(f"Send {song} card failed"):
39 | await send_song_card_msg(song)
40 | return
41 |
42 | receipt = ...
43 | if config.ncm_send_media:
44 | with warning_suppress(f"Send {song} file failed"):
45 | receipt = await send_song_media(song)
46 | await (await construct_info_msg(song, tip_command=(receipt is ...))).send(
47 | reply_to=receipt.get_reply() if receipt and (receipt is not ...) else None,
48 | )
49 |
50 | await send()
51 | await set_cache(song)
52 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/message/song_card.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 |
3 | from cookit.loguru import warning_suppress
4 | from httpx import AsyncClient
5 | from nonebot_plugin_alconna.builtins.uniseg.music_share import (
6 | MusicShare,
7 | MusicShareKind,
8 | )
9 | from nonebot_plugin_alconna.uniseg import UniMessage
10 |
11 | from ...config import config
12 |
13 | if TYPE_CHECKING:
14 | from ...data_source import BaseSong, SongInfo
15 |
16 |
17 | async def sign_music_card(info: "SongInfo") -> str:
18 | assert config.ncm_card_sign_url
19 | async with AsyncClient(
20 | follow_redirects=True,
21 | timeout=config.ncm_card_sign_timeout,
22 | ) as cli:
23 | body = {
24 | "type": "custom",
25 | "url": info.url,
26 | "audio": info.playable_url,
27 | "title": info.display_name,
28 | "image": info.cover_url,
29 | "singer": info.display_artists,
30 | }
31 | return (
32 | (await cli.post(config.ncm_card_sign_url, json=body))
33 | .raise_for_status()
34 | .text
35 | )
36 |
37 |
38 | async def send_song_card_msg(song: "BaseSong"):
39 | info = await song.get_info()
40 | if config.ncm_card_sign_url:
41 | with warning_suppress(
42 | f"Failed to send signed card for song {song}, fallback to MusicShare seg",
43 | ):
44 | return await UniMessage.hyper("json", await sign_music_card(info)).send(
45 | fallback=False,
46 | )
47 | return await UniMessage(
48 | MusicShare(
49 | kind=MusicShareKind.NeteaseCloudMusic,
50 | title=info.display_name,
51 | content=info.display_artists,
52 | url=info.url,
53 | thumbnail=info.cover_url,
54 | audio=info.playable_url,
55 | summary=info.display_artists,
56 | ),
57 | ).send(fallback=False)
58 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/message/song_file.py:
--------------------------------------------------------------------------------
1 | import mimetypes
2 | from contextlib import suppress
3 | from typing import TYPE_CHECKING, Any, Optional, cast
4 |
5 | from cookit.loguru import warning_suppress
6 | from httpx import AsyncClient
7 | from nonebot import logger
8 | from nonebot.matcher import current_bot, current_event
9 | from nonebot_plugin_alconna.uniseg import Receipt, UniMessage, get_exporter
10 |
11 | from ...config import config
12 | from ...const import SONG_CACHE_DIR
13 | from ...utils import encode_silk, ffmpeg_exists
14 |
15 | if TYPE_CHECKING:
16 | from pathlib import Path
17 |
18 | from ...data_source import BaseSong, SongInfo
19 |
20 |
21 | async def download_song(info: "SongInfo"):
22 | filename = info.download_filename
23 | file_path = SONG_CACHE_DIR / filename
24 | if file_path.exists():
25 | return file_path
26 |
27 | async with AsyncClient(follow_redirects=True) as cli, cli.stream("GET", info.playable_url) as resp: # fmt: skip
28 | resp.raise_for_status()
29 | SONG_CACHE_DIR.mkdir(parents=True, exist_ok=True)
30 | with file_path.open("wb") as f:
31 | async for chunk in resp.aiter_bytes():
32 | f.write(chunk)
33 | return file_path
34 |
35 |
36 | async def send_song_media_uni_msg(
37 | path: "Path",
38 | info: "SongInfo",
39 | raw: bool = False,
40 | as_file: bool = False,
41 | ):
42 | mime = t[0] if (t := mimetypes.guess_type(path.name)) else None
43 | kw_f = {"raw": path.read_bytes()} if raw else {"path": path}
44 | kw: Any = {**kw_f, "name": info.display_filename, "mimetype": mime}
45 | msg = UniMessage.file(**kw) if as_file else UniMessage.audio(**kw)
46 | return await msg.send(fallback=False)
47 |
48 |
49 | async def get_current_ev_receipt(msg_ids: Any):
50 | bot = current_bot.get()
51 | ev = current_event.get()
52 | exporter = get_exporter(bot)
53 | if not exporter:
54 | raise TypeError("This adapter is not supported")
55 | return Receipt(
56 | bot=bot,
57 | context=ev,
58 | exporter=exporter,
59 | msg_ids=msg_ids if isinstance(msg_ids, list) else [msg_ids],
60 | )
61 |
62 |
63 | async def send_song_media_telegram(info: "SongInfo", as_file: bool = False): # noqa: ARG001
64 | return await send_song_media_uni_msg(await download_song(info), info, as_file=False)
65 |
66 |
67 | async def send_song_media_onebot_v11(info: "SongInfo", as_file: bool = False):
68 | async def send_voice():
69 | if not await ffmpeg_exists():
70 | logger.warning(
71 | "FFmpeg 无法使用,插件将不会把音乐文件转为 silk 格式提交给协议端",
72 | )
73 | raise TypeError("FFmpeg unavailable, fallback to UniMessage")
74 |
75 | return await UniMessage.voice(
76 | raw=(await encode_silk(await download_song(info))).read_bytes(),
77 | ).send()
78 |
79 | async def send_file():
80 | from nonebot.adapters.onebot.v11 import (
81 | Bot as OB11Bot,
82 | GroupMessageEvent,
83 | PrivateMessageEvent,
84 | )
85 |
86 | bot = cast(OB11Bot, current_bot.get())
87 | event = current_event.get()
88 |
89 | if not isinstance(event, (GroupMessageEvent, PrivateMessageEvent)):
90 | raise TypeError("Event not supported")
91 |
92 | file = (
93 | (await download_song(info))
94 | if config.ncm_ob_v11_local_mode
95 | else cast(str, (await bot.download_file(url=info.playable_url))["file"])
96 | )
97 |
98 | if isinstance(event, PrivateMessageEvent):
99 | await bot.upload_private_file(
100 | user_id=event.user_id,
101 | file=file,
102 | name=info.display_filename,
103 | )
104 | else:
105 | await bot.upload_group_file(
106 | group_id=event.group_id,
107 | file=file,
108 | name=info.display_filename,
109 | )
110 |
111 | return (await send_file()) if as_file else (await send_voice())
112 |
113 |
114 | async def send_song_media_platform_specific(
115 | info: "SongInfo",
116 | as_file: bool = False,
117 | ) -> Optional[Receipt]:
118 | bot = current_bot.get()
119 | adapter_name = bot.adapter.get_name()
120 | processors = {
121 | "Telegram": send_song_media_telegram,
122 | "OneBot V11": send_song_media_onebot_v11,
123 | }
124 | if adapter_name not in processors:
125 | raise TypeError("This adapter is not supported")
126 | return await processors[adapter_name](info, as_file=as_file)
127 |
128 |
129 | async def send_song_media(song: "BaseSong", as_file: bool = config.ncm_send_as_file):
130 | info = await song.get_info()
131 |
132 | with warning_suppress(
133 | f"Failed to send {song} using platform specific method, fallback to UniMessage",
134 | ):
135 | with suppress(TypeError):
136 | return await send_song_media_platform_specific(info, as_file=as_file)
137 |
138 | path = await download_song(info)
139 | with warning_suppress(
140 | f"Failed to send {song} using file path, fallback using raw bytes",
141 | ):
142 | if not TYPE_CHECKING:
143 | return await send_song_media_uni_msg(path, info, raw=False, as_file=as_file)
144 | return await send_song_media_uni_msg(path, info, raw=True, as_file=as_file)
145 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/interaction/resolver.py:
--------------------------------------------------------------------------------
1 | import re
2 | from dataclasses import dataclass
3 | from typing import Annotated, Optional, Union
4 | from typing_extensions import TypeAlias
5 |
6 | from cachetools import TTLCache
7 | from cookit import flatten, queued
8 | from cookit.loguru import warning_suppress
9 | from httpx import AsyncClient
10 | from nonebot.adapters import Bot as BaseBot, Message as BaseMessage
11 | from nonebot.consts import REGEX_MATCHED
12 | from nonebot.matcher import Matcher
13 | from nonebot.params import Depends
14 | from nonebot.typing import T_State
15 | from nonebot_plugin_alconna import UniMsg
16 | from nonebot_plugin_alconna.uniseg import Hyper, Reply, UniMessage
17 |
18 | from ..config import config
19 | from ..const import SHORT_URL_BASE, SHORT_URL_REGEX, URL_REGEX
20 | from ..data_source import (
21 | GeneralPlaylist,
22 | GeneralSong,
23 | GeneralSongOrPlaylist,
24 | registered_playlist,
25 | registered_song,
26 | resolve_from_link_params,
27 | )
28 | from ..utils import is_song_card_supported
29 | from .cache import get_cache
30 |
31 | ExpectedTypeType: TypeAlias = Union[
32 | type[GeneralSongOrPlaylist],
33 | tuple[type[GeneralSongOrPlaylist], ...],
34 | ]
35 |
36 |
37 | resolved_cache: TTLCache[int, "ResolveCache"] = TTLCache(
38 | config.ncm_resolve_cool_down_cache_size,
39 | config.ncm_resolve_cool_down,
40 | )
41 |
42 |
43 | @dataclass(eq=True, unsafe_hash=True)
44 | class ResolveCache:
45 | link_type: str
46 | link_id: int
47 |
48 |
49 | @queued
50 | async def resolve_from_link_params_cool_down(link_type: str, link_id: int):
51 | cache = ResolveCache(link_type=link_type, link_id=link_id)
52 | cache_hash = hash(cache)
53 |
54 | if cache_hash in resolved_cache:
55 | resolved_cache[cache_hash] = cache # flush ttl
56 | return None
57 |
58 | result = await resolve_from_link_params(link_type, link_id)
59 | resolved_cache[cache_hash] = cache
60 | return result
61 |
62 |
63 | def check_is_expected_type(
64 | item_type: str,
65 | expected_type: Optional[ExpectedTypeType] = None,
66 | ) -> bool:
67 | if not expected_type:
68 | return True
69 | expected = flatten(
70 | x.link_types
71 | for x in (
72 | (expected_type) if isinstance(expected_type, tuple) else (expected_type,)
73 | )
74 | )
75 | return item_type in expected
76 |
77 |
78 | def extract_song_card_hyper(
79 | msg: UniMessage,
80 | bot: Optional[BaseBot] = None,
81 | ) -> Optional[Hyper]:
82 | if (Hyper in msg) and is_song_card_supported(bot):
83 | return msg[Hyper, 0]
84 | return None
85 |
86 |
87 | async def resolve_short_url(
88 | suffix: str,
89 | expected_type: Optional[ExpectedTypeType] = None,
90 | use_cool_down: bool = False,
91 | ) -> GeneralSongOrPlaylist:
92 | async with AsyncClient(base_url=SHORT_URL_BASE) as client:
93 | resp = await client.get(suffix, follow_redirects=False)
94 |
95 | if resp.status_code // 100 != 3:
96 | raise ValueError(
97 | f"Short url {suffix} "
98 | f"returned invalid status code {resp.status_code}",
99 | )
100 |
101 | location = resp.headers.get("Location")
102 | if not location:
103 | raise ValueError(f"Short url {suffix} returned no location header")
104 |
105 | matched = re.search(URL_REGEX, location, re.IGNORECASE)
106 | if not matched:
107 | raise ValueError(
108 | f"Location {location} of short url {suffix} is not a song url",
109 | )
110 |
111 | if it := await resolve_from_matched(matched, expected_type, use_cool_down):
112 | return it
113 | raise ValueError("Failed to resolve matched item url", expected_type)
114 |
115 |
116 | async def resolve_from_matched(
117 | matched: re.Match[str],
118 | expected_type: Optional[ExpectedTypeType] = None,
119 | use_cool_down: bool = False,
120 | ) -> Optional[GeneralSongOrPlaylist]:
121 | groups = matched.groupdict()
122 |
123 | if "suffix" in groups:
124 | suffix = groups["suffix"]
125 | with warning_suppress(f"Failed to resolve short url {suffix}"):
126 | return await resolve_short_url(suffix, expected_type, use_cool_down)
127 |
128 | elif "type" in groups and "id" in groups:
129 | link_type = groups["type"]
130 | if not check_is_expected_type(link_type, expected_type):
131 | return None
132 | link_id = groups["id"]
133 | with warning_suppress(f"Failed to resolve url {link_type}/{link_id}"):
134 | return await (
135 | resolve_from_link_params_cool_down
136 | if use_cool_down
137 | else resolve_from_link_params
138 | )(link_type, int(link_id))
139 |
140 | else:
141 | raise ValueError("Unknown regex match result passed in")
142 |
143 | return None
144 |
145 |
146 | async def resolve_from_plaintext(
147 | text: str,
148 | expected_type: Optional[ExpectedTypeType] = None,
149 | use_cool_down: bool = False,
150 | ) -> Optional[GeneralSongOrPlaylist]:
151 | for regex in (SHORT_URL_REGEX, URL_REGEX):
152 | if m := re.search(regex, text, re.IGNORECASE):
153 | return await resolve_from_matched(m, expected_type, use_cool_down)
154 | return None
155 |
156 |
157 | async def resolve_from_card(
158 | card: Hyper,
159 | resolve_playable: bool = True,
160 | expected_type: Optional[ExpectedTypeType] = None,
161 | use_cool_down: bool = False,
162 | ) -> Optional[GeneralSongOrPlaylist]:
163 | if not (raw := card.raw):
164 | return None
165 |
166 | is_playable_card = '"musicUrl"' in raw
167 | if (not resolve_playable) and is_playable_card:
168 | return None
169 |
170 | return await resolve_from_plaintext(raw, expected_type, use_cool_down)
171 |
172 |
173 | async def resolve_from_msg(
174 | msg: UniMessage,
175 | resolve_playable_card: bool = True,
176 | expected_type: Optional[ExpectedTypeType] = None,
177 | use_cool_down: bool = False,
178 | bot: Optional[BaseBot] = None,
179 | ) -> Optional[GeneralSongOrPlaylist]:
180 | if (h := extract_song_card_hyper(msg, bot)) and (
181 | it := await resolve_from_card(
182 | h,
183 | resolve_playable_card,
184 | expected_type,
185 | )
186 | ):
187 | return it
188 | return await resolve_from_plaintext(
189 | msg.extract_plain_text(),
190 | expected_type,
191 | use_cool_down,
192 | )
193 |
194 |
195 | async def resolve_from_ev_msg(
196 | msg: UniMessage,
197 | state: T_State,
198 | bot: BaseBot,
199 | matcher: Matcher,
200 | expected_type: Optional[ExpectedTypeType] = None,
201 | ) -> GeneralSongOrPlaylist:
202 | regex_matched: Optional[re.Match[str]] = state.get(REGEX_MATCHED)
203 | if regex_matched: # auto resolve
204 | if h := extract_song_card_hyper(msg, bot):
205 | if it := await resolve_from_card(
206 | h,
207 | resolve_playable=config.ncm_resolve_playable_card,
208 | expected_type=expected_type,
209 | use_cool_down=True,
210 | ):
211 | return it
212 |
213 | elif it := await resolve_from_matched(
214 | regex_matched,
215 | expected_type=expected_type,
216 | use_cool_down=True,
217 | ):
218 | return it
219 |
220 | elif ( # common command trigger
221 | (
222 | Reply in msg
223 | and isinstance((reply_raw := msg[Reply, 0].msg), BaseMessage)
224 | and (reply_msg := await UniMessage.generate(message=reply_raw))
225 | and (it := await resolve_from_msg(reply_msg, expected_type=expected_type))
226 | )
227 | or (it := await resolve_from_msg(msg, expected_type=expected_type))
228 | or (it := await get_cache(expected_type=expected_type))
229 | ):
230 | return it
231 |
232 | await matcher.finish() # noqa: RET503: NoReturn
233 |
234 |
235 | async def dependency_resolve_from_ev(
236 | msg: UniMsg,
237 | state: T_State,
238 | bot: BaseBot,
239 | matcher: Matcher,
240 | ):
241 | return await resolve_from_ev_msg(msg, state, bot, matcher)
242 |
243 |
244 | async def dependency_resolve_song_from_ev(
245 | msg: UniMsg,
246 | state: T_State,
247 | bot: BaseBot,
248 | matcher: Matcher,
249 | ):
250 | return await resolve_from_ev_msg(
251 | msg,
252 | state,
253 | bot,
254 | matcher,
255 | expected_type=tuple(registered_song),
256 | )
257 |
258 |
259 | async def dependency_resolve_playlist_from_ev(
260 | msg: UniMsg,
261 | state: T_State,
262 | bot: BaseBot,
263 | matcher: Matcher,
264 | ):
265 | return await resolve_from_ev_msg(
266 | msg,
267 | state,
268 | bot,
269 | matcher,
270 | expected_type=tuple(registered_playlist),
271 | )
272 |
273 |
274 | async def dependency_is_auto_resolve(state: T_State) -> bool:
275 | return bool(state.get(REGEX_MATCHED))
276 |
277 |
278 | ResolvedItem = Annotated[
279 | GeneralSongOrPlaylist,
280 | Depends(dependency_resolve_from_ev, use_cache=False),
281 | ]
282 | ResolvedSong = Annotated[
283 | GeneralSong,
284 | Depends(dependency_resolve_song_from_ev, use_cache=False),
285 | ]
286 | ResolvedPlaylist = Annotated[
287 | GeneralPlaylist,
288 | Depends(dependency_resolve_playlist_from_ev, use_cache=False),
289 | ]
290 | IsAutoResolve = Annotated[bool, Depends(dependency_is_auto_resolve)]
291 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/render/__init__.py:
--------------------------------------------------------------------------------
1 | from .card_list import (
2 | CardListRenderParams as CardListRenderParams,
3 | TrackCardRenderParams as TrackCardRenderParams,
4 | render_card_list as render_card_list,
5 | render_list_resp as render_list_resp,
6 | render_track_card_html as render_track_card_html,
7 | )
8 | from .lyrics import (
9 | render_lyrics as render_lyrics,
10 | )
11 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/render/card_list.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from typing import TYPE_CHECKING, TypedDict
3 | from typing_extensions import Unpack
4 |
5 | from ..utils import calc_min_index
6 | from .utils import render_html, render_template
7 |
8 | if TYPE_CHECKING:
9 | from ..data_source import GeneralSongListPage
10 |
11 |
12 | class CardListRenderParams(TypedDict):
13 | title: str
14 | cards: list[str]
15 | current_page: int
16 | max_page: int
17 | total_count: int
18 |
19 |
20 | class TrackCardRenderParams(TypedDict):
21 | index: int
22 | cover: str
23 | title: str
24 | alias: str
25 | extras: list[str]
26 | small_extras: list[str]
27 |
28 |
29 | async def render_card_list(**kwargs: Unpack[CardListRenderParams]) -> bytes:
30 | return await render_html(await render_template("card_list.html.jinja", **kwargs))
31 |
32 |
33 | async def render_track_card_html(**kwargs: Unpack[TrackCardRenderParams]) -> str:
34 | return await render_template("track_card.html.jinja", **kwargs)
35 |
36 |
37 | async def render_list_resp(resp: "GeneralSongListPage") -> bytes:
38 | index_offset = calc_min_index(resp.father.current_page)
39 | card_params = [
40 | {"index": i, **x.__dict__}
41 | for i, x in enumerate(await resp.transform_to_list_cards(), index_offset + 1)
42 | ]
43 | cards = await asyncio.gather(*(render_track_card_html(**x) for x in card_params))
44 | return await render_card_list(
45 | title=f"{resp.father.child_calling}列表",
46 | cards=cards,
47 | current_page=resp.father.current_page,
48 | max_page=resp.father.max_page,
49 | total_count=resp.father.total_count,
50 | )
51 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/render/lyrics.py:
--------------------------------------------------------------------------------
1 | from typing import TYPE_CHECKING
2 |
3 | from .utils import render_html, render_template
4 |
5 | if TYPE_CHECKING:
6 | from ..utils import NCMLrcGroupLine
7 |
8 |
9 | async def render_lyrics(groups: list["NCMLrcGroupLine"]) -> bytes:
10 | group_tuples = [[(n, r) for n, r in x.lrc.items()] for x in groups]
11 | sort_order = ("roma", "main", "trans")
12 | for group in group_tuples:
13 | group.sort(key=lambda x: sort_order.index(x[0]) if x[0] in sort_order else 999)
14 | return await render_html(
15 | await render_template(
16 | "lyrics.html.jinja",
17 | groups=group_tuples,
18 | ),
19 | )
20 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/render/templates/base.html.jinja:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
62 | {% block head %}{% endblock %}
63 |
64 |
65 |
66 | {% block main %}{% endblock %}
67 |
68 | {% if config.font_family -%}
69 |
79 | {%- endif %}
80 |
81 | {% block addition %}{% endblock %}
82 |
83 |
84 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/render/templates/card_list.html.jinja:
--------------------------------------------------------------------------------
1 | {%- extends 'base.html.jinja' -%}
2 |
3 | {% block head -%}
4 |
90 | {%- endblock %}
91 |
92 | {% block main -%}
93 | {{ title }}
94 |
95 | 直接发送 序号 进行选择 | 发送 P+数字 跳到指定页数
96 | 其他操作:上一页(P) | 下一页(N) | 退出(E)
97 |
98 |
99 | {% for c in cards -%}{{ c | safe }}{% endfor %}
100 |
101 |
102 | 第 {{ current_page }} 页 / 共 {{ max_page }} 页 | 总计 {{ total_count }} 项
103 |
104 |
107 | {%- endblock %}
108 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/render/templates/lyrics.html.jinja:
--------------------------------------------------------------------------------
1 | {%- extends 'base.html.jinja' -%}
2 |
3 | {% block head -%}
4 |
27 | {%- endblock %}
28 |
29 | {% block main -%}
30 | {% for group in groups -%}
31 |
32 | {% for n, r in group %}
{{ r }}
33 | {% endfor -%}
34 |
35 | {%- endfor %}
36 |
39 | {%- endblock %}
40 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/render/templates/track_card.html.jinja:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ index }}
4 |
5 |
6 |

7 |
8 |
{{ title }}{% if alias %}({{ alias }}){% endif %}
9 | {% for x in extras %}
{{ x }}
{% endfor %}
10 | {% for x in small_extras %}
{{ x }}
{% endfor %}
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/render/utils.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import Literal, Optional, TypedDict
3 | from urllib.parse import quote
4 |
5 | import jinja2
6 | from cookit.jinja import make_register_jinja_filter_deco, register_all_filters
7 | from nonebot_plugin_htmlrender import get_new_page
8 |
9 | from ..config import config
10 | from ..utils import debug
11 |
12 | jinja_env = jinja2.Environment(
13 | loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
14 | autoescape=jinja2.select_autoescape(["html", "xml"]),
15 | enable_async=True,
16 | )
17 | register_all_filters(jinja_env)
18 |
19 | register_filter = make_register_jinja_filter_deco(jinja_env)
20 |
21 |
22 | class RenderConfig(TypedDict):
23 | font_family: Optional[str]
24 | plugin_version: str
25 |
26 |
27 | def format_font_url(url: str) -> Optional[str]:
28 | return quote(p.as_uri()) if url and (p := Path(url)).exists() else url
29 |
30 |
31 | def get_config() -> RenderConfig:
32 | from ..__init__ import __version__ as plugin_version
33 |
34 | return {
35 | "font_family": (
36 | format_font_url(config.ncm_list_font) if config.ncm_list_font else None
37 | ),
38 | "plugin_version": plugin_version,
39 | }
40 |
41 |
42 | async def render_template(name: str, **kwargs):
43 | return await jinja_env.get_template(name).render_async(
44 | config=get_config(),
45 | **kwargs,
46 | )
47 |
48 |
49 | async def render_html(
50 | html: str,
51 | selector: str = "main",
52 | image_type: Literal["jpeg", "png"] = "jpeg",
53 | ) -> bytes:
54 | if debug.enabled:
55 | debug.write(html, "{time}.html")
56 | async with get_new_page() as page:
57 | await page.set_content(html)
58 | elem = await page.query_selector(selector)
59 | assert elem
60 | return await elem.screenshot(type=image_type)
61 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from .base import (
2 | build_item_link as build_item_link,
3 | calc_max_page as calc_max_page,
4 | calc_min_index as calc_min_index,
5 | calc_min_max_index as calc_min_max_index,
6 | calc_page_number as calc_page_number,
7 | cut_string as cut_string,
8 | debug as debug,
9 | encode_silk as encode_silk,
10 | ffmpeg_exists as ffmpeg_exists,
11 | format_alias as format_alias,
12 | format_artists as format_artists,
13 | format_time as format_time,
14 | get_thumb_url as get_thumb_url,
15 | is_song_card_supported as is_song_card_supported,
16 | merge_alias as merge_alias,
17 | )
18 | from .lrc_parser import (
19 | NCM_MAIN_LRC_GROUP as NCM_MAIN_LRC_GROUP,
20 | LrcGroupLine as LrcGroupLine,
21 | LrcLine as LrcLine,
22 | NCMLrcGroupLine as NCMLrcGroupLine,
23 | NCMLrcGroupNameType as NCMLrcGroupNameType,
24 | merge_lrc as merge_lrc,
25 | normalize_lrc as normalize_lrc,
26 | parse_lrc as parse_lrc,
27 | )
28 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/utils/base.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import math
3 | from pathlib import Path
4 | from typing import TYPE_CHECKING, Optional, TypeVar
5 | from typing_extensions import ParamSpec
6 |
7 | from cookit import DebugFileWriter, flatten
8 | from nonebot.adapters import Bot as BaseBot, Event as BaseEvent
9 | from nonebot.matcher import current_bot
10 | from nonebot.utils import run_sync
11 | from nonebot_plugin_alconna.uniseg import SupportScope, UniMessage
12 | from yarl import URL
13 |
14 | from ..config import config
15 |
16 | if TYPE_CHECKING:
17 | from ..data_source import md
18 |
19 | P = ParamSpec("P")
20 | TR = TypeVar("TR")
21 |
22 | debug = DebugFileWriter(Path.cwd() / "debug", "multincm")
23 |
24 |
25 | def format_time(time: int) -> str:
26 | ss, _ = divmod(time, 1000)
27 | mm, ss = divmod(ss, 60)
28 | return f"{mm:0>2d}:{ss:0>2d}"
29 |
30 |
31 | def format_alias(name: str, alias: Optional[list[str]] = None) -> str:
32 | return f"{name}({';'.join(alias)})" if alias else name
33 |
34 |
35 | def format_artists(artists: list["md.Artist"]) -> str:
36 | return "、".join([x.name for x in artists])
37 |
38 |
39 | def calc_page_number(index: int) -> int:
40 | return (index // config.ncm_list_limit) + 1
41 |
42 |
43 | def calc_min_index(page: int) -> int:
44 | return (page - 1) * config.ncm_list_limit
45 |
46 |
47 | def calc_min_max_index(page: int) -> tuple[int, int]:
48 | min_index = calc_min_index(page)
49 | max_index = min_index + config.ncm_list_limit
50 | return min_index, max_index
51 |
52 |
53 | def calc_max_page(total: int) -> int:
54 | return math.ceil(total / config.ncm_list_limit)
55 |
56 |
57 | def get_thumb_url(url: str, size: int = 64) -> str:
58 | return str(URL(url).update_query(param=f"{size}y{size}"))
59 |
60 |
61 | def build_item_link(item_type: str, item_id: int) -> str:
62 | return f"https://music.163.com/{item_type}?id={item_id}"
63 |
64 |
65 | def cut_string(text: str, length: int = 50) -> str:
66 | if len(text) <= length:
67 | return text
68 | return text[: length - 1] + "…"
69 |
70 |
71 | async def ffmpeg_exists() -> bool:
72 | proc = await asyncio.create_subprocess_exec(
73 | config.ncm_ffmpeg_executable,
74 | "-version",
75 | stdin=asyncio.subprocess.DEVNULL,
76 | stdout=asyncio.subprocess.PIPE,
77 | stderr=asyncio.subprocess.PIPE,
78 | )
79 | code = await proc.wait()
80 | return code == 0
81 |
82 |
83 | async def encode_silk(path: "Path", rate: int = 24000) -> "Path":
84 | silk_path = path.with_suffix(".silk")
85 | if silk_path.exists():
86 | return silk_path
87 |
88 | pcm_path = path.with_suffix(".pcm")
89 | proc = await asyncio.create_subprocess_exec(
90 | config.ncm_ffmpeg_executable,
91 | "-y",
92 | "-i", str(path),
93 | "-f", "s16le", "-ar", f"{rate}", "-ac", "1", str(pcm_path),
94 | stdin=asyncio.subprocess.DEVNULL,
95 | stdout=asyncio.subprocess.PIPE,
96 | stderr=asyncio.subprocess.PIPE,
97 | ) # fmt: skip
98 | code = await proc.wait()
99 | if code != 0:
100 | raise RuntimeError(
101 | f"Failed to use ffmpeg to convert {path} to pcm, return code {code}",
102 | )
103 |
104 | try:
105 | from pysilk import encode
106 |
107 | await run_sync(encode)(pcm_path.open("rb"), silk_path.open("wb"), rate, rate)
108 | finally:
109 | pcm_path.unlink(missing_ok=True)
110 |
111 | return silk_path
112 |
113 |
114 | def merge_alias(song: "md.Song") -> list[str]:
115 | alias = song.tns.copy() if song.tns else []
116 | alias.extend(
117 | x for x in flatten(x.split(";") for x in song.alias) if x not in alias
118 | )
119 | return alias
120 |
121 |
122 | def is_song_card_supported(
123 | bot: Optional[BaseBot] = None,
124 | event: Optional[BaseEvent] = None,
125 | ) -> bool:
126 | if bot is None:
127 | bot = current_bot.get()
128 | s = UniMessage.get_target(event, bot).scope
129 | return bool(s and s == SupportScope.qq_client.value)
130 |
--------------------------------------------------------------------------------
/nonebot_plugin_multincm/utils/lrc_parser.py:
--------------------------------------------------------------------------------
1 | import re
2 | from dataclasses import dataclass
3 | from typing import TYPE_CHECKING, Generic, Literal, Optional, TypeVar
4 | from typing_extensions import TypeAlias
5 |
6 | from ..config import config
7 |
8 | if TYPE_CHECKING:
9 | from ..data_source import md
10 |
11 | SK = TypeVar("SK", bound=str)
12 |
13 |
14 | @dataclass
15 | class LrcLine:
16 | time: int
17 | """Lyric Time (ms)"""
18 | lrc: str
19 | """Lyric Content"""
20 | skip_merge: bool = False
21 |
22 |
23 | @dataclass
24 | class LrcGroupLine(Generic[SK]):
25 | time: int
26 | """Lyric Time (ms)"""
27 | lrc: dict[SK, str]
28 |
29 |
30 | LRC_TIME_REGEX = r"(?P\d+):(?P\d+)([\.:](?P\d+))?(-(?P\d))?"
31 | LRC_LINE_REGEX = re.compile(rf"^((\[{LRC_TIME_REGEX}\])+)(?P.*)$", re.MULTILINE)
32 |
33 |
34 | def parse_lrc(
35 | lrc: str,
36 | ignore_empty: bool = False,
37 | merge_empty: bool = True,
38 | ) -> list[LrcLine]:
39 | parsed = []
40 | for line in re.finditer(LRC_LINE_REGEX, lrc):
41 | lrc = line["lrc"].strip().replace("\u3000", " ")
42 | times = [x.groupdict() for x in re.finditer(LRC_TIME_REGEX, line[0])]
43 |
44 | parsed.extend(
45 | [
46 | LrcLine(
47 | time=(
48 | int(i["min"]) * 60 * 1000
49 | + int(float(f"{i['sec']}.{i['mili'] or 0}") * 1000)
50 | ),
51 | lrc=lrc,
52 | skip_merge=bool(i["meta"])
53 | or lrc.startswith(("作词", "作曲", "编曲")),
54 | )
55 | for i in times
56 | ],
57 | )
58 |
59 | if ignore_empty:
60 | parsed = [x for x in parsed if x.lrc]
61 |
62 | elif merge_empty:
63 | new_parsed = []
64 |
65 | for line in parsed:
66 | if line.lrc or (new_parsed and new_parsed[-1].lrc and (not line.lrc)):
67 | new_parsed.append(line)
68 |
69 | if new_parsed and (not new_parsed[-1].lrc):
70 | new_parsed.pop()
71 |
72 | parsed = new_parsed
73 |
74 | parsed.sort(key=lambda x: x.time)
75 | return parsed
76 |
77 |
78 | def strip_lrc_lines(lines: list[LrcLine]) -> list[LrcLine]:
79 | for lrc in lines:
80 | lrc.lrc = lrc.lrc.strip()
81 | return lines
82 |
83 |
84 | def merge_lrc(
85 | lyric_groups: dict[SK, list[LrcLine]],
86 | main_group: Optional[SK] = None,
87 | threshold: int = 20,
88 | replace_empty_line: Optional[str] = None,
89 | skip_merge_group_name: Optional[SK] = None,
90 | ) -> list[LrcGroupLine[SK]]:
91 | lyric_groups = {k: v.copy() for k, v in lyric_groups.items()}
92 | for v in lyric_groups.values():
93 | while not v[-1].lrc:
94 | v.pop()
95 |
96 | if main_group is None:
97 | main_group, main_lyric = next(iter(lyric_groups.items()))
98 | else:
99 | main_lyric = lyric_groups[main_group]
100 | main_lyric = strip_lrc_lines(main_lyric)
101 |
102 | lyric_groups.pop(main_group)
103 | sub_lines = [(n, strip_lrc_lines(x)) for n, x in lyric_groups.items()]
104 |
105 | if replace_empty_line:
106 | for x in main_lyric:
107 | if not x.lrc:
108 | x.lrc = replace_empty_line
109 | x.skip_merge = True
110 |
111 | merged: list[LrcGroupLine] = []
112 | for main_line in main_lyric:
113 | if not main_line.lrc:
114 | continue
115 |
116 | main_time = main_line.time
117 | line_main_group = (
118 | skip_merge_group_name
119 | if main_line.skip_merge and skip_merge_group_name
120 | else main_group
121 | )
122 | line_group = LrcGroupLine(
123 | time=main_time,
124 | lrc={line_main_group: main_line.lrc},
125 | )
126 |
127 | for group, sub_lrc in sub_lines:
128 | for i, line in enumerate(sub_lrc):
129 | if (not line.lrc) or main_line.skip_merge:
130 | continue
131 |
132 | if (main_time - threshold) <= line.time < (main_time + threshold):
133 | for _ in range(i + 1):
134 | it = sub_lrc.pop(0) # noqa: B909
135 | if it.lrc:
136 | line_group.lrc[group] = it.lrc
137 | break
138 |
139 | merged.append(line_group)
140 |
141 | rest_lrc_len = max(len(x[1]) for x in sub_lines) if sub_lines else 0
142 | if rest_lrc_len:
143 | extra_lines = [
144 | LrcGroupLine(time=merged[-1].time + 1000, lrc={})
145 | for _ in range(rest_lrc_len)
146 | ]
147 | for group, line in sub_lines:
148 | for target, extra in zip(extra_lines, line):
149 | target.lrc[group] = extra.lrc
150 |
151 | return merged
152 |
153 |
154 | NCMLrcGroupNameType: TypeAlias = Literal["main", "roma", "trans", "meta"]
155 | NCM_MAIN_LRC_GROUP: NCMLrcGroupNameType = "main"
156 |
157 | NCMLrcGroupLine: TypeAlias = LrcGroupLine[NCMLrcGroupNameType]
158 |
159 |
160 | def normalize_lrc(lrc: "md.LyricData") -> Optional[list[NCMLrcGroupLine]]:
161 | def fmt_usr(usr: "md.User") -> str:
162 | return f"{usr.nickname} [{usr.user_id}]"
163 |
164 | raw = lrc.lrc
165 | if (not raw) or (not (raw_lrc := raw.lyric)):
166 | return None
167 |
168 | raw_lyric_groups: dict[NCMLrcGroupNameType, Optional[md.Lyric]] = {
169 | "main": raw,
170 | "roma": lrc.roma_lrc,
171 | "trans": lrc.trans_lrc,
172 | }
173 | lyrics: dict[NCMLrcGroupNameType, list[LrcLine]] = {
174 | k: x for k, v in raw_lyric_groups.items() if v and (x := parse_lrc(v.lyric))
175 | }
176 | empty_line = config.ncm_lrc_empty_line
177 |
178 | if not lyrics:
179 | lines = [
180 | LrcGroupLine(
181 | time=0,
182 | lrc={NCM_MAIN_LRC_GROUP: (x or empty_line or "")},
183 | )
184 | for x in raw_lrc.splitlines()
185 | ]
186 |
187 | else:
188 | if lyrics[NCM_MAIN_LRC_GROUP][-1].time >= 5940000:
189 | return None # 纯音乐
190 | lines: list[NCMLrcGroupLine] = merge_lrc(
191 | lyrics,
192 | main_group=NCM_MAIN_LRC_GROUP,
193 | replace_empty_line=empty_line,
194 | skip_merge_group_name="meta",
195 | )
196 |
197 | if lrc.lyric_user or lrc.trans_user:
198 | if usr := lrc.lyric_user:
199 | lines.append(
200 | LrcGroupLine(
201 | time=5940000,
202 | lrc={"meta": f"歌词贡献者:{fmt_usr(usr)}"},
203 | ),
204 | )
205 | if usr := lrc.trans_user:
206 | lines.append(
207 | LrcGroupLine(
208 | time=5940000,
209 | lrc={"meta": f"翻译贡献者:{fmt_usr(usr)}"},
210 | ),
211 | )
212 |
213 | return lines
214 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "nonebot-plugin-multincm"
3 | dynamic = ["version"]
4 | description = "NCM Song Searcher"
5 | authors = [{ name = "LgCookie", email = "lgc2333@126.com" }]
6 | dependencies = [
7 | "nonebot2>=2.4.1",
8 | "nonebot-plugin-htmlrender>=0.5.1",
9 | "nonebot-plugin-alconna>=0.57.4",
10 | "nonebot-plugin-localstore>=0.7.4",
11 | "pyncm>=1.7.1",
12 | "typing-extensions>=4.12.2",
13 | "httpx>=0.27.2",
14 | "jinja2>=3.1.5",
15 | "cookit[jinja,loguru,nonebot-alconna,nonebot-localstore,pydantic]>=0.11.0.post1",
16 | "nonebot-plugin-waiter>=0.8.1",
17 | "cachetools>=5.5.1",
18 | "yarl>=1.20.0",
19 | "silk-python>=0.2.6",
20 | "qrcode>=8.0",
21 | ]
22 | requires-python = ">=3.9,<4.0"
23 | readme = "README.md"
24 | license = { text = "MIT" }
25 |
26 | [project.urls]
27 | homepage = "https://github.com/lgc-NB2Dev/nonebot-plugin-multincm"
28 |
29 | [dependency-groups]
30 | dev = ["nonebot-adapter-kritor>=0.3.2", "nonebot-adapter-onebot>=2.4.6"]
31 |
32 | [tool.pdm.version]
33 | source = "file"
34 | path = "nonebot_plugin_multincm/__init__.py"
35 |
36 | [tool.pdm.build]
37 | includes = []
38 |
39 | [build-system]
40 | requires = ["pdm-backend"]
41 | build-backend = "pdm.backend"
42 |
--------------------------------------------------------------------------------