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