Store custom song json/jacket as md5 client side

This commit is contained in:
Ethan O'Brien
2026-07-05 12:14:11 -05:00
parent 7a72672451
commit 2aa2c6f2eb
2 changed files with 189 additions and 4 deletions

View File

@@ -274,3 +274,28 @@ pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue {
pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> bool { pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> bool {
DATABASE.lock_and_select("SELECT music_id FROM songs WHERE music_id!=?1 AND song LIKE ?2", params!(ignored_music_id, format!("%{}%", md5))).is_ok() DATABASE.lock_and_select("SELECT music_id FROM songs WHERE music_id!=?1 AND song LIKE ?2", params!(ignored_music_id, format!("%{}%", md5))).is_ok()
} }
// Resolve a content-addressed chart/jacket md5 to the per-song-id file that
// currently holds those bytes: (music_id, relative filename under
// custom_songs/{music_id}/). Charts and jackets live in the per-song directory
// (not a shared md5 store), and their catalog md5 always tracks the on-disk
// bytes - so this is the index the /custom_song/data/{md5} route serves from,
// and it self-heals: an edited asset gets a new md5 and old md5s stop resolving.
// Audio has its own route and is intentionally not resolved here.
pub fn find_asset_by_md5(md5: &str) -> Option<(i64, String)> {
let blob = DATABASE.lock_and_select("SELECT song FROM songs WHERE song LIKE ?1", params!(format!("%{}%", md5))).ok()?;
let song = jzon::parse(&blob).ok()?;
let music_id = song["music_id"].as_i64()?;
if song["jacket_md5"].as_str() == Some(md5) {
return Some((music_id, String::from("jacket.png")));
}
if song["jacket_blur_md5"].as_str() == Some(md5) {
return Some((music_id, String::from("jacket_blur.png")));
}
for level in song["levels"].members() {
if level["md5"].as_str() == Some(md5) {
return Some((music_id, format!("chart_{}.json", level["level"].as_i64()?)));
}
}
None
}

View File

@@ -61,6 +61,7 @@ pub fn web_routes(cfg: &mut web::ServiceConfig) {
web::scope("/custom_song") web::scope("/custom_song")
.route("/assets/{music_id}/{file}", web::get().to(assets)) .route("/assets/{music_id}/{file}", web::get().to(assets))
.route("/audio/{hash}/{file}", web::get().to(audio)) .route("/audio/{hash}/{file}", web::get().to(audio))
.route("/data/{hash}/{file}", web::get().to(data))
.route("/upload", web::post().to(upload)) .route("/upload", web::post().to(upload))
.route("/update", web::post().to(update)) .route("/update", web::post().to(update))
.route("/mine", web::get().to(mine)) .route("/mine", web::get().to(mine))
@@ -169,6 +170,36 @@ async fn audio(req: HttpRequest) -> HttpResponse {
} }
} }
// Content-addressed chart/jacket fetch, same '{server}/{hash}/{name}.{ext}'
// shape as the audio route. The game client builds the URL from the md5 it
// reads in the catalog (charts -> .json, jackets -> .png); the ext is cosmetic,
// the md5 resolves the bytes. Visible-to-all like the other asset routes (CDN
// semantics) - only the feature flag gates it. A changed asset has a new md5,
// so a stale md5 simply 404s and the client re-downloads under the new one.
async fn data(req: HttpRequest) -> HttpResponse {
if disabled() {
return HttpResponse::NotFound().finish();
}
let hash = req.match_info().get("hash").unwrap_or("").to_string();
let file = req.match_info().get("file").unwrap_or("").to_string();
if hash.len() != 32 || !hash.chars().all(|c| c.is_ascii_hexdigit()) || !file.starts_with(&format!("{}.", hash)) {
return HttpResponse::NotFound().finish();
}
let Some((music_id, filename)) = database::find_asset_by_md5(&hash) else {
return HttpResponse::NotFound().finish();
};
match fs::read(song_path(music_id, &filename)) {
Ok(body) => {
let mime = mime_guess::from_path(&filename).first_or_octet_stream();
HttpResponse::Ok()
.insert_header(ContentType(mime))
.insert_header(("content-length", body.len()))
.body(body)
},
Err(_) => HttpResponse::NotFound().finish()
}
}
fn get_session_uid(req: &HttpRequest) -> Option<i64> { fn get_session_uid(req: &HttpRequest) -> Option<i64> {
let token = webui::get_login_token(req)?; let token = webui::get_login_token(req)?;
let login_token = userdata::webui_login_token(&token)?; let login_token = userdata::webui_login_token(&token)?;
@@ -266,6 +297,13 @@ fn cue_json(cue: &audio::Cue, cue_name: String) -> JsonValue {
} }
} }
// (md5-hex, byte-length) of a downloadable asset's served bytes. The client
// caches charts/jackets content-addressed by this md5, so it must be the hash
// of the exact bytes the data route returns
fn asset_meta(bytes: &[u8]) -> (String, usize) {
(format!("{:x}", md5::compute(bytes)), bytes.len())
}
// Score thresholds when the uploader doesn't provide any: take the highest // Score thresholds when the uploader doesn't provide any: take the highest
// difficulty's full_combo and stars, budget base = full_combo * 200 * (1 + stars / 10) // difficulty's full_combo and stars, budget base = full_combo * 200 * (1 + stars / 10)
// (~200 points per note, scaled up for harder charts), then C/B/A/S at // (~200 points per note, scaled up for harder charts), then C/B/A/S at
@@ -343,7 +381,9 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
let suffix = format!("Custom{}", music_id); let suffix = format!("Custom{}", music_id);
let mut levels = array![]; let mut levels = array![];
for (level, _, full_combo, level_number, _) in charts.iter() { for (level, chart, full_combo, level_number, _) in charts.iter() {
// md5/size over the exact chart JSON bytes written to disk and served
let (md5, size) = asset_meta(jzon::stringify(chart.clone()).as_bytes());
levels.push(object!{ levels.push(object!{
"level": *level, "level": *level,
"level_number": *level_number, "level_number": *level_number,
@@ -351,9 +391,13 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
"score_coeff": 1.0, "score_coeff": 1.0,
// Official convention: the filename difficulty index is level+1 // Official convention: the filename difficulty index is level+1
"note_data_file_name": format!("{}_{}_{}", music_id, level + 1, suffix), "note_data_file_name": format!("{}_{}_{}", music_id, level + 1, suffix),
"chart": format!("/custom_song/assets/{}/chart_{}.json", music_id, level) "chart": format!("/custom_song/assets/{}/chart_{}.json", music_id, level),
"md5": md5,
"size": size
}).unwrap(); }).unwrap();
} }
let (jacket_md5, jacket_size) = asset_meta(&jacket);
let (jacket_blur_md5, jacket_blur_size) = asset_meta(&jacket_blur);
let (_, _, hardest_combo, hardest_stars, _) = charts.last().unwrap(); let (_, _, hardest_combo, hardest_stars, _) = charts.last().unwrap();
let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars); let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars);
@@ -402,6 +446,11 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
// Combo missions at 25/50/75/100% of the hardest difficulty's full combo // Combo missions at 25/50/75/100% of the hardest difficulty's full combo
"mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo], "mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo],
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id), "jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
"jacket_md5": jacket_md5,
"jacket_size": jacket_size,
"jacket_blur": format!("/custom_song/assets/{}/jacket_blur.png", music_id),
"jacket_blur_md5": jacket_blur_md5,
"jacket_blur_size": jacket_blur_size,
"levels": levels, "levels": levels,
"sound": { "sound": {
"cue_sheet": format!("song_{}_{}", music_id, suffix), "cue_sheet": format!("song_{}_{}", music_id, suffix),
@@ -550,7 +599,14 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
let suffix = format!("Custom{}", music_id); let suffix = format!("Custom{}", music_id);
let mut levels = array![]; let mut levels = array![];
for (level, _, full_combo, level_number) in charts.iter() { for (level, chart, full_combo, level_number) in charts.iter() {
// md5/size track the on-disk bytes: a replaced chart hashes its new
// bytes, an unchanged one re-hashes the existing file (so even a song
// edited before md5 fields existed gets a complete, correct catalog)
let (md5, size) = match chart {
Some((chart, _)) => asset_meta(jzon::stringify(chart.clone()).as_bytes()),
None => asset_meta(&fs::read(song_path(music_id, &format!("chart_{}.json", level))).map_err(|e| e.to_string())?)
};
levels.push(object!{ levels.push(object!{
"level": *level, "level": *level,
"level_number": *level_number, "level_number": *level_number,
@@ -558,9 +614,23 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
"score_coeff": 1.0, "score_coeff": 1.0,
// Official convention: the filename difficulty index is level+1 // Official convention: the filename difficulty index is level+1
"note_data_file_name": format!("{}_{}_{}", music_id, level + 1, suffix), "note_data_file_name": format!("{}_{}_{}", music_id, level + 1, suffix),
"chart": format!("/custom_song/assets/{}/chart_{}.json", music_id, level) "chart": format!("/custom_song/assets/{}/chart_{}.json", music_id, level),
"md5": md5,
"size": size
}).unwrap(); }).unwrap();
} }
let (jacket_md5, jacket_size, jacket_blur_md5, jacket_blur_size) = match &jacket {
Some((jacket, blur)) => {
let (jm, js) = asset_meta(jacket);
let (bm, bs) = asset_meta(blur);
(jm, js, bm, bs)
},
None => {
let (jm, js) = asset_meta(&fs::read(song_path(music_id, "jacket.png")).map_err(|e| e.to_string())?);
let (bm, bs) = asset_meta(&fs::read(song_path(music_id, "jacket_blur.png")).map_err(|e| e.to_string())?);
(jm, js, bm, bs)
}
};
// Scores and combo missions always derive from the resulting state, with // Scores and combo missions always derive from the resulting state, with
// the same formulas as upload // the same formulas as upload
@@ -618,6 +688,11 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
// Combo missions at 25/50/75/100% of the hardest difficulty's full combo // Combo missions at 25/50/75/100% of the hardest difficulty's full combo
"mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo], "mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo],
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id), "jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
"jacket_md5": jacket_md5,
"jacket_size": jacket_size,
"jacket_blur": format!("/custom_song/assets/{}/jacket_blur.png", music_id),
"jacket_blur_md5": jacket_blur_md5,
"jacket_blur_size": jacket_blur_size,
"levels": levels, "levels": levels,
"sound": sound "sound": sound
}; };
@@ -1299,4 +1374,89 @@ mod tests {
assert!(!client_supports_custom_songs(&wrong)); assert!(!client_supports_custom_songs(&wrong));
assert!(appended(&wrong).is_empty()); assert!(appended(&wrong).is_empty());
} }
// Catalog chart/jacket entries carry md5+size, and the content-addressed
// data route serves the exact bytes for a known md5 (404 for unknown / off)
#[test]
fn catalog_md5_and_data_route() {
use actix_web::test::TestRequest;
let _lock = crate::runtime::lock_test_data_path();
let mut fields = HashMap::new();
field(&mut fields, "name", "Data Route");
field(&mut fields, "artist", "A");
field(&mut fields, "attribute", "1");
fields.insert(String::from("jacket"), test_png());
fields.insert(String::from("audio"), test_ogg());
fields.insert(String::from("chart_1"), test_chart());
let music_id = create_song(9191, &fields).unwrap();
let song = database::get_song(music_id).unwrap();
let chart_md5 = song["levels"][0]["md5"].to_string();
let jacket_md5 = song["jacket_md5"].to_string();
let blur_md5 = song["jacket_blur_md5"].to_string();
// Every downloadable asset entry carries a 32-hex md5 and a nonzero size
for (md5, size) in [
(&chart_md5, song["levels"][0]["size"].as_usize()),
(&jacket_md5, song["jacket_size"].as_usize()),
(&blur_md5, song["jacket_blur_size"].as_usize())
] {
assert_eq!(md5.len(), 32, "md5 {}", md5);
assert!(md5.chars().all(|c| c.is_ascii_hexdigit()));
assert!(size.unwrap() > 0);
}
// Audio cues still carry md5+size (unchanged)
assert_eq!(song["sound"]["play"]["md5"].to_string().len(), 32);
assert!(song["sound"]["select"]["size"].as_usize().unwrap() > 0);
let call = |hash: &str, ext: &str| -> HttpResponse {
let req = TestRequest::default()
.param("hash", hash.to_string())
.param("file", format!("{}.{}", hash, ext))
.to_http_request();
actix_web::rt::System::new().block_on(async { data(req).await })
};
let body_of = |resp: HttpResponse| -> Vec<u8> {
actix_web::rt::System::new()
.block_on(async { actix_web::body::to_bytes(resp.into_body()).await.unwrap() })
.to_vec()
};
// The md5 route serves the exact bytes whose hash IS that md5
let resp = call(&chart_md5, "json");
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
let body = body_of(resp);
assert_eq!(format!("{:x}", md5::compute(&body)), chart_md5);
assert_eq!(body.len(), song["levels"][0]["size"].as_usize().unwrap());
let resp = call(&jacket_md5, "png");
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
assert_eq!(format!("{:x}", md5::compute(body_of(resp))), jacket_md5);
let resp = call(&blur_md5, "png");
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
assert_eq!(format!("{:x}", md5::compute(body_of(resp))), blur_md5);
// Unknown md5 -> 404
assert_eq!(call(&"0".repeat(32), "json").status(), actix_web::http::StatusCode::NOT_FOUND);
// Malformed hash -> 404
assert_eq!(call("nothex", "json").status(), actix_web::http::StatusCode::NOT_FOUND);
// Feature disabled -> 404 even for a real md5
crate::runtime::set_enable_custom_songs(false);
assert_eq!(call(&chart_md5, "json").status(), actix_web::http::StatusCode::NOT_FOUND);
crate::runtime::set_enable_custom_songs(true);
// Editing the chart changes its md5 (self-heal): old md5 stops resolving
let mut edit = HashMap::new();
edit.insert(String::from("chart_1"), jzon::stringify(jzon::array![
{"timing_sec": 0.5, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 4},
{"timing_sec": 1.2, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 8}
]).into_bytes());
update_song(music_id, &edit).unwrap();
let new_md5 = database::get_song(music_id).unwrap()["levels"][0]["md5"].to_string();
assert_ne!(new_md5, chart_md5);
assert_eq!(call(&chart_md5, "json").status(), actix_web::http::StatusCode::NOT_FOUND);
assert_eq!(call(&new_md5, "json").status(), actix_web::http::StatusCode::OK);
}
} }