├── .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 |
4 | 5 | 6 | NoneBotPluginLogo 7 | 8 | 9 |

10 | NoneBotPluginText 11 |

12 | 13 | # NoneBot-Plugin-MultiNCM 14 | 15 | _✨ 网易云多选点歌 ✨_ 16 | 17 | python 18 | 19 | pdm-managed 20 | 21 | 22 | wakatime 23 | 24 | 25 |
26 | 27 | 28 | Pydantic Version 1 Or 2 29 | 30 | 31 | license 32 | 33 | 34 | pypi 35 | 36 | 37 | pypi download 38 | 39 | 40 |
41 | 42 | 43 | NoneBot Registry 44 | 45 | 46 | Supported Adapters 47 | 48 | 49 |
50 | 51 | ## 📖 介绍 52 | 53 | 一个网易云多选点歌插件(也可以设置成单选,看下面),可以翻页,可以登录网易云账号点 vip 歌曲听(插件发送的是自定义音乐卡片),没了 54 | 55 | 插件获取的是音乐播放链接,不会消耗会员每月下载次数 56 | 57 | ### 效果图 58 | 59 |
60 | 歌曲列表效果图(点击展开) 61 | 62 | ![pic](https://raw.githubusercontent.com/lgc-NB2Dev/readme/main/multincm/song.jpg) 63 | 64 |
65 | 66 |
67 | 歌词效果图(点击展开) 68 | 69 | ![pic](https://raw.githubusercontent.com/lgc-NB2Dev/readme/main/multincm/lyrics.png) 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 | --------------------------------------------------------------------------------