├── .github
└── workflows
│ └── pypi-publish.yml
├── .gitignore
├── LICENSE
├── README.md
├── docs
├── guess_chart_screenshot.png
├── guess_cover_screenshot.png
└── open_character_screenshot.png
├── nonebot_plugin_guess_song
├── .prettierrc
├── __init__.py
├── config.py
└── libraries
│ ├── __init__.py
│ ├── guess_character.py
│ ├── guess_cover.py
│ ├── guess_song_chart.py
│ ├── guess_song_clue.py
│ ├── guess_song_listen.py
│ ├── guess_song_note.py
│ ├── music_model.py
│ └── utils.py
├── pyproject.toml
└── so_hard.jpg
/.github/workflows/pypi-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 | workflow_dispatch:
8 |
9 | jobs:
10 | pypi-publish:
11 | name: Upload release to PyPI
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@master
15 | - name: Set up Python
16 | uses: actions/setup-python@v1
17 | with:
18 | python-version: "3.x"
19 | - name: Install pypa/build
20 | run: >-
21 | python -m
22 | pip install
23 | build
24 | --user
25 | - name: Build a binary wheel and a source tarball
26 | run: >-
27 | python -m
28 | build
29 | --sdist
30 | --wheel
31 | --outdir dist/
32 | .
33 | - name: Publish distribution to PyPI
34 | uses: pypa/gh-action-pypi-publish@release/v1
35 | with:
36 | password: ${{ secrets.PYPI_API_TOKEN }}
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
104 | poetry.toml
105 |
106 | # pdm
107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
108 | #pdm.lock
109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
110 | # in version control.
111 | # https://pdm.fming.dev/#use-with-ide
112 | .pdm-python
113 |
114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
115 | __pypackages__/
116 |
117 | # Celery stuff
118 | celerybeat-schedule
119 | celerybeat.pid
120 |
121 | # SageMath parsed files
122 | *.sage.py
123 |
124 | # Environments
125 | .env
126 | .venv
127 | env/
128 | venv/
129 | ENV/
130 | env.bak/
131 | venv.bak/
132 |
133 | # Spyder project settings
134 | .spyderproject
135 | .spyproject
136 |
137 | # Rope project settings
138 | .ropeproject
139 |
140 | # mkdocs documentation
141 | /site
142 |
143 | # mypy
144 | .mypy_cache/
145 | .dmypy.json
146 | dmypy.json
147 |
148 | # Pyre type checker
149 | .pyre/
150 |
151 | # pytype static type analyzer
152 | .pytype/
153 |
154 | # Cython debug symbols
155 | cython_debug/
156 |
157 | # ruff
158 | .ruff_cache/
159 |
160 | # LSP config files
161 | pyrightconfig.json
162 |
163 | # PyCharm
164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166 | # and can be added to the global gitignore or merged into this file. For a more nuclear
167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168 | #.idea/
169 |
170 | # VisualStudioCode
171 | .vscode/*
172 | !.vscode/settings.json
173 | !.vscode/tasks.json
174 | !.vscode/launch.json
175 | !.vscode/extensions.json
176 | !.vscode/*.code-snippets
177 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 CainithmBot
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 |
7 |
8 |
9 | # nonebot-plugin-guess-song
10 |
11 | _✨ NoneBot 舞萌猜歌小游戏插件 ✨_
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |

21 |
22 |
23 |
24 |
25 | ## 📖 介绍
26 |
27 | 一个音游猜歌插件(主要为舞萌DX maimaiDX提供资源),有开字母、猜曲绘、听歌猜曲、谱面猜歌、线索猜歌、note音猜歌等游戏
28 |
29 | ## 💿 安装
30 |
31 |
32 | 使用 nb-cli 安装
33 | 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
34 |
35 | nb plugin install nonebot-plugin-guess-song
36 |
37 |
38 |
39 |
40 | 使用包管理器安装
41 | 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
42 |
43 |
44 | pip
45 |
46 | pip install nonebot-plugin-guess-song
47 |
48 |
49 | pdm
50 |
51 | pdm add nonebot-plugin-guess-song
52 |
53 |
54 | poetry
55 |
56 | poetry add nonebot-plugin-guess-song
57 |
58 |
59 | conda
60 |
61 | conda install nonebot-plugin-guess-song
62 |
63 |
64 | 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
65 |
66 | plugins = ["nonebot_plugin_guess_song"]
67 |
68 |
69 |
70 | ## ⚙️ 配置
71 |
72 | ⚠️⚠️⚠️请务必注意,若需要使用听歌猜曲以及谱面猜歌,必须先下载并配置ffmpeg⚠️⚠️⚠️
73 |
74 |
75 | 在 nonebot2 项目的`.env`文件中添加下表中的必填配置
76 |
77 | | 配置项 | 必填 | 默认值 | 说明 |
78 | |:-----:|:----:|:----:|:----:|
79 | | character_filter_japenese | 否 | True | 指示开字母游戏中,是否需要过滤掉含有日文字符的歌曲 |
80 | | everyday_is_add_credits | 否 | False | 指示是否需要每天给猜歌答对的用户加积分 |
81 |
82 |
83 | ### 资源需求
84 | 本插件需要一些资源(歌曲信息等)才能够正常使用,以下是说明及下载方法、配置教程。
85 |
86 | 💡**资源需求说明**:
87 | - 最低配置(所有游戏都需要):music_data.json、music_alias.json(在后文的static压缩包中)
88 | - 开字母:无需添加其它资源(可以按需添加[“这么难他都会”](https://github.com/apshuang/nonebot-plugin-guess-song/blob/master/so_hard.jpg)的图片资源到/resources/maimai/so_hard.jpg)
89 | - 猜曲绘:需要添加mai/cover(也在后文的static压缩包中)
90 | - 线索猜歌:需要添加mai/cover(如果添加了音乐资源,还会增加歌曲长度作为线索)
91 | - 听歌猜曲:需要添加music_guo——在动态资源路径/resources下,建立music_guo文件夹,将听歌猜曲文件放入内;
92 | - 谱面猜歌:需要添加chart_resources——在动态资源路径/resources下,建立chart_resources文件夹,将谱面猜歌资源文件放入内(保持每个版本一个文件夹)。
93 | - note音猜歌:资源需求同谱面猜歌。
94 |
95 |
96 | 🟩**static文件**:[GitHub Releases下载](https://github.com/apshuang/nonebot-plugin-guess-song/releases/tag/Static-resources)、[百度云盘](https://pan.baidu.com/s/1K5d7MqcNh83gh9yerfgGPg?pwd=fiwp)
97 |
98 | 内部包括music_data.json、music_alias.json文件,以及mai/cover,是歌曲的信息与别名,以及歌曲的曲绘。
99 | 推荐联合使用[其它maimai插件](https://github.com/Yuri-YuzuChaN/nonebot-plugin-maimaidx)来动态更新歌曲信息与别名信息。
100 |
101 |
102 | 🟩**动态资源文件**(针对听歌猜曲和谱面猜歌,可**按需下载**):
103 | - 听歌猜曲文件(共6.55GB,已切分为五个压缩包,可部分下载):[GitHub Releases下载](https://github.com/apshuang/nonebot-plugin-guess-song/releases/tag/guess_listen-resources)、[百度云盘](https://pan.baidu.com/s/1vVC8p7HDWfczMswOLmE8Og?pwd=gqu3)
104 | - 谱面猜歌文件(共22.37GB,已划分为按版本聚类,可下载部分版本使用):[GitHub Releases下载](https://github.com/apshuang/nonebot-plugin-guess-song/releases/tag/guess_chart-resources)、[百度云盘](https://pan.baidu.com/s/1kIMeYv46djxJe_p8DMTtfA?pwd=e6sf)
105 |
106 | 在动态资源路径/resources下,建立music_guo文件夹,将听歌猜曲文件放入内;建立chart_resources文件夹,将谱面猜歌资源文件放入内(保持每个版本一个文件夹)。
107 | ⚠️可以不下载动态资源, 也可以只下载部分动态资源(部分歌曲或部分版本),插件也能正常运行,只不过曲库会略微少一些。⚠️
108 |
109 | ### 资源目录配置教程
110 |
111 | 点击展开查看项目目录
112 |
113 | ```plaintext
114 | CainithmBot/
115 | ├── static/
116 | │ ├── mai/
117 | │ │ └── cover/
118 | │ ├── music_data.json # 歌曲信息
119 | │ ├── music_alias.json # 歌曲别名信息
120 | │ └── SourceHanSansSC-Bold.otf # 发送猜歌帮助所需字体
121 | ├── resources/
122 | │ ├── music_guo/ # 国服歌曲音乐文件
123 | │ │ ├── 8.mp3
124 | │ │ └── ...
125 | │ ├── chart_resources/ # 谱面猜歌资源文件
126 | │ │ ├── 01. maimai/ # 内部需按版本分开各个文件夹
127 | │ │ │ ├── mp3/
128 | │ │ │ └── mp4/
129 | │ │ ├── 02. maimai PLUS/
130 | │ │ │ ├── mp3/
131 | │ │ │ └── mp4/
132 | │ │ └── ...
133 | │ │ ├── remaster/
134 | │ │ │ ├── mp3/
135 | │ │ │ └── mp4/
136 | └── ...
137 |
138 | ```
139 |
140 |
141 |
142 | 根据上面的项目目录,我们可以看到,在某个存放资源的根目录(假设这个根目录为`E:/Bot/CainithmBot`)下面,有一个static文件夹和一个resources文件夹,只需要按照上面的指示,将对应的资源放到对应文件夹目录下即可。
143 | 同时,本插件使用了nonebot-plugin-localstore插件进行资源管理,所以,比较建议您为本插件单独设置一个资源根目录(因为资源较多),我们继续假设这个资源根目录为`E:/Bot/CainithmBot`,那么您需要在`.env`配置文件中添加以下配置:
144 | ```dotenv
145 | LOCALSTORE_PLUGIN_DATA_DIR='
146 | {
147 | "nonebot_plugin_guess_song": "E:/Bot/CainithmBot"
148 | }
149 | '
150 | ```
151 | 如果您希望直接使用一个全局的资源根目录,则直接通过`nb localstore`,查看全局的Data Dir,并将static和resources文件夹置于这个Data Dir下,就可以使用了!
152 |
153 |
154 | ## 🎉 使用
155 | ### 指令表
156 | 详细指令表及其高级用法(比如过滤某个版本、某些等级的歌曲)也可以通过“/猜歌帮助”来查看
157 | | 指令 | 权限 | 需要@ | 范围 | 说明 |
158 | |:-----:|:----:|:----:|:----:|:----:|
159 | | /开字母 | 群员 | 否 | 群聊 | 开始开字母游戏 |
160 | | /(连续)听歌猜曲 | 群员 | 否 | 群聊 | 开始(连续)听歌猜曲游戏 |
161 | | /(连续)谱面猜歌 | 群员 | 否 | 群聊 | 开始(连续)谱面猜歌游戏 |
162 | | /(连续)猜曲绘 | 群员 | 否 | 群聊 | 开始(连续)猜曲绘游戏 |
163 | | /(连续)线索猜歌 | 群员 | 否 | 群聊 | 开始(连续)线索猜歌游戏 |
164 | | /(连续)note音猜歌 | 群员 | 否 | 群聊 | 开始(连续)note音猜歌游戏 |
165 | | /(连续)随机猜歌 | 群员 | 否 | 群聊 | 在听歌猜曲、谱面猜歌、猜曲绘、线索猜歌中随机进行一个游戏 |
166 | | 猜歌 xxx | 群员 | 否 | 群聊 | 根据已知信息猜测歌曲 |
167 | | 不玩了 | 群员 | 否 | 群聊 | 揭晓当前猜歌的答案 |
168 | | 停止 | 群员 | 否 | 群聊 | 停止连续猜歌 |
169 | | /开启/关闭猜歌 xxx | 管理员/主人 | 否 | 群聊 | 开启或禁用某类或全部猜歌游戏 |
170 | | /猜曲绘配置 xxx | 管理员/主人 | 否 | 群聊 | 进行猜曲绘配置(高斯模糊程度、打乱程度、裁切程度等) |
171 | | /检查歌曲文件完整性 | 主人 | 否 | 群聊 | 检查听歌猜曲的音乐资源 |
172 | | /检查谱面完整性 | 主人 | 否 | 群聊 | 检查谱面猜歌的文件资源 |
173 |
174 |
175 | ## 📝 项目特点
176 |
177 | - ✅ 游戏新颖、有趣,更贴合游戏的核心要素(谱面与音乐)
178 | - ✅ 资源配置要求较低,可按需部分下载
179 | - ✅ 性能较高,使用preload技术加快谱面加载速度
180 | - ✅ 框架通用,可扩展性强
181 | - ✅ 使用简单、可使用别名猜歌,用户猜歌方便
182 | - ✅ 贡献了谱面猜歌数据集,可以用于制作猜歌视频
183 |
184 |
185 | ### 效果图
186 | 
187 | 
188 | 
189 |
190 |
191 | ## 🙏 鸣谢
192 |
193 | - [maimai插件](https://github.com/Yuri-YuzuChaN/nonebot-plugin-maimaidx) - maimai插件、static资源下载
194 | - [MajdataEdit](https://github.com/LingFeng-bbben/MajdataEdit) - maimai谱面编辑器
195 | - [MajdataEdit_BatchOutput_Tool](https://github.com/apshuang/MajdataEdit_BatchOutput_Tool) - 用于批量导出maimai谱面视频资源
196 | - [NoneBot2](https://github.com/nonebot/nonebot2) - 跨平台 Python 异步机器人框架
197 |
198 |
199 | ## 📞 联系
200 |
201 | | 猜你字母Bot游戏群(欢迎加群游玩) | QQ群:925120177 |
202 | | ---------------- | ---------------- |
203 |
204 | 发现任何问题或有任何建议,欢迎发Issue,也可以加群联系开发团队,感谢!
--------------------------------------------------------------------------------
/docs/guess_chart_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apshuang/nonebot-plugin-guess-song/7bfc3ed440f5e982360d663872f4a3d509184392/docs/guess_chart_screenshot.png
--------------------------------------------------------------------------------
/docs/guess_cover_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apshuang/nonebot-plugin-guess-song/7bfc3ed440f5e982360d663872f4a3d509184392/docs/guess_cover_screenshot.png
--------------------------------------------------------------------------------
/docs/open_character_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apshuang/nonebot-plugin-guess-song/7bfc3ed440f5e982360d663872f4a3d509184392/docs/open_character_screenshot.png
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false
4 | }
5 |
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import get_plugin_config, get_bot
2 | from nonebot import on_startswith, on_command, on_fullmatch, on_message
3 | from nonebot.plugin import PluginMetadata
4 | from nonebot.adapters.onebot.v11 import Message, MessageSegment, GroupMessageEvent, Bot
5 | from nonebot.params import Startswith
6 | from nonebot.matcher import Matcher
7 | from nonebot.plugin import PluginMetadata, require
8 |
9 | from .libraries import *
10 | from .config import *
11 |
12 |
13 | require('nonebot_plugin_apscheduler')
14 |
15 | from nonebot_plugin_apscheduler import scheduler
16 |
17 | __plugin_meta__ = PluginMetadata(
18 | name="maimai猜歌小游戏",
19 | description="音游猜歌游戏插件,提供开字母、听歌猜曲、谱面猜歌、猜曲绘、线索猜歌等游戏",
20 | usage="/猜歌帮助",
21 | type="application",
22 | config=Config,
23 | homepage="https://github.com/apshuang/nonebot-plugin-guess-song",
24 | supported_adapters={"~onebot.v11"},
25 | )
26 |
27 |
28 | def is_now_playing_game(event: GroupMessageEvent) -> bool:
29 | return gameplay_list.get(str(event.group_id)) is not None
30 |
31 | open_song = on_startswith(("开歌","猜歌"), priority=3, ignorecase=True, block=True, rule=is_now_playing_game)
32 | open_song_without_prefix = on_message(rule=is_now_playing_game, priority=10)
33 | stop_game = on_fullmatch(("不玩了", "不猜了"), priority=5)
34 | stop_game_force = on_fullmatch("强制停止", priority=5)
35 | stop_continuous = on_fullmatch('停止', priority=5)
36 | top_three = on_command('前三', priority=5)
37 | guess_random = on_command('随机猜歌', priority=5)
38 | continuous_guess_random = on_command('连续随机猜歌', priority=5)
39 | help_guess = on_command('猜歌帮助', priority=5)
40 | charter_names = on_command('查看谱师', priority=5)
41 | enable_guess_game = on_command('开启猜歌', priority=5, permission=GROUP_ADMIN | GROUP_OWNER | SUPERUSER)
42 | disable_guess_game = on_command('关闭猜歌', priority=5, permission=GROUP_ADMIN | GROUP_OWNER | SUPERUSER)
43 |
44 | @help_guess.handle()
45 | async def _(matcher: Matcher):
46 | await matcher.finish(MessageSegment.image(to_bytes_io(guess_help_message)))
47 |
48 | @charter_names.handle()
49 | async def _(bot: Bot, event: GroupMessageEvent):
50 | msglist = []
51 | for names in charterlist.values():
52 | msglist.append(', '.join(names))
53 | msglist.append('以上是目前已知的谱师名单,如有遗漏请联系管理员添加。')
54 | await send_forward_message(bot, event.group_id, bot.self_id, msglist)
55 |
56 | @guess_random.handle()
57 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
58 | if len(total_list.music_list) == 0:
59 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
60 | group_id = str(event.group_id)
61 | game_name = "random"
62 | if check_game_disable(group_id, game_name):
63 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
64 | params = args.extract_plain_text().strip().split()
65 | await isplayingcheck(group_id, matcher)
66 | choice = random.randint(1, 4)
67 | if choice == 1:
68 | await guess_cover_handler(group_id, matcher, params)
69 | elif choice == 2:
70 | await clue_guess_handler(group_id, matcher, params)
71 | elif choice == 3:
72 | await listen_guess_handler(group_id, matcher, params)
73 | elif choice == 4:
74 | await guess_chart_request(event, matcher, args)
75 | elif choice == 5:
76 | await note_guess_handler(group_id, matcher, params)
77 |
78 | @continuous_guess_random.handle()
79 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
80 | if len(total_list.music_list) == 0:
81 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
82 | group_id = str(event.group_id)
83 | game_name = "random"
84 | if check_game_disable(group_id, game_name):
85 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
86 | params = args.extract_plain_text().strip().split()
87 | await isplayingcheck(group_id, matcher)
88 | if not filter_random(total_list.music_list, params, 1):
89 | await matcher.finish(fault_tips, reply_message=True)
90 | await matcher.send('连续随机猜歌已开启,发送\"停止\"以结束')
91 | continuous_stop[group_id] = 1
92 |
93 | charts_pool = None
94 | # 此处开始preload一些谱面(如果是有参数的)
95 | if len(params) != 0:
96 | # 有参数的连续谱面猜歌
97 | valid_music = filter_random(chart_total_list, params, 10) # 至少需要有10首歌,不然失去“猜”的价值
98 | if not valid_music:
99 | continuous_stop.pop(group_id)
100 | await matcher.finish(fault_tips)
101 |
102 | groupID_params_map[group_id] = params
103 | sub_path_name = f"{str(event.group_id)}_{'_'.join(params)}"
104 | param_charts_pool.setdefault(sub_path_name, [])
105 | charts_pool = param_charts_pool[sub_path_name]
106 |
107 | # 先进行检验,查看旧的charts_pool里的文件是否都还存在(未被删除)
108 | charts_already_old = []
109 | for (question_clip_path, answer_clip_path) in charts_pool:
110 | if not os.path.isfile(question_clip_path) or not os.path.isfile(answer_clip_path):
111 | charts_already_old.append((question_clip_path, answer_clip_path))
112 | for (question_clip_path, answer_clip_path) in charts_already_old:
113 | charts_pool.remove((question_clip_path, answer_clip_path))
114 | logging.info(f"删除了过时的谱面:{question_clip_path}")
115 | make_param_chart_task = asyncio.create_task(make_param_chart_video(group_id, params))
116 | else:
117 | charts_pool = general_charts_pool
118 |
119 |
120 | while continuous_stop.get(group_id):
121 | if gameplay_list.get(group_id) is None:
122 | choice = random.randint(1, 4)
123 | if choice == 1:
124 | await guess_cover_handler(group_id, matcher, params)
125 | elif choice == 2:
126 | await clue_guess_handler(group_id, matcher, params)
127 | elif choice == 3:
128 | await listen_guess_handler(group_id, matcher, params)
129 | elif choice == 4:
130 | for i in range(30):
131 | if len(charts_pool) > 0:
132 | break
133 | await asyncio.sleep(1)
134 | if len(charts_pool) == 0:
135 | continuous_stop.pop(group_id)
136 | if groupID_params_map.get(group_id):
137 | groupID_params_map.pop(group_id)
138 | await matcher.finish("bot主的电脑太慢啦,过了30秒还没有一个谱面制作出来!建议vivo50让我换电脑!", reply_message=True)
139 | (question_clip_path, answer_clip_path) = charts_pool.pop(random.randint(0, len(charts_pool) - 1))
140 | await guess_chart_handler(group_id, matcher, question_clip_path, answer_clip_path, params)
141 | await asyncio.sleep(2)
142 | elif choice == 5:
143 | await note_guess_handler(group_id, matcher, params)
144 | await asyncio.sleep(2)
145 | if continuous_stop[group_id] > 3:
146 | continuous_stop.pop(group_id)
147 | if groupID_params_map.get(group_id):
148 | groupID_params_map.pop(group_id)
149 | await matcher.finish('没人猜了? 那我下班了。')
150 | await asyncio.sleep(1)
151 |
152 | async def open_song_dispatcher(matcher: Matcher, song_name, user_id, group_id, ignore_tag=False):
153 | if gameplay_list.get(group_id).get("open_character"):
154 | await character_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag)
155 | elif gameplay_list.get(group_id).get("listen"):
156 | await listen_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag)
157 | elif gameplay_list.get(group_id).get("cover"):
158 | await cover_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag)
159 | elif gameplay_list.get(group_id).get("clue"):
160 | await clue_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag)
161 | elif gameplay_list.get(group_id).get("chart"):
162 | await chart_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag)
163 | elif gameplay_list.get(group_id).get("note"):
164 | await note_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag)
165 |
166 | @open_song.handle()
167 | async def open_song_handler(event: GroupMessageEvent, matcher: Matcher, start: str = Startswith()):
168 | song_name = event.get_plaintext().lower()[len(start):].strip()
169 | if song_name == "":
170 | await matcher.finish("无效的请求,请使用格式:开歌xxxx(xxxx 是歌曲名称)", reply_message=True)
171 | await open_song_dispatcher(matcher, song_name, event.user_id, str(event.group_id))
172 |
173 |
174 | @open_song_without_prefix.handle()
175 | async def open_song_without_prefix_handler(event: GroupMessageEvent, matcher: Matcher):
176 | # 直接输入曲名也可以视作答题,但是答题错误的话不返回任何信息(防止正常聊天也被视作答题)
177 | song_name = event.get_plaintext().strip().lower()
178 | if song_name == "" or alias_dict.get(song_name) is None:
179 | return
180 | await open_song_dispatcher(matcher, song_name, event.user_id, str(event.group_id), True)
181 |
182 |
183 | @stop_game_force.handle()
184 | async def _(event: GroupMessageEvent, matcher: Matcher):
185 | # 中途停止的处理函数
186 | group_id = str(event.group_id)
187 | if gameplay_list.get(group_id):
188 | gameplay_list.pop(group_id)
189 | if continuous_stop.get(group_id):
190 | continuous_stop.pop(group_id)
191 | if groupID_params_map.get(group_id):
192 | groupID_params_map.pop(group_id)
193 | await matcher.finish("已强行停止当前游戏")
194 |
195 |
196 | @stop_game.handle()
197 | async def _(event: GroupMessageEvent, matcher: Matcher):
198 | # 中途停止的处理函数
199 | group_id = str(event.group_id)
200 | if gameplay_list.get(group_id) is not None:
201 | now_playing_game = list(gameplay_list.get(group_id).keys())[0]
202 | if now_playing_game == "open_character":
203 | message = open_character_message(group_id, early_stop = True)
204 | await matcher.finish(message)
205 | elif now_playing_game in ["listen", "cover", "clue"]:
206 | music = gameplay_list.get(group_id).get(now_playing_game)
207 | gameplay_list.pop(group_id)
208 | await matcher.finish(
209 | MessageSegment.text(f'很遗憾,你没有猜到答案,正确的答案是:\n') + song_txt(music) + MessageSegment.text('\n\n。。30秒都坚持不了吗')
210 | ,reply_message=True)
211 | elif now_playing_game == "chart":
212 | answer_clip_path = gameplay_list.get(group_id).get("chart")
213 | gameplay_list.pop(group_id) # 需要先pop,不然的话发了答案之后还能猜
214 | random_music_id, start_time = split_id_from_path(answer_clip_path)
215 | is_remaster = False
216 | if int(random_music_id) > 500000:
217 | # 是白谱,需要找回原始music对象
218 | random_music_id = str(int(random_music_id) % 500000)
219 | is_remaster = True
220 | random_music = total_list.by_id(random_music_id)
221 | charts_save_list.append(answer_clip_path) # 但是必须先把它保护住,否则有可能发到一半被删掉
222 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music, is_remaster) + "\n对应的原片段如下:"
223 | await matcher.send(reply_message, reply_message=True)
224 | if answer_clip_path in loading_clip_list:
225 | for i in range(31):
226 | await asyncio.sleep(1)
227 | if answer_clip_path not in loading_clip_list:
228 | break
229 | if i == 30:
230 | await matcher.finish(f"答案文件可能坏掉了,这个谱的开始时间是{start_time}秒,如果感兴趣可以自己去搜索噢", reply_message=True)
231 | await matcher.send(MessageSegment.video(f"file://{answer_clip_path}"))
232 | os.remove(answer_clip_path)
233 | charts_save_list.remove(answer_clip_path) # 发完了就可以解除保护了
234 | elif now_playing_game == "note":
235 | (answer_music, start_time) = gameplay_list.get(group_id).get(now_playing_game)
236 | random_music_id = answer_music.id
237 | is_remaster = False
238 | if int(random_music_id) > 500000:
239 | # 是白谱,需要找回原始music对象
240 | random_music_id = str(int(random_music_id) % 500000)
241 | is_remaster = True
242 | random_music = total_list.by_id(random_music_id)
243 | gameplay_list.pop(group_id)
244 | await matcher.send(
245 | MessageSegment.text(f'很遗憾,你没有猜到答案,正确的答案是:\n') + song_txt(random_music, is_remaster) + MessageSegment.text('\n\n。。30秒都坚持不了吗')
246 | ,reply_message=True)
247 | answer_input_path = id_mp3_file_map.get(answer_music.id) # 这个是旧的id,即白谱应该找回白谱的id
248 | answer_output_path: Path = guess_resources_path / f"{group_id}_{start_time}_answer.mp3"
249 | await asyncio.to_thread(make_note_sound, answer_input_path, start_time, answer_output_path)
250 | await matcher.send(MessageSegment.record(f"file://{answer_output_path}"))
251 | os.remove(answer_output_path)
252 |
253 | @stop_continuous.handle()
254 | async def _(event: GroupMessageEvent, matcher: Matcher):
255 | group_id = str(event.group_id)
256 | if groupID_params_map.get(group_id):
257 | groupID_params_map.pop(group_id)
258 | if continuous_stop.get(group_id):
259 | continuous_stop.pop(group_id)
260 | await matcher.finish('已停止,坚持把最后一首歌猜完吧!')
261 |
262 | def add_credit_message(group_id) -> list[str]:
263 | record = {}
264 | gid = str(group_id)
265 | game_data = load_game_data_json(gid)
266 | for game_name, data in game_data[gid]['rank'].items():
267 | for user_id, point in data.items():
268 | record.setdefault(user_id, [0, 0])
269 | record[user_id][0] += point
270 | record[user_id][1] += point // point_per_credit_dict[game_name]
271 | sorted_record = sorted(record.items(), key=lambda x: (x[1][1], x[1][0]), reverse=True)
272 | if sorted_record:
273 | msg_list = []
274 | msg = f'今日猜歌总记录:\n'
275 | for rank, (user_id, count) in enumerate(sorted_record, 1):
276 | if (rank-1) % 30 == 0 and rank != 1:
277 | msg_list.append(msg)
278 | msg = ""
279 | if config.everyday_is_add_credits:
280 | msg += f"{rank}. {MessageSegment.at(user_id)} 今天共答对{count[0]}题,加{count[1]}分!\n"
281 | # -------------请在此处填写你的加分代码(如需要)------------------
282 |
283 |
284 | # -------------请在此处填写你的加分代码(如需要)------------------
285 | else:
286 | msg += f"{rank}. {MessageSegment.at(user_id)} 今天共答对{count[0]}题!\n"
287 | if config.everyday_is_add_credits:
288 | msg += "便宜你们了。。"
289 | msg_list.append(msg)
290 | return msg_list
291 | else:
292 | return []
293 |
294 | async def send_top_three(bot, group_id, isaddcredit = False, is_force = False):
295 | sender_id = bot.self_id
296 | char_msg = open_character_rank_message(group_id)
297 | listen_msg = listen_rank_message(group_id)
298 | cover_msg = cover_rank_message(group_id)
299 | clue_msg = clue_rank_message(group_id)
300 | chart_msg = chart_rank_message(group_id)
301 | note_msg = note_rank_message(group_id)
302 |
303 | origin_messages = [char_msg, listen_msg, cover_msg, clue_msg, chart_msg, note_msg]
304 | if isaddcredit:
305 | origin_messages.extend(add_credit_message(group_id))
306 | if is_force:
307 | empty_tag = True
308 | for msg in origin_messages:
309 | if msg is not None:
310 | empty_tag = False
311 | if empty_tag:
312 | await bot.send_group_msg(group_id=group_id, message=Message(MessageSegment.text("本群还没有猜歌排名数据噢!快来玩一下猜歌游戏吧!")))
313 | return
314 | await send_forward_message(bot, group_id, sender_id, origin_messages)
315 |
316 |
317 | @enable_guess_game.handle()
318 | @disable_guess_game.handle()
319 | async def _(matcher: Matcher, event: GroupMessageEvent, arg: Message = CommandArg()):
320 | gid = str(event.group_id)
321 | arg = arg.extract_plain_text().strip().lower()
322 | enable_sign = True
323 | if type(matcher) is enable_guess_game:
324 | enable_sign = True
325 | elif type(matcher) is disable_guess_game:
326 | enable_sign = False
327 | else:
328 | raise ValueError('matcher type error')
329 |
330 | global global_game_data
331 | global_game_data = load_game_data_json(gid)
332 | if arg == "all" or arg == "全部":
333 | for key in global_game_data[gid]['game_enable'].keys():
334 | global_game_data[gid]['game_enable'][key] = enable_sign
335 | msg = f'已将本群的全部猜歌游戏全部设为{"开启" if enable_sign else "禁用"}'
336 | elif game_alias_map.get(arg):
337 | global_game_data[gid]["game_enable"][arg] = enable_sign
338 | msg = f'已将本群的{game_alias_map.get(arg)}设为{"开启" if enable_sign else "禁用"}'
339 | elif game_alias_map_reverse.get(arg):
340 | global_game_data[gid]["game_enable"][game_alias_map_reverse[arg]] = enable_sign
341 | msg = f'已将本群的{arg}设为{"开启" if enable_sign else "关闭"}'
342 | else:
343 | msg = '您的输入有误,请输入游戏名(比如开字母、谱面猜歌、全部)或其英文(比如listen、cover、all)来进行开启或禁用猜歌游戏'
344 | save_game_data(global_game_data, game_data_path)
345 | #print(global_game_data)
346 | await matcher.finish(msg)
347 |
348 | @top_three.handle()
349 | async def top_three_handler(event: GroupMessageEvent, matcher: Matcher, bot: Bot):
350 | await send_top_three(bot, event.group_id, is_force=True)
351 |
352 | @scheduler.scheduled_job('cron', hour=15, minute=00)
353 | async def _():
354 | bot: Bot = get_bot()
355 | group_list = await bot.call_api("get_group_list")
356 | for group_info in group_list:
357 | group_id = str(group_info.get("group_id"))
358 | await send_top_three(bot, group_id)
359 |
360 |
361 | @scheduler.scheduled_job('cron', hour=23, minute=57)
362 | async def send_top_three_schedule():
363 | bot: Bot = get_bot()
364 | group_list = await bot.call_api("get_group_list")
365 | for group_info in group_list:
366 | group_id = str(group_info.get("group_id"))
367 |
368 | # 如果不需要给用户加分(仅展示答对题数与排名),可以将这里的isaddcredit设为False
369 | await send_top_three(bot, group_id, isaddcredit=game_config.everyday_is_add_credits)
370 |
371 |
372 | @scheduler.scheduled_job('cron', hour=00, minute=00)
373 | async def reset_game_data():
374 | data = load_data(game_data_path)
375 | for gid in data.keys():
376 | data[gid]['rank'] = {"listen": {}, "open_character": {},"cover": {}, "clue": {}, "chart": {}, "note": {}}
377 | save_game_data(data, game_data_path)
378 |
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/config.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from pydantic import BaseModel
3 | from textwrap import dedent
4 |
5 | from nonebot import get_plugin_config
6 | from nonebot.plugin import require
7 |
8 | require("nonebot_plugin_localstore")
9 |
10 | import nonebot_plugin_localstore as store
11 |
12 |
13 | class Config(BaseModel):
14 | character_filter_japenese: bool = True # 开字母游戏是否需要过滤掉含日文字符的歌曲
15 | everyday_is_add_credits: bool = False # 每天给猜歌答对的用户加积分
16 |
17 | game_config = get_plugin_config(Config)
18 |
19 | plugin_data_dir: Path = store.get_plugin_data_dir()
20 |
21 | guess_static_resources_path: Path = plugin_data_dir / "static"
22 | guess_resources_path: Path = plugin_data_dir / "resources"
23 |
24 | music_cover_path: Path = guess_static_resources_path / "mai/cover"
25 | music_info_path: Path = guess_static_resources_path / "music_data.json"
26 | music_alias_path: Path = guess_static_resources_path / "music_alias.json"
27 | font_path: Path = guess_static_resources_path / "SourceHanSansSC-Bold.otf"
28 |
29 | user_info_path: Path = guess_resources_path / "user_info.json" # 基本只用于用户加分
30 | game_data_path: Path = guess_resources_path / "game_data.json" # 需要记录用户猜对的数量以及每个群猜曲绘的配置数据
31 |
32 | game_pic_path: Path = guess_resources_path / "maimai"
33 | music_file_path: Path = guess_resources_path / "music_guo"
34 | chart_file_path: Path = guess_resources_path / "chart_resources"
35 | chart_preload_path: Path = guess_resources_path / "chart_preload"
36 |
37 |
38 | point_per_credit_dict: dict[str, int] = {
39 | "listen": 5,
40 | "open_character": 5,
41 | "cover": 5,
42 | "clue": 3,
43 | "chart": 2,
44 | "note": 2
45 | }
46 |
47 | levelList: list[str] = ['1', '2', '3', '4', '5', '6', '7', '7+', '8', '8+', '9', '9+', '10', '10+', '11', '11+', '12', '12+', '13', '13+', '14', '14+', '15']
48 | plate_to_version: dict[str, str] = {
49 | '初': 'maimai',
50 | '真': 'maimai PLUS',
51 | '超': 'maimai GreeN',
52 | '檄': 'maimai GreeN PLUS',
53 | '橙': 'maimai ORANGE',
54 | #'暁': 'maimai ORANGE PLUS',
55 | '晓': 'maimai ORANGE PLUS',
56 | '桃': 'maimai PiNK',
57 | #'櫻': 'maimai PiNK PLUS',
58 | '樱': 'maimai PiNK PLUS',
59 | '紫': 'maimai MURASAKi',
60 | #'菫': 'maimai MURASAKi PLUS',
61 | '堇': 'maimai MURASAKi PLUS',
62 | '白': 'maimai MiLK',
63 | '雪': 'MiLK PLUS',
64 | #'輝': 'maimai FiNALE',
65 | '辉': 'maimai FiNALE',
66 | '熊': 'maimai でらっくす',
67 | #'華': 'maimai でらっくす PLUS',
68 | '华': 'maimai でらっくす PLUS',
69 | '爽': 'maimai でらっくす Splash',
70 | '煌': 'maimai でらっくす Splash PLUS',
71 | '宙': 'maimai でらっくす UNiVERSE',
72 | '星': 'maimai でらっくす UNiVERSE PLUS',
73 | '祭': 'maimai でらっくす FESTiVAL',
74 | '祝': 'maimai でらっくす FESTiVAL PLUS',
75 | '双': 'maimai でらっくす BUDDiES'
76 | }
77 |
78 | labelmap = {'华': '熊', '華': '熊', '煌': '爽', '星': '宙', '祝': '祭'} #国服特供
79 | category: dict[str, str] = {
80 | '流行&动漫': 'anime',
81 | '舞萌': 'maimai',
82 | 'niconico & VOCALOID': 'niconico',
83 | '东方Project': 'touhou',
84 | '其他游戏': 'game',
85 | '音击&中二节奏': 'ongeki',
86 | 'POPSアニメ': 'anime',
87 | 'maimai': 'maimai',
88 | 'niconicoボーカロイド': 'niconico',
89 | '東方Project': 'touhou',
90 | 'ゲームバラエティ': 'game',
91 | 'オンゲキCHUNITHM': 'ongeki',
92 | '宴会場': '宴会场'
93 | }
94 |
95 | charterlist: dict[str, list[str]] = {
96 | "0": [
97 | "-"
98 | ],
99 | "1": [
100 | "譜面-100号",
101 | "100号",
102 | "譜面-100号とはっぴー",
103 | "谱面100号"
104 | ],
105 | "2": [
106 | "ニャイン",
107 | "二爷",
108 | "二大爷",
109 | "二先生"
110 | ],
111 | "3": [
112 | "happy",
113 | "はっぴー",
114 | "譜面-100号とはっぴー",
115 | "はっぴー星人",
116 | "はぴネコ(はっぴー&ぴちネコ)",
117 | "緑風 犬三郎",
118 | "Sukiyaki vs Happy",
119 | "はっぴー respects for 某S氏",
120 | "Jack & はっぴー vs からめる & ぐるん",
121 | "はっぴー & サファ太",
122 | "舞舞10年ズ(チャンとはっぴー)",
123 | "哈皮",
124 | "“H”ack", #happy + jack
125 | "“H”ack underground",
126 | "原田", #ow谱师 = happy
127 | "原田ひろゆき"
128 | ],
129 | "4": [
130 | "dp",
131 | "チャン@DP皆伝",
132 | "チャン@DP皆伝 vs シチミヘルツ",
133 | "dp皆传",
134 | "DP皆传",
135 | "dq皆传"
136 | ],
137 | "5": [
138 | "jack",
139 | "Jack",
140 | "“H”ack",
141 | "JAQ",
142 | "7.3Hz+Jack",
143 | "チェシャ猫とハートのジャック",
144 | "“H”ack underground",
145 | "jacK on Phoenix",
146 | "Jack & Licorice Gunjyo",
147 | "Jack & はっぴー vs からめる & ぐるん",
148 | "jacK on Phoenix & -ZONE- SaFaRi",
149 | "Jack vs あまくちジンジャー",
150 | "Garakuta Scramble!" #gdp + 牛奶 = 小鸟游 + jack
151 | ],
152 | "6": [
153 | "桃子猫",
154 | "ぴちネコ",
155 | "SHICHIMI☆CAT",
156 | "チェシャ猫とハートのジャック",
157 | "はぴネコ(はっぴー&ぴちネコ)",
158 | "アマリリスせんせえ with ぴちネコせんせえ",
159 | "猫",
160 | "ネコトリサーカス団",
161 | "ロシアンブラック" #russianblack = 俄罗斯黑猫 = 桃子猫
162 | ],
163 | "7": [
164 | "maistar",
165 | "mai-Star",
166 | "迈斯达"
167 | ],
168 | "8": [
169 | "rion",
170 | "rioN"
171 | ],
172 | "9": [
173 | "科技厨房",
174 | "Techno Kitchen"
175 | ],
176 | "10": [
177 | "奉行",
178 | "すきやき奉行",
179 | "Sukiyaki vs Happy"
180 | ],
181 | "11": [
182 | "合作", #不署名多人统一处理
183 | "合作だよ",
184 | "maimai TEAM",
185 | "みんなでマイマイマー",
186 | "譜面ボーイズからの挑戦状",
187 | "PANDORA BOXXX",
188 | "PANDORA PARADOXXX",
189 | "舞舞10年ズ ~ファイナル~",
190 | "ゲキ*チュウマイ Fumen Team",
191 | "maimai Fumen All-Stars",
192 | "maimai TEAM DX",
193 | "BEYOND THE MEMORiES",
194 | "群星"
195 | ],
196 | "12": [
197 | "某S氏",
198 | "はっぴー respects for 某S氏"
199 | ],
200 | "13": [
201 | "monoclock",
202 | "ものくろっく",
203 | "ものくロシェ",
204 | "一ノ瀬 リズ" #马甲
205 | ],
206 | "14": [
207 | "柠檬",
208 | "じゃこレモン",
209 | "サファ太 vs じゃこレモン",
210 | "僕の檸檬本当上手"
211 | ],
212 | "15": [
213 | "小鸟游",
214 | "小鳥遊さん",
215 | "Phoenix",
216 | "-ZONE-Phoenix",
217 | "小鳥遊チミ",
218 | "小鳥遊さん fused with Phoenix",
219 | "7.3GHz vs Phoenix",
220 | "jacK on Phoenix",
221 | "The ALiEN vs. Phoenix",
222 | "小鳥遊さん vs 華火職人",
223 | "red phoenix",
224 | "小鳥遊さん×アミノハバキリ",
225 | "Garakuta Scramble!"
226 | ],
227 | "16": [
228 | "moonstrix",
229 | "Moon Strix"
230 | ],
231 | "17": [
232 | "玉子豆腐"
233 | ],
234 | "18": [
235 | "企鹅",
236 | "ロシェ@ペンギン",
237 | "ものくロシェ"
238 | ],
239 | "19": [
240 | "7.3",
241 | "シチミヘルツ",
242 | "7.3Hz+Jack",
243 | "シチミッピー",
244 | "Safata.Hz",
245 | "7.3GHz",
246 | "七味星人",
247 | "7.3連発華火",
248 | "SHICHIMI☆CAT",
249 | "超七味星人",
250 | "小鳥遊チミ",
251 | "Hz-R.Arrow",
252 | "あまくちヘルツ",
253 | "Safata.GHz",
254 | "7.3GHz vs Phoenix",
255 | "しちみりこりす",
256 | "7.3GHz -Før The Legends-",
257 | "チャン@DP皆伝 vs シチミヘルツ"
258 | ],
259 | "21": [
260 | "revo",
261 | "Revo@LC"
262 | ],
263 | "22": [
264 | "沙发太",
265 | "サファ太",
266 | "safaTAmago",
267 | "Safata.Hz",
268 | "Safata.GHz",
269 | "-ZONE- SaFaRi",
270 | "サファ太 vs -ZONE- SaFaRi",
271 | "サファ太 vs じゃこレモン",
272 | "サファ太 vs 翠楼屋",
273 | "jacK on Phoenix & -ZONE- SaFaRi",
274 | "DANCE TIME(サファ太)",
275 | "はっぴー & サファ太",
276 | "さふぁた",
277 | "脆脆鲨", #翠楼屋 + 沙发太
278 | "ボコ太",
279 | "鳩サファzhel",
280 | "Ruby" #squad谱师 = 沙发太
281 | ],
282 | "23": [
283 | "隅田川星人",
284 | "七味星人",
285 | "隅田川華火大会",
286 | "超七味星人",
287 | "はっぴー星人",
288 | "The ALiEN",
289 | "The ALiEN vs. Phoenix"
290 | ],
291 | "24": [
292 | "华火职人",
293 | "華火職人",
294 | "隅田川華火大会",
295 | "7.3連発華火",
296 | "“Carpe diem” * HAN∀BI",
297 | "小鳥遊さん vs 華火職人",
298 | "大国奏音"
299 | ],
300 | "25": [
301 | "labi",
302 | "LabiLabi"
303 | ],
304 | "26": [
305 | "如月",
306 | "如月 ゆかり"
307 | ],
308 | "27": [
309 | "畳返し"
310 | ],
311 | "28": [
312 | "翠楼屋",
313 | "翡翠マナ",
314 | "作譜:翠楼屋",
315 | "KOP3rd with 翡翠マナ",
316 | "Redarrow VS 翠楼屋",
317 | "翠楼屋 vs あまくちジンジャー",
318 | "サファ太 vs 翠楼屋",
319 | "翡翠マナ -Memoir-",
320 | "脆脆鲨",
321 | "Ruby"
322 | ],
323 | "29": [
324 | "鸽子",
325 | "鳩ホルダー",
326 | "鳩ホルダー & Luxizhel",
327 | "鳩サファzhel",
328 | "鸠",
329 | "九鸟"
330 | ],
331 | "30": [
332 | "莉莉丝",
333 | "アマリリス",
334 | "アマリリスせんせえ",
335 | "アマリリスせんせえ with ぴちネコせんせえ"
336 | ],
337 | "31": [
338 | "redarrow",
339 | "Redarrow",
340 | "Hz-R.Arrow",
341 | "red phoenix",
342 | "Redarrow VS 翠楼屋",
343 | "红箭"
344 | ],
345 | "32": [
346 | "泸溪河",
347 | "Luxizhel",
348 | "鳩ホルダー & Luxizhel",
349 | "桃酥",
350 | "鳩サファzhel"
351 | ],
352 | "33": [
353 | "amano",
354 | "アミノハバキリ",
355 | "小鳥遊さん×アミノハバキリ"
356 | ],
357 | "34": [
358 | "甜口姜",
359 | "あまくちジンジャー",
360 | "あまくちヘルツ",
361 | "翠楼屋 vs あまくちジンジャー",
362 | "Jack vs あまくちジンジャー"
363 | ],
364 | "35": [
365 | "カマボコ君",
366 | "ボコ太"
367 | ],
368 | "36": [
369 | "rintarosoma",
370 | "rintaro soma"
371 | ],
372 | "37": [
373 | "味增",
374 | "みそかつ侍"
375 | ],
376 | "38": [
377 | "佑"
378 | ],
379 | "39": [
380 | "群青リコリス"
381 | ],
382 | "41": [
383 | "しろいろ"
384 | ],
385 | "42": [
386 | "ミニミライト"
387 | ],
388 | "43": [
389 | "メロンポップ",
390 | "ずんだポップ"
391 | ]
392 | }
393 |
394 | guess_help_message = dedent('''
395 | 猜歌游戏介绍:
396 | 猜曲绘:根据曲绘猜歌
397 | 听歌猜曲:根据歌曲猜歌
398 | 谱面猜歌:根据谱面猜歌
399 | 线索猜歌:根据歌曲信息猜歌
400 | 开字母:每次可选择揭开一个字母(将歌名中该字母的位置显现出来),或者选择根据已有信息猜一首歌。从支离破碎的信息中,破解歌曲的名称。
401 | note音猜歌:根据谱面的note音来猜歌
402 | 随机猜歌:从猜曲绘、线索猜歌、听歌猜曲、谱面猜歌中随机选择一种猜歌方式
403 |
404 | 游戏命令(直接输左侧的任一命令):
405 | /开字母 /猜曲绘 /线索猜歌 /听歌猜曲 /谱面猜歌 /note音猜歌 /随机猜歌 - 开始游戏
406 | /连续猜曲绘 /连续听歌猜曲 /连续线索猜歌 /连续谱面猜歌 /连续note音猜歌 /连续随机猜歌 - 开始连续游戏
407 | 开<字母> - 开字母游戏中用于开对应字母
408 | (猜歌/开歌) <歌名/别名/id> - 猜歌
409 | 不玩了 - 结束本轮游戏
410 | 停止 - 停止连续游戏
411 | 强制停止 - 强制停止所有游戏(在遇到非预期情况时,可以使用该指令强行恢复)
412 |
413 | 其他命令:
414 | /猜歌帮助 - 查看帮助
415 | /前三 - 查看今日截至当前的答对数量的玩家排名
416 | /查看谱师 - 查看可用谱师
417 |
418 | 管理员命令:
419 | /猜曲绘配置 <参数> - 配置猜曲绘游戏难度,详细帮助可直接使用此命令查询
420 | /检查谱面完整性 - 检查谱面猜歌所需的谱面源文件的完整性
421 | /检查歌曲文件完整性 - 检查听歌猜曲所需的音乐源文件的完整性
422 | /开启猜歌 <游戏名/all> - 开启某类或全部猜歌游戏
423 | /关闭猜歌 <游戏名/all> - 关闭某类或全部猜歌游戏
424 |
425 | 说明:
426 | 一个群内只能同时玩一个游戏。
427 | 玩游戏可获得积分,不同游戏获得的积分不同(谱面猜歌与note音猜歌每2首1积分,线索猜歌每3首1积分,其他每5首1积分)。
428 |
429 | 开始游戏、开始连续游戏的命令都可附加参数,命令后面接若干个参数,每个参数用空格隔开。(可以看最下方的示例参照使用)
430 | 可用参数有:
431 | 版本<版本名> - 可用版本有:初、真、超、檄、橙、晓、桃、樱、紫、堇、白、雪、辉、熊、华、爽、煌、宙、星、祭、祝、双。
432 | 分区<分区名> - 可用分区有:anime、maimai、niconico、touhou、game、ongeki。
433 | 谱师<谱师名> - 可用谱师可用“/查看谱师”命令查询。
434 | 等级<等级> - 只考虑歌曲的紫谱及白谱(如有)。可用等级为1-15。
435 | 定数<定数> - 只考虑歌曲的紫谱及白谱(如有)。可用定数为1.0-15.0。
436 | 新 旧 sd dx - 新为在b35的歌;旧为在b15的歌;sd为标准谱;dx为dx谱。
437 | 同类参数取并集,不同类参数取交集。
438 | 所有参数都可以添加前缀“-”表示排除该条件。
439 | 版本、等级、定数可在自选参数前添加 >= > <= < 中之一表示范围选择。
440 | 附上版本参数的正则表达式:'^(-?)版本([<>]?)(\\=?)(.)$'。
441 |
442 | 示例:
443 | /开字母
444 | /猜曲绘 定数14.0
445 | /连续随机猜歌 谱师沙发太 分区maimai -版本<=檄 等级>12+ (指定了谱师和分区,版本不包含初真超檄,等级大于12+)
446 | ''')
447 |
448 | superuser_help_message = dedent('''
449 | 猜曲绘配置指令可用参数有:
450 | cut = [0,1) 控制切片大小的比例,默认为0.5
451 | gauss = [0,无穷) 控制模糊程度,默认为10
452 | shuffle = [0,1) 控制打乱方块大小的比例,建议取1/n,默认为0.1
453 | gray = [0,1] 控制灰度程度,默认为0.8
454 | transpose = 0 or 1 是否开启旋转及翻转,默认为0
455 | 每次游戏从前三属性中三选一,再分别随机选择是否应用后两个效果。
456 | 因此需确保前三属性不能同时为0。
457 | ---
458 | 示例:
459 | /猜曲绘配置 cut=0.5 gray=0.8
460 | ''')
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/libraries/__init__.py:
--------------------------------------------------------------------------------
1 | from .music_model import *
2 | from .utils import *
3 | from .guess_cover import *
4 | from .guess_character import *
5 | from .guess_song_listen import *
6 | from .guess_song_clue import *
7 | from .guess_song_chart import *
8 | from .guess_song_note import *
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/libraries/guess_character.py:
--------------------------------------------------------------------------------
1 | import random
2 |
3 | from .utils import *
4 | from .music_model import gameplay_list, game_alias_map, filter_list, alias_dict, total_list
5 |
6 | from nonebot import on_command, on_startswith
7 | from nonebot.matcher import Matcher
8 | from nonebot.adapters.onebot.v11 import GroupMessageEvent
9 | from nonebot.params import Startswith, CommandArg
10 |
11 |
12 | guess_character_request = on_command("开字母", aliases = {"猜字母"}, priority=5)
13 | guess_open = on_startswith("开", priority=5)
14 |
15 |
16 | def open_character_message(group_id, first = False, early_stop = False):
17 |
18 | character_info = gameplay_list.get(group_id).get("open_character")
19 | song_info_list = character_info.get("info")
20 | guessed_character = character_info.get("guessed")
21 | params = character_info.get("params")
22 |
23 | message = "[猜你字母bot 开字母]\n"
24 | if len(params):
25 | message += f"本次开字母范围:{', '.join(params)}\n"
26 | total_success = 0
27 |
28 | # 展示各歌曲的开字母情况
29 | for i in range(len(song_info_list)):
30 | if song_info_list[i].get("state"):
31 | message += f"✅{i+1}. {song_info_list[i].get('title')}\n"
32 | total_success += 1
33 | elif early_stop:
34 | message += f"❌{i+1}. {song_info_list[i].get('title')}\n"
35 | else:
36 | message += f"❓{i+1}. {song_info_list[i].get('guessed')}\n"
37 |
38 | message += (
39 | f"已开字符:{', '.join(guessed_character)}\n"
40 | )
41 |
42 | if total_success == len(song_info_list):
43 | start_time = character_info.get("start_time")
44 | current_time = datetime.now()
45 | time_diff = current_time - start_time
46 | total_seconds = int(time_diff.total_seconds())
47 | message += f"全部猜对啦!本次开字母用时{total_seconds}秒\n"
48 | gameplay_list.pop(group_id)
49 |
50 | if early_stop:
51 | message += f"本次开字母只猜对了{total_success}首歌,要再接再厉哦!本次开字母结束\n"
52 | gameplay_list.pop(group_id)
53 |
54 | if first:
55 | message += (
56 | "发送 “开x” 揭开对应的字母(或字符)\n"
57 | "发送 “开歌xxx”,来提交您认为在答案中的曲目(无需带序号)\n"
58 | "发送 “不玩了” 退出开字母并公布答案"
59 | )
60 |
61 | return message
62 |
63 |
64 | def open_character_reply_message(success_guess):
65 | message = ""
66 | if len(success_guess) == 0:
67 | message += "本次没有猜对任何曲目哦。"
68 | else:
69 | message += "你猜对了"
70 | for i in range(len(success_guess)):
71 | message += success_guess[i]
72 | if i != len(success_guess)-1:
73 | message += ", "
74 | message += "\n"
75 | return MessageSegment("text", {"text": message})
76 |
77 |
78 | def open_character_rank_message(group_id):
79 | top_open_character = get_top_three(group_id, "open_character")
80 | if top_open_character:
81 | msg = "今天的前三名开字母高手:\n"
82 | for rank, (user_id, count) in enumerate(top_open_character, 1):
83 | msg += f"{rank}. {MessageSegment.at(user_id)} 开出了{count}首歌!\n"
84 | msg += "一堆maip。。。"
85 | return msg
86 |
87 |
88 | @guess_character_request.handle()
89 | async def guess_request_handler(matcher: Matcher, event: GroupMessageEvent, args: Message = CommandArg()):
90 | # 启动游戏
91 | if len(total_list.music_list) == 0:
92 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
93 | group_id = str(event.group_id)
94 | game_name = "open_character"
95 | if check_game_disable(group_id, game_name):
96 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
97 | params = args.extract_plain_text().strip().split()
98 | await isplayingcheck(group_id, matcher)
99 | if not (random_music := filter_random(filter_list, params, 10)):
100 | await matcher.finish(fault_tips, reply_message=True)
101 | start_time = datetime.now()
102 | info_list = []
103 | for music in random_music:
104 | guessed = ''.join(['?' if char != ' ' else ' ' for char in music.title])
105 | info_list.append({"title": music.title, "guessed": guessed, "state": False})
106 | gameplay_list[group_id] = {}
107 | gameplay_list[group_id]["open_character"] = {"info": info_list, "guessed": [], "start_time": start_time, "params": params}
108 | await matcher.finish(open_character_message(group_id, first = True))
109 |
110 |
111 | @guess_open.handle()
112 | async def guess_open_handler(matcher: Matcher, event: GroupMessageEvent, start: str = Startswith()):
113 | # 开单个字母
114 | group_id = str(event.group_id)
115 | if gameplay_list.get(group_id) and gameplay_list.get(group_id).get("open_character") is not None:
116 | character_info = gameplay_list.get(group_id).get("open_character")
117 | character = event.get_plaintext().lower()[len(start):].strip()
118 | if len(character) != 1:
119 | return
120 | if character != "":
121 | character = character.lower()
122 | if character_info.get("guessed").count(character) != 0:
123 | await matcher.finish("这个字母已经开过了哦,换一个字母吧", reply_message=True)
124 | character_info.get("guessed").append(character)
125 | for music in character_info.get("info"):
126 | title = music.get("title")
127 | guessed = list(music.get("guessed"))
128 | for i in range(len(title)):
129 | if title[i].lower() == character:
130 | guessed[i] = title[i]
131 | music["title"] = title
132 | music["guessed"] = ''.join(guessed)
133 |
134 | await matcher.finish(open_character_message(group_id))
135 | else:
136 | await matcher.finish("无效指令,请使用格式:开x(x只能是一个字符)", reply_message=True)
137 |
138 |
139 | async def character_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag):
140 | # 开歌的处理函数
141 | character_info = gameplay_list.get(group_id).get("open_character")
142 | music_candidates = alias_dict.get(song_name)
143 | if music_candidates is None:
144 | if ignore_tag:
145 | return
146 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名")
147 |
148 | success_guess = []
149 | if len(music_candidates) < 20:
150 | for music_index in music_candidates:
151 | music = total_list.music_list[music_index]
152 | for info in character_info.get('info'):
153 | if info.get('title') == music.title and info.get("state") == False:
154 | blindly_guess_check = True
155 | for char in info['guessed']:
156 | if char != ' ' and char != '?':
157 | blindly_guess_check = False
158 | break
159 | if blindly_guess_check:
160 | # 如果盲狙中了,就随机发送“这么难他都会”的图
161 | probability = random.random()
162 | if probability < 0.4:
163 | try:
164 | pic_path: Path = game_pic_path / "so_hard.jpg"
165 | await matcher.send(MessageSegment.image(f"file://{pic_path}"))
166 | except:
167 | await matcher.send("这么难他都会")
168 | success_guess.append(music.title)
169 | info['guessed'] = music.title
170 | info['state'] = True
171 | record_game_success(user_id=user_id, group_id=group_id, game_type="open_character")
172 | if len(success_guess) == 0 and ignore_tag:
173 | return
174 | message = open_character_reply_message(success_guess)
175 | message += open_character_message(group_id)
176 | await matcher.finish(message, reply_message=True)
177 |
178 |
179 |
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/libraries/guess_cover.py:
--------------------------------------------------------------------------------
1 | import random
2 | import asyncio
3 | from typing import Tuple
4 | from PIL import Image, ImageFilter, ImageEnhance
5 |
6 | from .utils import *
7 | from .music_model import gameplay_list, alias_dict, total_list, game_alias_map, continuous_stop
8 |
9 | from nonebot import on_fullmatch, on_command
10 | from nonebot.matcher import Matcher
11 | from nonebot.adapters.onebot.v11 import GroupMessageEvent, GROUP_ADMIN, GROUP_OWNER
12 | from nonebot.permission import SUPERUSER
13 | from nonebot.params import CommandArg
14 |
15 |
16 | guess_cover_config_dict = {}
17 |
18 | continuous_guess_cover = on_command('连续猜曲绘', priority=5)
19 | guess_cover = on_command('猜曲绘', priority=5)
20 | guess_cover_config = on_command('猜曲绘配置', permission=SUPERUSER | GROUP_ADMIN | GROUP_OWNER, priority=5)
21 |
22 |
23 | def apply_gray(im: Image.Image, gray) -> Image.Image:
24 | gray_im = im.convert('L')
25 | enhancer = ImageEnhance.Brightness(gray_im)
26 | semi_gray = enhancer.enhance(0.5)
27 | result = Image.blend(im.convert('RGBA'), semi_gray.convert('RGBA'), alpha=gray)
28 | return result
29 |
30 | def apply_gauss(im: Image.Image, gauss) -> Image.Image:
31 | return im.filter(ImageFilter.GaussianBlur(radius = gauss))
32 |
33 | def apply_cut(im: Image.Image, cut) -> Image.Image:
34 | w, h = im.size
35 | w2, h2 = int(w*cut), int(h*cut)
36 | l, u = random.randrange(0, int(w-w2)), random.randrange(0, int(h-h2))
37 | return im.crop((l, u, l + w2, u + h2))
38 |
39 | def apply_transpose(im: Image.Image) -> Image.Image:
40 | angle = random.randint(0, 3) * 90
41 | im = im.rotate(angle)
42 | flip = random.randint(0, 2)
43 | if flip == 1:
44 | im = im.transpose(Image.FLIP_LEFT_RIGHT)
45 | elif flip == 2:
46 | im = im.transpose(Image.FLIP_TOP_BOTTOM)
47 | return im
48 |
49 | def apply_shuffle(im: Image.Image, shuffle) -> Image.Image:
50 | """拆分成方块随机排列"""
51 | w, h = im.size
52 | block_size = int(w * shuffle)
53 | blocks = []
54 | for i in range(0, w, block_size):
55 | for j in range(0, h, block_size):
56 | block = im.crop((i, j, i + block_size, j + block_size))
57 | blocks.append(block)
58 | random.shuffle(blocks)
59 | shuffled = Image.new("RGB", im.size)
60 | idx = 0
61 | for i in range(0, w, block_size):
62 | for j in range(0, h, block_size):
63 | shuffled.paste(blocks[idx], (i, j))
64 | idx += 1
65 | return shuffled
66 |
67 |
68 | vital_apply = {
69 | 'gauss': '模糊',
70 | 'cut': '裁切',
71 | 'shuffle': '打乱',
72 | }
73 |
74 |
75 | async def pic(path: str, gid: str) -> Tuple[Image.Image, str]:
76 | im = Image.open(path)
77 | data = load_game_data_json(gid)
78 | args = data[gid]['config']
79 | sample_pool = [name for name,value in args.items() if name in vital_apply.keys() and value != 0]
80 | pattern = random.sample(sample_pool, 1)
81 | if pattern[0] == 'gauss':
82 | im = apply_gauss(im, args['gauss'])
83 | elif pattern[0] == 'cut':
84 | im = apply_cut(im, args['cut'])
85 | elif pattern[0] == 'shuffle':
86 | im = apply_shuffle(im, args['shuffle'])
87 |
88 | if args['gray'] and random.randint(0,1):
89 | im = apply_gray(im, float(args['gray']))
90 | if args['transpose'] and random.randint(0,1):
91 | im = apply_transpose(im)
92 | return im, vital_apply[pattern[0]]
93 |
94 |
95 | def cover_rank_message(group_id):
96 | top_guess_cover = get_top_three(group_id, "cover")
97 | if top_guess_cover:
98 | msg = "今天的前三名猜曲绘王:\n"
99 | for rank, (user_id, count) in enumerate(top_guess_cover, 1):
100 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n"
101 | msg += "蓝的盆。。。"
102 | return msg
103 |
104 |
105 | async def cover_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag):
106 | pic_info = gameplay_list.get(group_id).get("cover")
107 |
108 | music_candidates = alias_dict.get(song_name)
109 | if music_candidates is None:
110 | if ignore_tag:
111 | return
112 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True)
113 |
114 | if len(music_candidates) < 20:
115 | for music_index in music_candidates:
116 | music = total_list.music_list[music_index]
117 | if pic_info.title == music.title:
118 | gameplay_list.pop(group_id)
119 | record_game_success(user_id=user_id, group_id=group_id, game_type="cover")
120 | answer = MessageSegment.text(f'猜对啦!答案是:\n')+song_txt(pic_info)
121 | await matcher.finish(answer, reply_message=True)
122 | if not ignore_tag:
123 | await matcher.finish("你猜的答案不对噢,再仔细看一下吧!", reply_message=True)
124 |
125 |
126 | @continuous_guess_cover.handle()
127 | async def continuous_guess_cover_handler(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
128 | if len(total_list.music_list) == 0:
129 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
130 | gid = str(event.group_id)
131 | game_name = "cover"
132 | if check_game_disable(gid, game_name):
133 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
134 | params = args.extract_plain_text().strip().split()
135 | await isplayingcheck(gid, matcher)
136 | if not filter_random(total_list.music_list, params, 1):
137 | await matcher.finish(fault_tips, reply_message=True)
138 | await matcher.send('连续猜曲绘已开启,发送\"停止\"以结束')
139 | continuous_stop[gid] = 1
140 | while continuous_stop.get(gid):
141 | if gameplay_list.get(gid) is None:
142 | await guess_cover_handler(gid, matcher, params)
143 | if continuous_stop[gid] > 3:
144 | continuous_stop.pop(gid)
145 | await matcher.finish('没人猜了? 那我下班了。')
146 | await asyncio.sleep(1)
147 |
148 |
149 | @guess_cover.handle()
150 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
151 | if len(total_list.music_list) == 0:
152 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
153 | gid = str(event.group_id)
154 | game_name = "cover"
155 | if check_game_disable(gid, game_name):
156 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
157 | params = args.extract_plain_text().strip().split()
158 | await isplayingcheck(gid, matcher)
159 | await guess_cover_handler(gid, matcher, params)
160 |
161 | async def guess_cover_handler(gid, matcher: Matcher, args):
162 | if _ := filter_random(total_list.music_list, args, 1):
163 | random_music = _[0]
164 | else:
165 | await matcher.finish(fault_tips, reply_message=True)
166 | pic_path = music_cover_path / (get_cover_len5_id(random_music.id) + ".png")
167 | draw, pictype = await pic(pic_path, str(gid))
168 | gameplay_list[gid] = {}
169 | gameplay_list[gid]["cover"] = random_music
170 |
171 | msg = MessageSegment.text(f'以下{pictype}图片是哪首歌的曲绘:\n')
172 | msg += MessageSegment.image(image_to_base64(draw))
173 | msg += MessageSegment.text('请在30s内输入答案')
174 | if len(args):
175 | msg += MessageSegment.text(f"\n本次猜曲绘范围:{', '.join(args)}")
176 | await matcher.send(msg)
177 | for _ in range(30):
178 | await asyncio.sleep(1)
179 | if gameplay_list.get(gid) is None or not gameplay_list[gid].get("cover") or gameplay_list[gid].get("cover") != random_music:
180 | if continuous_stop.get(gid):
181 | continuous_stop[gid] = 1
182 | return
183 | gameplay_list.pop(gid)
184 | answer = MessageSegment.text(f'答案是:\n') + song_txt(random_music)
185 | await matcher.send(answer)
186 | if continuous_stop.get(gid):
187 | continuous_stop[gid] += 1
188 |
189 |
190 | @guess_cover_config.handle()
191 | async def guess_cover_config_handler(event: GroupMessageEvent, matcher: Matcher, arg: Message = CommandArg()):
192 | gid = str(event.group_id)
193 | mes = arg.extract_plain_text().split()
194 | if len(mes) == 0:
195 | await matcher.finish(MessageSegment.image(to_bytes_io(superuser_help_message)), reply_message=True)
196 | data = load_game_data_json(gid)
197 | config = data[gid]['config']
198 | try:
199 | for update in mes:
200 | name, value = update.split('=')
201 | if name == 'cut':
202 | value = float(value)
203 | if value < 0 or value >= 1:
204 | raise ValueError
205 | elif name == 'gauss':
206 | value = int(value)
207 | if value < 0:
208 | raise ValueError
209 | elif name == 'gray':
210 | value = float(value)
211 | if value < 0 or value > 1:
212 | raise ValueError
213 | elif name == 'transpose':
214 | value = bool(int(value))
215 | elif name == 'shuffle':
216 | value = float(value)
217 | if value < 0 or value >= 1:
218 | raise ValueError
219 | else:
220 | raise ValueError
221 | config[name] = value
222 | except ValueError:
223 | await matcher.finish('参数错误' + MessageSegment.image(to_bytes_io(superuser_help_message)), reply_message=True)
224 | if config['cut'] == 0 and config['gauss'] == 0 and config['shuffle'] == 0:
225 | config['cut'] = 1
226 | save_game_data(data, game_data_path)
227 | await matcher.finish(f'更新配置完成:\n{config}')
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/libraries/guess_song_chart.py:
--------------------------------------------------------------------------------
1 | import re
2 | import copy
3 | import random
4 | import shutil
5 | import asyncio
6 | import subprocess
7 |
8 | from .utils import *
9 | from .music_model import gameplay_list, game_alias_map, alias_dict, total_list, continuous_stop
10 |
11 | from nonebot import on_fullmatch, on_command
12 | from nonebot.plugin import require
13 | from nonebot.matcher import Matcher
14 | from nonebot.params import CommandArg
15 | from nonebot.permission import SUPERUSER
16 | from nonebot.adapters.onebot.v11 import Message, GroupMessageEvent
17 |
18 | scheduler = require('nonebot_plugin_apscheduler')
19 |
20 | from nonebot_plugin_apscheduler import scheduler
21 |
22 | config = get_plugin_config(Config)
23 |
24 | id_mp3_file_map = {}
25 | id_mp4_file_map = {}
26 | id_music_map = {}
27 | groupID_params_map: Dict[int, str] = {} # 这个map只在带参数的连续谱面猜歌下需要使用
28 | chart_total_list: List[Music] = [] # 为谱面猜歌特制的total_list,包含紫白谱的music(两份),紫谱music去掉白谱的内容(定数等),白谱music去掉紫谱内容并顶到紫谱位置
29 | loading_clip_list = []
30 | groupID_folder2delete_timestamp_list: List = {} # 在猜完之后需要删除参数文件夹,所以需记录下来(如果直接删除可能会导致race condition)
31 | CLIP_DURATION = 30
32 | GUESS_TIME = 90
33 | PRELOAD_CHECK_TIME = 40 # 这个时间请根据服务器性能来设置,建议设置为制作一套谱面视频的时间+10秒左右
34 | PRELOAD_CNT = 10
35 | PARAM_PRELOAD_CHECK_TIME = 35
36 | PARAM_PRELOAD_CNT = 3
37 | TIME_TO_DELETE = 120
38 |
39 | general_charts_pool = []
40 | charts_save_list = [] # 用于存住当前正在游玩的answer文件,防止自检时删掉(因为它的question已经被删掉了,所以他可能会被当成单身谱面而被删掉)
41 | param_charts_pool: Dict[str, List] = {}
42 |
43 | guess_chart = on_command("看谱猜歌", aliases={"谱面猜歌"}, priority=5)
44 | continuous_guess_chart = on_command('连续谱面猜歌', priority=5)
45 | check_chart_file_completeness = on_command("检查谱面完整性", permission=SUPERUSER, priority=5)
46 |
47 |
48 | def cut_video(input_path, output_path, start_time):
49 | input_path = str(input_path)
50 | output_path = str(output_path)
51 | command = [
52 | "ffmpeg",
53 | "-ss", seconds_to_hms(start_time),
54 | "-i", input_path,
55 | "-strict", "experimental",
56 | "-c:v", "libx264", # Copy the video stream without re-encoding
57 | "-crf", "23",
58 | "-ar", "44100", # 这个一定要加,因为原本是11025hz的采样率,发到qq有部分用户会出现卡顿的情况
59 | "-t", str(CLIP_DURATION),
60 | output_path
61 | ]
62 | try:
63 | logging.info("Cutting video...")
64 | subprocess.run(command, check=True)
65 | logging.info(f"Cutting completed. File saved as '{output_path}'.")
66 | except subprocess.CalledProcessError as e:
67 | logging.error(f"Error during cutting: {e}", exc_info=True)
68 | except Exception as ex:
69 | logging.error(f"An unexpected error occurred: {ex}", exc_info=True)
70 |
71 |
72 | def random_video_clip(input_path, duration, music_id, output_folder):
73 | if not os.path.isfile(input_path):
74 | raise FileNotFoundError(f"输入文件不存在: {input_path}")
75 | os.makedirs(output_folder, exist_ok=True)
76 |
77 | video_duration = get_video_duration(input_path)
78 | if duration > video_duration - 15:
79 | raise ValueError(f"截取时长不能超过视频总时长减去{15 + duration}秒")
80 |
81 | # 前5秒是片头,会露出曲名,后10秒是片尾,只会有无用的all perfect画面
82 | start_time = random.uniform(5, video_duration - duration - 10)
83 | # 需要注意,output_folder需要为绝对路径,才能准确地找到对应的文件
84 | output_path = os.path.join(output_folder, f"{music_id}_{int(start_time)}_clip.mp4")
85 |
86 | if os.path.isfile(output_path):
87 | raise Exception(f"生成了重复的文件")
88 |
89 | cut_video(input_path, output_path, start_time)
90 |
91 | return start_time, output_path
92 |
93 |
94 | async def make_answer_video(video_file, audio_file, music_id, output_folder, start_time, duration):
95 | if not os.path.exists(video_file):
96 | logging.error(f"Error: Video file '{video_file}' does not exist.", exc_info=True)
97 | return
98 | if not os.path.exists(audio_file):
99 | logging.error(f"Error: Audio file '{audio_file}' does not exist.", exc_info=True)
100 | return
101 |
102 | os.makedirs(output_folder, exist_ok=True)
103 | answer_file = os.path.join(output_folder, f"{music_id}_answer.mp4")
104 | answer_clip_path = os.path.join(output_folder, f"{music_id}_{int(start_time)}_answer_clip.mp4")
105 |
106 | loading_clip_list.append(answer_clip_path) # 保护一下(防止还没准备完毕就被发出了)
107 |
108 | # 使用异步线程来执行ffmpeg命令
109 | await asyncio.to_thread(merge_video_and_sound, video_file, audio_file, answer_file)
110 | # 使用异步线程来处理视频
111 | await asyncio.to_thread(cut_video, answer_file, answer_clip_path, start_time)
112 |
113 | # 删除文件(可以保留同步操作)
114 | os.remove(answer_file) # 删掉完整的答案文件(只发送前面猜的片段)
115 | loading_clip_list.remove(answer_clip_path) # 解除保护
116 |
117 | return answer_clip_path
118 |
119 |
120 | # 在后台线程中运行ffmpeg命令
121 | def merge_video_and_sound(video_file, audio_file, answer_file):
122 | command = [
123 | "ffmpeg",
124 | "-i", video_file,
125 | "-i", audio_file,
126 | "-c:v", "copy", # Copy the video stream without re-encoding
127 | "-c:a", "aac", # Encode the audio stream using AAC
128 | "-strict", "experimental", # Allow experimental features
129 | "-map", "0:v:0", # Use the video stream from the first input
130 | "-map", "1:a:0", # Use the audio stream from the second input
131 | answer_file
132 | ]
133 | try:
134 | logging.info("Merging video and audio...")
135 | subprocess.run(command, check=True) # 合成视频文件
136 | logging.info(f"Merging completed. File saved as '{answer_file}'.")
137 | except subprocess.CalledProcessError as e:
138 | logging.error(f"Error during merging: {e}", exc_info=True)
139 | except Exception as ex:
140 | logging.error(f"An unexpected error occurred: {ex}", exc_info=True)
141 |
142 |
143 | def chart_rank_message(group_id):
144 | top_chart = get_top_three(group_id, "chart")
145 | if top_chart:
146 | msg = "今天的前三名谱面猜歌王:\n"
147 | for rank, (user_id, count) in enumerate(top_chart, 1):
148 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n"
149 | msg += "记忆大神啊。。。"
150 | return msg
151 |
152 |
153 | async def chart_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag):
154 | '''开歌处理函数'''
155 | answer_clip_path = gameplay_list[group_id].get("chart")
156 |
157 | random_music_id, start_time = split_id_from_path(answer_clip_path)
158 | is_remaster = False
159 | if int(random_music_id) > 500000:
160 | # 是白谱,需要找回原始music对象
161 | random_music_id = str(int(random_music_id) % 500000)
162 | is_remaster = True
163 | random_music = total_list.by_id(random_music_id)
164 |
165 | music_candidates = alias_dict.get(song_name)
166 | if music_candidates is None:
167 | if ignore_tag:
168 | return
169 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True)
170 |
171 | if len(music_candidates) < 20:
172 | for music_index in music_candidates:
173 | music = total_list.music_list[music_index]
174 | if random_music.id == music.id:
175 | record_game_success(user_id=user_id, group_id=group_id, game_type="chart")
176 | gameplay_list.pop(group_id) # 需要先pop,不然的话发了答案之后还能猜
177 | charts_save_list.append(answer_clip_path) # 但是必须先把它保护住,否则有可能发到一半被删掉
178 | reply_message = MessageSegment.text("恭喜你猜对啦!答案就是:\n") + song_txt(random_music, is_remaster) + "\n对应的原片段如下:"
179 | await matcher.send(reply_message, reply_message=True)
180 | if answer_clip_path in loading_clip_list:
181 | for i in range(31):
182 | await asyncio.sleep(1)
183 | if answer_clip_path not in loading_clip_list:
184 | break
185 | if i == 30:
186 | await matcher.finish(f"答案文件可能坏掉了,这个谱的开始时间是{start_time}秒,如果感兴趣可以自己去搜索噢", reply_message=True)
187 | await matcher.send(MessageSegment.video(f"file://{answer_clip_path}"))
188 | os.remove(answer_clip_path)
189 | charts_save_list.remove(answer_clip_path) # 发完了就可以解除保护了
190 | return
191 | if not ignore_tag:
192 | await matcher.finish("你猜的答案不对噢,再仔细看一下吧!", reply_message=True)
193 |
194 |
195 | @guess_chart.handle()
196 | async def guess_chart_request(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
197 | if len(chart_total_list) == 0:
198 | await matcher.finish("文件夹没有下载任何谱面视频资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
199 | group_id = str(event.group_id)
200 | game_name = "chart"
201 | if check_game_disable(group_id, game_name):
202 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
203 | await isplayingcheck(group_id, matcher)
204 | params = args.extract_plain_text().strip().split()
205 | if len(params) == 0:
206 | # 没有参数的谱面猜歌
207 | if len(general_charts_pool) == 0:
208 | await matcher.finish("当前还没有准备好的谱面噢,请过15秒再尝试一下吧!如果反复出现该问题,请联系bot主扩大preload容量!", reply_message=True)
209 | (question_clip_path, answer_clip_path) = general_charts_pool.pop(random.randint(0, len(general_charts_pool) - 1))
210 | await guess_chart_handler(group_id, matcher, question_clip_path, answer_clip_path, params)
211 | else:
212 | gameplay_list[group_id] = {}
213 | gameplay_list[group_id]["chart"] = {} # 先占住坑,因为制作视频的时间很长,防止在此期间有其它猜歌请求
214 | random_music = filter_random(chart_total_list, params, 1)
215 | if not random_music:
216 | gameplay_list.pop(group_id)
217 | await matcher.finish(fault_tips, reply_message=True)
218 | await matcher.send("使用带参数的谱面猜歌需要等待15秒左右噢!带参数游玩推荐使用“连续谱面猜歌”,等待时间会缩短很多!", reply_message=True)
219 | random_music = random_music[0]
220 |
221 | sub_path_name = f"{str(event.group_id)}_{'_'.join(params)}"
222 | output_folder = chart_preload_path / sub_path_name
223 | random_music_id = random_music.id
224 | music_mp4_file = id_mp4_file_map[random_music_id]
225 | music_mp3_file = id_mp3_file_map[random_music_id]
226 |
227 | # 使用异步线程才能保证其他任务不被影响
228 | start_time, clip_path = await asyncio.to_thread(random_video_clip, music_mp4_file, CLIP_DURATION, random_music_id, output_folder)
229 | make_answer_task = asyncio.create_task(make_answer_video(music_mp4_file, music_mp3_file, random_music_id, output_folder, start_time, CLIP_DURATION))
230 | answer_clip_path = os.path.join(output_folder, f"{random_music_id}_{int(start_time)}_answer_clip.mp4")
231 | await guess_chart_handler(group_id, matcher, clip_path, answer_clip_path, params)
232 |
233 | # 带参数的谱面猜歌要自己管好自己的文件
234 | now = datetime.now()
235 | now_timestamp = int(now.timestamp())
236 | groupID_folder2delete_timestamp_list.append((group_id, chart_preload_path / sub_path_name, now_timestamp))
237 |
238 |
239 | @continuous_guess_chart.handle()
240 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
241 | if len(chart_total_list) == 0:
242 | await matcher.finish("文件夹没有下载任何谱面视频资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
243 | group_id = str(event.group_id)
244 | game_name = "chart"
245 | if check_game_disable(group_id, game_name):
246 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
247 | await isplayingcheck(group_id, matcher)
248 | await matcher.send('连续谱面猜曲已开启,发送\"停止\"以结束')
249 | continuous_stop[group_id] = 1
250 | charts_pool = None
251 | params = args.extract_plain_text().strip().split()
252 | if len(params) == 0:
253 | # 没有参数的连续谱面猜歌(和普通的区别不大)
254 | charts_pool = general_charts_pool
255 | else:
256 | # 有参数的连续谱面猜歌
257 | valid_music = filter_random(chart_total_list, params, 10) # 至少需要有10首歌,不然失去“猜”的价值
258 | if not valid_music:
259 | continuous_stop.pop(group_id)
260 | await matcher.finish(fault_tips)
261 |
262 | groupID_params_map[group_id] = params
263 | sub_path_name = f"{str(event.group_id)}_{'_'.join(params)}"
264 | param_charts_pool.setdefault(sub_path_name, [])
265 | charts_pool = param_charts_pool[sub_path_name]
266 |
267 | # 先进行检验,查看旧的charts_pool里的文件是否都还存在(未被删除)
268 | charts_already_old = []
269 | for (question_clip_path, answer_clip_path) in charts_pool:
270 | if not os.path.isfile(question_clip_path) or not os.path.isfile(answer_clip_path):
271 | charts_already_old.append((question_clip_path, answer_clip_path))
272 | for (question_clip_path, answer_clip_path) in charts_already_old:
273 | charts_pool.remove((question_clip_path, answer_clip_path))
274 | logging.info(f"删除了过时的谱面:{question_clip_path}")
275 |
276 | make_param_chart_task = asyncio.create_task(make_param_chart_video(group_id, params))
277 | await matcher.send("使用带参数的谱面猜歌,如果猜得太快可能会导致谱面文件来不及制作噢,请稍微耐心等待一下")
278 |
279 | while continuous_stop.get(group_id):
280 | if gameplay_list.get(group_id) is None:
281 | for i in range(30):
282 | if len(charts_pool) > 0:
283 | break
284 | await asyncio.sleep(1)
285 | if len(charts_pool) == 0:
286 | continuous_stop.pop(group_id)
287 | groupID_params_map.pop(group_id)
288 | await matcher.finish("bot主的电脑太慢啦,过了30秒还没有一个谱面制作出来!建议vivo50让我换电脑!", reply_message=True)
289 | (question_clip_path, answer_clip_path) = charts_pool.pop(random.randint(0, len(charts_pool) - 1))
290 | await guess_chart_handler(group_id, matcher, question_clip_path, answer_clip_path, params)
291 | await asyncio.sleep(1.5)
292 | try:
293 | if continuous_stop[group_id] > 3:
294 | continuous_stop.pop(group_id)
295 | groupID_params_map.pop(group_id)
296 | await matcher.finish('没人猜了? 那我下班了。')
297 | except Exception as e:
298 | logging.error(f"continuous guess chart error: {e}", exc_info=True)
299 | await asyncio.sleep(2.5)
300 |
301 | if len(params) != 0:
302 | # 带参数的谱面猜歌要自己管好自己的文件
303 | now = datetime.now()
304 | now_timestamp = int(now.timestamp())
305 | groupID_folder2delete_timestamp_list.append((group_id, chart_preload_path / sub_path_name, now_timestamp))
306 |
307 |
308 | async def guess_chart_handler(group_id, matcher: Matcher, question_clip_path, answer_clip_path, filter_params):
309 | # 进来之前必须保证question视频已经准备好
310 | gameplay_list[group_id] = {}
311 | gameplay_list[group_id]["chart"] = answer_clip_path # 将answer的路径存下来,方便猜歌以及主动停止时使用
312 | charts_save_list.append(question_clip_path) # 存住它,不让这个“单身谱面”被删除,同时也让它不被其它任务当作一个完整谱面
313 | charts_save_list.append(answer_clip_path)
314 |
315 | msg = "这个谱面截取的一个片段如下。请回复“猜歌xxx/开歌xxx”或直接发送别名来猜歌,或回复“不猜了“来停止游戏。"
316 | if len(filter_params):
317 | msg += f"\n本次谱面猜歌范围:{', '.join(filter_params)}"
318 | await matcher.send(msg)
319 | await matcher.send(MessageSegment.video(f"file://{question_clip_path}"))
320 |
321 | os.remove(question_clip_path) # 用完马上删,防止被其它任务拿到
322 | charts_save_list.remove(question_clip_path)
323 |
324 | for _ in range(GUESS_TIME):
325 | await asyncio.sleep(1)
326 | if gameplay_list.get(group_id) is None or not gameplay_list[group_id].get("chart") or gameplay_list[group_id].get("chart") != answer_clip_path:
327 | if continuous_stop.get(group_id):
328 | continuous_stop[group_id] = 1
329 | return
330 |
331 | random_music_id, start_time = split_id_from_path(question_clip_path)
332 | is_remaster = False
333 | if int(random_music_id) > 500000:
334 | # 是白谱,需要找回原始music对象
335 | random_music_id = str(int(random_music_id) % 500000)
336 | is_remaster = True
337 | random_music = total_list.by_id(random_music_id)
338 |
339 | gameplay_list.pop(group_id) # 需要先pop,不然的话发了答案之后还能猜
340 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music, is_remaster) + "\n对应的原片段如下:"
341 | await matcher.send(reply_message, reply_message=True)
342 | if answer_clip_path in loading_clip_list:
343 | for i in range(31):
344 | await asyncio.sleep(1)
345 | if answer_clip_path not in loading_clip_list:
346 | break
347 | if i == 30:
348 | await matcher.finish(f"答案文件可能坏掉了,这个谱的开始时间是{start_time}秒,如果感兴趣可以自己去搜索噢", reply_message=True)
349 | await matcher.send(MessageSegment.video(f"file://{answer_clip_path}"))
350 | os.remove(answer_clip_path)
351 | charts_save_list.remove(answer_clip_path) # 发完了就可以解除保护了
352 | if continuous_stop.get(group_id):
353 | continuous_stop[group_id] += 1
354 |
355 |
356 | async def make_param_chart_video(group_id, params):
357 | sub_path_name = f"{str(group_id)}_{'_'.join(params)}"
358 |
359 | param_chart_path: Path = chart_preload_path / sub_path_name
360 | while groupID_params_map.get(group_id) == params:
361 | while len(param_charts_pool[sub_path_name]) >= PARAM_PRELOAD_CNT:
362 | if groupID_params_map.get(group_id) != params:
363 | return
364 | await asyncio.sleep(5)
365 |
366 | random_music = filter_random(chart_total_list, params, 1)[0] # 前面已经做过检验,保证过可以
367 | random_music_id = random_music.id
368 |
369 | music_mp4_file = id_mp4_file_map[random_music_id]
370 | music_mp3_file = id_mp3_file_map[random_music_id]
371 |
372 | # 使用异步线程才能保证其他任务不被影响
373 | start_time, clip_path = await asyncio.to_thread(random_video_clip, music_mp4_file, CLIP_DURATION, random_music_id, param_chart_path)
374 |
375 | make_answer_task = asyncio.create_task(make_answer_video(music_mp4_file, music_mp3_file, random_music_id, param_chart_path, start_time, CLIP_DURATION))
376 | answer_clip_path = os.path.join(param_chart_path, f"{random_music_id}_{int(start_time)}_answer_clip.mp4")
377 | param_charts_pool[sub_path_name].append((clip_path, answer_clip_path))
378 | await asyncio.sleep(20) # 这个时间建议取make单个视频的时间(15s)再加5s
379 |
380 |
381 | async def make_chart_video():
382 | if len(chart_total_list) == 0:
383 | # 如果没有配置任何谱面源文件资源,便直接跳出
384 | return
385 | await init_chart_pool_by_existing_files(chart_preload_path, general_charts_pool)
386 | if len(general_charts_pool) < PRELOAD_CNT:
387 | random_music = random.choice(chart_total_list)
388 | random_music_id = random_music.id
389 |
390 | music_mp4_file = id_mp4_file_map[random_music_id]
391 | music_mp3_file = id_mp3_file_map[random_music_id]
392 |
393 | # 使用异步线程才能保证其他任务不被影响
394 | start_time, clip_path = await asyncio.to_thread(random_video_clip, music_mp4_file, CLIP_DURATION, random_music_id, chart_preload_path)
395 |
396 | make_answer_task = asyncio.create_task(make_answer_video(music_mp4_file, music_mp3_file, random_music_id, chart_preload_path, start_time, CLIP_DURATION))
397 | answer_clip_path = await make_answer_task
398 |
399 | general_charts_pool.append((clip_path, answer_clip_path))
400 |
401 |
402 | async def init_chart_pool_by_existing_files(root_directory, pool: List, force = False):
403 | os.makedirs(root_directory, exist_ok=True)
404 | question_pattern = re.compile(r"(\d+)_([\d\.]+)_clip\.mp4")
405 | answer_pattern = re.compile(r"(\d+)_([\d\.]+)_answer_clip\.mp4")
406 |
407 | files = set(os.listdir(root_directory))
408 | question_files = {(m.group(1), m.group(2)): os.path.join(root_directory, f) for f in files if (m := question_pattern.match(f))}
409 | answer_files = {(m.group(1), m.group(2)): os.path.join(root_directory, f) for f in files if (m := answer_pattern.match(f))}
410 |
411 | matched_keys = question_files.keys() & answer_files.keys()
412 | existing_charts = []
413 | for key in matched_keys:
414 | if question_files[key] in charts_save_list:
415 | # 说明这个谱面已经被使用了,即使它暂时还同时有question和answer视频对,我们也不能再重复地使用了
416 | continue
417 | existing_charts.append((question_files[key], answer_files[key]))
418 |
419 | old_pool = set(pool)
420 | new_charts = [chart for chart in existing_charts if chart not in old_pool]
421 | pool.extend(new_charts)
422 |
423 | unmatched_files = (set(question_files.values()) | set(answer_files.values())) - set(sum(pool, ()))
424 | for file in unmatched_files:
425 | if force or file not in charts_save_list:
426 | # 只在第一次启动时强制删除单身谱面
427 | os.remove(file)
428 | logging.info(f"Deleted: {file}")
429 |
430 | # 删掉那些参数谱面猜歌的文件夹
431 | global groupID_folder2delete_timestamp_list
432 | todelete_dict = {}
433 | # 只保留更新的删除请求
434 | for groupID, path, timestamp in groupID_folder2delete_timestamp_list:
435 | key = (groupID, path)
436 |
437 | if key not in todelete_dict or timestamp > todelete_dict[key][2]:
438 | todelete_dict[key] = (groupID, path, timestamp)
439 |
440 | groupID_folder2delete_timestamp_list = list(todelete_dict.values())
441 | for (group_id, folder2delete, timestamp) in groupID_folder2delete_timestamp_list:
442 | if not os.path.exists(folder2delete):
443 | groupID_folder2delete_timestamp_list.remove((group_id, folder2delete, timestamp))
444 | if continuous_stop.get(group_id) is not None or gameplay_list.get(group_id) is not None:
445 | continue
446 | now = datetime.now()
447 | now_timestamp = int(now.timestamp())
448 | if now_timestamp - timestamp <= TIME_TO_DELETE:
449 | continue
450 | shutil.rmtree(folder2delete)
451 | groupID_folder2delete_timestamp_list.remove((group_id, folder2delete, timestamp))
452 |
453 | if force:
454 | # 在启动的时候,删除一些之前的没用的参数谱面
455 | valid_folders = set()
456 | for group_id, params in groupID_params_map.items():
457 | sub_path_name = f"{group_id}_{'_'.join(params)}"
458 | valid_folders.add(sub_path_name)
459 |
460 | for folder_name in os.listdir(root_directory):
461 | folder_path = os.path.join(root_directory, folder_name)
462 |
463 | if os.path.isdir(folder_path) and folder_name not in valid_folders:
464 | try:
465 | shutil.rmtree(folder_path)
466 | logging.info(f"已删除文件夹: {folder_path}")
467 | except Exception as e:
468 | logging.error(f"删除文件夹 {folder_path} 时出错: {e}", exc_info=True)
469 |
470 |
471 | @check_chart_file_completeness.handle()
472 | async def check_chart_files(matcher: Matcher):
473 | mp3_file_lost_list = []
474 | mp4_file_lost_list = []
475 | for music in total_list.music_list:
476 | if music.genre == "\u5bb4\u4f1a\u5834":
477 | continue
478 | if not id_mp3_file_map.get(music.id):
479 | mp3_file_lost_list.append(music.id)
480 | if not id_mp4_file_map.get(music.id):
481 | mp4_file_lost_list.append(music.id)
482 | if len(music.ds) == 5:
483 | # 检测白谱
484 | if not id_mp3_file_map.get(str(int(music.id) + 500000)):
485 | mp3_file_lost_list.append(str(int(music.id) + 500000))
486 | if not id_mp4_file_map.get(str(int(music.id) + 500000)):
487 | mp4_file_lost_list.append(str(int(music.id) + 500000))
488 | if len(mp3_file_lost_list) == 0 and len(mp4_file_lost_list) == 0:
489 | await matcher.finish("mp3和mp4文件均完整,请放心进行谱面猜歌")
490 | if len(mp3_file_lost_list) != 0:
491 | await matcher.send(f"当前一共缺失了{len(mp3_file_lost_list)}首mp3文件,缺失的歌曲id如下{','.join(mp3_file_lost_list)}。其中大于500000的id是白谱资源。")
492 | if len(mp4_file_lost_list) != 0:
493 | await matcher.send(f"当前一共缺失了{len(mp4_file_lost_list)}首mp4文件,缺失的歌曲id如下{','.join(mp4_file_lost_list)}。其中大于500000的id是白谱资源。")
494 |
495 |
496 | async def init_chart_guess_info():
497 | global id_music_map
498 | for dirpath, dirnames, filenames in os.walk(chart_file_path):
499 | for file in filenames:
500 | file_id = os.path.splitext(file)[0]
501 | if "remaster" in dirpath:
502 | file_id = str(500000 + int(file_id))
503 | if file.endswith(".mp3"):
504 | id_mp3_file_map[file_id] = os.path.join(dirpath, file)
505 | elif file.endswith(".mp4"):
506 | id_mp4_file_map[file_id] = os.path.join(dirpath, file)
507 | common_music_list = list(id_mp3_file_map.keys() & id_mp4_file_map.keys())
508 | id_to_music = {music.id: music for music in total_list.music_list}
509 | id_music_map = {id: id_to_music[str(int(id) % 500000)] for id in common_music_list if str(int(id) % 500000) in id_to_music}
510 | for id, music in id_music_map.items():
511 | new_music = copy.deepcopy(music)
512 | if int(id) > 500000:
513 | # 白谱处理,将白谱的内容(定数、谱面信息等全部顶到紫谱的位置)
514 | new_music.id = id
515 | new_music.ds[3] = new_music.ds[4]
516 | new_music.level[3] = new_music.level[4]
517 | new_music.cids[3] = new_music.cids[4]
518 | new_music.charts[3] = new_music.charts[4]
519 |
520 | if len(new_music.ds) == 5:
521 | # 即有白谱的music
522 | new_music.ds.pop(4)
523 | new_music.level.pop(4)
524 | new_music.cids.pop(4)
525 | new_music.charts.pop(4)
526 | chart_total_list.append(new_music)
527 |
528 |
529 | asyncio.run(init_chart_guess_info())
530 | asyncio.run(init_chart_pool_by_existing_files(chart_preload_path, general_charts_pool, True))
531 | scheduler.add_job(make_chart_video, 'interval', seconds=PRELOAD_CHECK_TIME)
532 |
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/libraries/guess_song_clue.py:
--------------------------------------------------------------------------------
1 | import random
2 | import asyncio
3 | from PIL import Image
4 | from pydub import AudioSegment # 请注意,这里还需要ffmpeg,请自行安装
5 |
6 | from .utils import *
7 | from .music_model import gameplay_list, game_alias_map, alias_dict, total_list, Music, continuous_stop
8 | from .guess_cover import image_to_base64
9 |
10 | from nonebot import on_fullmatch, on_command
11 | from nonebot.matcher import Matcher
12 | from nonebot.adapters.onebot.v11 import Message, GroupMessageEvent
13 | from nonebot.params import CommandArg
14 |
15 |
16 | guess_clue = on_command("线索猜歌", aliases={"猜歌"}, priority=5)
17 | continuous_guess_clue = on_command('连续线索猜歌', priority=5)
18 |
19 | async def pic(path: str) -> Image.Image:
20 | """裁切曲绘"""
21 | im = Image.open(path)
22 | w, h = im.size
23 | w2, h2 = int(w / 3), int(h / 3)
24 | l, u = random.randrange(0, int(2 * w / 3)), random.randrange(0, int(2 * h / 3))
25 | im = im.crop((l, u, l + w2, u + h2))
26 | return im
27 |
28 |
29 | async def get_clues(music: Music):
30 | clue_list = [
31 | f'的 Expert 难度是 {music.level[2]}',
32 | f'的分类是 {music.genre}',
33 | f'的版本是 {music.version}',
34 | f'的 BPM 是 {music.bpm}',
35 | f'的紫谱有 {music.charts[3].note_sum} 个note,而且有 {music.charts[3].brk} 个绝赞'
36 | ]
37 | vital_clue = [
38 | f'的 Master 难度是 {music.level[3]}',
39 | f'的艺术家是 {music.artist}',
40 | f'的紫谱谱师为{music.charts[3].charter}',
41 | ]
42 | title = list(music.title)
43 | random.shuffle(title)
44 | title = ''.join(title)
45 | final_clue = f'的歌名组成为{title}'
46 |
47 | if music_file_path:
48 | # 若有歌曲文件,就加入歌曲长度作为线索
49 | music_file = get_music_file_path(music)
50 | try:
51 | song = AudioSegment.from_file(music_file)
52 | song_length = len(song) / 1000
53 |
54 | clue_list.append(f'的歌曲长度为 {int(song_length/60)}分{int(song_length%60)}秒')
55 | except Exception as e:
56 | logging.error(e, exc_info=True)
57 |
58 | # 特殊属性加入重要线索
59 | if total_list.by_id(str(int(music.id) % 10000)) and total_list.by_id(str(int(music.id) % 10000 + 10000)):
60 | # 如果sd和dx谱都存在的话
61 | vital_clue.append(f'既有SD谱面也有DX谱面')
62 | else:
63 | clue_list.append(f'{"不" if music.type == "SD" else ""}是 DX 谱面')
64 | if total_list.by_id(str(int(music.id) + 100000)):
65 | vital_clue.append(f'有宴谱')
66 |
67 | if len(music.ds) == 5:
68 | vital_clue.append(f'的白谱谱师为{music.charts[4].charter}')
69 | clue_list.append(f'的 Re:Master 难度是 {music.level[4]}')
70 | else:
71 | clue_list.append(f'没有白谱')
72 |
73 | random_clue = vital_clue
74 | random_clue += random.sample(clue_list, 6 - len(vital_clue))
75 | random.shuffle(random_clue)
76 | random_clue.append(final_clue)
77 | return random_clue
78 |
79 |
80 | @guess_clue.handle()
81 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
82 | if len(total_list.music_list) == 0:
83 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
84 | group_id = str(event.group_id)
85 | game_name = "clue"
86 | if check_game_disable(group_id, game_name):
87 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
88 | params = args.extract_plain_text().strip().split()
89 | await isplayingcheck(group_id, matcher)
90 | await clue_guess_handler(group_id, matcher, params)
91 |
92 | @continuous_guess_clue.handle()
93 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
94 | if len(total_list.music_list) == 0:
95 | await matcher.finish("本插件还没有配置好static资源噢,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
96 | group_id = str(event.group_id)
97 | game_name = "clue"
98 | if check_game_disable(group_id, game_name):
99 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
100 | params = args.extract_plain_text().strip().split()
101 | await isplayingcheck(group_id, matcher)
102 | if not filter_random(total_list.music_list, params, 1):
103 | await matcher.finish(fault_tips, reply_message=True)
104 | await matcher.send('连续线索猜歌已开启,发送\"停止\"以结束')
105 | continuous_stop[group_id] = 1
106 | while continuous_stop.get(group_id):
107 | if gameplay_list.get(group_id) is None:
108 | await clue_guess_handler(group_id, matcher, params)
109 | if continuous_stop[group_id] > 3:
110 | continuous_stop.pop(group_id)
111 | await matcher.finish('没人猜了? 那我下班了。')
112 | await asyncio.sleep(1)
113 |
114 | async def clue_guess_handler(group_id, matcher: Matcher, args):
115 | if _ := filter_random(total_list.music_list, args, 1):
116 | random_music = _[0]
117 | else:
118 | await matcher.finish(fault_tips, reply_message=True)
119 | clues = await get_clues(random_music)
120 | gameplay_list[group_id] = {}
121 | gameplay_list[group_id]["clue"] = random_music
122 |
123 | msg = "我将每隔8秒给你一些和这首歌相关的线索,直接输入歌曲的 id、标题、有效别名 都可以进行猜歌。"
124 | if len(args):
125 | msg += f"\n本次线索猜歌范围:{', '.join(args)}"
126 | await matcher.send(msg)
127 | await asyncio.sleep(5)
128 | for cycle in range(8):
129 | if group_id not in gameplay_list or not gameplay_list[group_id].get("clue"):
130 | break
131 | if gameplay_list[group_id].get("clue") != random_music:
132 | break
133 |
134 | if cycle < 5:
135 | await matcher.send(f'[线索 {cycle + 1}/8] 这首歌{clues[cycle]}')
136 | await asyncio.sleep(8)
137 | elif cycle < 7:
138 | # 最后俩线索太明显,多等一会
139 | await matcher.send(f'[线索 {cycle + 1}/8] 这首歌{clues[cycle]}')
140 | await asyncio.sleep(12)
141 | else:
142 | pic_path = music_cover_path / (get_cover_len5_id(random_music.id) + ".png")
143 | draw = await pic(pic_path)
144 | await matcher.send(
145 | MessageSegment.text('[线索 8/8] 这首歌封面的一部分是:\n') +
146 | MessageSegment.image(image_to_base64(draw)) +
147 | MessageSegment.text('答案将在30秒后揭晓')
148 | )
149 | for _ in range(30):
150 | await asyncio.sleep(1)
151 | if gameplay_list.get(group_id) is None or not gameplay_list[group_id].get("clue") or gameplay_list[group_id].get("clue") != random_music:
152 | if continuous_stop.get(group_id):
153 | continuous_stop[group_id] = 1
154 | return
155 |
156 | gameplay_list.pop(group_id)
157 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music)
158 | await matcher.send(reply_message)
159 | if continuous_stop.get(group_id):
160 | continuous_stop[group_id] += 1
161 |
162 |
163 | async def clue_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag):
164 | '''开歌处理函数'''
165 | clue_info = gameplay_list.get(group_id).get("clue")
166 | music_candidates = alias_dict.get(song_name)
167 | if music_candidates is None:
168 | if ignore_tag:
169 | return
170 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True)
171 |
172 | if len(music_candidates) < 20:
173 | for music_index in music_candidates:
174 | music = total_list.music_list[music_index]
175 | if clue_info.id == music.id:
176 | gameplay_list.pop(group_id)
177 | record_game_success(user_id=user_id, group_id=group_id, game_type="clue")
178 | reply_message = MessageSegment.text("恭喜你猜对啦!答案就是:\n") + song_txt(music)
179 | await matcher.finish(Message(reply_message), reply_message=True)
180 | if not ignore_tag:
181 | await matcher.finish("你猜的答案不对噢,再仔细听一下吧!", reply_message=True)
182 |
183 |
184 | def clue_rank_message(group_id):
185 | top_clue = get_top_three(group_id, "clue")
186 | if top_clue:
187 | msg = "今天的前三名线索猜歌王:\n"
188 | for rank, (user_id, count) in enumerate(top_clue, 1):
189 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n"
190 | msg += "你们赢了……"
191 | return msg
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/libraries/guess_song_listen.py:
--------------------------------------------------------------------------------
1 | import random
2 | import asyncio
3 | from pydub import AudioSegment # 请注意,这里还需要ffmpeg,请自行安装
4 |
5 | from .utils import *
6 | from .music_model import gameplay_list, game_alias_map, alias_dict, total_list, continuous_stop
7 |
8 | from nonebot import on_fullmatch, on_command
9 | from nonebot.matcher import Matcher
10 | from nonebot.adapters.onebot.v11 import Message, GroupMessageEvent
11 | from nonebot.params import CommandArg
12 | from nonebot.permission import SUPERUSER
13 |
14 |
15 | guess_listen = on_command("听歌猜曲", aliases={"听歌辩曲"}, priority=5)
16 | continuous_guess_listen = on_command('连续听歌猜曲', priority=5)
17 | check_listen_file_completeness = on_command("检查歌曲文件完整性", permission=SUPERUSER, priority=5)
18 |
19 | listen_total_list: List[Music] = []
20 |
21 | def extract_random_clip(music_file, duration_ms=10000):
22 | song = AudioSegment.from_file(music_file)
23 | song_length = len(song)
24 | if song_length <= duration_ms:
25 | return song
26 | start_ms = random.randint(0, song_length - duration_ms)
27 | end_ms = start_ms + duration_ms
28 | clip = song[start_ms:end_ms]
29 | return clip
30 |
31 |
32 | def save_clip_as_audio(clip, output_file):
33 | '''将截取的片段保存为音频文件'''
34 | clip.export(output_file, format="mp3", bitrate="320k", parameters=["-ar", "44100"])
35 |
36 |
37 | def listen_rank_message(group_id):
38 | top_listen = get_top_three(group_id, "listen")
39 | if top_listen:
40 | msg = "今天的前三名猜歌王:\n"
41 | for rank, (user_id, count) in enumerate(top_listen, 1):
42 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n"
43 | msg += "一堆wmc。。。"
44 | return msg
45 |
46 |
47 | async def listen_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag):
48 | '''开歌处理函数'''
49 | listen_info = gameplay_list.get(group_id).get("listen")
50 | music_candidates = alias_dict.get(song_name)
51 | if music_candidates is None:
52 | if ignore_tag:
53 | return
54 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True)
55 |
56 | if len(music_candidates) < 20:
57 | for music_index in music_candidates:
58 | music = total_list.music_list[music_index]
59 | if listen_info.id == music.id:
60 | gameplay_list.pop(group_id)
61 | record_game_success(user_id=user_id, group_id=group_id, game_type="listen")
62 | reply_message = MessageSegment.text("恭喜你猜对啦!答案就是:\n") + song_txt(music)
63 | await matcher.finish(Message(reply_message), reply_message=True)
64 | if not ignore_tag:
65 | await matcher.finish("你猜的答案不对噢,再仔细听一下吧!", reply_message=True)
66 |
67 |
68 | @guess_listen.handle()
69 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
70 | group_id = str(event.group_id)
71 | game_name = "listen"
72 | if check_game_disable(group_id, game_name):
73 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
74 | params = args.extract_plain_text().strip().split()
75 | await isplayingcheck(group_id, matcher)
76 | await listen_guess_handler(group_id, matcher, params)
77 |
78 | @continuous_guess_listen.handle()
79 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
80 | if len(listen_total_list) == 0:
81 | await matcher.finish("文件夹没有配置任何音乐资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
82 | group_id = str(event.group_id)
83 | game_name = "listen"
84 | if check_game_disable(group_id, game_name):
85 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
86 | params = args.extract_plain_text().strip().split()
87 | await isplayingcheck(group_id, matcher)
88 | if not filter_random(listen_total_list, params, 1):
89 | await matcher.finish(fault_tips, reply_message=True)
90 | await matcher.send('连续听歌猜曲已开启,发送\"停止\"以结束')
91 | continuous_stop[group_id] = 1
92 | while continuous_stop.get(group_id):
93 | if gameplay_list.get(group_id) is None:
94 | await listen_guess_handler(group_id, matcher, params)
95 | if continuous_stop[group_id] > 3:
96 | continuous_stop.pop(group_id)
97 | await matcher.finish('没人猜了? 那我下班了。')
98 | await asyncio.sleep(1)
99 |
100 | async def listen_guess_handler(group_id, matcher: Matcher, args):
101 | if len(listen_total_list) == 0:
102 | await matcher.finish("文件夹没有配置任何音乐资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!")
103 | if _ := filter_random(listen_total_list, args, 1):
104 | random_music = _[0]
105 | else:
106 | await matcher.finish(fault_tips, reply_message=True)
107 | music_file = get_music_file_path(random_music)
108 | if not os.path.isfile(music_file):
109 | await matcher.finish(f"文件夹中没有{random_music.title}的音乐文件,请让bot主尽快补充吧!")
110 | msg = "这首歌截取的一个片段如下,请回复\"猜歌xxx\"提交您认为在答案中的曲目(可以是歌曲的别名),或回复\"不猜了\"来停止游戏"
111 | if len(args):
112 | msg += f"\n本次听歌猜曲范围:{', '.join(args)}"
113 | await matcher.send(msg)
114 | gameplay_list[group_id] = {}
115 | gameplay_list[group_id]["listen"] = random_music
116 |
117 | clip = extract_random_clip(music_file)
118 |
119 | output_file = f"./{group_id}clip.mp3"
120 | output_file = convert_to_absolute_path(output_file)
121 | save_clip_as_audio(clip, output_file)
122 |
123 | # 发送语音到群组
124 | await matcher.send(MessageSegment.record(f"file://{output_file}"))
125 | os.remove(output_file)
126 |
127 | for _ in range(30):
128 | await asyncio.sleep(1)
129 | if gameplay_list.get(group_id) is None or not gameplay_list[group_id].get("listen") or gameplay_list[group_id].get("listen") != random_music:
130 | if continuous_stop.get(group_id):
131 | continuous_stop[group_id] = 1
132 | return
133 |
134 | gameplay_list.pop(group_id)
135 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music)
136 | await matcher.send(reply_message)
137 | if continuous_stop.get(group_id):
138 | continuous_stop[group_id] += 1
139 |
140 |
141 | @check_listen_file_completeness.handle()
142 | async def check_listen_files(matcher: Matcher):
143 | mp3_files = []
144 | for filename in os.listdir(music_file_path):
145 | if filename.endswith(".mp3"):
146 | mp3_files.append(filename.replace(".mp3", ""))
147 | mp3_lost_files = []
148 | for music in total_list.music_list:
149 | music_file_name = (int)(music.id)
150 | music_file_name %= 10000
151 | music_file_name = str(music_file_name)
152 | if music_file_name not in mp3_files:
153 | mp3_lost_files.append(music_file_name)
154 | if len(mp3_lost_files) == 0:
155 | await matcher.finish("听歌猜曲的所有文件均完整,请放心进行听歌猜曲")
156 | if len(mp3_lost_files) != 0:
157 | await matcher.send(f"当前一共缺失了{len(mp3_lost_files)}首mp3文件,缺失的歌曲id如下{','.join(mp3_lost_files)}")
158 |
159 |
160 | async def init_listen_guess_list():
161 | if not music_file_path.is_dir():
162 | # 如果没有配置猜歌资源,则跳过
163 | return
164 | mp3_files = []
165 | for filename in os.listdir(music_file_path):
166 | if filename.endswith(".mp3"):
167 | mp3_files.append(filename.replace(".mp3", ""))
168 | for music in total_list.music_list:
169 | # 过滤出有资源的music
170 | music_file_name = (int)(music.id)
171 | music_file_name %= 10000
172 | music_file_name = str(music_file_name)
173 | if music_file_name in mp3_files:
174 | listen_total_list.append(music)
175 |
176 |
177 | asyncio.run(init_listen_guess_list())
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/libraries/guess_song_note.py:
--------------------------------------------------------------------------------
1 | import re
2 | import copy
3 | import random
4 | import shutil
5 | import asyncio
6 | import subprocess
7 |
8 | from .utils import *
9 | from .music_model import gameplay_list, game_alias_map, alias_dict, total_list, continuous_stop
10 | from .guess_song_chart import chart_total_list, id_mp4_file_map, id_mp3_file_map
11 |
12 | from nonebot import on_fullmatch, on_command
13 | from nonebot.plugin import require
14 | from nonebot.matcher import Matcher
15 | from nonebot.params import CommandArg
16 | from nonebot.permission import SUPERUSER
17 | from nonebot.adapters.onebot.v11 import Message, GroupMessageEvent
18 |
19 |
20 | CLIP_DURATION = 30
21 | GUESS_TIME = 90
22 |
23 | guess_note = on_command("note猜歌", aliases={"note音猜歌"}, priority=5)
24 | continuous_guess_note = on_command("连续note猜歌", aliases={"连续note音猜歌"}, priority=5)
25 |
26 |
27 | def make_note_sound(input_path, start_time, output_path):
28 | command = [
29 | "ffmpeg",
30 | "-i", input_path,
31 | "-ss", seconds_to_hms(start_time),
32 | "-t", str(CLIP_DURATION),
33 | "-q:a", "0",
34 | output_path
35 | ]
36 | try:
37 | logging.info("Cutting note sound...")
38 | subprocess.run(command, check=True)
39 | logging.info(f"Cutting completed. File saved as '{output_path}'.")
40 | except subprocess.CalledProcessError as e:
41 | logging.error(f"Error during cutting: {e}", exc_info=True)
42 | except Exception as ex:
43 | logging.error(f"An unexpected error occurred: {ex}", exc_info=True)
44 |
45 |
46 | def note_rank_message(group_id):
47 | top_listen = get_top_three(group_id, "note")
48 | if top_listen:
49 | msg = "今天的前三名note音猜歌王:\n"
50 | for rank, (user_id, count) in enumerate(top_listen, 1):
51 | msg += f"{rank}. {MessageSegment.at(user_id)} 猜对了{count}首歌!\n"
52 | msg += "这都听得出,你们也是无敌了。。。"
53 | return msg
54 |
55 |
56 | async def note_open_song_handler(matcher, song_name, group_id, user_id, ignore_tag):
57 | '''开歌处理函数'''
58 | (answer_music, start_time) = gameplay_list.get(group_id).get("note")
59 | random_music_id = answer_music.id
60 | is_remaster = False
61 | if int(random_music_id) > 500000:
62 | # 是白谱,需要找回原始music对象
63 | random_music_id = str(int(random_music_id) % 500000)
64 | is_remaster = True
65 | random_music = total_list.by_id(random_music_id)
66 |
67 | music_candidates = alias_dict.get(song_name)
68 | if music_candidates is None:
69 | if ignore_tag:
70 | return
71 | await matcher.finish("没有找到这样的乐曲。请输入正确的名称或别名", reply_message=True)
72 |
73 | if len(music_candidates) < 20:
74 | for music_index in music_candidates:
75 | music = total_list.music_list[music_index]
76 | if random_music.id == music.id:
77 | gameplay_list.pop(group_id)
78 | record_game_success(user_id=user_id, group_id=group_id, game_type="note")
79 | reply_message = MessageSegment.text("恭喜你猜对啦!答案就是:\n") + song_txt(random_music, is_remaster) + "\n对应的片段如下:"
80 | await matcher.send(Message(reply_message), reply_message=True)
81 | answer_input_path = id_mp3_file_map.get(answer_music.id) # 这个是旧的id,即白谱应该找回白谱的id
82 | answer_output_path: Path = guess_resources_path / f"{group_id}_{start_time}_answer.mp3"
83 | await asyncio.to_thread(make_note_sound, answer_input_path, start_time, answer_output_path)
84 | await matcher.send(MessageSegment.record(f"file://{answer_output_path}"))
85 | os.remove(answer_output_path)
86 | if not ignore_tag:
87 | await matcher.finish("你猜的答案不对噢,再仔细听一下吧!", reply_message=True)
88 |
89 |
90 | @guess_note.handle()
91 | async def note_guess_handler(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
92 | group_id = str(event.group_id)
93 | game_name = "note"
94 | if check_game_disable(group_id, game_name):
95 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
96 | await isplayingcheck(group_id, matcher)
97 | params = args.extract_plain_text().strip().split()
98 | await note_guess_handler(group_id, matcher, params)
99 |
100 |
101 | @continuous_guess_note.handle()
102 | async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()):
103 | if len(chart_total_list) == 0:
104 | await matcher.finish("文件夹没有下载任何谱面视频资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!需要配置好谱面资源才能进行note音猜歌噢")
105 | group_id = str(event.group_id)
106 | game_name = "note"
107 | if check_game_disable(group_id, game_name):
108 | await matcher.finish(f"本群禁用了{game_alias_map[game_name]}游戏,请联系管理员使用“/开启猜歌 {game_alias_map[game_name]}”来开启游戏吧!")
109 | params = args.extract_plain_text().strip().split()
110 | await isplayingcheck(group_id, matcher)
111 | if not filter_random(chart_total_list, params, 1):
112 | await matcher.finish(fault_tips, reply_message=True)
113 | await matcher.send('连续note音猜歌已开启,发送\"停止\"以结束')
114 | continuous_stop[group_id] = 1
115 | while continuous_stop.get(group_id):
116 | if gameplay_list.get(group_id) is None:
117 | await note_guess_handler(group_id, matcher, params)
118 | if continuous_stop[group_id] > 3:
119 | continuous_stop.pop(group_id)
120 | await matcher.finish('没人猜了? 那我下班了。')
121 | await asyncio.sleep(1)
122 |
123 |
124 | async def note_guess_handler(group_id, matcher: Matcher, args):
125 | if len(chart_total_list) == 0:
126 | await matcher.finish("文件夹没有下载任何谱面视频资源,请让bot主尽快到 https://github.com/apshuang/nonebot-plugin-guess-song 下载资源吧!需要配置好谱面资源才能进行note音猜歌噢")
127 | if _ := filter_random(chart_total_list, args, 1):
128 | random_music = _[0]
129 | else:
130 | await matcher.finish(fault_tips, reply_message=True)
131 | input_path = id_mp4_file_map.get(random_music.id)
132 | video_duration = get_video_duration(input_path)
133 | if CLIP_DURATION > video_duration - 15:
134 | raise ValueError(f"截取时长不能超过视频总时长减去15秒")
135 |
136 | start_time = random.uniform(5, video_duration - CLIP_DURATION - 10)
137 | output_path: Path = guess_resources_path / f"{group_id}_{start_time}.mp3"
138 | await asyncio.to_thread(make_note_sound, input_path, start_time, output_path)
139 | msg = "这首歌的谱面截取的一个片段的note音如下,请回复\"猜歌xxx\"提交您认为在答案中的曲目(可以是歌曲的别名),或回复\"不猜了\"来停止游戏"
140 | if len(args):
141 | msg += f"\n本次note音猜歌范围:{', '.join(args)}"
142 | await matcher.send(msg)
143 | gameplay_list[group_id] = {}
144 | gameplay_list[group_id]["note"] = (random_music, start_time)
145 |
146 | # 发送语音到群组
147 | await matcher.send(MessageSegment.record(f"file://{output_path}"))
148 | os.remove(output_path)
149 |
150 | for _ in range(GUESS_TIME):
151 | await asyncio.sleep(1)
152 | if gameplay_list.get(group_id) is None or not gameplay_list[group_id].get("note") or gameplay_list[group_id].get("note") != random_music:
153 | if continuous_stop.get(group_id):
154 | continuous_stop[group_id] = 1
155 | return
156 |
157 | answer_music_id = random_music.id
158 | random_music_id = random_music.id
159 | is_remaster = False
160 | if int(random_music_id) > 500000:
161 | # 是白谱,需要找回原始music对象
162 | random_music_id = str(int(random_music_id) % 500000)
163 | is_remaster = True
164 | random_music = total_list.by_id(random_music_id)
165 | gameplay_list.pop(group_id)
166 | reply_message = MessageSegment.text("很遗憾,你没有猜到答案,正确的答案是:\n") + song_txt(random_music, is_remaster) + "\n对应的片段如下:"
167 | await matcher.send(reply_message)
168 | answer_input_path = id_mp3_file_map.get(answer_music_id) # 这个是旧的id,即白谱应该找回白谱的id
169 | answer_output_path: Path = guess_resources_path / f"{group_id}_{start_time}_answer.mp3"
170 | await asyncio.to_thread(make_note_sound, answer_input_path, start_time, answer_output_path)
171 | await matcher.send(MessageSegment.record(f"file://{answer_output_path}"))
172 | os.remove(answer_output_path)
173 | if continuous_stop.get(group_id):
174 | continuous_stop[group_id] += 1
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/libraries/music_model.py:
--------------------------------------------------------------------------------
1 | import re
2 | import json
3 | import asyncio
4 | from typing import Dict, List, Optional
5 |
6 | from ..config import *
7 |
8 | from nonebot import get_plugin_config
9 |
10 |
11 | config = get_plugin_config(Config)
12 |
13 |
14 | def contains_japanese(text):
15 | # 日文字符的 Unicode 范围包括平假名、片假名、以及部分汉字
16 | japanese_pattern = re.compile(r'[\u3040-\u30FF\u4E00-\u9FAF]')
17 | return bool(japanese_pattern.search(text))
18 |
19 |
20 | class Chart():
21 | tap: Optional[int] = None
22 | slide: Optional[int] = None
23 | hold: Optional[int] = None
24 | brk: Optional[int] = None
25 | touch: Optional[int] = None
26 | charter: Optional[str] = None
27 | note_sum: Optional[int] = None
28 |
29 | def __init__(self, data: Dict):
30 | note_list = data.get('notes')
31 | self.tap = note_list[0]
32 | self.hold = note_list[1]
33 | self.slide = note_list[2]
34 | self.brk = note_list[3]
35 | if len(note_list) == 5:
36 | self.touch = note_list[3]
37 | self.brk = note_list[4]
38 | else:
39 | self.touch = 0
40 | self.charter = data.get('charter')
41 | self.note_sum = self.tap + self.slide + self.hold + self.brk + self.touch
42 |
43 |
44 | class Music():
45 | id: Optional[str] = None
46 | title: Optional[str] = None
47 | type: Optional[str] = None
48 | ds: Optional[List[float]] = None
49 | level: Optional[List[str]] = None
50 | cids: Optional[List[int]] = None
51 | charts: Optional[List[Chart]] = None
52 | artist: Optional[str] = None
53 | genre: Optional[str] = None
54 | bpm: Optional[float] = None
55 | release_date: Optional[str] = None
56 | version: Optional[str] = None
57 | is_new: Optional[bool] = None
58 |
59 | diff: List[int] = []
60 | alias: List[str] = []
61 |
62 | def __init__(self, data: Dict):
63 | # 从字典中获取值并设置类的属性
64 | self.id: Optional[str] = data.get('id')
65 | self.title: Optional[str] = data.get('title')
66 | self.type: Optional[str] = data.get('type')
67 | self.ds: Optional[List[float]] = data.get('ds')
68 | self.level: Optional[List[str]] = data.get('level')
69 | self.cids: Optional[List[int]] = data.get('cids')
70 | self.charts: Optional[List[Chart]] = [Chart(chart) for chart in data.get('charts')]
71 | self.artist: Optional[str] = data.get('basic_info').get('artist')
72 | self.genre: Optional[str] = data.get('basic_info').get('genre')
73 | self.bpm: Optional[float] = data.get('basic_info').get('bpm')
74 | self.release_date: Optional[str] = data.get('basic_info').get('release_date')
75 | self.version: Optional[str] = data.get('basic_info').get('from')
76 | self.is_new: Optional[bool] = data.get('basic_info').get('is_new')
77 |
78 | class MusicList():
79 | music_list: Optional[List[Music]] = []
80 |
81 | def __init__(self, data: List):
82 | for music_data in data:
83 | music = Music(music_data)
84 | self.music_list.append(music)
85 |
86 | def init_alias(self, data: Dict):
87 | cache_dict = {}
88 | for alias_info in data:
89 | cache_dict[str(alias_info.get("SongID"))] = alias_info.get("Alias")
90 | for music in self.music_list:
91 | if cache_dict.get(music.id):
92 | music.alias = cache_dict.get(music.id)
93 | else:
94 | music.alias = []
95 |
96 |
97 | def by_id(self, music_id: str) -> Optional[Music]:
98 | for music in self.music_list:
99 | if music.id == music_id:
100 | return music
101 | return None
102 |
103 |
104 | async def main():
105 |
106 | global total_list, alias_dict, filter_list
107 |
108 | def load_data(data_path):
109 | try:
110 | with open(data_path, 'r', encoding='utf-8') as f:
111 | return json.load(f)
112 | except FileNotFoundError:
113 | return {}
114 |
115 | music_data = load_data(music_info_path)
116 | total_list = MusicList(music_data)
117 |
118 | alias_data = load_data(music_alias_path)
119 | total_list.init_alias(alias_data)
120 |
121 | alias_dict = {}
122 | for i in range(len(total_list.music_list)):
123 |
124 | # 将歌曲的id也加入别名
125 | id = total_list.music_list[i].id
126 | if alias_dict.get(id) is None:
127 | alias_dict[id] = [i]
128 | else:
129 | alias_dict[id].append(i)
130 |
131 | title = total_list.music_list[i].title
132 |
133 | # 将歌曲的原名也加入别名(统一转为小写)
134 | title_lower = title.lower()
135 | if alias_dict.get(title_lower) is None:
136 | alias_dict[title_lower] = [i]
137 | else:
138 | alias_dict[title_lower].append(i)
139 |
140 | for alias in total_list.music_list[i].alias:
141 | # 将别名库中的别名载入到内存字典中(统一转为小写)
142 | alias_lower = alias.lower()
143 | if alias_dict.get(alias_lower) is None:
144 | alias_dict[alias_lower] = [i]
145 | else:
146 | alias_dict[alias_lower].append(i)
147 |
148 | # 对别名字典进行去重
149 | for key, value_list in alias_dict.items():
150 | alias_dict[key] = list(set(value_list))
151 |
152 | # 选出没有日文字的歌曲,作为开字母的曲库(因为如果曲名有日文字很难开出来)
153 | filter_list = []
154 | for music in total_list.music_list:
155 | if (not game_config.character_filter_japenese) or (not contains_japanese(music.title)):
156 | # 如果不过滤那就都可以加,如果要过滤,那就不含有日文才能加
157 | filter_list.append(music)
158 |
159 | gameplay_list = {}
160 | continuous_stop = {}
161 | game_alias_map = {
162 | "open_character" : "开字母",
163 | "listen" : "听歌猜曲",
164 | "cover" : "猜曲绘",
165 | "clue" : "线索猜歌",
166 | "chart" : "谱面猜歌",
167 | "random" : "随机猜歌",
168 | "note": "note音猜歌",
169 | }
170 |
171 | game_alias_map_reverse = {
172 | "开字母" : "open_character",
173 | "听歌猜曲" : "listen",
174 | "猜曲绘" : "cover",
175 | "线索猜歌" : "clue",
176 | "谱面猜歌" : "chart",
177 | "随机猜歌" : "random",
178 | "note音猜歌": "note",
179 | }
180 |
181 | asyncio.run(main())
182 |
--------------------------------------------------------------------------------
/nonebot_plugin_guess_song/libraries/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | from datetime import datetime
4 | from io import BytesIO
5 | import base64
6 | from PIL import Image
7 | from typing import Union, Optional
8 | import random
9 | import subprocess
10 | from PIL import Image, ImageDraw, ImageFont
11 | import logging
12 |
13 | from .music_model import *
14 | from ..config import *
15 |
16 | from nonebot.adapters.onebot.v11 import Message, MessageSegment, Bot
17 | from nonebot.matcher import Matcher
18 |
19 |
20 | def convert_to_absolute_path(input_path):
21 | input_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), input_path)
22 | input_path = os.path.normpath(input_path)
23 | return input_path
24 |
25 |
26 | def split_id_from_path(file_path):
27 | if file_path.find('/') != -1:
28 | # Linux下通过斜杠/来分割
29 | file_name = file_path.split('/')[-1]
30 | elif file_path.find('\\') != -1:
31 | # Windows下通过反斜杠\来分割
32 | file_name = file_path.split('\\')[-1]
33 | music_id = file_name.split('_')[0]
34 | start_time = file_name.split('_')[1]
35 | return music_id, start_time
36 |
37 |
38 | def get_cover_len5_id(mid) -> str:
39 | '''获取曲绘id'''
40 | mid = int(mid)
41 | mid %= 10000
42 | if mid > 10000 and mid <= 11000:
43 | mid -= 10000
44 | if mid > 1000 and mid <= 10000:
45 | mid += 10000
46 | return str(mid)
47 |
48 |
49 | def get_music_file_path(music: Music):
50 | '''获取音频路径'''
51 | music_file_name = (int)(music.id)
52 | music_file_name %= 10000
53 | music_file_name = str(music_file_name)
54 | music_file_name += ".mp3"
55 | music_file = os.path.join(music_file_path, music_file_name)
56 | return music_file
57 |
58 |
59 | def song_txt(music: Music, is_remaster: bool = False):
60 | '''返回歌曲介绍的message'''
61 | output = (
62 | f"{music.id}. {music.title}\n"
63 | f"艺术家:{music.artist}\n"
64 | f"分类:{music.genre}\n"
65 | f"版本:{music.version}\n"
66 | f"{f'紫谱谱师:{music.charts[3].charter}' if not is_remaster else f'白谱谱师:{music.charts[4].charter}'}\n"
67 | f"BPM:{music.bpm}\n"
68 | f"定数:{'/'.join(map(str, music.ds))}"
69 | )
70 | pic_path = music_cover_path / (get_cover_len5_id(music.id) + ".png")
71 | #print(pic_path)
72 | return [
73 | MessageSegment.image(f"file://{pic_path}"),
74 | MessageSegment("text", {"text": output})]
75 |
76 |
77 | def image_to_base64(img: Image.Image, format='PNG') -> str:
78 | output_buffer = BytesIO()
79 | img.save(output_buffer, format)
80 | byte_data = output_buffer.getvalue()
81 | base64_str = base64.b64encode(byte_data).decode()
82 | return 'base64://' + base64_str
83 |
84 |
85 | def load_data(data_path):
86 | try:
87 | with open(data_path, 'r', encoding='utf-8') as f:
88 | return json.load(f)
89 | except FileNotFoundError:
90 | return {}
91 |
92 |
93 | def save_game_data(data, data_path):
94 | with open(data_path, 'w', encoding='utf-8') as f:
95 | json.dump(data, f, ensure_ascii=False, indent=4)
96 |
97 |
98 | def record_game_success(user_id: int, group_id: int, game_type: str):
99 | '''记录各用户猜对的次数,作为排行榜'''
100 | gid = str(group_id)
101 | uid = str(user_id)
102 | data = load_game_data_json(gid)
103 |
104 | if data[gid]['rank'][game_type].get(uid) is None:
105 | data[gid]['rank'][game_type][uid] = 0
106 | data[gid]['rank'][game_type][uid] += 1
107 | save_game_data(data, game_data_path)
108 |
109 |
110 | def get_top_three(group_id: int, game_type: str):
111 | gid = str(group_id)
112 | data = load_game_data_json(gid)
113 | game_data = data[gid]['rank'][game_type]
114 | sorted_users = sorted(game_data.items(), key=lambda x: x[1], reverse=True)
115 | return sorted_users[:3]
116 |
117 |
118 | async def send_forward_message(bot: Bot, target_group_id, sender_id, origin_messages):
119 | '''将多条信息合并发出'''
120 | messages = []
121 | for msg in origin_messages:
122 | if not msg:
123 | continue
124 | messages.append(
125 | MessageSegment.node_custom(
126 | user_id=sender_id,
127 | nickname="猜你字母bot",
128 | content=Message(msg)
129 | )
130 | )
131 | if len(messages) == 0:
132 | return
133 | try:
134 | # 该方法适用于拉格兰框架
135 | res_id = await bot.call_api("send_forward_msg", messages=messages)
136 | await bot.send_group_msg(group_id=target_group_id, message=Message(MessageSegment.forward(res_id)))
137 | except Exception as e:
138 | try:
139 | # 该方法适用于napcat框架
140 | res_id = await bot.call_api("send_forward_msg", group_id=target_group_id, messages=messages)
141 | except Exception as e2:
142 | logging.error(e2, exc_info=True)
143 |
144 |
145 | def load_game_data_json(gid: str):
146 | data = load_data(game_data_path)
147 | data.setdefault(gid, {
148 | "config": {
149 | "gauss": 10,
150 | "cut": 0.5,
151 | "shuffle": 0.1,
152 | "gray": 0.8,
153 | "transpose": False
154 | },
155 | "rank": {
156 | "listen": {},
157 | "open_character": {},
158 | "cover": {},
159 | "clue": {},
160 | "chart": {},
161 | "note": {}
162 | },
163 | "game_enable": {
164 | "listen": True,
165 | "open_character": True,
166 | "cover": True,
167 | "clue": True,
168 | "chart": True,
169 | "random": True,
170 | "note": True
171 | }
172 | })
173 | data[gid].setdefault('config', {
174 | "gauss": 10,
175 | "cut": 0.5,
176 | "shuffle": 0.1,
177 | "gray": 0.8,
178 | "transpose": False
179 | })
180 | data[gid].setdefault('rank', {
181 | "listen": {},
182 | "open_character": {},
183 | "cover": {},
184 | "clue": {},
185 | "chart": {},
186 | "note": {}
187 | })
188 | data[gid].setdefault('game_enable', {
189 | "listen": True,
190 | "open_character": True,
191 | "cover": True,
192 | "clue": True,
193 | "chart": True,
194 | "random": True,
195 | "note": True
196 | })
197 | data[gid]['config'].setdefault('gauss', 10)
198 | data[gid]['config'].setdefault('cut', 0.5)
199 | data[gid]['config'].setdefault('shuffle', 0.1)
200 | data[gid]['config'].setdefault('gray', 0.8)
201 | data[gid]['config'].setdefault('transpose', False)
202 | data[gid]['rank'].setdefault("listen", {})
203 | data[gid]['rank'].setdefault("open_character", {})
204 | data[gid]['rank'].setdefault("cover", {})
205 | data[gid]['rank'].setdefault("clue", {})
206 | data[gid]['rank'].setdefault("chart", {})
207 | data[gid]['rank'].setdefault("note", {})
208 | data[gid]['game_enable'].setdefault("listen", True)
209 | data[gid]['game_enable'].setdefault("open_character", True)
210 | data[gid]['game_enable'].setdefault("cover", True)
211 | data[gid]['game_enable'].setdefault("clue", True)
212 | data[gid]['game_enable'].setdefault("chart", True)
213 | data[gid]['game_enable'].setdefault("random", True)
214 | data[gid]['game_enable'].setdefault("note", True)
215 | return data
216 |
217 | async def isplayingcheck(gid, matcher: Matcher):
218 | gid = str(gid)
219 | if continuous_stop.get(gid) is not None:
220 | await matcher.finish(f"当前正在运行连续猜歌,可以发送\"停止\"来结束连续猜歌", reply_message=True)
221 | if gameplay_list.get(gid) is not None:
222 | now_playing_game = game_alias_map.get(list(gameplay_list.get(gid).keys())[0])
223 | await matcher.finish(f"当前有一个{now_playing_game}正在运行,可以发送\"不玩了\"来结束游戏并公布答案", reply_message=True)
224 |
225 |
226 | def filter_random(data: list[Music], args: list[str], cnt: int = 1) -> Union[list[Music], None]:
227 | filters = {'other': []}
228 | flip_filters = {'other': []}
229 | for param in args:
230 | if not matchparam(param, filters):
231 | if param[0] != '-' or not matchparam(param[1:], flip_filters):
232 | return None
233 | data = list(filter(lambda x: x.genre != '宴会場', data))
234 | for func in filters['other']:
235 | data = list(filter(func, data))
236 | for func in flip_filters['other']:
237 | data = list(filter(lambda x: not func(x), data))
238 | filters.pop('other')
239 | flip_filters.pop('other')
240 | for funcs in filters.values():
241 | def total_func(x):
242 | result = False
243 | for func in funcs:
244 | result |= func(x)
245 | return result
246 | data = list(filter(total_func, data))
247 | for funcs in flip_filters.values():
248 | def total_flip_func(x):
249 | result = True
250 | for func in funcs:
251 | result &= not func(x)
252 | return result
253 | data = list(filter(total_flip_func, data))
254 | try:
255 | result = random.sample(data, cnt)
256 | except ValueError:
257 | return None
258 | return result
259 |
260 | fault_tips = f'参数错误,可能是满足条件的歌曲数不足或格式错误,可用“/猜歌帮助”命令获取帮助哦!'
261 |
262 | def matchparam(param: str, filters: dict) -> bool:
263 | try:
264 | if _ := b50listb.get(param): #过滤参数
265 | filters['other'].append(_)
266 |
267 | elif _ := re.match(r'^版本([<>]?)(\=?)(.)$', param):
268 | if (ver := _.group(3)) not in plate_to_version:
269 | return False
270 | filters.setdefault('ver', [])
271 | ver = plate_to_version[labelmap.get(ver) or ver]
272 | if sign := _.group(1):
273 | if _.group(2):
274 | filter_func = lambda x: ver_filter(x, ver)
275 | filters['ver'].append(filter_func)
276 | for i, _ver in enumerate(verlist := list(plate_to_version.values())):
277 | if (sign == '>' and i > verlist.index(ver)) or (sign == '<' and i < verlist.index(ver)):
278 | filter_func = lambda x, __ver=_ver: ver_filter(x, __ver)
279 | filters['ver'].append(filter_func)
280 | else:
281 | filter_func = lambda x: ver_filter(x, ver)
282 | filters['ver'].append(filter_func)
283 |
284 | elif param[:2] == '分区':
285 | label = param[2:]
286 | if label not in category.values():
287 | return False
288 | filter_func = lambda x: cat_filter(x, label)
289 | filters.setdefault('category', [])
290 | filters['category'].append(filter_func)
291 |
292 | elif param[:2] == '谱师':
293 | name = param[2:]
294 | if not (musicdata := find_charter(name)):
295 | return False
296 | filter_func = lambda x: charter_filter(x, musicdata)
297 | filters.setdefault('charter', [])
298 | filters['charter'].append(filter_func)
299 |
300 | elif _ := re.match(r'^等级([<>]?)(\=?)(.+)$', param):
301 | if (level := _.group(3)) not in levelList:
302 | return False
303 | filters.setdefault('level', [])
304 | if sign := _.group(1):
305 | if _.group(2):
306 | filter_func = lambda x: level_filter(x, level)
307 | filters['level'].append(filter_func)
308 | for i, _level in enumerate(levelList):
309 | if (sign == '>' and i > levelList.index(level)) or (sign == '<' and i < levelList.index(level)):
310 | filter_func = lambda x, lv=_level: level_filter(x, lv)
311 | filters['level'].append(filter_func)
312 | else:
313 | filter_func = lambda x: level_filter(x, level)
314 | filters['level'].append(filter_func)
315 |
316 | elif _ := re.match(r'^定数([<>]?)(\=?)(\d+\.\d)$', param):
317 | if not (1.0 <= (ds := float( _.group(3))) <= 15.0):
318 | return False
319 | filters.setdefault('ds', [])
320 | if sign := _.group(1):
321 | if _.group(2):
322 | filter_func = lambda x: ds_filter(x, ds)
323 | filters['ds'].append(filter_func)
324 | for i in range(10, 151):
325 | _ds = i / 10
326 | if (sign == '>' and _ds > ds) or (sign == '<' and _ds < ds):
327 | filter_func = lambda x, __ds=_ds: ds_filter(x, __ds)
328 | filters['ds'].append(filter_func)
329 | else:
330 | filter_func = lambda x: ds_filter(x, ds)
331 | filters['ds'].append(filter_func)
332 |
333 | else:
334 | return False
335 | except Exception as e:
336 | logging.error(e, exc_info=True)
337 | return False
338 | return True
339 |
340 |
341 | def ver_filter(music: Music, ver: str) -> bool:
342 | return music.version == ver
343 |
344 | def cat_filter(music: Music, cat: str) -> bool:
345 | genre = music.genre
346 | if category.get(genre):
347 | genre = category[genre]
348 | return genre == cat
349 |
350 | def charter_filter(music: Music, musiclist: list) -> bool:
351 | return music.id in musiclist
352 |
353 |
354 | def is_new(music: Music) -> bool:
355 | return music.is_new
356 |
357 | def is_old(music: Music) -> bool:
358 | return not music.is_new
359 |
360 | def is_sd(music: Music) -> bool:
361 | return music.type == 'SD'
362 |
363 | def is_dx(music: Music) -> bool:
364 | return music.type == 'DX'
365 |
366 | def level_filter(music: Music, level: str) -> bool:
367 | return level in music.level[3:] #只考虑紫白谱
368 |
369 | def ds_filter(music: Music, ds: float) -> bool:
370 | return ds in music.ds[3:] #只考虑紫白谱
371 |
372 | b50listb = {
373 | '新': is_new,
374 | '旧': is_old,
375 | 'sd': is_sd,
376 | 'dx': is_dx
377 | }
378 |
379 | def find_charter(name: str):
380 | all_music = list(filter(lambda x: x.genre != '宴会場', total_list.music_list))
381 | white_music = list(filter(lambda x: len(x.charts) == 5, all_music))
382 | music_data = []
383 | for namelist in charterlist.values():
384 | if name in namelist:
385 | for alias in namelist:
386 | music_data.extend(list(filter(lambda x: x.charts[3].charter == alias, all_music)))
387 | music_data.extend(list(filter(lambda x: x.charts[4].charter == alias, white_music)))
388 | if not len(music_data):
389 | music_data.extend(list(filter(lambda x: name.lower() in x.charts[3].charter.lower(), all_music)))
390 | music_data.extend(list(filter(lambda x: name.lower() in x.charts[4].charter.lower(), white_music)))
391 | return [music.id for music in music_data]
392 |
393 | def text_to_image(text: str) -> Image.Image:
394 | font = ImageFont.truetype(str(guess_static_resources_path / "SourceHanSansSC-Bold.otf"), 24)
395 | padding = 10
396 | margin = 4
397 | lines = text.strip().split('\n')
398 | max_width = 0
399 | b = 0
400 | for line in lines:
401 | l, t, r, b = font.getbbox(line)
402 | max_width = max(max_width, r)
403 | wa = max_width + padding * 2
404 | ha = b * len(lines) + margin * (len(lines) - 1) + padding * 2
405 | im = Image.new('RGB', (wa, ha), color=(255, 255, 255))
406 | draw = ImageDraw.Draw(im)
407 | for index, line in enumerate(lines):
408 | draw.text((padding, padding + index * (margin + b)), line, font=font, fill=(0, 0, 0))
409 | return im
410 |
411 | def to_bytes_io(text: str) -> BytesIO:
412 | bio = BytesIO()
413 | text_to_image(text).save(bio, format='PNG')
414 | bio.seek(0)
415 | return bio
416 |
417 |
418 | def seconds_to_hms(seconds: int) -> str:
419 | hours = int(seconds // 3600)
420 | minutes = int((seconds % 3600) // 60)
421 | secs = int(seconds % 60)
422 | return f"{hours:02}:{minutes:02}:{secs:02}"
423 |
424 |
425 | def get_video_duration(video_path):
426 | cmd = [
427 | "ffprobe",
428 | "-v", "error",
429 | "-select_streams", "v:0",
430 | "-show_entries", "format=duration",
431 | "-of", "json",
432 | video_path
433 | ]
434 | result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
435 | duration_info = json.loads(result.stdout)
436 | return int(float(duration_info["format"]["duration"])) if "format" in duration_info else None
437 |
438 |
439 | global_game_data = load_data(game_data_path)
440 |
441 | def check_game_disable(gid, game_name):
442 | global_game_data = load_game_data_json(gid) # 包含一个初始化
443 | enable_sign = global_game_data[gid]["game_enable"][game_name]
444 | return not enable_sign
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "nonebot-plugin-guess-song"
3 | version = "1.2.3"
4 | description = "A Guess song plugin for Nonebot2"
5 | authors = [
6 | { name = "ssgXD", email = "554700172@qq.com" },
7 | { name = "sab_tico", email = "3116295766@qq.com" },
8 | { name = "WAduck", email = "1447671663@qq.com" },
9 | { name = "qianorange", email = "2080396637@qq.com" }
10 | ]
11 | dependencies = [
12 | "nonebot2>=2.3.0",
13 | "nonebot-adapter-onebot>=2.3.0",
14 | "nonebot-plugin-apscheduler>=0.5.0",
15 | "nonebot-plugin-localstore>=0.7.0",
16 | "pillow>=11.1.0",
17 | "pydub>=0.24.1,<0.26.0"
18 | ]
19 | requires-python = ">=3.9,<4.0"
20 | readme = "README.md"
21 | license = { text = "MIT" }
22 |
23 | keywords = ["nonebot2", "plugin", "maimaiDX", "舞萌DX", "guess", "song"]
24 | packages = [
25 | { include = "nonebot_plugin_guess_song" }
26 | ]
27 |
28 | [project.urls]
29 | homepage = "https://github.com/apshuang/nonebot-plugin-guess-song"
30 | repository = "https://github.com/apshuang/nonebot-plugin-guess-song"
31 | documentation = "https://github.com/apshuang/nonebot-plugin-guess-song#readme"
32 |
33 | [tool.nonebot]
34 | adapters = [
35 | { name = "OneBot V11", module_name = "nonebot.adapters.onebot.v11" }
36 | ]
37 | plugins = ["nonebot_plugin_guess_song"]
38 |
39 | [tool.pdm]
40 | [tool.pdm.build]
41 | includes = ["nonebot_plugin_guess_song"]
42 |
43 | [build-system]
44 | requires = ["pdm-backend"]
45 | build-backend = "pdm.backend"
--------------------------------------------------------------------------------
/so_hard.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apshuang/nonebot-plugin-guess-song/7bfc3ed440f5e982360d663872f4a3d509184392/so_hard.jpg
--------------------------------------------------------------------------------