use lazy_static::lazy_static; use rusqlite::params; use jzon::{array, JsonValue}; use crate::sql::SQLite; lazy_static! { static ref DATABASE: SQLite = SQLite::new("custom_songs.db", setup_tables); } // Song visibility: "public" (everyone), "private" (owner only) or "shared" // (owner plus the user ids in shared_users) pub const VISIBILITIES: &[&str] = &["public", "private", "shared"]; // master_group_id must be a real GroupMst id whose category matches the song's // band_category, or the client's music-library group filter throws // KeyNotFoundException (0 is never a valid GroupMst id). Custom songs go into // each band's misc / "その他" catch-all group so they don't masquerade as an // official sub-unit. Bands with no misc group (and OTHER/unknown) fall back to // 9999, the OTHER catch-all pub fn band_group_id(band_category: &str) -> i64 { match band_category { "MUSE" => 199, "AQOURS" => 299, "NIJIGAKU" => 399, "LIELLA" => 499, "HASUNOSORA" => 599, _ => 9999 } } fn setup_tables(conn: &rusqlite::Connection) { conn.execute_batch(" CREATE TABLE IF NOT EXISTS songs ( music_id BIGINT NOT NULL PRIMARY KEY, owner_id BIGINT NOT NULL, song TEXT NOT NULL, visibility TEXT NOT NULL DEFAULT 'public', downloads_disabled INT NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS shared_users ( music_id BIGINT NOT NULL, user_id BIGINT NOT NULL, PRIMARY KEY (music_id, user_id) ); CREATE TABLE IF NOT EXISTS revision ( id INT NOT NULL PRIMARY KEY, revision BIGINT NOT NULL, last_music_id BIGINT NOT NULL ); ").unwrap(); // Upgrade pre-visibility databases. Existing songs stay public if conn.prepare("SELECT visibility FROM songs LIMIT 1;").is_err() { println!("Upgrading custom song table"); conn.execute("ALTER TABLE songs ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public';", []).unwrap(); } // Upgrade pre-download-toggle databases. Existing songs stay downloadable if conn.prepare("SELECT downloads_disabled FROM songs LIMIT 1;").is_err() { println!("Upgrading custom song table (downloads)"); conn.execute("ALTER TABLE songs ADD COLUMN downloads_disabled INT NOT NULL DEFAULT 0;", []).unwrap(); } // Rewrite blobs that stored the old invalid master_group_id 0 to the band's // real GroupMst id (the client's group filter crashes on 0). The blob is // served verbatim, so this must be persisted, not just applied at read time let rows: Vec<(i64, String)> = { let mut stmt = conn.prepare("SELECT music_id, song FROM songs").unwrap(); let mapped = stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))).unwrap(); mapped.filter_map(|r| r.ok()).collect() }; let mut fixed = 0; for (music_id, blob) in rows { let mut song = jzon::parse(&blob).unwrap(); if song["master_group_id"].as_i64() == Some(0) { song["master_group_id"] = band_group_id(&song["band_category"].to_string()).into(); conn.execute("UPDATE songs SET song=?1 WHERE music_id=?2", params!(jzon::stringify(song), music_id)).unwrap(); fixed += 1; } } if fixed > 0 { println!("Upgrading custom song table (master_group_id): {} row(s)", fixed); } } pub fn get_revision() -> i64 { DATABASE.lock_and_select("SELECT revision FROM revision WHERE id=1", params!()).unwrap_or_default().parse::().unwrap_or(0) } // Bumped on every upload/update/delete/visibility change so the client can invalidate its cache pub fn bump_revision() { DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_music_id) VALUES (1, 1, 0) ON CONFLICT(id) DO UPDATE SET revision=revision+1", params!()); } // music_ids are assigned sequentially starting at 10000. live_id == music_id. // The official music mst occupies 4-digit ids up to 9032 (School Idol Musical // + 異次元フェス) along with bgm 310090xx/320090xx, so the custom range gets // the 5-digit space to itself (10000 -> bgm 31010000/32010000). Ids are never // reused after a delete, so a client's cached copy of a dead id can't get // confused with a new upload. Stock live ids (master_live_id) are 7-digit // (>= 1_000_000), so the custom range stops below them - a stock clear must // never be mistaken for a deleted custom song pub const FIRST_MUSIC_ID: i64 = 10000; pub const LAST_MUSIC_ID: i64 = 999_999; pub fn next_music_id() -> i64 { let issued = DATABASE.lock_and_select("SELECT last_music_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::().unwrap_or(0); let max = DATABASE.lock_and_select("SELECT MAX(music_id) FROM songs", params!()).unwrap_or_default().parse::().unwrap_or(0); std::cmp::max(std::cmp::max(issued, max), FIRST_MUSIC_ID - 1) + 1 } pub fn insert_song(music_id: i64, owner_id: i64, song: &JsonValue, visibility: &str, shared_with: &JsonValue, downloads_disabled: bool) { DATABASE.lock_and_exec("INSERT INTO songs (music_id, owner_id, song, visibility, downloads_disabled) VALUES (?1, ?2, ?3, ?4, ?5)", params!(music_id, owner_id, jzon::stringify(song.clone()), visibility, downloads_disabled as i64)); DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_music_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_music_id=?1", params!(music_id)); set_shared_users(music_id, shared_with); } // Replace an existing song's catalog blob in place. The owner, visibility, // shared list and download toggle live in their own columns and are untouched pub fn update_song(music_id: i64, song: &JsonValue) { DATABASE.lock_and_exec("UPDATE songs SET song=?1 WHERE music_id=?2", params!(jzon::stringify(song.clone()), music_id)); } pub fn delete_song(music_id: i64) { DATABASE.lock_and_exec("DELETE FROM songs WHERE music_id=?1", params!(music_id)); DATABASE.lock_and_exec("DELETE FROM shared_users WHERE music_id=?1", params!(music_id)); } pub fn get_song(music_id: i64) -> Option { let song = DATABASE.lock_and_select("SELECT song FROM songs WHERE music_id=?1", params!(music_id)).ok()?; jzon::parse(&song).ok() } pub fn get_song_owner(music_id: i64) -> Option { DATABASE.lock_and_select("SELECT owner_id FROM songs WHERE music_id=?1", params!(music_id)).ok()?.parse::().ok() } pub fn set_visibility(music_id: i64, visibility: &str, shared_with: &JsonValue) { DATABASE.lock_and_exec("UPDATE songs SET visibility=?1 WHERE music_id=?2", params!(visibility, music_id)); set_shared_users(music_id, shared_with); } pub fn set_downloads_disabled(music_id: i64, downloads_disabled: bool) { DATABASE.lock_and_exec("UPDATE songs SET downloads_disabled=?1 WHERE music_id=?2", params!(downloads_disabled as i64, music_id)); } fn get_downloads_disabled(music_id: i64) -> bool { DATABASE.lock_and_select("SELECT downloads_disabled FROM songs WHERE music_id=?1", params!(music_id)).unwrap_or_default() == "1" } fn set_shared_users(music_id: i64, shared_with: &JsonValue) { DATABASE.lock_and_exec("DELETE FROM shared_users WHERE music_id=?1", params!(music_id)); for id in shared_with.members() { DATABASE.lock_and_exec("INSERT OR IGNORE INTO shared_users (music_id, user_id) VALUES (?1, ?2)", params!(music_id, id.as_i64().unwrap())); } } fn get_visibility(music_id: i64) -> String { DATABASE.lock_and_select("SELECT visibility FROM songs WHERE music_id=?1", params!(music_id)).unwrap_or(String::from("public")) } fn get_shared_users(music_id: i64) -> JsonValue { DATABASE.lock_and_select_all("SELECT user_id FROM shared_users WHERE music_id=?1 ORDER BY user_id", params!(music_id)).unwrap_or(array![]) } // The catalog a given user is allowed to see: public songs, their own songs, // and songs shared with them pub fn get_songs_for_user(user_id: i64) -> JsonValue { let songs = DATABASE.lock_and_select_all(" SELECT song FROM songs WHERE visibility='public' OR owner_id=?1 OR (visibility='shared' AND music_id IN (SELECT music_id FROM shared_users WHERE user_id=?1)) ORDER BY music_id", params!(user_id)).unwrap_or(array![]); let mut rv = array![]; for data in songs.members() { rv.push(jzon::parse(&data.to_string()).unwrap()).unwrap(); } rv } // Song blobs plus the visibility fields, for the webui manage view pub fn get_songs_by_owner(owner_id: i64) -> JsonValue { let songs = DATABASE.lock_and_select_all("SELECT song FROM songs WHERE owner_id=?1 ORDER BY music_id", params!(owner_id)).unwrap_or(array![]); let mut rv = array![]; for data in songs.members() { let mut song = jzon::parse(&data.to_string()).unwrap(); let music_id = song["music_id"].as_i64().unwrap(); song["visibility"] = get_visibility(music_id).into(); song["shared_with"] = get_shared_users(music_id); song["downloads_disabled"] = get_downloads_disabled(music_id).into(); rv.push(song).unwrap(); } rv } // The webui song browser: everything the viewer is allowed to see under the // visibility rules, plus the fields the browser page needs. Anonymous viewers // (no webui session) see the public catalog only pub fn get_browse_songs(viewer: Option) -> JsonValue { let viewer = viewer.unwrap_or(0); let songs = DATABASE.lock_and_select_all(" SELECT song FROM songs WHERE visibility='public' OR owner_id=?1 OR (visibility='shared' AND music_id IN (SELECT music_id FROM shared_users WHERE user_id=?1)) ORDER BY music_id", params!(viewer)).unwrap_or(array![]); let mut rv = array![]; for data in songs.members() { let mut song = jzon::parse(&data.to_string()).unwrap(); let music_id = song["music_id"].as_i64().unwrap(); let owner = get_song_owner(music_id).unwrap_or(0); song["owner_id"] = owner.into(); song["mine"] = (owner == viewer).into(); song["downloads_disabled"] = get_downloads_disabled(music_id).into(); rv.push(song).unwrap(); } rv } // Whether this viewer may download the song's export package: they must be // able to SEE it (same rules as the catalog - a private song 404s rather than // admitting it exists), and downloads must be enabled unless they own it pub fn export_allowed(music_id: i64, viewer: Option) -> Result<(), &'static str> { let Some(owner) = get_song_owner(music_id) else { return Err("Song not found"); }; let viewer = viewer.unwrap_or(0); if owner == viewer { return Ok(()); } let visible = match get_visibility(music_id).as_str() { "public" => true, "shared" => get_shared_users(music_id).contains(viewer), _ => false }; if !visible { return Err("Song not found"); } if get_downloads_disabled(music_id) { return Err("The uploader has disabled downloads for this song"); } Ok(()) } pub fn get_music_ids_for_user(user_id: i64) -> JsonValue { DATABASE.lock_and_select_all(" SELECT music_id FROM songs WHERE visibility='public' OR owner_id=?1 OR (visibility='shared' AND music_id IN (SELECT music_id FROM shared_users WHERE user_id=?1)) ORDER BY music_id", params!(user_id)).unwrap_or(array![]) } pub fn non_public_music_ids() -> JsonValue { DATABASE.lock_and_select_all("SELECT music_id FROM songs WHERE visibility!='public' ORDER BY music_id", params!()).unwrap_or(array![]) } pub fn non_public_music_ids_for(user_id: i64) -> JsonValue { DATABASE.lock_and_select_all(" SELECT music_id FROM songs WHERE visibility!='public' AND owner_id!=?1 AND music_id NOT IN (SELECT music_id FROM shared_users WHERE user_id=?1) ORDER BY music_id", params!(user_id)).unwrap_or(array![]) } // Which of these candidate ids no longer exist in the catalog. Only the custom // range is ever considered, so official songs can't come back from this. A song // that's merely private/shared still has its row - only genuinely deleted ids // (which are never reused) are returned pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue { let mut ids: Vec = Vec::new(); for id in candidates.members() { let Some(id) = id.as_i64() else { continue; }; if id >= FIRST_MUSIC_ID && id <= LAST_MUSIC_ID && !ids.contains(&id) { ids.push(id); } } if ids.is_empty() { return array![]; } let list = ids.iter().map(|id| id.to_string()).collect::>().join(","); let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).unwrap_or(array![]); let mut rv = array![]; for id in ids { if !alive.contains(id) { rv.push(id).unwrap(); } } rv } // Audio files are content-addressed and may be shared between songs 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() } // 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 }