1
Fork 0

Revampt jacket loading system

This commit is contained in:
prescientmoon 2024-09-09 18:06:07 +02:00
parent cba88c5def
commit ac4145ee40
Signed by: prescientmoon
SSH key fingerprint: SHA256:WFp/cO76nbarETAoQcQXuV+0h7XJsEsOCI0UsyPIy6U
24 changed files with 750 additions and 261 deletions

2
.gitignore vendored
View file

@ -4,7 +4,7 @@
shimmering/data shimmering/data
shimmering/logs shimmering/logs
shimmering/assets/fonts shimmering/assets/fonts
shimmering/assets/songs shimmering/assets/songs*
shimmering/assets/b30_background.* shimmering/assets/b30_background.*
target target

215
Cargo.lock generated
View file

@ -76,10 +76,59 @@ dependencies = [
] ]
[[package]] [[package]]
name = "anyhow" name = "anstream"
version = "1.0.86" version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1"
[[package]]
name = "anstyle-parse"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8"
dependencies = [
"anstyle",
"windows-sys 0.52.0",
]
[[package]]
name = "anyhow"
version = "1.0.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10f00e1f6e58a40e807377c75c6a7f97bf9044fab57816f2414e6f5f4499d7b8"
[[package]] [[package]]
name = "approx" name = "approx"
@ -331,12 +380,64 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "clap"
version = "4.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]]
name = "clap_lex"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97"
[[package]]
name = "cobs"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15"
[[package]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -509,7 +610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"hashbrown", "hashbrown 0.14.5",
"lock_api", "lock_api",
"once_cell", "once_cell",
"parking_lot_core", "parking_lot_core",
@ -601,6 +702,18 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b"
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.34" version = "0.8.34"
@ -945,7 +1058,7 @@ dependencies = [
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"http 0.2.12", "http 0.2.12",
"indexmap", "indexmap 2.2.6",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@ -962,6 +1075,12 @@ dependencies = [
"crunchy", "crunchy",
] ]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@ -977,7 +1096,7 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [ dependencies = [
"hashbrown", "hashbrown 0.14.5",
] ]
[[package]] [[package]]
@ -992,6 +1111,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "http" name = "http"
version = "0.2.12" version = "0.2.12"
@ -1214,6 +1339,17 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.2.6" version = "2.2.6"
@ -1221,7 +1357,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.14.5",
"serde",
] ]
[[package]] [[package]]
@ -1241,6 +1378,12 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.12.1" version = "0.12.1"
@ -1316,7 +1459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-targets 0.48.5", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@ -1791,6 +1934,18 @@ dependencies = [
"syn 2.0.66", "syn 2.0.66",
] ]
[[package]]
name = "postcard"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f7f0a8d620d71c457dd1d47df76bb18960378da56af4527aaa10f515eee732e"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"serde",
]
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@ -2366,6 +2521,36 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_with"
version = "3.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857"
dependencies = [
"base64 0.22.1",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.2.6",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.66",
]
[[package]] [[package]]
name = "serenity" name = "serenity"
version = "0.12.2" version = "0.12.2"
@ -2414,7 +2599,9 @@ dependencies = [
name = "shimmeringmoon" name = "shimmeringmoon"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"chrono", "chrono",
"clap",
"freetype-rs", "freetype-rs",
"hypertesseract", "hypertesseract",
"image 0.25.2", "image 0.25.2",
@ -2423,11 +2610,13 @@ dependencies = [
"num", "num",
"plotters", "plotters",
"poise", "poise",
"postcard",
"r2d2", "r2d2",
"r2d2_sqlite", "r2d2_sqlite",
"rusqlite", "rusqlite",
"rusqlite_migration", "rusqlite_migration",
"serde", "serde",
"serde_with",
"tempfile", "tempfile",
"tokio", "tokio",
"toml", "toml",
@ -2815,7 +3004,7 @@ version = "0.22.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d"
dependencies = [ dependencies = [
"indexmap", "indexmap 2.2.6",
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
@ -2925,7 +3114,7 @@ checksum = "eb704842c709bc76f63e99e704cb208beeccca2abbabd0d9aec02e48ca1cee0f"
dependencies = [ dependencies = [
"chrono", "chrono",
"dashmap", "dashmap",
"hashbrown", "hashbrown 0.14.5",
"mini-moka", "mini-moka",
"parking_lot", "parking_lot",
"secrecy", "secrecy",
@ -3000,6 +3189,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.10.0" version = "1.10.0"

View file

@ -21,6 +21,10 @@ include_dir = "0.7.4"
serde = "1.0.209" serde = "1.0.209"
toml = "0.8.19" toml = "0.8.19"
tempfile = "3.12.0" tempfile = "3.12.0"
clap = { version = "4.5.17", features = ["derive"] }
postcard = { version = "1.0.10", features = ["use-std"], default-features = false }
serde_with = "3.9.0"
anyhow = "1.0.87"
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 3 opt-level = 3

View file

@ -15,6 +15,20 @@
inherit (pkgs) lib; inherit (pkgs) lib;
in in
{ {
packages.shimmeringmoon = pkgs.rustPlatform.buildRustPackage {
pname = "shimmeringmoon";
version = "unstable-2024-09-06";
src = lib.cleanSource ./.;
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"hypertesseract-0.1.0" = "sha256-G0dos5yvvcfBKznAo1IIzLgXqRDxmyZwB93QQ6hVZSo=";
"plotters-0.4.0" = "sha256-9wtd7lig1vQ2RJVaEHdicfPZy2AyuoNav8shPMZ1EuE=";
};
};
};
devShell = pkgs.mkShell rec { devShell = pkgs.mkShell rec {
packages = with pkgs; [ packages = with pkgs; [
(fenix.complete.withComponents [ (fenix.complete.withComponents [

13
scripts/copy-chart-info.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
if [ "$#" != 2 ]; then
echo "Usage: $0 <from> <to>"
echo "This script copies the chart/song data from a db to another. Useful for creating new dbs for testing."
exit 1
fi
from="$1/db.sqlite"
to ="$2/db.sqlite"
sqlite3 $from ".dump songs" | sqlite3 $to
sqlite3 $from ".dump charts" | sqlite3 $to

View file

@ -47,3 +47,15 @@ Mistempered Malignance,,,mismal
Twilight Concerto,,,tasogare Twilight Concerto,,,tasogare
Heart,,,kokoro Heart,,,kokoro
Dancin' on a Cat's Paw,,,nekonote Dancin' on a Cat's Paw,,,nekonote
Bookmaker (2D Version),,,bookmaker
Dement ~after legend~,,,dement
Einherjar Joker,,,einherjar
GOODTEK (Arcaea Edit),,,goodtek
Kanagawa Cyber Culvert,,,kanagawa
La'qryma of the Wasteland,,,laqryma
PRAGMATISM -RESURRECTION-,,,pragmatism
qualia -ideaesthesia-,,,qualia
Shades of Light in a Transcendent Realm,,,shadesoflight
trappola bewitching,,,trappola
Vicious [ANTi] Heroism,,,viciousheroism
eden,,,edenwacca

1 Name Difficulty Artist Shorthand
47 Twilight Concerto tasogare
48 Heart kokoro
49 Dancin' on a Cat's Paw nekonote
50 Bookmaker (2D Version) bookmaker
51 Dement ~after legend~ dement
52 Einherjar Joker einherjar
53 GOODTEK (Arcaea Edit) goodtek
54 Kanagawa Cyber Culvert kanagawa
55 La'qryma of the Wasteland laqryma
56 PRAGMATISM -RESURRECTION- pragmatism
57 qualia -ideaesthesia- qualia
58 Shades of Light in a Transcendent Realm shadesoflight
59 trappola bewitching trappola
60 Vicious [ANTi] Heroism viciousheroism
61 eden edenwacca

View file

@ -1,3 +1,4 @@
use anyhow::anyhow;
use image::RgbaImage; use image::RgbaImage;
use crate::{ use crate::{
@ -121,7 +122,8 @@ impl GoalStats {
user: &User, user: &User,
scoring_system: ScoringSystem, scoring_system: ScoringSystem,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let plays = get_best_plays(ctx, user.id, scoring_system, 0, usize::MAX, None)??; let plays = get_best_plays(ctx, user.id, scoring_system, 0, usize::MAX, None)?
.map_err(|s| anyhow!("{s}"))?;
let conn = ctx.db.get()?; let conn = ctx.db.get()?;
// {{{ PM count // {{{ PM count
@ -157,7 +159,7 @@ impl GoalStats {
), ),
|row| row.get(0), |row| row.get(0),
) )
.map_err(|_| "No ptt history data found")?; .map_err(|_| anyhow!("No ptt history data found"))?;
// }}} // }}}
// {{{ Peak PM relay // {{{ Peak PM relay
let peak_pm_relay = { let peak_pm_relay = {

View file

@ -1,5 +1,6 @@
use std::{fmt::Display, num::NonZeroU16, path::PathBuf}; use std::{fmt::Display, num::NonZeroU16, path::PathBuf};
use anyhow::anyhow;
use image::{ImageBuffer, Rgb}; use image::{ImageBuffer, Rgb};
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef}; use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
@ -237,6 +238,14 @@ impl CachedSong {
chart_ids: [None; 5], chart_ids: [None; 5],
} }
} }
#[inline]
pub fn charts(&self) -> impl Iterator<Item = u32> {
self.chart_ids
.into_iter()
.filter_map(|i| i)
.map(|i| i.get() as u32)
}
} }
// }}} // }}}
// {{{ Song cache // {{{ Song cache
@ -252,7 +261,7 @@ impl SongCache {
self.songs self.songs
.get(id as usize) .get(id as usize)
.and_then(|i| i.as_ref()) .and_then(|i| i.as_ref())
.ok_or_else(|| format!("Could not find song with id {}", id).into()) .ok_or_else(|| anyhow!("Could not find song with id {}", id))
} }
#[inline] #[inline]
@ -261,7 +270,7 @@ impl SongCache {
.charts .charts
.get(chart_id as usize) .get(chart_id as usize)
.and_then(|i| i.as_ref()) .and_then(|i| i.as_ref())
.ok_or_else(|| format!("Could not find chart with id {}", chart_id))?; .ok_or_else(|| anyhow!("Could not find chart with id {}", chart_id))?;
let song = &self.lookup_song(chart.song_id)?.song; let song = &self.lookup_song(chart.song_id)?.song;
Ok((song, chart)) Ok((song, chart))
@ -272,7 +281,7 @@ impl SongCache {
self.songs self.songs
.get_mut(id as usize) .get_mut(id as usize)
.and_then(|i| i.as_mut()) .and_then(|i| i.as_mut())
.ok_or_else(|| format!("Could not find song with id {}", id).into()) .ok_or_else(|| anyhow!("Could not find song with id {}", id))
} }
#[inline] #[inline]
@ -280,7 +289,7 @@ impl SongCache {
self.charts self.charts
.get_mut(chart_id as usize) .get_mut(chart_id as usize)
.and_then(|i| i.as_mut()) .and_then(|i| i.as_mut())
.ok_or_else(|| format!("Could not find chart with id {}", chart_id).into()) .ok_or_else(|| anyhow!("Could not find chart with id {}", chart_id))
} }
#[inline] #[inline]
@ -292,7 +301,7 @@ impl SongCache {
let cached_song = self.lookup_song(id)?; let cached_song = self.lookup_song(id)?;
let chart_id = cached_song.chart_ids[difficulty.to_index()] let chart_id = cached_song.chart_ids[difficulty.to_index()]
.ok_or_else(|| { .ok_or_else(|| {
format!( anyhow!(
"Cannot find chart {} [{difficulty:?}]", "Cannot find chart {} [{difficulty:?}]",
cached_song.song.title cached_song.song.title
) )
@ -302,6 +311,25 @@ impl SongCache {
Ok((&cached_song.song, chart)) Ok((&cached_song.song, chart))
} }
#[inline]
pub fn lookup_by_difficulty_mut(
&mut self,
id: u32,
difficulty: Difficulty,
) -> Result<&mut Chart, Error> {
let cached_song = self.lookup_song(id)?;
let chart_id = cached_song.chart_ids[difficulty.to_index()]
.ok_or_else(|| {
anyhow!(
"Cannot find chart {} [{difficulty:?}]",
cached_song.song.title
)
})?
.get() as u32;
let chart = self.lookup_chart_mut(chart_id)?;
Ok(chart)
}
#[inline] #[inline]
pub fn charts(&self) -> impl Iterator<Item = &Chart> { pub fn charts(&self) -> impl Iterator<Item = &Chart> {
self.charts.iter().filter_map(|i| i.as_ref()) self.charts.iter().filter_map(|i| i.as_ref())

View file

@ -1,13 +1,15 @@
use std::{fs, io::Cursor}; use std::fs;
use anyhow::Context;
use image::{imageops::FilterType, GenericImageView, Rgba}; use image::{imageops::FilterType, GenericImageView, Rgba};
use num::Integer; use num::Integer;
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use crate::{ use crate::{
arcaea::chart::{Difficulty, Jacket, SongCache}, arcaea::chart::{Difficulty, Jacket, SongCache},
assets::{get_asset_dir, should_blur_jacket_art, should_skip_jacket_art}, assets::{get_asset_dir, should_skip_jacket_art},
context::Error, context::Error,
recognition::fuzzy_song_name::guess_chart_name,
}; };
/// How many sub-segments to split each side into /// How many sub-segments to split each side into
@ -15,14 +17,16 @@ pub const SPLIT_FACTOR: u32 = 8;
pub const IMAGE_VEC_DIM: usize = (SPLIT_FACTOR * SPLIT_FACTOR * 3) as usize; pub const IMAGE_VEC_DIM: usize = (SPLIT_FACTOR * SPLIT_FACTOR * 3) as usize;
pub const BITMAP_IMAGE_SIZE: u32 = 174; pub const BITMAP_IMAGE_SIZE: u32 = 174;
#[derive(Debug, Clone)] #[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageVec { pub struct ImageVec {
#[serde_as(as = "[_; IMAGE_VEC_DIM]")]
pub colors: [f32; IMAGE_VEC_DIM], pub colors: [f32; IMAGE_VEC_DIM],
} }
impl ImageVec { impl ImageVec {
// {{{ (Image => vector) encoding // {{{ (Image => vector) encoding
fn from_image(image: &impl GenericImageView<Pixel = Rgba<u8>>) -> Self { pub fn from_image(image: &impl GenericImageView<Pixel = Rgba<u8>>) -> Self {
let mut colors = [0.0; IMAGE_VEC_DIM]; let mut colors = [0.0; IMAGE_VEC_DIM];
let chunk_width = image.width() / SPLIT_FACTOR; let chunk_width = image.width() / SPLIT_FACTOR;
let chunk_height = image.height() / SPLIT_FACTOR; let chunk_height = image.height() / SPLIT_FACTOR;
@ -101,91 +105,61 @@ impl JacketCache {
Vec::new() Vec::new()
} else { } else {
let songs_dir = get_asset_dir().join("songs/by_id");
let entries = let entries =
fs::read_dir(get_asset_dir().join("songs")).expect("Couldn't read songs directory"); fs::read_dir(songs_dir).with_context(|| "Couldn't read songs directory")?;
let mut jacket_vectors = vec![]; let bytes = fs::read(get_asset_dir().join("songs/recognition_matrix"))
.with_context(|| "Could not read jacket recognition matrix")?;
let jacket_vectors = postcard::from_bytes(&bytes)
.with_context(|| "Could not decode jacket recognition matrix")?;
for entry in entries { for entry in entries {
let dir = entry?; let dir = entry?;
let raw_dir_name = dir.file_name(); let raw_dir_name = dir.file_name();
let dir_name = raw_dir_name.to_str().unwrap(); let dir_name = raw_dir_name.to_str().unwrap();
for entry in fs::read_dir(dir.path()).expect("Couldn't read song directory") { let song_id = dir_name.parse().with_context(|| {
format!("Dir name {dir_name} could not be parsed as `u32` song id")
})?;
let entries =
fs::read_dir(dir.path()).with_context(|| "Couldn't read song directory")?;
for entry in entries {
let file = entry?; let file = entry?;
let raw_name = file.file_name(); let raw_name = file.file_name();
let name = raw_name.to_str().unwrap().strip_suffix(".jpg").unwrap(); let name = raw_name.to_str().unwrap().strip_suffix(".jpg").unwrap();
if !name.ends_with("_256") { let difficulty = Difficulty::DIFFICULTY_SHORTHANDS
continue; .iter()
} .zip(Difficulty::DIFFICULTIES)
.find_map(|(s, d)| Some(d).filter(|_| name == s.to_lowercase()));
let name = name.strip_suffix("_256").unwrap(); let contents: &'static _ = fs::read(file.path())
.with_context(|| "Coult not read prepared jacket image")?
.leak();
let difficulty = match name { let image = image::load_from_memory(contents)
"0" => Some(Difficulty::PST), .with_context(|| "Could not load jacket image from prepared bytes")?;
"1" => Some(Difficulty::PRS),
"2" => Some(Difficulty::FTR),
"3" => Some(Difficulty::BYD),
"4" => Some(Difficulty::ETR),
"base" => None,
"base_night" => None,
"base_ja" => None,
_ => Err(format!("Unknown jacket suffix {}", name))?,
};
let (song_id, chart_id) = {
let (song, chart) =
guess_chart_name(dir_name, &song_cache, difficulty, true)?;
(song.id, chart.id)
};
let contents: &'static _ = fs::read(file.path())?.leak();
let image = image::load_from_memory(contents)?;
jacket_vectors.push((song_id, ImageVec::from_image(&image)));
let mut image =
image.resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Nearest);
if should_blur_jacket_art() {
image = image.blur(40.0);
}
let encoded_pic = {
let mut processed_pic = Vec::new();
image.write_to(
&mut Cursor::new(&mut processed_pic),
image::ImageFormat::Jpeg,
)?;
processed_pic.leak()
};
let bitmap: &'static _ = Box::leak(Box::new(image.into_rgb8())); let bitmap: &'static _ = Box::leak(Box::new(image.into_rgb8()));
if name == "base" { if let Some(difficulty) = difficulty {
// Inefficiently iterates over everything, but it's fine for ~1k entries let chart = song_cache
for chart in song_cache.charts_mut() { .lookup_by_difficulty_mut(song_id, difficulty)
if chart.song_id == song_id && chart.cached_jacket.is_none() { .unwrap();
chart.cached_jacket = Some(Jacket { chart.cached_jacket = Some(Jacket {
raw: encoded_pic, raw: contents,
bitmap, bitmap,
}); });
} } else {
} for chart_id in song_cache.lookup_song(song_id)?.charts() {
} else if difficulty.is_some() { let chart = song_cache.lookup_chart_mut(chart_id)?;
let chart = song_cache.lookup_chart_mut(chart_id).unwrap();
chart.cached_jacket = Some(Jacket {
raw: encoded_pic,
bitmap,
});
}
}
}
for chart in song_cache.charts() {
if chart.cached_jacket.is_none() { if chart.cached_jacket.is_none() {
println!( chart.cached_jacket = Some(Jacket {
"No jacket found for '{} [{:?}]'", raw: contents,
song_cache.lookup_song(chart.song_id)?.song.title, bitmap,
chart.difficulty });
) }
}
}
} }
} }

View file

@ -85,6 +85,7 @@ pub fn should_skip_jacket_art() -> bool {
} }
#[inline] #[inline]
#[allow(dead_code)]
pub fn should_blur_jacket_art() -> bool { pub fn should_blur_jacket_art() -> bool {
var("SHIMMERING_BLUR_JACKETS").unwrap_or_default() == "1" var("SHIMMERING_BLUR_JACKETS").unwrap_or_default() == "1"
} }

View file

@ -6,6 +6,7 @@
//! There's still stuff to be implemented here, like a cache for glyphs and //! There's still stuff to be implemented here, like a cache for glyphs and
//! whatnot, but this does run pretty stably for the b30 renderer. //! whatnot, but this does run pretty stably for the b30 renderer.
use anyhow::anyhow;
use freetype::{ use freetype::{
bitmap::PixelMode, bitmap::PixelMode,
face::{KerningMode, LoadFlag}, face::{KerningMode, LoadFlag},
@ -355,7 +356,7 @@ impl BitmapCanvas {
Some((i, glyph_index)) Some((i, glyph_index))
}) })
.ok_or_else(|| { .ok_or_else(|| {
format!("Could not get glyph index for char '{}' in \"{}\"", c, text) anyhow!("Could not get glyph index for char '{}' in \"{}\"", c, text)
})?; })?;
let face = &mut faces[face_index]; let face = &mut faces[face_index];

15
src/cli/mod.rs Normal file
View file

@ -0,0 +1,15 @@
pub mod prepare_jackets;
#[derive(clap::Parser)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(clap::Subcommand)]
pub enum Command {
/// Start the discord bot
Discord {},
PrepareJackets {},
}

155
src/cli/prepare_jackets.rs Normal file
View file

@ -0,0 +1,155 @@
use std::{
fs,
io::{stdout, Write},
};
use anyhow::{anyhow, bail, Context};
use image::imageops::FilterType;
use crate::{
arcaea::{
chart::{Difficulty, SongCache},
jacket::{ImageVec, BITMAP_IMAGE_SIZE},
},
assets::{get_asset_dir, get_data_dir},
context::{connect_db, Error},
recognition::fuzzy_song_name::guess_chart_name,
};
pub fn prepare_jackets() -> Result<(), Error> {
let db = connect_db(&get_data_dir());
let song_cache = SongCache::new(&db)?;
let songs_dir = get_asset_dir().join("songs");
let raw_songs_dir = songs_dir.join("raw");
let by_id_dir = songs_dir.join("by_id");
if by_id_dir.exists() {
fs::remove_dir_all(&by_id_dir).with_context(|| "Could not remove `by_id` dir")?;
}
fs::create_dir_all(&by_id_dir).with_context(|| "Could not create `by_id` dir")?;
let mut jacket_vectors = vec![];
let entries = fs::read_dir(&raw_songs_dir)
.with_context(|| "Couldn't read songs directory")?
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("Could not read member of `songs/raw`"))?;
for (i, dir) in entries.iter().enumerate() {
let raw_dir_name = dir.file_name();
let dir_name = raw_dir_name.to_str().unwrap();
// {{{ Update progress live
print!(
"{}/{}: {dir_name} \r",
i,
entries.len()
);
if i % 5 == 0 {
stdout().flush()?;
}
// }}}
let entries = fs::read_dir(dir.path())
.with_context(|| "Couldn't read song directory")?
.map(|f| f.unwrap())
.filter(|f| f.file_name().to_str().unwrap().ends_with("_256.jpg"))
.collect::<Vec<_>>();
for file in &entries {
let raw_name = file.file_name();
let name = raw_name
.to_str()
.unwrap()
.strip_suffix("_256.jpg")
.ok_or_else(|| {
anyhow!("No '_256.jpg' suffix to remove from filename {raw_name:?}")
})?;
let difficulty = match name {
"0" => Some(Difficulty::PST),
"1" => Some(Difficulty::PRS),
"2" => Some(Difficulty::FTR),
"3" => Some(Difficulty::BYD),
"4" => Some(Difficulty::ETR),
"base" => None,
"base_night" => None,
"base_ja" => None,
_ => bail!("Unknown jacket suffix {}", name),
};
// Sometimes it's useful to distinguish between separate (but related)
// charts like "Vicious Heroism" and "Vicious [ANTi] Heroism" being in
// the same directory. To do this, we only allow the base jacket to refer
// to the FUTURE difficulty, unless it's the only jacket present
// (or unless we are parsing the tutorial)
let search_difficulty =
if entries.len() > 1 && difficulty.is_none() && dir_name != "tutorial" {
Some(Difficulty::FTR)
} else {
difficulty
};
let (song, _) = guess_chart_name(dir_name, &song_cache, search_difficulty, true)
.with_context(|| format!("Could not recognise chart name from '{dir_name}'"))?;
// {{{ Set up `out_dir` paths
let out_dir = {
let out = by_id_dir.join(song.id.to_string());
if !out.exists() {
fs::create_dir_all(&out).with_context(|| {
format!(
"Could not create parent dir for song '{}' inside `by_id`",
song.title
)
})?;
}
out
};
// }}}
let difficulty_string = if let Some(difficulty) = difficulty {
&Difficulty::DIFFICULTY_SHORTHANDS[difficulty.to_index()].to_lowercase()
} else {
"def"
};
let contents: &'static _ = fs::read(file.path())
.with_context(|| format!("Could not read image for file {:?}", file.path()))?
.leak();
let image = image::load_from_memory(contents)?;
jacket_vectors.push((song.id, ImageVec::from_image(&image)));
let image = image.resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Gaussian);
let image_out_path =
out_dir.join(format!("{difficulty_string}_{BITMAP_IMAGE_SIZE}.jpg"));
image
.save(&image_out_path)
.with_context(|| format!("Could not save image to {image_out_path:?}"))?;
}
}
// NOTE: this is N^2, but it's a one-off warning thing, so it's fine
for chart in song_cache.charts() {
if jacket_vectors.iter().all(|(i, _)| chart.song_id != *i) {
println!(
"No jacket found for '{} [{:?}]'",
song_cache.lookup_song(chart.song_id)?.song.title,
chart.difficulty
)
}
}
{
println!("Encoded {} images", jacket_vectors.len());
let bytes = postcard::to_allocvec(&jacket_vectors)
.with_context(|| format!("Coult not encode jacket matrix"))?;
fs::write(songs_dir.join("recognition_matrix"), bytes)
.with_context(|| format!("Could not write jacket matrix"))?;
}
Ok(())
}

View file

@ -1,3 +1,4 @@
use anyhow::anyhow;
use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage}; use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage};
use crate::{ use crate::{
@ -25,6 +26,8 @@ use crate::{
user::discord_id_to_discord_user, user::discord_id_to_discord_user,
}; };
use super::discord::MessageContext;
// {{{ Top command // {{{ Top command
/// Chart-related stats /// Chart-related stats
#[poise::command( #[poise::command(
@ -38,15 +41,9 @@ pub async fn chart(_ctx: Context<'_>) -> Result<(), Error> {
} }
// }}} // }}}
// {{{ Info // {{{ Info
/// Show a chart given it's name // {{{ Implementation
#[poise::command(prefix_command, slash_command, user_cooldown = 1)] async fn info_impl(ctx: &mut impl MessageContext, name: &str) -> Result<(), Error> {
async fn info( let (song, chart) = guess_song_and_chart(&ctx.data(), name)?;
ctx: Context<'_>,
#[rest]
#[description = "Name of chart to show (difficulty at the end)"]
name: String,
) -> Result<(), Error> {
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
let attachement_name = "chart.png"; let attachement_name = "chart.png";
let icon_attachement = match chart.cached_jacket.as_ref() { let icon_attachement = match chart.cached_jacket.as_ref() {
@ -95,17 +92,62 @@ async fn info(
embed = embed.thumbnail(format!("attachment://{}", &attachement_name)); embed = embed.thumbnail(format!("attachment://{}", &attachement_name));
} }
ctx.channel_id() ctx.send_files(icon_attachement, CreateMessage::new().embed(embed))
.send_files(
ctx.http(),
icon_attachement,
CreateMessage::new().embed(embed),
)
.await?; .await?;
Ok(()) Ok(())
} }
// }}} // }}}
// {{{ Tests
#[cfg(test)]
mod info_tests {
use crate::{commands::discord::mock::MockContext, with_test_ctx};
use super::*;
#[tokio::test]
async fn no_suffix() -> Result<(), Error> {
with_test_ctx!("test/commands/chart/info/no_suffix", async |ctx| {
info_impl(ctx, "Pentiment").await?;
Ok(())
})
}
#[tokio::test]
async fn specify_difficulty() -> Result<(), Error> {
with_test_ctx!("test/commands/chart/info/specify_difficulty", async |ctx| {
info_impl(ctx, "Hellohell [ETR]").await?;
Ok(())
})
}
#[tokio::test]
async fn last_byd() -> Result<(), Error> {
with_test_ctx!(
"test/commands/chart/info/last_byd",
async |ctx: &mut MockContext| {
info_impl(ctx, "Last | Moment [BYD]").await?;
info_impl(ctx, "Last | Eternity [BYD]").await?;
Ok(())
}
)
}
}
// }}}
/// Show a chart given it's name
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
async fn info(
mut ctx: Context<'_>,
#[rest]
#[description = "Name of chart to show (difficulty at the end)"]
name: String,
) -> Result<(), Error> {
info_impl(&mut ctx, &name).await?;
Ok(())
}
// }}}
// {{{ Best score // {{{ Best score
/// Show the best score on a given chart /// Show the best score on a given chart
#[poise::command(prefix_command, slash_command, user_cooldown = 1)] #[poise::command(prefix_command, slash_command, user_cooldown = 1)]
@ -138,9 +180,10 @@ async fn best(
)? )?
.query_row((user.id, chart.id), |row| Play::from_sql(chart, row)) .query_row((user.id, chart.id), |row| Play::from_sql(chart, row))
.map_err(|_| { .map_err(|_| {
format!( anyhow!(
"Could not find any scores for {} [{:?}]", "Could not find any scores for {} [{:?}]",
song.title, chart.difficulty song.title,
chart.difficulty
) )
})?; })?;

View file

@ -53,9 +53,7 @@ pub trait MessageContext {
} }
// }}} // }}}
// {{{ Poise implementation // {{{ Poise implementation
impl<'a, 'b> MessageContext impl<'a> MessageContext for poise::Context<'a, UserContext, Error> {
for poise::Context<'a, UserContext, Box<dyn std::error::Error + Send + Sync + 'b>>
{
type Attachment = poise::serenity_prelude::Attachment; type Attachment = poise::serenity_prelude::Attachment;
fn data(&self) -> &UserContext { fn data(&self) -> &UserContext {

View file

@ -4,6 +4,7 @@ use crate::context::{Context, Error};
use crate::recognition::recognize::{ImageAnalyzer, ScoreKind}; use crate::recognition::recognize::{ImageAnalyzer, ScoreKind};
use crate::user::{discord_id_to_discord_user, User}; use crate::user::{discord_id_to_discord_user, User};
use crate::{get_user, timed}; use crate::{get_user, timed};
use anyhow::anyhow;
use image::DynamicImage; use image::DynamicImage;
use poise::serenity_prelude as serenity; use poise::serenity_prelude as serenity;
use poise::serenity_prelude::CreateMessage; use poise::serenity_prelude::CreateMessage;
@ -90,9 +91,10 @@ async fn magic_impl<C: MessageContext>(
analyzer analyzer
.read_score(ctx.data(), Some(chart.note_count), &grayscale_image, kind) .read_score(ctx.data(), Some(chart.note_count), &grayscale_image, kind)
.map_err(|err| { .map_err(|err| {
format!( anyhow!(
"Could not read score for chart {} [{:?}]: {err}", "Could not read score for chart {} [{:?}]: {err}",
song.title, chart.difficulty song.title,
chart.difficulty
) )
})? })?
}); });
@ -137,52 +139,24 @@ async fn magic_impl<C: MessageContext>(
// {{{ Tests // {{{ Tests
#[cfg(test)] #[cfg(test)]
mod magic_tests { mod magic_tests {
use std::{path::PathBuf, process::Command, str::FromStr};
use r2d2_sqlite::SqliteConnectionManager; use std::path::PathBuf;
use crate::{ use crate::with_test_ctx;
commands::discord::mock::MockContext,
context::{connect_db, get_shared_context},
};
use super::*; use super::*;
macro_rules! with_ctx {
($test_path:expr, $f:expr) => {{
let mut data = (*get_shared_context().await).clone();
let dir = tempfile::tempdir()?;
let path = dir.path().join("db.sqlite");
println!("path {path:?}");
data.db = connect_db(SqliteConnectionManager::file(path));
Command::new("scripts/import-charts.py")
.env("SHIMMERING_DATA_DIR", dir.path().to_str().unwrap())
.output()
.unwrap();
let mut ctx = MockContext::new(data);
User::create_from_context(&ctx)?;
let res: Result<(), Error> = $f(&mut ctx).await;
res?;
ctx.write_to(&PathBuf::from_str($test_path)?)?;
Ok(())
}};
}
#[tokio::test] #[tokio::test]
async fn no_pics() -> Result<(), Error> { async fn no_pics() -> Result<(), Error> {
with_ctx!("test/commands/score/magic/no_pics", async |ctx| { with_test_ctx!("test/commands/score/magic/no_pics", async |ctx| {
magic_impl(ctx, vec![]).await?; magic_impl(ctx, vec![]).await?;
Ok(()) Ok(())
}) })
} }
#[tokio::test] #[tokio::test]
async fn basic_pic() -> Result<(), Error> { async fn simple_pic() -> Result<(), Error> {
with_ctx!("test/commands/score/magic/single_pic", async |ctx| { with_test_ctx!("test/commands/score/magic/single_pic", async |ctx| {
magic_impl( magic_impl(
ctx, ctx,
vec![PathBuf::from_str("test/screenshots/alter_ego.jpg")?], vec![PathBuf::from_str("test/screenshots/alter_ego.jpg")?],
@ -194,7 +168,7 @@ mod magic_tests {
#[tokio::test] #[tokio::test]
async fn weird_kerning() -> Result<(), Error> { async fn weird_kerning() -> Result<(), Error> {
with_ctx!("test/commands/score/magic/weird_kerning", async |ctx| { with_test_ctx!("test/commands/score/magic/weird_kerning", async |ctx| {
magic_impl( magic_impl(
ctx, ctx,
vec![ vec![
@ -298,7 +272,7 @@ pub async fn show(
Ok((song, chart, play, discord_id)) Ok((song, chart, play, discord_id))
})? })?
.next() .next()
.ok_or_else(|| format!("Could not find play with id {}", id))??; .ok_or_else(|| anyhow!("Could not find play with id {}", id))??;
let author = discord_id_to_discord_user(&ctx, &discord_id).await?; let author = discord_id_to_discord_user(&ctx, &discord_id).await?;
let user = User::by_id(ctx.data(), play.user_id)?; let user = User::by_id(ctx.data(), play.user_id)?;

View file

@ -1,5 +1,6 @@
use std::io::Cursor; use std::io::Cursor;
use anyhow::anyhow;
use image::{DynamicImage, ImageBuffer}; use image::{DynamicImage, ImageBuffer};
use poise::{ use poise::{
serenity_prelude::{CreateAttachment, CreateEmbed}, serenity_prelude::{CreateAttachment, CreateEmbed},
@ -194,9 +195,10 @@ async fn best_plays(
// }}} // }}}
// {{{ Display jacket // {{{ Display jacket
let jacket = chart.cached_jacket.as_ref().ok_or_else(|| { let jacket = chart.cached_jacket.as_ref().ok_or_else(|| {
format!( anyhow!(
"Cannot find jacket for chart {} [{:?}]", "Cannot find jacket for chart {} [{:?}]",
song.title, chart.difficulty song.title,
chart.difficulty
) )
})?; })?;
@ -289,7 +291,7 @@ async fn best_plays(
// {{{ Display status text // {{{ Display status text
with_font(&EXO_FONT, |faces| { with_font(&EXO_FONT, |faces| {
let status = play.short_status(scoring_system, chart).ok_or_else(|| { let status = play.short_status(scoring_system, chart).ok_or_else(|| {
format!( anyhow!(
"Could not get status for score {}", "Could not get status for score {}",
play.score(scoring_system) play.score(scoring_system)
) )

View file

@ -3,6 +3,7 @@ use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
use rusqlite_migration::Migrations; use rusqlite_migration::Migrations;
use std::fs; use std::fs;
use std::path::Path;
use std::sync::LazyLock; use std::sync::LazyLock;
use crate::{ use crate::{
@ -13,7 +14,7 @@ use crate::{
}; };
// Types used by all command functions // Types used by all command functions
pub type Error = Box<dyn std::error::Error + Send + Sync>; pub type Error = anyhow::Error;
pub type Context<'a> = poise::Context<'a, UserContext, Error>; pub type Context<'a> = poise::Context<'a, UserContext, Error>;
pub type DbConnection = r2d2::Pool<SqliteConnectionManager>; pub type DbConnection = r2d2::Pool<SqliteConnectionManager>;
@ -33,21 +34,24 @@ pub struct UserContext {
pub kazesawa_bold_measurements: CharMeasurements, pub kazesawa_bold_measurements: CharMeasurements,
} }
pub fn connect_db(manager: SqliteConnectionManager) -> DbConnection { pub fn connect_db(data_dir: &Path) -> DbConnection {
timed!("create_sqlite_pool", { timed!("create_sqlite_pool", {
Pool::new(manager.with_init(|conn| { fs::create_dir_all(data_dir).expect("Could not create $SHIMMERING_DATA_DIR");
let data_dir = data_dir.to_str().unwrap().to_owned();
let db_path = format!("{}/db.sqlite", data_dir);
let mut conn = rusqlite::Connection::open(&db_path).unwrap();
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
static MIGRATIONS: LazyLock<Migrations> = LazyLock::new(|| { static MIGRATIONS: LazyLock<Migrations> = LazyLock::new(|| {
Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations") Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations")
}); });
MIGRATIONS MIGRATIONS
.to_latest(conn) .to_latest(&mut conn)
.expect("Could not run migrations"); .expect("Could not run migrations");
Ok(()) Pool::new(SqliteConnectionManager::file(&db_path)).expect("Could not open sqlite database.")
}))
.expect("Could not open sqlite database.")
}) })
} }
@ -55,12 +59,7 @@ impl UserContext {
#[inline] #[inline]
pub async fn new() -> Result<Self, Error> { pub async fn new() -> Result<Self, Error> {
timed!("create_context", { timed!("create_context", {
fs::create_dir_all(get_data_dir())?; let db = connect_db(&get_data_dir());
let db = connect_db(SqliteConnectionManager::file(&format!(
"{}/db.sqlite",
get_data_dir().to_str().unwrap()
)));
let mut song_cache = timed!("make_song_cache", { SongCache::new(&db)? }); let mut song_cache = timed!("make_song_cache", { SongCache::new(&db)? });
let jacket_cache = timed!("make_jacket_cache", { JacketCache::new(&mut song_cache)? }); let jacket_cache = timed!("make_jacket_cache", { JacketCache::new(&mut song_cache)? });
@ -93,8 +92,45 @@ impl UserContext {
} }
} }
#[cfg(test)]
pub mod testing {
use super::*;
pub async fn get_shared_context() -> &'static UserContext { pub async fn get_shared_context() -> &'static UserContext {
static CELL: tokio::sync::OnceCell<UserContext> = tokio::sync::OnceCell::const_new(); static CELL: tokio::sync::OnceCell<UserContext> = tokio::sync::OnceCell::const_new();
CELL.get_or_init(async || UserContext::new().await.unwrap()) CELL.get_or_init(async || {
// env::set_var("SHIMMERING_DATA_DIR", "")
UserContext::new().await.unwrap()
})
.await .await
} }
pub fn import_songs_and_jackets_from(to: &Path) -> () {
std::process::Command::new("scripts/copy-chart-info.sh")
.arg(get_data_dir())
.arg(to)
.output()
.expect("Could not run sh chart info copy script");
}
#[macro_export]
macro_rules! with_test_ctx {
($test_path:expr, $f:expr) => {{
use std::str::FromStr;
let mut data = (*crate::context::testing::get_shared_context().await).clone();
let dir = tempfile::tempdir()?;
data.db = crate::context::connect_db(dir.path());
crate::context::testing::import_songs_and_jackets_from(dir.path());
let mut ctx = crate::commands::discord::mock::MockContext::new(data);
crate::user::User::create_from_context(&ctx)?;
let res: Result<(), Error> = $f(&mut ctx).await;
res?;
ctx.write_to(&std::path::PathBuf::from_str($test_path)?)?;
Ok(())
}};
}
}

View file

@ -7,10 +7,12 @@
#![feature(thread_local)] #![feature(thread_local)]
#![feature(generic_arg_infer)] #![feature(generic_arg_infer)]
#![feature(lazy_cell_consume)] #![feature(lazy_cell_consume)]
#![feature(iter_collect_into)]
mod arcaea; mod arcaea;
mod assets; mod assets;
mod bitmap; mod bitmap;
mod cli;
mod commands; mod commands;
mod context; mod context;
mod levenshtein; mod levenshtein;
@ -21,6 +23,8 @@ mod transform;
mod user; mod user;
use arcaea::play::generate_missing_scores; use arcaea::play::generate_missing_scores;
use clap::Parser;
use cli::{prepare_jackets::prepare_jackets, Cli, Command};
use context::{Error, UserContext}; use context::{Error, UserContext};
use poise::serenity_prelude::{self as serenity}; use poise::serenity_prelude::{self as serenity};
use std::{env::var, sync::Arc, time::Duration}; use std::{env::var, sync::Arc, time::Duration};
@ -39,6 +43,9 @@ async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let cli = Cli::parse();
match cli.command {
Command::Discord {} => {
// {{{ Poise options // {{{ Poise options
let options = poise::FrameworkOptions { let options = poise::FrameworkOptions {
commands: vec![ commands: vec![
@ -79,7 +86,8 @@ async fn main() {
.setup(move |ctx, _ready, framework| { .setup(move |ctx, _ready, framework| {
Box::pin(async move { Box::pin(async move {
println!("Logged in as {}", _ready.user.name); println!("Logged in as {}", _ready.user.name);
poise::builtins::register_globally(ctx, &framework.options().commands).await?; poise::builtins::register_globally(ctx, &framework.options().commands)
.await?;
let ctx = UserContext::new().await?; let ctx = UserContext::new().await?;
if var("SHIMMERING_REGEN_SCORES").unwrap_or_default() == "1" { if var("SHIMMERING_REGEN_SCORES").unwrap_or_default() == "1" {
@ -94,10 +102,10 @@ async fn main() {
.options(options) .options(options)
.build(); .build();
let token = let token = var("SHIMMERING_DISCORD_TOKEN")
var("SHIMMERING_DISCORD_TOKEN").expect("Missing `SHIMMERING_DISCORD_TOKEN` env var"); .expect("Missing `SHIMMERING_DISCORD_TOKEN` env var");
let intents = let intents = serenity::GatewayIntents::non_privileged()
serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; | serenity::GatewayIntents::MESSAGE_CONTENT;
let client = serenity::ClientBuilder::new(token, intents) let client = serenity::ClientBuilder::new(token, intents)
.framework(framework) .framework(framework)
@ -106,3 +114,8 @@ async fn main() {
client.unwrap().start().await.unwrap() client.unwrap().start().await.unwrap()
// }}} // }}}
} }
Command::PrepareJackets {} => {
prepare_jackets().expect("Could not prepare jackets");
}
}
}

View file

@ -10,6 +10,8 @@
//! databases extracted from the game, but this is still useful for having a //! databases extracted from the game, but this is still useful for having a
//! "canonical" way to refer to some weirdly-named charts). //! "canonical" way to refer to some weirdly-named charts).
use anyhow::bail;
use crate::arcaea::chart::{Chart, Difficulty, Song, SongCache}; use crate::arcaea::chart::{Chart, Difficulty, Song, SongCache};
use crate::context::{Error, UserContext}; use crate::context::{Error, UserContext};
use crate::levenshtein::edit_distance_with; use crate::levenshtein::edit_distance_with;
@ -82,26 +84,29 @@ pub fn guess_chart_name<'a>(
let song_title = &song.lowercase_title; let song_title = &song.lowercase_title;
distance_vec.clear(); distance_vec.clear();
// Apply raw distance
let base_distance = edit_distance_with(&text, &song_title, &mut levenshtein_vec); let base_distance = edit_distance_with(&text, &song_title, &mut levenshtein_vec);
if base_distance < 1.max(song.title.len() / 3) { if base_distance <= song.title.len() / 3 {
distance_vec.push(base_distance * 10 + 2); distance_vec.push(base_distance * 10 + 2);
} }
// Cut title to the length of the text, and then check
let shortest_len = Ord::min(song_title.len(), text.len()); let shortest_len = Ord::min(song_title.len(), text.len());
if let Some(sliced) = &song_title.get(..shortest_len) if let Some(sliced) = &song_title.get(..shortest_len)
&& (text.len() >= 6 || unsafe_heuristics) && (text.len() >= 6 || unsafe_heuristics)
{ {
let slice_distance = edit_distance_with(&text, sliced, &mut levenshtein_vec); let slice_distance = edit_distance_with(&text, sliced, &mut levenshtein_vec);
if slice_distance < 1 { if slice_distance == 0 {
distance_vec.push(slice_distance * 10 + 3); distance_vec.push(3);
} }
} }
// Shorthand-based matching
if let Some(shorthand) = &chart.shorthand if let Some(shorthand) = &chart.shorthand
&& unsafe_heuristics && unsafe_heuristics
{ {
let short_distance = edit_distance_with(&text, shorthand, &mut levenshtein_vec); let short_distance = edit_distance_with(&text, shorthand, &mut levenshtein_vec);
if short_distance < 1.max(shorthand.len() / 3) { if short_distance <= shorthand.len() / 3 {
distance_vec.push(short_distance * 10 + 1); distance_vec.push(short_distance * 10 + 1);
} }
} }
@ -113,12 +118,16 @@ pub fn guess_chart_name<'a>(
}) })
.collect(); .collect();
close_enough.sort_by_key(|(song, _, _)| song.id);
close_enough.dedup_by_key(|(song, _, _)| song.id);
if close_enough.len() == 0 { if close_enough.len() == 0 {
if text.len() <= 1 { if text.len() <= 1 {
Err(format!( bail!(
"Could not find match for chart name '{}' [{:?}]", "Could not find match for chart name '{}' [{:?}]",
raw_text, difficulty raw_text,
))?; difficulty
);
} else { } else {
text = &text[..text.len() - 1]; text = &text[..text.len() - 1];
} }
@ -129,7 +138,7 @@ pub fn guess_chart_name<'a>(
close_enough.sort_by_key(|(_, _, distance)| *distance); close_enough.sort_by_key(|(_, _, distance)| *distance);
break (close_enough[0].0, close_enough[0].1); break (close_enough[0].0, close_enough[0].1);
} else { } else {
return Err(format!("Name '{}' is too vague to choose a match", raw_text).into()); bail!("Name '{}' is too vague to choose a match", raw_text);
}; };
}; };
}; };

View file

@ -21,6 +21,7 @@
//! aforementioned precomputed vectors are generated using almost the exact //! aforementioned precomputed vectors are generated using almost the exact
//! procedure described in steps 1-6, except the images are generated at //! procedure described in steps 1-6, except the images are generated at
//! startup using my very own bitmap rendering module (`crate::bitmap`). //! startup using my very own bitmap rendering module (`crate::bitmap`).
use anyhow::{anyhow, bail};
use freetype::Face; use freetype::Face;
use image::{DynamicImage, ImageBuffer, Luma}; use image::{DynamicImage, ImageBuffer, Luma};
use imageproc::{ use imageproc::{
@ -58,7 +59,7 @@ impl ComponentVec {
.bounds .bounds
.get(component as usize - 1) .get(component as usize - 1)
.and_then(|o| o.as_ref()) .and_then(|o| o.as_ref())
.ok_or_else(|| "Missing bounds for given connected component")?; .ok_or_else(|| anyhow!("Missing bounds for given connected component"))?;
for i in 0..(SPLIT_FACTOR * SPLIT_FACTOR) { for i in 0..(SPLIT_FACTOR * SPLIT_FACTOR) {
let (iy, ix) = i.div_rem_euclid(&SPLIT_FACTOR); let (iy, ix) = i.div_rem_euclid(&SPLIT_FACTOR);
@ -82,10 +83,7 @@ impl ComponentVec {
let size = (x_end + 1 - x_start) * (y_end + 1 - y_start); let size = (x_end + 1 - x_start) * (y_end + 1 - y_start);
if size == 0 { if size == 0 {
return Err(format!( bail!("Got zero size for chunk [{x_start},{x_end}]x[{y_start},{y_end}]");
"Got zero size for chunk [{x_start},{x_end}]x[{y_start},{y_end}]"
)
.into());
} }
chunks[i as usize] = count as f32 / size as f32; chunks[i as usize] = count as f32 / size as f32;
@ -256,7 +254,7 @@ impl CharMeasurements {
canvas.text(padding, &mut [face], style, &string)?; canvas.text(padding, &mut [face], style, &string)?;
let buffer = let buffer =
ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec()) ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec())
.ok_or_else(|| "Failed to turn buffer into canvas")?; .ok_or_else(|| anyhow!("Failed to turn buffer into canvas"))?;
let image = DynamicImage::ImageRgb8(buffer); let image = DynamicImage::ImageRgb8(buffer);
debug_image_log(&image); debug_image_log(&image);
@ -270,14 +268,14 @@ impl CharMeasurements {
.filter_map(|o| o.as_ref()) .filter_map(|o| o.as_ref())
.map(|b| b.x_max - b.x_min) .map(|b| b.x_max - b.x_min)
.max() .max()
.ok_or_else(|| "No connected components found")?; .ok_or_else(|| anyhow!("No connected components found"))?;
let max_height = components let max_height = components
.bounds .bounds
.iter() .iter()
.filter_map(|o| o.as_ref()) .filter_map(|o| o.as_ref())
.map(|b| b.y_max - b.y_min) .map(|b| b.y_max - b.y_min)
.max() .max()
.ok_or_else(|| "No connected components found")?; .ok_or_else(|| anyhow!("No connected components found"))?;
// }}} // }}}
let mut chars = Vec::with_capacity(string.len()); let mut chars = Vec::with_capacity(string.len());
@ -318,7 +316,7 @@ impl CharMeasurements {
.filter_map(|o| o.as_ref()) .filter_map(|o| o.as_ref())
.map(|b| b.y_max - b.y_min) .map(|b| b.y_max - b.y_min)
.max() .max()
.ok_or_else(|| "No connected components found")?; .ok_or_else(|| anyhow!("No connected components found"))?;
let max_width = self.max_width * max_height / self.max_height; let max_width = self.max_width * max_height / self.max_height;
for i in &components.bounds_by_position { for i in &components.bounds_by_position {
@ -334,7 +332,7 @@ impl CharMeasurements {
d1.partial_cmp(d2).expect("NaN distance encountered") d1.partial_cmp(d2).expect("NaN distance encountered")
}) })
.map(|(i, _, d)| (d.sqrt(), i)) .map(|(i, _, d)| (d.sqrt(), i))
.ok_or_else(|| "No chars in cache")?; .ok_or_else(|| anyhow!("No chars in cache"))?;
println!("char '{}', distance {}", best_match.1, best_match.0); println!("char '{}', distance {}", best_match.1, best_match.0);
if best_match.0 <= 0.75 { if best_match.0 <= 0.75 {

View file

@ -1,5 +1,6 @@
use std::fmt::Display; use std::fmt::Display;
use anyhow::{anyhow, bail};
use hypertesseract::{PageSegMode, Tesseract}; use hypertesseract::{PageSegMode, Tesseract};
use image::imageops::FilterType; use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView}; use image::{DynamicImage, GenericImageView};
@ -170,7 +171,7 @@ impl ImageAnalyzer {
}) { }) {
Ok(result) Ok(result)
} else { } else {
Err(format!("Score {result} is not vaild").into()) Err(anyhow!("Score {result} is not vaild"))
} }
} }
// }}} // }}}
@ -228,7 +229,7 @@ impl ImageAnalyzer {
.zip(Difficulty::DIFFICULTY_STRINGS) .zip(Difficulty::DIFFICULTY_STRINGS)
.min_by_key(|(_, difficulty_string)| edit_distance(difficulty_string, &text)) .min_by_key(|(_, difficulty_string)| edit_distance(difficulty_string, &text))
.map(|(difficulty, _)| *difficulty) .map(|(difficulty, _)| *difficulty)
.ok_or_else(|| format!("Unrecognised difficulty '{}'", text))?; .ok_or_else(|| anyhow!("Unrecognised difficulty '{}'", text))?;
Ok(difficulty) Ok(difficulty)
} }
@ -272,12 +273,11 @@ impl ImageAnalyzer {
)?; )?;
if conf < 20 && conf != 0 { if conf < 20 && conf != 0 {
return Err(format!( bail!(
"Title text is not readable (confidence = {}, text = {}).", "Title text is not readable (confidence = {}, text = {}).",
conf, conf,
text.trim() text.trim()
) );
.into());
} }
guess_chart_name(&text, &ctx.song_cache, Some(difficulty), false) guess_chart_name(&text, &ctx.song_cache, Some(difficulty), false)
@ -319,10 +319,10 @@ impl ImageAnalyzer {
let (distance, song_id) = ctx let (distance, song_id) = ctx
.jacket_cache .jacket_cache
.recognise(&*cropped) .recognise(&*cropped)
.ok_or_else(|| "Could not recognise jacket")?; .ok_or_else(|| anyhow!("Could not recognise jacket"))?;
if distance > (IMAGE_VEC_DIM * 3) as f32 { if distance > (IMAGE_VEC_DIM * 3) as f32 {
Err("No known jacket looks like this")?; bail!("No known jacket looks like this");
} }
let (song, chart) = ctx.song_cache.lookup_by_difficulty(*song_id, difficulty)?; let (song, chart) = ctx.song_cache.lookup_by_difficulty(*song_id, difficulty)?;

View file

@ -1,5 +1,6 @@
use std::fs; use std::fs;
use anyhow::anyhow;
use image::GenericImage; use image::GenericImage;
use crate::{assets::get_config_dir, bitmap::Rect, context::Error}; use crate::{assets::get_config_dir, bitmap::Rect, context::Error};
@ -172,7 +173,7 @@ impl UIMeasurements {
} }
} }
Err(format!("Could no find rect for {rect:?} in image").into()) Err(anyhow!("Could no find rect for {rect:?} in image"))
} }
// }}} // }}}
} }

View file

@ -1,5 +1,6 @@
use std::str::FromStr; use std::str::FromStr;
use anyhow::anyhow;
use poise::serenity_prelude::UserId; use poise::serenity_prelude::UserId;
use rusqlite::Row; use rusqlite::Row;
@ -39,7 +40,7 @@ impl User {
)? )?
.query_map([&discord_id], |row| row.get("id"))? .query_map([&discord_id], |row| row.get("id"))?
.next() .next()
.ok_or_else(|| "Failed to create user")??; .ok_or_else(|| anyhow!("Failed to create user"))??;
Ok(Self { Ok(Self {
discord_id, discord_id,
@ -57,7 +58,7 @@ impl User {
.prepare_cached("SELECT * FROM users WHERE discord_id = ?")? .prepare_cached("SELECT * FROM users WHERE discord_id = ?")?
.query_map([id], Self::from_row)? .query_map([id], Self::from_row)?
.next() .next()
.ok_or_else(|| "You are not an user in my database, sowwy ^~^")??; .ok_or_else(|| anyhow!("You are not an user in my database, sowwy ^~^"))??;
Ok(user) Ok(user)
} }
@ -69,7 +70,7 @@ impl User {
.prepare_cached("SELECT * FROM users WHERE id = ?")? .prepare_cached("SELECT * FROM users WHERE id = ?")?
.query_map([id], Self::from_row)? .query_map([id], Self::from_row)?
.next() .next()
.ok_or_else(|| "You are not an user in my database, sowwy ^~^")??; .ok_or_else(|| anyhow!("You are not an user in my database, sowwy ^~^"))??;
Ok(user) Ok(user)
} }