443 lines
18 KiB
Python
Raw Normal View History

import asyncio
import contextlib
2021-01-13 22:33:50 -05:00
import enum
2020-12-18 15:23:49 -05:00
import logging
import wave
2021-01-13 22:33:50 -05:00
from functools import lru_cache
2021-01-16 17:23:08 -05:00
from inspect import cleandoc
2021-01-11 13:41:37 -05:00
from typing import Tuple
2020-12-18 15:23:49 -05:00
import discord
from d4dj_utils.master.chart_master import ChartDifficulty, ChartMaster
from d4dj_utils.master.common_enums import ChartSectionType
from d4dj_utils.master.music_master import MusicMaster
from discord.ext import commands
2021-01-11 13:41:37 -05:00
from main import asset_manager, masters
2021-01-15 03:43:15 -05:00
from miyu_bot.commands.common.argument_parsing import parse_arguments, ArgumentError, list_operator_for
from miyu_bot.commands.common.emoji import difficulty_emoji_ids
2020-12-25 14:37:31 -05:00
from miyu_bot.commands.common.formatting import format_info
2021-01-11 13:41:37 -05:00
from miyu_bot.commands.common.fuzzy_matching import romanize
from miyu_bot.commands.common.master_asset_manager import hash_master
2020-12-27 00:59:11 -05:00
from miyu_bot.commands.common.reaction_message import run_tabbed_message, run_paged_message
2020-12-18 15:23:49 -05:00
2020-12-19 20:02:52 -05:00
class Music(commands.Cog):
def __init__(self, bot: commands.Bot):
2020-12-18 15:23:49 -05:00
self.bot = bot
self.logger = logging.getLogger(__name__)
@property
def reaction_emojis(self):
return [self.bot.get_emoji(eid) for eid in difficulty_emoji_ids.values()]
2020-12-18 15:23:49 -05:00
difficulty_names = {
'expert': ChartDifficulty.Expert,
'hard': ChartDifficulty.Hard,
'normal': ChartDifficulty.Normal,
'easy': ChartDifficulty.Easy,
'exp': ChartDifficulty.Expert,
'hrd': ChartDifficulty.Hard,
'nrm': ChartDifficulty.Normal,
'esy': ChartDifficulty.Easy,
'ex': ChartDifficulty.Expert,
'hd': ChartDifficulty.Hard,
'nm': ChartDifficulty.Normal,
'es': ChartDifficulty.Easy,
}
2020-12-19 20:02:52 -05:00
@commands.command(name='song',
aliases=['music'],
description='Finds the song with the given name.',
help='!song grgr')
2021-01-14 08:41:49 -05:00
async def song(self, ctx: commands.Context, *, arg: commands.clean_content):
2020-12-19 20:02:52 -05:00
self.logger.info(f'Searching for song "{arg}".')
2020-12-18 15:23:49 -05:00
2021-01-11 13:41:37 -05:00
song = masters.music.get(arg, ctx)
2020-12-25 14:37:31 -05:00
2020-12-19 20:02:52 -05:00
if not song:
msg = f'No results for song "{arg}".'
2020-12-19 20:02:52 -05:00
await ctx.send(msg)
self.logger.info(msg)
2020-12-18 15:23:49 -05:00
return
2020-12-25 14:37:31 -05:00
self.logger.info(f'Found song "{song}" ({romanize(song.name)}).')
2020-12-19 20:02:52 -05:00
2021-01-11 12:18:28 -05:00
try:
thumb = discord.File(song.jacket_path, filename='jacket.png')
except FileNotFoundError:
2021-01-11 13:48:47 -05:00
# Just a fallback
thumb = discord.File(asset_manager.path / 'ondemand/stamp/stamp_10006.png', filename='jacket.png')
2020-12-19 20:02:52 -05:00
embed = discord.Embed(title=song.name)
embed.set_thumbnail(url=f'attachment://jacket.png')
artist_info = {
'Lyricist': song.lyricist,
'Composer': song.composer,
'Arranger': song.arranger,
'Unit': song.unit.name,
'Special Unit Name': song.special_unit_name,
}
music_info = {
'Category': song.category.name,
2020-12-26 19:30:49 -05:00
'Duration': self.format_duration(self.get_music_duration(song)),
2020-12-19 20:02:52 -05:00
'BPM': song.bpm,
'Section Trend': song.section_trend.name,
'Sort Order': song.default_order,
'Levels': ', '.join(c.display_level for c in song.charts.values()),
'Release Date': song.start_datetime,
}
embed.add_field(name='Artist',
2020-12-25 14:37:31 -05:00
value=format_info(artist_info),
2020-12-19 20:02:52 -05:00
inline=False)
embed.add_field(name='Info',
2020-12-25 14:37:31 -05:00
value=format_info(music_info),
2020-12-19 20:02:52 -05:00
inline=False)
await ctx.send(files=[thumb], embed=embed)
@commands.command(name='chart',
aliases=[],
description='Finds the chart with the given name.',
help='!chart grgr\n!chart grgr normal')
2021-01-14 08:41:49 -05:00
async def chart(self, ctx: commands.Context, *, arg: commands.clean_content):
2020-12-19 20:02:52 -05:00
self.logger.info(f'Searching for chart "{arg}".')
2020-12-18 15:23:49 -05:00
2020-12-25 14:37:31 -05:00
name, difficulty = self.parse_chart_args(arg)
2021-01-11 13:41:37 -05:00
song = masters.music.get(name, ctx)
2020-12-18 15:23:49 -05:00
if not song:
2020-12-25 14:37:31 -05:00
msg = f'Failed to find chart "{name}".'
2020-12-18 15:23:49 -05:00
await ctx.send(msg)
self.logger.info(msg)
return
2020-12-25 14:37:31 -05:00
self.logger.info(f'Found song "{song}" ({romanize(song.name)}).')
2020-12-18 15:23:49 -05:00
embeds, files = self.get_chart_embed_info(song)
2021-01-15 13:11:57 -05:00
# Difficulty enum easy-expert are 1-4, one more than the embed index
asyncio.ensure_future(run_tabbed_message(ctx, self.reaction_emojis, embeds, files, difficulty - 1))
2020-12-22 04:07:00 -05:00
@commands.command(name='sections',
aliases=['mixes'],
description='Finds the sections of the chart with the given name.',
help='!sections grgr')
2021-01-14 08:41:49 -05:00
async def sections(self, ctx: commands.Context, *, arg: commands.clean_content):
2020-12-22 04:07:00 -05:00
self.logger.info(f'Searching for chart sections "{arg}".')
2020-12-25 14:37:31 -05:00
name, difficulty = self.parse_chart_args(arg)
2021-01-11 13:41:37 -05:00
song = masters.music.get(name, ctx)
2020-12-22 04:07:00 -05:00
if not song:
2020-12-25 14:37:31 -05:00
msg = f'Failed to find chart "{name}".'
2020-12-22 04:07:00 -05:00
await ctx.send(msg)
self.logger.info(msg)
return
2021-01-13 22:33:50 -05:00
if not song.mix_info:
2020-12-22 04:07:00 -05:00
msg = f'Song "{song.name}" does not have mix enabled.'
await ctx.send(msg)
self.logger.info(msg)
return
2020-12-25 14:37:31 -05:00
self.logger.info(f'Found song "{song}" ({romanize(song.name)}).')
2020-12-22 04:07:00 -05:00
embeds, files = self.get_mix_embed_info(song)
2021-01-15 13:11:57 -05:00
asyncio.ensure_future(run_tabbed_message(ctx, self.reaction_emojis, embeds, files, difficulty - 1))
2020-12-22 04:07:00 -05:00
@commands.command(name='songs',
2020-12-27 00:59:11 -05:00
aliases=['songsearch', 'song_search'],
description='Finds songs matching the given name.',
2021-01-16 17:23:08 -05:00
brief='!songs lhg',
help=cleandoc('''
Named arguments:
2021-01-17 00:19:23 -05:00
sort (<, =) [default|name|id|unit|level|difficulty|duration|date]
[display|disp] = [default|name|id|unit|level|difficulty|duration|date]
[difficulty|diff|level] ? <difficulty (11, 11.5, 11+, ...)>...
Tags:
unit: [happy_around|peaky_p-key|photon_maiden|merm4id|rondo|lyrical_lily|other]
2021-01-16 17:23:08 -05:00
Extended examples:
2021-01-17 00:19:23 -05:00
Songs in descending difficulty order
!songs sort<difficulty
Songs with difficulty from 11+ to 13+
!songs diff>=11+ diff<=13+
Songs with difficulty exactly 10 or 14, sorted alphabetically, displaying duration
!songs diff=10,14 sort=name disp=duration
Songs by happy around
!songs $happy_around'''))
2021-01-14 08:41:49 -05:00
async def songs(self, ctx: commands.Context, *, arg: commands.clean_content = ''):
2021-01-11 12:18:28 -05:00
self.logger.info(f'Searching for songs "{arg}".' if arg else 'Listing songs.')
2021-01-13 22:33:50 -05:00
arguments = parse_arguments(arg)
try:
sort, sort_op = arguments.single('sort', MusicAttribute.DefaultOrder,
2021-01-15 03:43:15 -05:00
allowed_operators=['<', '>', '='], converter=music_attribute_aliases)
2021-01-17 00:19:23 -05:00
reverse_sort = sort_op == '<' or arguments.tag('reverse')
2021-01-15 03:43:15 -05:00
display, _ = arguments.single(['display', 'disp'], sort, allowed_operators=['='],
converter=music_attribute_aliases)
2021-01-17 00:19:23 -05:00
units = arguments.tags(
names=['happy_around', 'peaky_p-key', 'photon_maiden', 'merm4id', 'rondo', 'lyrical_lily', 'other'],
aliases={
'hapiara': 'happy_around',
'ha': 'happy_around',
'peaky': 'peaky_p-key',
'p-key': 'peaky_p-key',
'pkey': 'peaky_p-key',
'pkpk': 'peaky_p-key',
'photome': 'photon_maiden',
'photon': 'photon_maiden',
'pm': 'photon_maiden',
'mermaid': 'merm4id',
'riririri': 'lyrical_lily',
'lililili': 'lyrical_lily',
'lily': 'lyrical_lily',
'lili': 'lyrical_lily',
})
2021-01-15 03:43:15 -05:00
def difficulty_converter(d):
return int(d[:-1]) + 0.5 if d[-1] == '+' else int(d)
difficulty = arguments.repeatable(['difficulty', 'diff', 'level'], is_list=True,
converter=difficulty_converter)
songs = masters.music.get_sorted(arguments.text(), ctx)
2021-01-13 22:33:50 -05:00
arguments.require_all_arguments_used()
except ArgumentError as e:
await ctx.send(str(e))
return
2021-01-15 03:43:15 -05:00
for value, op in difficulty:
operator = list_operator_for(op)
songs = [song for song in songs if operator(song.charts[4].level, value)]
2021-01-17 00:19:23 -05:00
if units:
unit_ids = {
'happy_around': 1,
'peaky_p-key': 2,
'photon_maiden': 3,
'merm4id': 4,
'rondo': 5,
'lyrical_lily': 6,
}
allowed_unit_ids = set()
for unit in units:
if unit == 'other':
allowed_unit_ids.add(30)
allowed_unit_ids.add(50)
else:
allowed_unit_ids.add(unit_ids[unit])
songs = [song for song in songs if song.unit.id in allowed_unit_ids]
2021-01-13 22:33:50 -05:00
if not (arguments.text_argument and sort == MusicAttribute.DefaultOrder):
songs = sorted(songs, key=lambda s: sort.get_sort_key_from_music(s))
2021-01-15 03:43:15 -05:00
if sort == MusicAttribute.DefaultOrder and songs and songs[0].id == 1:
2021-01-13 22:33:50 -05:00
songs = [*songs[1:], songs[0]]
if sort in [MusicAttribute.Level, MusicAttribute.Date]:
songs = songs[::-1]
2021-01-13 22:33:50 -05:00
if reverse_sort:
songs = songs[::-1]
2021-01-13 22:33:50 -05:00
listing = []
for song in songs:
display_prefix = display.get_formatted_from_music(song)
if display_prefix:
2021-01-15 03:43:15 -05:00
listing.append(
f'{display_prefix} : {song.name}{" (" + song.special_unit_name + ")" if song.special_unit_name else ""}')
2021-01-13 22:33:50 -05:00
else:
listing.append(f'{song.name}{" (" + song.special_unit_name + ")" if song.special_unit_name else ""}')
embed = discord.Embed(title=f'Song Search "{arg}"' if arg else 'Songs')
2021-01-13 09:33:17 -05:00
asyncio.ensure_future(run_paged_message(ctx, embed, listing))
2020-12-22 04:07:00 -05:00
def get_chart_embed_info(self, song):
embeds = []
2021-01-11 12:18:28 -05:00
try:
thumb = discord.File(song.jacket_path, filename='jacket.png')
except FileNotFoundError:
# fallback
thumb = discord.File(asset_manager.path / 'ondemand/stamp/stamp_10006.png', filename='jacket.png')
2021-01-11 12:18:28 -05:00
files = [thumb]
for difficulty in [ChartDifficulty.Easy, ChartDifficulty.Normal, ChartDifficulty.Hard, ChartDifficulty.Expert]:
chart = song.charts[difficulty]
chart_hash = hash_master(chart)
chart_path = chart.image_path
embed = discord.Embed(title=f'{song.name} [{chart.difficulty.name}]')
embed.set_thumbnail(url=f'attachment://jacket.png')
embed.set_image(
url=f'https://qwewqa.github.io/d4dj-dumps/music/charts/{chart_path.stem}_{chart_hash}{chart_path.suffix}'
)
chart_data = chart.load_chart_data()
note_counts = chart_data.get_note_counts()
embed.add_field(name='Info',
value=f'Level: {chart.display_level}\n'
2020-12-26 19:30:49 -05:00
f'Duration: {self.format_duration(self.get_music_duration(song))}\n'
f'Unit: {song.special_unit_name or song.unit.name}\n'
f'Category: {song.category.name}\n'
f'BPM: {song.bpm}',
inline=False)
embed.add_field(name='Combo',
value=f'Max Combo: {chart.note_counts[ChartSectionType.Full].count}\n'
f'Taps: {note_counts["tap"]} (dark: {note_counts["tap1"]}, light: {note_counts["tap2"]})\n'
f'Scratches: {note_counts["scratch"]} (left: {note_counts["scratch_left"]}, right: {note_counts["scratch_right"]})\n'
f'Stops: {note_counts["stop"]} (head: {note_counts["stop_start"]}, tail: {note_counts["stop_end"]})\n'
f'Long: {note_counts["long"]} (head: {note_counts["long_start"]}, tail: {note_counts["long_end"]})\n'
f'Slide: {note_counts["slide"]} (tick: {note_counts["slide_tick"]}, flick {note_counts["slide_flick"]})',
inline=True)
embed.add_field(name='Ratings',
value=f'NTS: {round(chart.trends[0] * 100, 2)}%\n'
f'DNG: {round(chart.trends[1] * 100, 2)}%\n'
f'SCR: {round(chart.trends[2] * 100, 2)}%\n'
f'EFT: {round(chart.trends[3] * 100, 2)}%\n'
f'TEC: {round(chart.trends[4] * 100, 2)}%\n',
inline=True)
embed.set_footer(text='1 column = 10 seconds')
embeds.append(embed)
return embeds, files
2020-12-18 15:23:49 -05:00
2020-12-22 04:07:00 -05:00
def get_mix_embed_info(self, song):
embeds = []
files = [discord.File(song.jacket_path, filename=f'jacket.png')]
for difficulty in [ChartDifficulty.Easy, ChartDifficulty.Normal, ChartDifficulty.Hard, ChartDifficulty.Expert]:
chart: ChartMaster = song.charts[difficulty]
chart_hash = hash_master(chart)
mix_path = chart.mix_path
2020-12-22 04:07:00 -05:00
embed = discord.Embed(title=f'Mix: {song.name} [{chart.difficulty.name}]')
embed.set_thumbnail(url=f'attachment://jacket.png')
embed.set_image(
url=f'https://qwewqa.github.io/d4dj-dumps/music/charts/{mix_path.stem}_{chart_hash}{mix_path.suffix}'
2020-12-22 04:07:00 -05:00
)
note_counts = chart.note_counts
mix_info = chart.mix_info
info = {
'Level': chart.display_level,
'Unit': song.unit.name,
'BPM': song.bpm,
'Section Trend': song.section_trend.name,
}
begin = {
'Time': f'{round(mix_info[ChartSectionType.Begin].duration, 2)}s',
'Combo': note_counts[ChartSectionType.Begin].count,
}
middle = {
'Time': f'{round(mix_info[ChartSectionType.Middle].duration, 2)}s',
'Combo': note_counts[ChartSectionType.Middle].count,
}
end = {
'Time': f'{round(mix_info[ChartSectionType.End].duration, 2)}s',
'Combo': note_counts[ChartSectionType.End].count,
}
embed.add_field(name='Info',
2020-12-25 14:37:31 -05:00
value=format_info(info),
2020-12-22 04:07:00 -05:00
inline=False)
embed.add_field(name='Begin',
2020-12-25 14:37:31 -05:00
value=format_info(begin),
2020-12-22 04:07:00 -05:00
inline=True)
embed.add_field(name='Middle',
2020-12-25 14:37:31 -05:00
value=format_info(middle),
2020-12-22 04:07:00 -05:00
inline=True)
embed.add_field(name='End',
2020-12-25 14:37:31 -05:00
value=format_info(end),
2020-12-22 04:07:00 -05:00
inline=True)
embed.set_footer(text='1 column = 10 seconds')
embeds.append(embed)
return embeds, files
2021-01-14 09:03:33 -05:00
def parse_chart_args(self, arg: str) -> Tuple[str, ChartDifficulty]:
2020-12-25 14:37:31 -05:00
split_args = arg.split()
difficulty = ChartDifficulty.Expert
if len(split_args) >= 2:
final_word = split_args[-1]
if final_word in self.difficulty_names:
difficulty = self.difficulty_names[final_word]
arg = ''.join(split_args[:-1])
return arg, difficulty
2021-01-13 22:33:50 -05:00
_music_durations = {}
@staticmethod
def get_music_duration(music: MusicMaster):
if music.id in Music._music_durations:
return Music._music_durations[music.id]
2020-12-26 19:30:49 -05:00
with contextlib.closing(wave.open(str(music.audio_path.with_name(music.audio_path.name + '.wav')), 'r')) as f:
frames = f.getnframes()
rate = f.getframerate()
duration = frames / float(rate)
2021-01-13 22:33:50 -05:00
Music._music_durations[music.id] = duration
2020-12-26 19:30:49 -05:00
return duration
2021-01-13 22:33:50 -05:00
@staticmethod
def format_duration(seconds):
minutes = int(seconds // 60)
seconds = round(seconds % 60, 2)
return f'{minutes}:{str(int(seconds)).zfill(2)}.{str(int(seconds % 1 * 100)).zfill(2)}'
2020-12-26 19:30:49 -05:00
2020-12-18 15:23:49 -05:00
2021-01-13 22:33:50 -05:00
class MusicAttribute(enum.Enum):
DefaultOrder = enum.auto()
Name = enum.auto()
Id = enum.auto()
2021-01-13 22:33:50 -05:00
Unit = enum.auto()
Level = enum.auto()
Duration = enum.auto()
Date = enum.auto()
def get_sort_key_from_music(self, music: MusicMaster):
2021-01-13 22:33:50 -05:00
return {
self.DefaultOrder: -music.default_order,
self.Name: music.name,
self.Id: music.id,
2021-01-13 22:33:50 -05:00
self.Unit: music.unit.name if not music.special_unit_name else f'{music.unit.name} ({music.special_unit_name})',
self.Level: music.charts[4].display_level,
self.Duration: Music.get_music_duration(music),
self.Date: music.start_datetime
}[self]
def get_formatted_from_music(self, music: MusicMaster):
return {
self.DefaultOrder: None,
self.Name: None,
self.Id: str(music.id).zfill(7),
2021-01-13 22:33:50 -05:00
self.Unit: music.unit.name if not music.special_unit_name else f'{music.unit.name} ({music.special_unit_name})',
2021-01-17 17:54:48 -05:00
self.Level: music.charts[4].display_level.ljust(3),
2021-01-13 22:33:50 -05:00
self.Duration: Music.format_duration(Music.get_music_duration(music)),
self.Date: str(music.start_datetime.date()),
}[self]
2021-01-15 03:43:15 -05:00
music_attribute_aliases = {
2021-01-13 22:33:50 -05:00
'default': MusicAttribute.DefaultOrder,
'name': MusicAttribute.Name,
'id': MusicAttribute.Id,
2021-01-13 22:33:50 -05:00
'relevance': MusicAttribute.Name,
'unit': MusicAttribute.Unit,
'level': MusicAttribute.Level,
'difficulty': MusicAttribute.Level,
2021-01-15 03:43:15 -05:00
'diff': MusicAttribute.Level,
2021-01-13 22:33:50 -05:00
'duration': MusicAttribute.Duration,
'length': MusicAttribute.Duration,
'date': MusicAttribute.Date,
}
2020-12-18 15:23:49 -05:00
def setup(bot):
2020-12-19 20:02:52 -05:00
bot.add_cog(Music(bot))