1
Fork 0
shimmeringmoon/src/commands/score.rs
prescientmoon 0c90628c9d
Got score select recognition to work
Signed-off-by: prescientmoon <git@moonythm.dev>
2024-08-01 15:41:20 +02:00

420 lines
10 KiB
Rust

use std::fmt::Display;
use crate::context::{Context, Error};
use crate::score::{CreatePlay, ImageCropper, Play, Score, ScoreKind};
use crate::user::{discord_it_to_discord_user, User};
use image::imageops::FilterType;
use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage};
use poise::{serenity_prelude as serenity, CreateReply};
use sqlx::query;
// {{{ Score
/// Score management
#[poise::command(
prefix_command,
slash_command,
subcommands("magic", "delete", "show"),
subcommand_required
)]
pub async fn score(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
// }}}
// {{{ Score magic
// {{{ Send error embed with image
async fn error_with_image(
ctx: Context<'_>,
bytes: &[u8],
filename: &str,
message: &str,
err: impl Display,
) -> Result<(), Error> {
let error_attachement = CreateAttachment::bytes(bytes, filename);
let msg = CreateMessage::default().embed(
CreateEmbed::default()
.title(message)
.attachment(filename)
.description(format!("{}", err)),
);
ctx.channel_id()
.send_files(ctx.http(), [error_attachement], msg)
.await?;
Ok(())
}
// }}}
/// Identify scores from attached images.
#[poise::command(prefix_command, slash_command)]
pub async fn magic(
ctx: Context<'_>,
#[description = "Images containing scores"] files: Vec<serenity::Attachment>,
) -> Result<(), Error> {
let user = match User::from_context(&ctx).await {
Ok(user) => user,
Err(_) => {
ctx.say("You are not an user in my database, sorry!")
.await?;
return Ok(());
}
};
println!("Handling command from user {:?}", user.discord_id);
if files.len() == 0 {
ctx.reply("No images found attached to message").await?;
} else {
let mut embeds = Vec::with_capacity(files.len());
let mut attachments = Vec::with_capacity(files.len());
let handle = ctx
.reply(format!("Processed 0/{} scores", files.len()))
.await?;
for (i, file) in files.iter().enumerate() {
if let Some(_) = file.dimensions() {
// {{{ Image pre-processing
let bytes = file.download().await?;
let image = image::load_from_memory(&bytes)?;
let mut image = image.resize(1024, 1024, FilterType::Nearest);
// }}}
// {{{ Detection
// Create cropper and run OCR
let mut cropper = ImageCropper::default();
let edited = CreateReply::default()
.reply(true)
.content(format!("Image {}: reading jacket", i + 1));
handle.edit(ctx, edited).await?;
// This makes OCR more likely to work
let mut ocr_image = image.grayscale().blur(1.);
// {{{ Kind
let edited = CreateReply::default()
.reply(true)
.content(format!("Image {}: reading kind", i + 1));
handle.edit(ctx, edited).await?;
let kind = match cropper.read_score_kind(&ocr_image) {
// {{{ OCR error handling
Err(err) => {
error_with_image(
ctx,
&cropper.bytes,
&file.filename,
"Could not read kind from picture",
&err,
)
.await?;
continue;
}
// }}}
Ok(k) => k,
};
// }}}
// {{{ Difficulty
let edited = CreateReply::default()
.reply(true)
.content(format!("Image {}: reading difficulty", i + 1));
handle.edit(ctx, edited).await?;
// Do not use `ocr_image` because this reads the colors
let difficulty = match cropper.read_difficulty(&image, kind) {
// {{{ OCR error handling
Err(err) => {
error_with_image(
ctx,
&cropper.bytes,
&file.filename,
"Could not read difficulty from picture",
&err,
)
.await?;
continue;
}
// }}}
Ok(d) => d,
};
println!("{difficulty:?}");
// }}}
// {{{ Jacket & distribution
let mut jacket_rect = None;
let song_by_jacket = cropper
.read_jacket(ctx.data(), &mut image, kind, difficulty, &mut jacket_rect)
.await;
let note_distribution = cropper.read_distribution(&image)?;
// }}}
ocr_image.invert();
// {{{ Title
let edited = CreateReply::default()
.reply(true)
.content(format!("Image {}: reading title", i + 1));
handle.edit(ctx, edited).await?;
let song_by_name = match kind {
ScoreKind::SongSelect => None,
ScoreKind::ScoreScreen => {
Some(cropper.read_song(&ocr_image, &ctx.data().song_cache, difficulty))
}
};
let (song, chart) = match (song_by_jacket, song_by_name) {
// {{{ Only name succeeded
(Err(err_jacket), Some(Ok(by_name))) => {
println!("Could not recognise jacket with error: {}", err_jacket);
by_name
}
// }}}
// {{{ Both succeeded
(Ok(by_jacket), Some(Ok(by_name))) => {
if by_name.0.id != by_jacket.0.id {
println!(
"Got diverging choices between '{}' and '{}'",
by_jacket.0.title, by_name.0.title
);
};
by_jacket
} // }}}
// {{{ Only jacket succeeded
(Ok(by_jacket), err_name) => {
if let Some(err) = err_name {
println!("Could not read name with error: {:?}", err.unwrap_err());
}
by_jacket
}
// }}}
// {{{ Both errors
(Err(err_jacket), err_name) => {
if let Some(rect) = jacket_rect {
cropper.crop_image_to_bytes(&image, rect)?;
error_with_image(
ctx,
&cropper.bytes,
&file.filename,
"Hey! I could not read the score in the provided picture.",
&format!(
"This can mean one of three things:
1. The image you provided is *not that of an Arcaea score
2. The image you provided contains a newly added chart that is not in my database yet
3. The image you provided contains character art that covers the chart name. When this happens, I try to make use of the jacket art in order to determine the chart. Contact `@prescientmoon` on discord to try and resolve the issue!
Nerdy info:
```
Jacket error: {}
Title error: {:?}
```" ,
err_jacket, err_name
),
)
.await?;
} else {
ctx.reply(format!(
"This is a weird error that should never happen...
Nerdy info:
```
Jacket error: {}
Title error: {:?}
```",
err_jacket, err_name
))
.await?;
}
continue;
} // }}}
};
println!("{}", song.title);
// }}}
// {{{ Score
let edited = CreateReply::default()
.reply(true)
.content(format!("Image {}: reading score", i + 1));
handle.edit(ctx, edited).await?;
let score_possibilities =
match cropper.read_score(Some(chart.note_count), &ocr_image, kind) {
// {{{ OCR error handling
Err(err) => {
error_with_image(
ctx,
&cropper.bytes,
&file.filename,
"Could not read score from picture",
&err,
)
.await?;
continue;
}
// }}}
Ok(scores) => scores,
};
// }}}
// {{{ Build play
let (score, maybe_fars, score_warning) = Score::resolve_ambiguities(
score_possibilities,
Some(note_distribution),
chart.note_count,
)
.map_err(|err| {
format!(
"Error occurred when disambiguating scores for '{}' [{:?}] by {}: {}",
song.title, difficulty, song.artist, err
)
})?;
println!(
"Maybe fars {:?}, distribution {:?}",
maybe_fars, note_distribution
);
let play = CreatePlay::new(score, &chart, &user)
.with_attachment(file)
.with_fars(maybe_fars)
.save(&ctx.data())
.await?;
// }}}
// }}}
// {{{ Deliver embed
let (mut embed, attachment) = play.to_embed(&song, &chart, i, None).await?;
if let Some(warning) = score_warning {
embed = embed.description(warning);
}
embeds.push(embed);
attachments.extend(attachment);
// }}}
} else {
ctx.reply("One of the attached files is not an image!")
.await?;
continue;
}
let edited = CreateReply::default().reply(true).content(format!(
"Processed {}/{} scores",
i + 1,
files.len()
));
handle.edit(ctx, edited).await?;
}
handle.delete(ctx).await?;
ctx.channel_id()
.send_files(ctx.http(), attachments, CreateMessage::new().embeds(embeds))
.await?;
}
Ok(())
}
// }}}
// {{{ Score delete
/// Delete scores, given their IDs.
#[poise::command(prefix_command, slash_command)]
pub async fn delete(
ctx: Context<'_>,
#[description = "Id of score to delete"] ids: Vec<u32>,
) -> Result<(), Error> {
let user = match User::from_context(&ctx).await {
Ok(user) => user,
Err(_) => {
ctx.say("You are not an user in my database, sorry!")
.await?;
return Ok(());
}
};
if ids.len() == 0 {
ctx.reply("Empty ID list provided").await?;
return Ok(());
}
let mut count = 0;
for id in ids {
let res = query!("DELETE FROM plays WHERE id=? AND user_id=?", id, user.id)
.execute(&ctx.data().db)
.await?;
if res.rows_affected() == 0 {
ctx.reply(format!("No play with id {} found", id)).await?;
} else {
count += 1;
}
}
if count > 0 {
ctx.reply(format!("Deleted {} play(s) successfully!", count))
.await?;
}
Ok(())
}
// }}}
// {{{ Score show
/// Show scores given their ides
#[poise::command(prefix_command, slash_command)]
pub async fn show(
ctx: Context<'_>,
#[description = "Ids of score to show"] ids: Vec<u32>,
) -> Result<(), Error> {
if ids.len() == 0 {
ctx.reply("Empty ID list provided").await?;
return Ok(());
}
let mut embeds = Vec::with_capacity(ids.len());
let mut attachments = Vec::with_capacity(ids.len());
for (i, id) in ids.iter().enumerate() {
let res = query!(
"
SELECT
p.id,p.chart_id,p.user_id,p.score,p.zeta_score,
p.max_recall,p.created_at,p.far_notes,
u.discord_id
FROM plays p
JOIN users u ON p.user_id = u.id
WHERE p.id=?
",
id
)
.fetch_one(&ctx.data().db)
.await
.map_err(|_| format!("Could not find play with id {}", id))?;
let play = Play {
id: res.id as u32,
chart_id: res.chart_id as u32,
user_id: res.user_id as u32,
score: Score(res.score as u32),
zeta_score: Score(res.zeta_score as u32),
max_recall: res.max_recall.map(|r| r as u32),
far_notes: res.far_notes.map(|r| r as u32),
created_at: res.created_at,
discord_attachment_id: None,
creation_ptt: None,
creation_zeta_ptt: None,
};
let user = discord_it_to_discord_user(&ctx, &res.discord_id).await?;
let (song, chart) = ctx.data().song_cache.lookup_chart(play.chart_id)?;
let (embed, attachment) = play.to_embed(song, chart, i, Some(&user)).await?;
embeds.push(embed);
attachments.extend(attachment);
}
ctx.channel_id()
.send_files(ctx.http(), attachments, CreateMessage::new().embeds(embeds))
.await?;
Ok(())
}
// }}}