420 lines
10 KiB
Rust
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(())
|
|
}
|
|
// }}}
|