mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-10-11 17:14:30 +08:00
Some design fixes in custom data handling
This commit is contained in:
+61
-15
@@ -53,12 +53,18 @@ pub fn next_mv_id() -> i64 {
|
||||
std::cmp::max(std::cmp::max(issued, max), FIRST_MV_ID - 1) + 1
|
||||
}
|
||||
|
||||
pub fn insert_mv(mv_id: i64, music_id: i64, owner_id: i64, mv: &JsonValue, published: bool) {
|
||||
DATABASE.lock_and_exec(
|
||||
"INSERT INTO mvs (mv_id, music_id, owner_id, mv, published) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params!(mv_id, music_id, owner_id, jzon::stringify(mv.clone()), published as i64)
|
||||
);
|
||||
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_mv_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_mv_id=?1", params!(mv_id));
|
||||
// The row and the high-water mark are one write: a failure between them leaves an
|
||||
// MV whose id the next upload would reissue, and the failure has to reach the
|
||||
// uploader as an error rather than panic the worker (lock_and_exec unwraps)
|
||||
pub fn insert_mv(mv_id: i64, music_id: i64, owner_id: i64, mv: &JsonValue, published: bool) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO mvs (mv_id, music_id, owner_id, mv, published) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params!(mv_id, music_id, owner_id, jzon::stringify(mv.clone()), published as i64)
|
||||
)?;
|
||||
conn.execute("INSERT INTO revision (id, revision, last_mv_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_mv_id=?1", params!(mv_id))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// The catalog blob only. The owner and the published flag live in their own
|
||||
@@ -195,17 +201,24 @@ pub fn all_mv_blobs() -> Option<JsonValue> {
|
||||
|
||||
// Blobs are content-addressed and may be shared between MVs (or roles), so
|
||||
// every candidate row is checked - a single-row scan could land on a
|
||||
// coincidental substring match and miss the real reference
|
||||
pub fn blob_in_use(md5: &str) -> bool {
|
||||
let rows = DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE mv LIKE ?1", params!(format!("%{}%", md5))).unwrap_or(array![]);
|
||||
// coincidental substring match and miss the real reference.
|
||||
//
|
||||
// Returns Result and never folds an error into "not referenced": the caller
|
||||
// unlinks on false, and a read that failed (SQLITE_BUSY under a concurrent
|
||||
// write, a corrupt page) says nothing about whether the blob is live. The
|
||||
// startup sweep is deliberately fail-closed; this is its online half
|
||||
pub fn blob_in_use(md5: &str) -> Result<bool, rusqlite::Error> {
|
||||
let rows = DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE mv LIKE ?1", params!(format!("%{}%", md5)))?;
|
||||
for blob in rows.members() {
|
||||
if let Ok(mv) = jzon::parse(&blob.to_string()) {
|
||||
if mv["files"].members().any(|file| file["md5"].as_str() == Some(md5)) {
|
||||
return true;
|
||||
}
|
||||
let Ok(mv) = jzon::parse(&blob.to_string()) else {
|
||||
// An unparseable row could reference anything - assume it does
|
||||
return Ok(true);
|
||||
};
|
||||
if mv["files"].members().any(|file| file["md5"].as_str() == Some(md5)) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
false
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// Two-step content-addressed lookup for the data route: the LIKE scan finds a
|
||||
@@ -213,5 +226,38 @@ pub fn blob_in_use(md5: &str) -> bool {
|
||||
// stored file of a live MV (and not a substring coincidence elsewhere in the
|
||||
// blob). The blob path itself derives from the md5
|
||||
pub fn find_blob_by_md5(md5: &str) -> bool {
|
||||
blob_in_use(md5)
|
||||
// A read failure here means "cannot prove this blob is served", which the
|
||||
// route turns into a 404 - the client re-requests. Unlike the GC, guessing
|
||||
// wrong in this direction costs nothing permanent
|
||||
blob_in_use(md5).unwrap_or(false)
|
||||
}
|
||||
|
||||
// Every MV this account uploaded, for the account-deletion purge
|
||||
pub fn mv_ids_for_owner(owner_id: i64) -> Vec<i64> {
|
||||
let rows = DATABASE.lock_and_select_all("SELECT mv_id FROM mvs WHERE owner_id=?1 ORDER BY mv_id", params!(owner_id)).unwrap_or(array![]);
|
||||
rows.members().filter_map(|id| id.as_i64()).collect()
|
||||
}
|
||||
|
||||
// The stored bytes an MV's files account for. Blobs are shared, so two MVs that
|
||||
// carry the same model both count it: the quota is "what this account asked the
|
||||
// server to store", not a dedup-aware disk figure
|
||||
pub fn mv_bytes(files: &JsonValue) -> i64 {
|
||||
files.members().map(|file| file["size"].as_i64().unwrap_or(0)).sum()
|
||||
}
|
||||
|
||||
// What this account's MVs already occupy, optionally ignoring one (the MV being
|
||||
// replaced by an in-place edit, whose new size is counted instead)
|
||||
pub fn owner_bytes(owner_id: i64, excluding_mv_id: i64) -> i64 {
|
||||
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![]));
|
||||
rows.members()
|
||||
.filter(|mv| mv["mv_id"].as_i64() != Some(excluding_mv_id))
|
||||
.map(|mv| mv_bytes(&mv["files"]))
|
||||
.sum()
|
||||
}
|
||||
|
||||
// The on-disk database file, so a test can force the read errors the fail-closed
|
||||
// GC paths are built for
|
||||
#[cfg(test)]
|
||||
pub fn test_db_path() -> String {
|
||||
DATABASE.get_path().to_string()
|
||||
}
|
||||
|
||||
+76
-26
@@ -87,20 +87,29 @@ pub fn next_character_id() -> i64 {
|
||||
std::cmp::max(std::cmp::max(issued, max), FIRST_CHARACTER_ID - 1) + 1
|
||||
}
|
||||
|
||||
pub fn insert_card(master_card_id: i64, master_character_id: i64, owner_id: i64, card: &JsonValue, published: bool, obtainable: bool) {
|
||||
DATABASE.lock_and_exec(
|
||||
"INSERT INTO cards (master_card_id, master_character_id, owner_id, card, rarity, published, obtainable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params!(master_card_id, master_character_id, owner_id, jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), published as i64, obtainable as i64)
|
||||
);
|
||||
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, ?1, 0) ON CONFLICT(id) DO UPDATE SET last_card_id=?1", params!(master_card_id));
|
||||
// The row and the high-water mark are one write: a failure between them leaves a
|
||||
// card whose id the next upload would reissue, and the failure has to reach the
|
||||
// uploader as an error rather than panic the worker (lock_and_exec unwraps)
|
||||
pub fn insert_card(master_card_id: i64, master_character_id: i64, owner_id: i64, card: &JsonValue, published: bool, obtainable: bool) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO cards (master_card_id, master_character_id, owner_id, card, rarity, published, obtainable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params!(master_card_id, master_character_id, owner_id, jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), published as i64, obtainable as i64)
|
||||
)?;
|
||||
conn.execute("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, ?1, 0) ON CONFLICT(id) DO UPDATE SET last_card_id=?1", params!(master_card_id))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_character(master_character_id: i64, owner_id: i64, character: &JsonValue) {
|
||||
DATABASE.lock_and_exec(
|
||||
"INSERT INTO characters (master_character_id, owner_id, character) VALUES (?1, ?2, ?3)",
|
||||
params!(master_character_id, owner_id, jzon::stringify(character.clone()))
|
||||
);
|
||||
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_character_id=?1", params!(master_character_id));
|
||||
pub fn insert_character(master_character_id: i64, owner_id: i64, character: &JsonValue) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO characters (master_character_id, owner_id, character) VALUES (?1, ?2, ?3)",
|
||||
params!(master_character_id, owner_id, jzon::stringify(character.clone()))
|
||||
)?;
|
||||
conn.execute("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_character_id=?1", params!(master_character_id))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// The catalog blob only. The owner and the published/obtainable flags live in
|
||||
@@ -173,6 +182,47 @@ pub fn has_character(master_character_id: i64) -> bool {
|
||||
DATABASE.lock_and_select("SELECT master_character_id FROM characters WHERE master_character_id=?1", params!(master_character_id)).is_ok()
|
||||
}
|
||||
|
||||
// Every card / character this account uploaded, for the account-deletion purge
|
||||
pub fn card_ids_for_owner(owner_id: i64) -> Vec<i64> {
|
||||
DATABASE.lock_and_select_all("SELECT master_card_id FROM cards WHERE owner_id=?1 ORDER BY master_card_id", params!(owner_id))
|
||||
.unwrap_or(array![]).members().filter_map(|id| id.as_i64()).collect()
|
||||
}
|
||||
|
||||
pub fn character_ids_for_owner(owner_id: i64) -> Vec<i64> {
|
||||
DATABASE.lock_and_select_all("SELECT master_character_id FROM characters WHERE owner_id=?1 ORDER BY master_character_id", params!(owner_id))
|
||||
.unwrap_or(array![]).members().filter_map(|id| id.as_i64()).collect()
|
||||
}
|
||||
|
||||
// The stored bytes one card / character accounts for: every derived art png
|
||||
// plus, for a character, its voiceline oggs
|
||||
pub fn card_bytes(card: &JsonValue) -> i64 {
|
||||
card["art"].members().map(|art| art["size"].as_i64().unwrap_or(0)).sum()
|
||||
}
|
||||
|
||||
pub fn character_bytes(character: &JsonValue) -> i64 {
|
||||
let art: i64 = character["art"].members().map(|art| art["size"].as_i64().unwrap_or(0)).sum();
|
||||
let voice: i64 = character["voice"].members().map(|line| line["size"].as_i64().unwrap_or(0)).sum();
|
||||
art + voice
|
||||
}
|
||||
|
||||
// What this account's uploads already occupy. Cards and characters share one
|
||||
// quota (they share one storage root), each optionally ignoring the entity being
|
||||
// replaced by an in-place edit, whose new size is counted instead
|
||||
pub fn owner_bytes(owner_id: i64, excluding_card_id: i64, excluding_character_id: i64) -> i64 {
|
||||
let mut total = 0;
|
||||
for blob in parse_blobs(DATABASE.lock_and_select_all("SELECT card FROM cards WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![])).members() {
|
||||
if blob["master_card_id"].as_i64() != Some(excluding_card_id) {
|
||||
total += card_bytes(blob);
|
||||
}
|
||||
}
|
||||
for blob in parse_blobs(DATABASE.lock_and_select_all("SELECT character FROM characters WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![])).members() {
|
||||
if blob["master_character_id"].as_i64() != Some(excluding_character_id) {
|
||||
total += character_bytes(blob);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
pub fn card_count_for_owner(owner_id: i64) -> i64 {
|
||||
DATABASE.lock_and_select_type::<i64>("SELECT COUNT(*) FROM cards WHERE owner_id=?1", params!(owner_id)).unwrap_or(0)
|
||||
}
|
||||
@@ -410,10 +460,10 @@ mod tests {
|
||||
let first = next_card_id();
|
||||
assert!(first >= FIRST_CARD_ID);
|
||||
assert_ne!(first % 10000, 0);
|
||||
insert_card(first, 1001, 3001, &card_blob(first, 1), false, false);
|
||||
insert_card(first, 1001, 3001, &card_blob(first, 1), false, false).unwrap();
|
||||
let second = next_card_id();
|
||||
assert_eq!(second, first + 1);
|
||||
insert_card(second, 1001, 3001, &card_blob(second, 1), false, false);
|
||||
insert_card(second, 1001, 3001, &card_blob(second, 1), false, false).unwrap();
|
||||
delete_card(second);
|
||||
assert_eq!(get_card(second), None);
|
||||
// The high-water mark survives the delete, so the id is retired
|
||||
@@ -421,7 +471,7 @@ mod tests {
|
||||
|
||||
let character = next_character_id();
|
||||
assert!(character >= FIRST_CHARACTER_ID);
|
||||
insert_character(character, 3001, &object!{ "master_character_id": character });
|
||||
insert_character(character, 3001, &object!{ "master_character_id": character }).unwrap();
|
||||
assert_eq!(next_character_id(), character + 1);
|
||||
delete_character(character);
|
||||
assert_eq!(next_character_id(), character + 1);
|
||||
@@ -440,13 +490,13 @@ mod tests {
|
||||
wipe(3004);
|
||||
|
||||
let character = next_character_id();
|
||||
insert_character(character, 3003, &object!{ "master_character_id": character });
|
||||
insert_character(character, 3003, &object!{ "master_character_id": character }).unwrap();
|
||||
let published = next_card_id();
|
||||
let mut blob = card_blob(published, 3);
|
||||
blob["master_character_id"] = character.into();
|
||||
insert_card(published, character, 3003, &blob, true, true);
|
||||
insert_card(published, character, 3003, &blob, true, true).unwrap();
|
||||
let draft = next_card_id();
|
||||
insert_card(draft, character, 3003, &card_blob(draft, 1), false, false);
|
||||
insert_card(draft, character, 3003, &card_blob(draft, 1), false, false).unwrap();
|
||||
|
||||
let owner_view = get_cards_for_user(3003, &[]);
|
||||
assert_eq!(owner_view.len(), 2);
|
||||
@@ -493,13 +543,13 @@ mod tests {
|
||||
wipe(3005);
|
||||
|
||||
let r1 = next_card_id();
|
||||
insert_card(r1, 1001, 3005, &card_blob(r1, 1), true, true);
|
||||
insert_card(r1, 1001, 3005, &card_blob(r1, 1), true, true).unwrap();
|
||||
let r3 = next_card_id();
|
||||
insert_card(r3, 1001, 3005, &card_blob(r3, 3), true, true);
|
||||
insert_card(r3, 1001, 3005, &card_blob(r3, 3), true, true).unwrap();
|
||||
let unpublished = next_card_id();
|
||||
insert_card(unpublished, 1001, 3005, &card_blob(unpublished, 1), false, true);
|
||||
insert_card(unpublished, 1001, 3005, &card_blob(unpublished, 1), false, true).unwrap();
|
||||
let unobtainable = next_card_id();
|
||||
insert_card(unobtainable, 1001, 3005, &card_blob(unobtainable, 1), true, false);
|
||||
insert_card(unobtainable, 1001, 3005, &card_blob(unobtainable, 1), true, false).unwrap();
|
||||
|
||||
assert_eq!(obtainable_card_ids(1), vec![r1]);
|
||||
assert_eq!(obtainable_card_ids(3), vec![r3]);
|
||||
@@ -516,7 +566,7 @@ mod tests {
|
||||
wipe(3007);
|
||||
|
||||
let id = next_card_id();
|
||||
insert_card(id, 1001, 3007, &card_blob(id, 1), true, false);
|
||||
insert_card(id, 1001, 3007, &card_blob(id, 1), true, false).unwrap();
|
||||
assert_eq!(find_asset_by_md5(&format!("{:032x}", id)), Some(format!("{}/c_00.png", id)));
|
||||
assert_eq!(find_asset_by_md5("00000000000000000000000000000000"), None);
|
||||
|
||||
@@ -524,7 +574,7 @@ mod tests {
|
||||
insert_character(character, 3007, &object!{
|
||||
"master_character_id": character,
|
||||
"art": [{ "kind": "icon", "md5": "aabbccddeeff00112233445566778899", "size": 1 }]
|
||||
});
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
find_asset_by_md5("aabbccddeeff00112233445566778899"),
|
||||
Some(format!("characters/{}/icon.png", character))
|
||||
@@ -541,9 +591,9 @@ mod tests {
|
||||
wipe(3008);
|
||||
|
||||
let alive = next_card_id();
|
||||
insert_card(alive, 1001, 3008, &card_blob(alive, 1), false, false);
|
||||
insert_card(alive, 1001, 3008, &card_blob(alive, 1), false, false).unwrap();
|
||||
let dead = next_card_id();
|
||||
insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false);
|
||||
insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false).unwrap();
|
||||
delete_card(dead);
|
||||
|
||||
let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]);
|
||||
|
||||
+122
-16
@@ -107,10 +107,16 @@ pub fn next_music_id() -> i64 {
|
||||
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);
|
||||
// The row, the high-water mark and the shared-user list are one write: a failure
|
||||
// between them would leave a song whose id the next upload reissues, and the
|
||||
// failure itself must reach the uploader as an error rather than panic the worker
|
||||
// (lock_and_exec unwraps, lock_and_transact returns)
|
||||
pub fn insert_song(music_id: i64, owner_id: i64, song: &JsonValue, visibility: &str, shared_with: &JsonValue, downloads_disabled: bool) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute("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))?;
|
||||
conn.execute("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))?;
|
||||
write_shared_users(conn, music_id, shared_with)
|
||||
})
|
||||
}
|
||||
|
||||
// Replace an existing song's catalog blob in place. The owner, visibility,
|
||||
@@ -119,9 +125,12 @@ 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 delete_song(music_id: i64) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute("DELETE FROM songs WHERE music_id=?1", params!(music_id))?;
|
||||
conn.execute("DELETE FROM shared_users WHERE music_id=?1", params!(music_id))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_song(music_id: i64) -> Option<JsonValue> {
|
||||
@@ -133,9 +142,11 @@ 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_visibility(music_id: i64, visibility: &str, shared_with: &JsonValue) -> Result<(), rusqlite::Error> {
|
||||
DATABASE.lock_and_transact(|conn| {
|
||||
conn.execute("UPDATE songs SET visibility=?1 WHERE music_id=?2", params!(visibility, music_id))?;
|
||||
write_shared_users(conn, music_id, shared_with)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_downloads_disabled(music_id: i64, downloads_disabled: bool) {
|
||||
@@ -146,11 +157,15 @@ 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));
|
||||
// Replace-the-whole-list, inside the caller's transaction: a half-written list is a
|
||||
// song shared with the wrong people
|
||||
fn write_shared_users(conn: &rusqlite::Connection, music_id: i64, shared_with: &JsonValue) -> Result<(), rusqlite::Error> {
|
||||
conn.execute("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()));
|
||||
let Some(id) = id.as_i64() else { continue; };
|
||||
conn.execute("INSERT OR IGNORE INTO shared_users (music_id, user_id) VALUES (?1, ?2)", params!(music_id, id))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_visibility(music_id: i64) -> String {
|
||||
@@ -257,6 +272,67 @@ pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
|
||||
Some(if english && !name_en.is_empty() { name_en } else { name })
|
||||
}
|
||||
|
||||
// Whether this viewer may fetch the song's per-id asset files (jacket, blur,
|
||||
// chart JSON). Exactly the catalog's visibility rule: public to everyone, private
|
||||
// to its owner, shared to its owner and the shared-user list. Anonymous viewers
|
||||
// (no webui session) are only ever shown public songs.
|
||||
//
|
||||
// The /data/{md5} route stays sessionless on purpose - a 128-bit content hash IS
|
||||
// the capability there - but /assets/{music_id}/{file} is addressed by a
|
||||
// sequential id, so it needs the real rule
|
||||
pub fn asset_visible(music_id: i64, viewer: Option<i64>) -> bool {
|
||||
let Some(owner) = get_song_owner(music_id) else {
|
||||
return false;
|
||||
};
|
||||
let viewer = viewer.unwrap_or(0);
|
||||
if owner == viewer {
|
||||
return true;
|
||||
}
|
||||
match get_visibility(music_id).as_str() {
|
||||
"public" => true,
|
||||
"shared" => get_shared_users(music_id).contains(viewer),
|
||||
_ => false
|
||||
}
|
||||
}
|
||||
|
||||
// Every song this account uploaded, for the account-deletion purge
|
||||
pub fn music_ids_by_owner(owner_id: i64) -> Vec<i64> {
|
||||
DATABASE.lock_and_select_all("SELECT music_id FROM songs WHERE owner_id=?1 ORDER BY music_id", params!(owner_id))
|
||||
.unwrap_or(array![])
|
||||
.members().filter_map(|id| id.as_i64()).collect()
|
||||
}
|
||||
|
||||
// The served bytes one song accounts for: both jackets, every chart and both
|
||||
// audio cues, straight out of the catalog blob that quotes them to the client.
|
||||
// The original upload artifacts kept under original/ are NOT counted (the
|
||||
// catalog does not record their sizes); the per-account cap is set with that
|
||||
// roughly-2x on-disk factor in mind
|
||||
pub fn song_bytes(song: &JsonValue) -> i64 {
|
||||
let mut total = song["jacket_size"].as_i64().unwrap_or(0) + song["jacket_blur_size"].as_i64().unwrap_or(0);
|
||||
for level in song["levels"].members() {
|
||||
total += level["size"].as_i64().unwrap_or(0);
|
||||
}
|
||||
for key in ["play", "select"] {
|
||||
total += song["sound"][key]["size"].as_i64().unwrap_or(0);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
// What this account's songs already occupy, optionally ignoring one song (the
|
||||
// one being replaced by an in-place edit, whose new size is counted instead)
|
||||
pub fn owner_bytes(owner_id: i64, excluding_music_id: i64) -> i64 {
|
||||
let songs = DATABASE.lock_and_select_all("SELECT song FROM songs WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![]);
|
||||
let mut total = 0;
|
||||
for blob in songs.members() {
|
||||
let Ok(song) = jzon::parse(&blob.to_string()) else { continue; };
|
||||
if song["music_id"].as_i64() == Some(excluding_music_id) {
|
||||
continue;
|
||||
}
|
||||
total += song_bytes(&song);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
// Whether the song exists and is publicly visible - what lets another
|
||||
// uploader attach cross-feature content (a custom 3D MV) to it. The
|
||||
// existence check comes first: get_visibility defaults to "public" for an
|
||||
@@ -319,9 +395,32 @@ pub fn all_song_blobs() -> Option<JsonValue> {
|
||||
DATABASE.lock_and_select_all("SELECT song FROM songs ORDER BY music_id", params!()).ok()
|
||||
}
|
||||
|
||||
// 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()
|
||||
// Audio files are content-addressed and may be shared between songs, so an ogg is
|
||||
// only unlinkable once no other song's play/select cue names it. The LIKE scan finds
|
||||
// candidate rows cheaply and the parsed cues confirm the hit (a substring coincidence
|
||||
// elsewhere in the blob is not a reference), exactly like custom_3dmv::blob_in_use.
|
||||
//
|
||||
// Returns Result and NEVER folds an error into "false": the caller unlinks on false,
|
||||
// and a read that failed (SQLITE_BUSY under a concurrent write, a corrupt page) says
|
||||
// nothing about whether the file is referenced. The startup sweep is deliberately
|
||||
// fail-closed for the same reason; this is the online half of that rule
|
||||
pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> Result<bool, rusqlite::Error> {
|
||||
let rows = DATABASE.lock_and_select_all(
|
||||
"SELECT song FROM songs WHERE music_id!=?1 AND song LIKE ?2",
|
||||
params!(ignored_music_id, format!("%{}%", md5))
|
||||
)?;
|
||||
for blob in rows.members() {
|
||||
let Ok(song) = jzon::parse(&blob.to_string()) else {
|
||||
// An unparseable row could reference anything - assume it does
|
||||
return Ok(true);
|
||||
};
|
||||
for key in ["play", "select"] {
|
||||
if song["sound"][key]["md5"].as_str() == Some(md5) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// Resolve a content-addressed chart/jacket md5 to the per-song-id file that
|
||||
@@ -348,3 +447,10 @@ pub fn find_asset_by_md5(md5: &str) -> Option<(i64, String)> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// The on-disk database file, so a test can force the read errors the fail-closed
|
||||
// GC paths are built for
|
||||
#[cfg(test)]
|
||||
pub fn test_db_path() -> String {
|
||||
DATABASE.get_path().to_string()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user