mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 15:02:18 +08:00
317 lines
14 KiB
Rust
317 lines
14 KiB
Rust
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::<i64>().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::<i64>().unwrap_or(0);
|
|
let max = DATABASE.lock_and_select("SELECT MAX(music_id) FROM songs", params!()).unwrap_or_default().parse::<i64>().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<JsonValue> {
|
|
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<i64> {
|
|
DATABASE.lock_and_select("SELECT owner_id FROM songs WHERE music_id=?1", params!(music_id)).ok()?.parse::<i64>().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<i64>) -> 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<i64>) -> 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<i64> = 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::<Vec<_>>().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
|
|
}
|