mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-07-12 00:32:20 +08:00
Minor changes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ docker/data/
|
|||||||
.idea/
|
.idea/
|
||||||
ndk/
|
ndk/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
custom_songs/
|
||||||
|
|||||||
1043
Cargo.lock
generated
1043
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
@@ -30,8 +30,14 @@ mime_guess = "2.0.5"
|
|||||||
include_dir = "0.7.4"
|
include_dir = "0.7.4"
|
||||||
jzon = "0.12.5"
|
jzon = "0.12.5"
|
||||||
csv = "1.3"
|
csv = "1.3"
|
||||||
argon2 = "0.5.3"
|
actix-multipart = "0.8"
|
||||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
futures-util = "0.3"
|
||||||
|
image = "0.25"
|
||||||
|
zip = { version = "8.6", default-features = false, features = ["deflate"] }
|
||||||
|
symphonia = { version = "0.6", default-features = false, features = ["ogg", "vorbis", "mp3", "wav", "pcm"] }
|
||||||
|
vorbis_rs = "0.5"
|
||||||
|
argon2 = { version = "0.5.3", features = ["std"] }
|
||||||
|
rand_core = "0.10"
|
||||||
|
|
||||||
[target.aarch64-linux-android.dependencies]
|
[target.aarch64-linux-android.dependencies]
|
||||||
jni = { version = "0.21", features = ["invocation", "default"], optional = true }
|
jni = { version = "0.21", features = ["invocation", "default"], optional = true }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ services:
|
|||||||
HIDDEN: false # Will disable the webui
|
HIDDEN: false # Will disable the webui
|
||||||
DISABLE_IMPORTS: false # Will disable account imports
|
DISABLE_IMPORTS: false # Will disable account imports
|
||||||
DISABLE_EXPORTS: false # Will disable account exports
|
DISABLE_EXPORTS: false # Will disable account exports
|
||||||
|
#ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints)
|
||||||
#PURGE: false # Purge dead user accounts on startup
|
#PURGE: false # Purge dead user accounts on startup
|
||||||
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
|
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
|
||||||
#MASTERDATA: /masterdata/ # Override bundled asset lists / CSVs / new_user.json at runtime (missing files fall back to the internal copies)
|
#MASTERDATA: /masterdata/ # Override bundled asset lists / CSVs / new_user.json at runtime (missing files fall back to the internal copies)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ args=(
|
|||||||
[ "${PURGE:-}" = "true" ] && args+=(--purge)
|
[ "${PURGE:-}" = "true" ] && args+=(--purge)
|
||||||
[ "${DISABLE_IMPORTS:-}" = "true" ] && args+=(--disable-imports)
|
[ "${DISABLE_IMPORTS:-}" = "true" ] && args+=(--disable-imports)
|
||||||
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
|
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
|
||||||
|
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
|
||||||
|
|
||||||
add_opt() {
|
add_opt() {
|
||||||
local value="$1" flag="$2"
|
local value="$1" flag="$2"
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
pub mod gree;
|
pub mod gree;
|
||||||
|
pub mod custom_song;
|
||||||
|
|||||||
270
src/database/custom_song.rs
Normal file
270
src/database/custom_song.rs
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
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/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
|
||||||
|
pub const FIRST_MUSIC_ID: i64 = 10000;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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![])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 && !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()
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ pub struct Args {
|
|||||||
#[arg(long, default_value_t = false, help = "Disable webui, act completely like the original server")]
|
#[arg(long, default_value_t = false, help = "Disable webui, act completely like the original server")]
|
||||||
pub hidden: bool,
|
pub hidden: bool,
|
||||||
|
|
||||||
|
#[arg(long, default_value_t = false, help = "Enable the custom songs feature (upload/browse/download). Disabled by default; every custom-songs endpoint and webui element is hidden unless this is set")]
|
||||||
|
pub enable_custom_songs: bool,
|
||||||
|
|
||||||
#[arg(long, default_value_t = false, help = "Purge dead user accounts on startup")]
|
#[arg(long, default_value_t = false, help = "Purge dead user accounts on startup")]
|
||||||
pub purge: bool,
|
pub purge: bool,
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod serial_code;
|
|||||||
pub mod web;
|
pub mod web;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod shop;
|
pub mod shop;
|
||||||
|
pub mod custom_song;
|
||||||
pub mod webui;
|
pub mod webui;
|
||||||
pub mod clear_rate;
|
pub mod clear_rate;
|
||||||
pub mod exchange;
|
pub mod exchange;
|
||||||
@@ -141,6 +142,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
)
|
)
|
||||||
.configure(card::routes)
|
.configure(card::routes)
|
||||||
.configure(chat::routes)
|
.configure(chat::routes)
|
||||||
|
.configure(custom_song::routes)
|
||||||
.configure(debug::routes)
|
.configure(debug::routes)
|
||||||
.configure(event::routes)
|
.configure(event::routes)
|
||||||
.configure(exchange::routes)
|
.configure(exchange::routes)
|
||||||
@@ -166,4 +168,5 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
actix_web::web::scope("/v1.0")
|
actix_web::web::scope("/v1.0")
|
||||||
.configure(gree::routes)
|
.configure(gree::routes)
|
||||||
);
|
);
|
||||||
|
cfg.configure(custom_song::web_routes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,14 @@ fn update_live_score(id: i64, uid: i64, score: i64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete live id when custom song deleted
|
||||||
|
pub fn purge_live(live_id: i64) {
|
||||||
|
DATABASE.lock_and_exec("DELETE FROM lives WHERE live_id=?1", params!(live_id));
|
||||||
|
DATABASE.lock_and_exec("DELETE FROM scores WHERE live_id=?1", params!(live_id));
|
||||||
|
crate::lock_onto_mutex!(CACHED_DATA).take();
|
||||||
|
crate::lock_onto_mutex!(CACHED_HTML_DATA).take();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) {
|
pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) {
|
||||||
update_live_score(id, uid, score);
|
update_live_score(id, uid, score);
|
||||||
match DATABASE.get_live_data(id) {
|
match DATABASE.get_live_data(id) {
|
||||||
@@ -190,7 +198,8 @@ fn get_json() -> JsonValue {
|
|||||||
expert: get_pass_percent(info.expert_failed, info.expert_pass),
|
expert: get_pass_percent(info.expert_failed, info.expert_pass),
|
||||||
master: get_pass_percent(info.master_failed, info.master_pass)
|
master: get_pass_percent(info.master_failed, info.master_pass)
|
||||||
};
|
};
|
||||||
ids.push(databases::LIVE_LIST[info.live_id.to_string()]["masterMusicId"].as_i64().unwrap()).unwrap();
|
// Custom songs aren't in the official live mst; their live_id == music_id
|
||||||
|
ids.push(databases::LIVE_LIST[info.live_id.to_string()]["masterMusicId"].as_i64().unwrap_or(info.live_id as i64)).unwrap();
|
||||||
rates.push(to_push).unwrap();
|
rates.push(to_push).unwrap();
|
||||||
}
|
}
|
||||||
object!{
|
object!{
|
||||||
|
|||||||
902
src/router/custom_song.rs
Normal file
902
src/router/custom_song.rs
Normal file
@@ -0,0 +1,902 @@
|
|||||||
|
mod audio;
|
||||||
|
mod chart;
|
||||||
|
mod package;
|
||||||
|
|
||||||
|
use jzon::{array, object, JsonValue};
|
||||||
|
use actix_web::{web, HttpRequest, HttpResponse, Responder, http::header::ContentType};
|
||||||
|
use actix_multipart::Multipart;
|
||||||
|
use futures_util::TryStreamExt;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::router::{global, userdata, webui};
|
||||||
|
use crate::database::custom_song as database;
|
||||||
|
use crate::runtime::get_data_path;
|
||||||
|
use crate::lock_onto_mutex;
|
||||||
|
|
||||||
|
// Custom songs are owned by their uploader: only the owner can change or
|
||||||
|
// delete them through the webui. Visibility is per song - "public" (default,
|
||||||
|
// every user sees it), "private" (owner only) or "shared" (owner plus a list
|
||||||
|
// of user ids). Filtering happens at the CATALOG level (/api/custom_song/list
|
||||||
|
// and the user/get unlock list); the asset/audio GETs are content-addressed
|
||||||
|
// and sessionless, like a CDN.
|
||||||
|
//
|
||||||
|
// Storage layout (under --path):
|
||||||
|
// custom_songs/{music_id}/jacket.png 512x512 png
|
||||||
|
// custom_songs/{music_id}/jacket_blur.png 512x512 png, heavily blurred
|
||||||
|
// custom_songs/{music_id}/chart_{level}.json
|
||||||
|
// custom_songs/audio/{md5}.ogg content-addressed vorbis oggs
|
||||||
|
// Metadata lives in custom_songs.db as one JSON blob per song, in the exact
|
||||||
|
// shape /api/custom_song/list serves.
|
||||||
|
|
||||||
|
// Shock.BAND_CATEGORY enum names
|
||||||
|
const BAND_CATEGORIES: &[&str] = &["NONE", "MUSE", "AQOURS", "NIJIGAKU", "LIELLA", "HASUNOSORA", "OTHER", "YOHANE"];
|
||||||
|
|
||||||
|
// NORMAL, HARD, EXPERT, MASTER
|
||||||
|
const LEVEL_COUNT: i64 = 4;
|
||||||
|
const DEFAULT_LEVEL_NUMBERS: &[i64] = &[3, 6, 9, 12];
|
||||||
|
|
||||||
|
const DEFAULT_BPM: f64 = 120.0;
|
||||||
|
const DEFAULT_PREVIEW_LENGTH_SEC: f64 = 30.0;
|
||||||
|
const PREVIEW_FADE_SEC: f64 = 0.5;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
// music_id assignment and the insert must not race between two uploads
|
||||||
|
static ref UPLOAD_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game endpoints (/api scope, standard envelope)
|
||||||
|
pub fn routes(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(
|
||||||
|
web::scope("/custom_song")
|
||||||
|
.route("/list", web::post().to(list))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain asset GETs for the game + session-authenticated management API for the webui
|
||||||
|
pub fn web_routes(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(
|
||||||
|
web::scope("/custom_song")
|
||||||
|
.route("/assets/{music_id}/{file}", web::get().to(assets))
|
||||||
|
.route("/audio/{hash}/{file}", web::get().to(audio))
|
||||||
|
.route("/upload", web::post().to(upload))
|
||||||
|
.route("/mine", web::get().to(mine))
|
||||||
|
.route("/browse", web::get().to(browse))
|
||||||
|
.route("/download/{music_id}", web::get().to(download))
|
||||||
|
.route("/visibility", web::post().to(visibility))
|
||||||
|
.route("/delete", web::post().to(delete))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole feature is opt-in (--enable-custom-songs) and additionally off in
|
||||||
|
// --hidden mode. When disabled every endpoint 404s / errors as if it never
|
||||||
|
// existed, nothing touches custom_songs.db (so no table setup or migration
|
||||||
|
// runs), and no custom ids leak into unlock lists
|
||||||
|
pub fn disabled() -> bool {
|
||||||
|
let args = crate::get_args();
|
||||||
|
args.hidden || !args.enable_custom_songs
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog is filtered per requesting user: private songs only show for
|
||||||
|
// their owner, shared songs for the owner plus their shared-user list
|
||||||
|
async fn list(req: HttpRequest, body: String) -> impl Responder {
|
||||||
|
if disabled() {
|
||||||
|
// As if the endpoint doesn't exist - the client treats this as feature-off
|
||||||
|
return global::api(&req, None);
|
||||||
|
}
|
||||||
|
let key = global::get_login(req.headers(), &body);
|
||||||
|
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
||||||
|
global::api(&req, Some(object!{
|
||||||
|
"revision": database::get_revision(),
|
||||||
|
"songs": database::get_songs_for_user(uid)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appended to the master_music_ids unlock list in user/get, filtered like the
|
||||||
|
// catalog. Empty (and touches no DB) when the feature is disabled
|
||||||
|
pub fn get_music_ids(uid: i64) -> JsonValue {
|
||||||
|
if disabled() {
|
||||||
|
return array![];
|
||||||
|
}
|
||||||
|
database::get_music_ids_for_user(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn song_path(music_id: i64, file: &str) -> String {
|
||||||
|
get_data_path(&format!("custom_songs/{}/{}", music_id, file))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audio_file_path(md5: &str) -> String {
|
||||||
|
get_data_path(&format!("custom_songs/audio/{}.ogg", md5))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assets(req: HttpRequest) -> HttpResponse {
|
||||||
|
if disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let music_id = req.match_info().get("music_id").unwrap_or("").parse::<i64>().unwrap_or(0);
|
||||||
|
let file = req.match_info().get("file").unwrap_or("").to_string();
|
||||||
|
let valid = file == "jacket.png" || file == "jacket_blur.png"
|
||||||
|
|| (1..=LEVEL_COUNT).any(|level| file == format!("chart_{}.json", level));
|
||||||
|
if music_id < database::FIRST_MUSIC_ID || !valid {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
match fs::read(song_path(music_id, &file)) {
|
||||||
|
Ok(body) => {
|
||||||
|
let mime = mime_guess::from_path(&file).first_or_octet_stream();
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType(mime))
|
||||||
|
.insert_header(("content-length", body.len()))
|
||||||
|
.body(body)
|
||||||
|
},
|
||||||
|
Err(_) => HttpResponse::NotFound().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches the client's '{server}/{hash}/{name}.ogg' sound downloader format
|
||||||
|
async fn audio(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 != format!("{}.ogg", hash) {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
match fs::read(audio_file_path(&hash)) {
|
||||||
|
Ok(body) => {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(("content-type", "audio/ogg"))
|
||||||
|
.insert_header(("content-length", body.len()))
|
||||||
|
.body(body)
|
||||||
|
},
|
||||||
|
Err(_) => HttpResponse::NotFound().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_session_uid(req: &HttpRequest) -> Option<i64> {
|
||||||
|
let token = webui::get_login_token(req)?;
|
||||||
|
let login_token = userdata::webui_login_token(&token)?;
|
||||||
|
userdata::get_acc(&login_token)["user"]["id"].as_i64()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_json(resp: JsonValue) -> HttpResponse {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(ContentType::json())
|
||||||
|
.body(jzon::stringify(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_multipart(mut payload: Multipart) -> Result<HashMap<String, Vec<u8>>, String> {
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
while let Some(mut field) = payload.try_next().await.map_err(|e| e.to_string())? {
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
let mut data = Vec::new();
|
||||||
|
while let Some(chunk) = field.try_next().await.map_err(|e| e.to_string())? {
|
||||||
|
data.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
fields.insert(name, data);
|
||||||
|
}
|
||||||
|
Ok(fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_str(fields: &HashMap<String, Vec<u8>>, key: &str) -> String {
|
||||||
|
String::from_utf8_lossy(fields.get(key).map(|v| v.as_slice()).unwrap_or(&[])).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_f64(fields: &HashMap<String, Vec<u8>>, key: &str) -> Option<f64> {
|
||||||
|
field_str(fields, key).parse::<f64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkbox-style flag: "1", "true" or "on"
|
||||||
|
fn field_flag(fields: &HashMap<String, Vec<u8>>, key: &str) -> bool {
|
||||||
|
matches!(field_str(fields, key).to_lowercase().as_str(), "1" | "true" | "on")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared users are designated by their numeric user id (the id shown on the
|
||||||
|
// account page and used for webui login/friend requests), comma-separated in
|
||||||
|
// the webui form
|
||||||
|
fn parse_shared_users(input: &str) -> Result<JsonValue, String> {
|
||||||
|
let mut rv = array![];
|
||||||
|
for part in input.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = part.parse::<i64>().map_err(|_| format!("'{}' is not a valid user id", part))?;
|
||||||
|
if !rv.contains(id) {
|
||||||
|
rv.push(id).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_shared_users(shared_with: &JsonValue) -> Result<(), String> {
|
||||||
|
for id in shared_with.members() {
|
||||||
|
let Some(id) = id.as_i64() else {
|
||||||
|
return Err(format!("'{}' is not a valid user id", id));
|
||||||
|
};
|
||||||
|
if userdata::get_login_token(id) == String::new() {
|
||||||
|
return Err(format!("User {} does not exist", id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad/crop the upload to a square, then resize to 512x512
|
||||||
|
fn process_jacket(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
|
||||||
|
let img = image::load_from_memory(bytes).map_err(|_| String::from("Jacket is not a valid png/jpg image"))?;
|
||||||
|
let size = std::cmp::min(img.width(), img.height());
|
||||||
|
let jacket = img
|
||||||
|
.crop_imm((img.width() - size) / 2, (img.height() - size) / 2, size, size)
|
||||||
|
.resize_exact(512, 512, image::imageops::FilterType::Lanczos3);
|
||||||
|
// Mirror the official _blur art: a heavily blurred copy of the jacket
|
||||||
|
let blur = jacket.blur(24.0);
|
||||||
|
|
||||||
|
let mut jacket_png = Vec::new();
|
||||||
|
jacket.write_to(&mut std::io::Cursor::new(&mut jacket_png), image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||||
|
let mut blur_png = Vec::new();
|
||||||
|
blur.write_to(&mut std::io::Cursor::new(&mut blur_png), image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||||
|
Ok((jacket_png, blur_png))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cue_json(cue: &audio::Cue, cue_name: String) -> JsonValue {
|
||||||
|
object!{
|
||||||
|
"cue_name": cue_name,
|
||||||
|
"md5": cue.md5.clone(),
|
||||||
|
"size": cue.bytes.len(),
|
||||||
|
"duration_sec": cue.duration_sec as f32,
|
||||||
|
"is_loop": true,
|
||||||
|
"loop_start_sec": 0.0,
|
||||||
|
"loop_end_sec": cue.duration_sec as f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
// (~200 points per note, scaled up for harder charts), then C/B/A/S at
|
||||||
|
// 50%/75%/100%/130% of base. Multi live thresholds are 1.2x the solo ones.
|
||||||
|
fn default_scores(full_combo: i64, level_number: i64) -> (JsonValue, JsonValue) {
|
||||||
|
let base = full_combo as f64 * 200.0 * (1.0 + level_number as f64 / 10.0);
|
||||||
|
let score = |mult: f64| (base * mult) as u32;
|
||||||
|
(object!{
|
||||||
|
"c": score(0.5), "b": score(0.75), "a": score(1.0), "s": score(1.3)
|
||||||
|
}, object!{
|
||||||
|
"c": score(0.5 * 1.2), "b": score(0.75 * 1.2), "a": score(1.0 * 1.2), "s": score(1.3 * 1.2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, String> {
|
||||||
|
let name = field_str(fields, "name");
|
||||||
|
let artist = field_str(fields, "artist");
|
||||||
|
if name.is_empty() || artist.is_empty() {
|
||||||
|
return Err(String::from("Song name and artist are required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let attribute = field_str(fields, "attribute").parse::<i64>().unwrap_or(0);
|
||||||
|
if !(1..=3).contains(&attribute) {
|
||||||
|
return Err(String::from("Attribute must be 1 (smile), 2 (pure) or 3 (cool)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut band_category = field_str(fields, "band_category");
|
||||||
|
if band_category.is_empty() {
|
||||||
|
band_category = String::from("OTHER");
|
||||||
|
}
|
||||||
|
if !BAND_CATEGORIES.contains(&band_category.as_str()) {
|
||||||
|
return Err(format!("Unknown band category '{}'", band_category));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visibility = field_str(fields, "visibility");
|
||||||
|
if visibility.is_empty() {
|
||||||
|
visibility = String::from("public");
|
||||||
|
}
|
||||||
|
if !database::VISIBILITIES.contains(&visibility.as_str()) {
|
||||||
|
return Err(format!("Unknown visibility '{}'", visibility));
|
||||||
|
}
|
||||||
|
let shared_with = parse_shared_users(&field_str(fields, "shared_with"))?;
|
||||||
|
validate_shared_users(&shared_with)?;
|
||||||
|
let downloads_disabled = field_flag(fields, "downloads_disabled");
|
||||||
|
|
||||||
|
// (level, chart json, full_combo, level_number, original SIF1 bytes)
|
||||||
|
let mut charts: Vec<(i64, JsonValue, i64, i64, Vec<u8>)> = Vec::new();
|
||||||
|
for level in 1..=LEVEL_COUNT {
|
||||||
|
let Some(raw) = fields.get(&format!("chart_{}", level)) else { continue; };
|
||||||
|
if raw.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let beatmap = jzon::parse(&String::from_utf8_lossy(raw))
|
||||||
|
.map_err(|_| format!("Difficulty {}: chart is not valid JSON", level))?;
|
||||||
|
let (chart, full_combo) = chart::transcode(&beatmap)
|
||||||
|
.map_err(|e| format!("Difficulty {}: {}", level, e))?;
|
||||||
|
let level_number = field_str(fields, &format!("level_number_{}", level))
|
||||||
|
.parse::<i64>().unwrap_or(DEFAULT_LEVEL_NUMBERS[(level - 1) as usize]);
|
||||||
|
charts.push((level, chart, full_combo, level_number, raw.clone()));
|
||||||
|
}
|
||||||
|
if charts.is_empty() {
|
||||||
|
return Err(String::from("At least one difficulty chart is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let jacket_bytes = fields.get("jacket").filter(|v| !v.is_empty())
|
||||||
|
.ok_or(String::from("A jacket image is required"))?;
|
||||||
|
let (jacket, jacket_blur) = process_jacket(jacket_bytes)?;
|
||||||
|
|
||||||
|
let audio_bytes = fields.get("audio").filter(|v| !v.is_empty())
|
||||||
|
.ok_or(String::from("An audio track is required"))?;
|
||||||
|
let (play, select) = audio::process(audio_bytes, field_f64(fields, "preview_start_sec"), field_f64(fields, "preview_length_sec"))?;
|
||||||
|
|
||||||
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||||
|
let music_id = database::next_music_id();
|
||||||
|
|
||||||
|
let suffix = format!("Custom{}", music_id);
|
||||||
|
let mut levels = array![];
|
||||||
|
for (level, _, full_combo, level_number, _) in charts.iter() {
|
||||||
|
levels.push(object!{
|
||||||
|
"level": *level,
|
||||||
|
"level_number": *level_number,
|
||||||
|
"full_combo": *full_combo,
|
||||||
|
"score_coeff": 1.0,
|
||||||
|
// Official convention: the filename difficulty index is level+1
|
||||||
|
"note_data_file_name": format!("{}_{}_{}", music_id, level + 1, suffix),
|
||||||
|
"chart": format!("/custom_song/assets/{}/chart_{}.json", music_id, level)
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, _, hardest_combo, hardest_stars, _) = charts.last().unwrap();
|
||||||
|
let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars);
|
||||||
|
|
||||||
|
// The upload metadata in the multipart-field schema, kept alongside the
|
||||||
|
// original artifacts so the song can be exported and re-uploaded elsewhere
|
||||||
|
let mut manifest_levels = array![];
|
||||||
|
for (level, _, _, level_number, _) in charts.iter() {
|
||||||
|
manifest_levels.push(object!{
|
||||||
|
"level": *level,
|
||||||
|
"level_number": *level_number
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
let manifest = object!{
|
||||||
|
"format": 1,
|
||||||
|
"name": name.clone(),
|
||||||
|
"name_en": field_str(fields, "name_en"),
|
||||||
|
"short_name": field_str(fields, "short_name"),
|
||||||
|
"kana": field_str(fields, "kana"),
|
||||||
|
"artist": artist.clone(),
|
||||||
|
"artist_en": field_str(fields, "artist_en"),
|
||||||
|
"attribute": attribute,
|
||||||
|
"band_category": band_category.clone(),
|
||||||
|
"bpm": field_f64(fields, "bpm"),
|
||||||
|
"preview_start_sec": field_f64(fields, "preview_start_sec"),
|
||||||
|
"preview_length_sec": field_f64(fields, "preview_length_sec"),
|
||||||
|
"levels": manifest_levels
|
||||||
|
};
|
||||||
|
|
||||||
|
let song = object!{
|
||||||
|
"music_id": music_id,
|
||||||
|
"name": name,
|
||||||
|
"name_en": field_str(fields, "name_en"),
|
||||||
|
"short_name": field_str(fields, "short_name"),
|
||||||
|
"kana": field_str(fields, "kana"),
|
||||||
|
"artist": artist,
|
||||||
|
"artist_en": field_str(fields, "artist_en"),
|
||||||
|
"band_category": band_category.clone(),
|
||||||
|
"master_group_id": database::band_group_id(&band_category),
|
||||||
|
"attribute": attribute,
|
||||||
|
"bpm": field_f64(fields, "bpm").unwrap_or(DEFAULT_BPM) as f32,
|
||||||
|
"start_wait": 2.0,
|
||||||
|
"end_wait": 0.0,
|
||||||
|
"score": score,
|
||||||
|
"multi_score": multi_score,
|
||||||
|
// 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],
|
||||||
|
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
|
||||||
|
"levels": levels,
|
||||||
|
"sound": {
|
||||||
|
"cue_sheet": format!("song_{}_{}", music_id, suffix),
|
||||||
|
"play": cue_json(&play, format!("play_{}_{}", music_id, suffix)),
|
||||||
|
"select": cue_json(&select, format!("select_{}_{}", music_id, suffix))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::create_dir_all(get_data_path(&format!("custom_songs/{}", music_id))).map_err(|e| e.to_string())?;
|
||||||
|
fs::create_dir_all(get_data_path("custom_songs/audio")).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(song_path(music_id, "jacket.png"), jacket).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(song_path(music_id, "jacket_blur.png"), jacket_blur).map_err(|e| e.to_string())?;
|
||||||
|
for (level, chart, _, _, _) in charts.iter() {
|
||||||
|
fs::write(song_path(music_id, &format!("chart_{}.json", level)), jzon::stringify(chart.clone())).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
fs::write(audio_file_path(&play.md5), &play.bytes).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(audio_file_path(&select.md5), &select.bytes).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// The original upload artifacts. SIF1 is the canonical interchange format:
|
||||||
|
// these exact bytes (plus the manifest) form the export package, and
|
||||||
|
// importing one on another server replays this same upload pipeline
|
||||||
|
fs::create_dir_all(get_data_path(&format!("custom_songs/{}/original", music_id))).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(song_path(music_id, "original/manifest.json"), jzon::stringify(manifest)).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(song_path(music_id, "original/jacket"), jacket_bytes).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(song_path(music_id, "original/audio"), audio_bytes).map_err(|e| e.to_string())?;
|
||||||
|
for (level, _, _, _, raw) in charts.iter() {
|
||||||
|
fs::write(song_path(music_id, &format!("original/chart_{}.json", level)), raw).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
database::insert_song(music_id, uid, &song, &visibility, &shared_with, downloads_disabled);
|
||||||
|
database::bump_revision();
|
||||||
|
drop(lock);
|
||||||
|
|
||||||
|
Ok(music_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload(req: HttpRequest, payload: Multipart) -> HttpResponse {
|
||||||
|
if disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let Some(uid) = get_session_uid(&req) else {
|
||||||
|
return webui::error("Not logged in");
|
||||||
|
};
|
||||||
|
let mut fields = match read_multipart(payload).await {
|
||||||
|
Ok(fields) => fields,
|
||||||
|
Err(e) => return webui::error(&e)
|
||||||
|
};
|
||||||
|
// An export package from another server: its contents map 1:1 onto the
|
||||||
|
// normal upload fields, so importing is just an upload
|
||||||
|
if let Some(bytes) = fields.remove("package") {
|
||||||
|
if !bytes.is_empty() {
|
||||||
|
if let Err(e) = package::expand(&bytes, &mut fields) {
|
||||||
|
return webui::error(&e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("UPLOAD5");
|
||||||
|
match create_song(uid, &fields) {
|
||||||
|
Ok(music_id) => send_json(object!{
|
||||||
|
result: "OK",
|
||||||
|
music_id: music_id
|
||||||
|
}),
|
||||||
|
Err(e) => webui::error(&e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mine(req: HttpRequest) -> HttpResponse {
|
||||||
|
if disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let Some(uid) = get_session_uid(&req) else {
|
||||||
|
return webui::error("Not logged in");
|
||||||
|
};
|
||||||
|
send_json(object!{
|
||||||
|
result: "OK",
|
||||||
|
songs: database::get_songs_by_owner(uid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The public song browser. Anonymous viewers see the public catalog; a webui
|
||||||
|
// session additionally shows the viewer's own and shared-with-them songs
|
||||||
|
// (the same visibility rules as the game catalog)
|
||||||
|
async fn browse(req: HttpRequest) -> HttpResponse {
|
||||||
|
if disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let viewer = get_session_uid(&req);
|
||||||
|
let mut songs = database::get_browse_songs(viewer);
|
||||||
|
for song in songs.members_mut() {
|
||||||
|
song["uploader"] = userdata::get_name_and_rank(song["owner_id"].as_i64().unwrap())["user_name"].clone();
|
||||||
|
song.remove("owner_id");
|
||||||
|
}
|
||||||
|
send_json(object!{
|
||||||
|
result: "OK",
|
||||||
|
songs: songs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download a song as an export package, re-uploadable on any ew server. The
|
||||||
|
// viewer must be able to see the song, and downloads must be enabled unless
|
||||||
|
// they own it
|
||||||
|
async fn download(req: HttpRequest) -> HttpResponse {
|
||||||
|
if disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let music_id = req.match_info().get("music_id").unwrap_or("").parse::<i64>().unwrap_or(0);
|
||||||
|
if let Err(e) = database::export_allowed(music_id, get_session_uid(&req)) {
|
||||||
|
return webui::error(e);
|
||||||
|
}
|
||||||
|
match package::build(music_id) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header(("content-type", "application/zip"))
|
||||||
|
.insert_header(("content-disposition", format!("attachment; filename=\"custom_song_{}.zip\"", music_id)))
|
||||||
|
.insert_header(("content-length", bytes.len()))
|
||||||
|
.body(bytes)
|
||||||
|
},
|
||||||
|
Err(e) => webui::error(&e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner-only: change a song's visibility and/or its shared-user list
|
||||||
|
async fn visibility(req: HttpRequest, body: String) -> HttpResponse {
|
||||||
|
if disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let Some(uid) = get_session_uid(&req) else {
|
||||||
|
return webui::error("Not logged in");
|
||||||
|
};
|
||||||
|
let body = jzon::parse(&body).unwrap_or(object!{});
|
||||||
|
let music_id = body["music_id"].as_i64().unwrap_or(0);
|
||||||
|
let Some(owner) = database::get_song_owner(music_id) else {
|
||||||
|
return webui::error("Song not found");
|
||||||
|
};
|
||||||
|
if owner != uid {
|
||||||
|
return webui::error("You can only manage your own songs");
|
||||||
|
}
|
||||||
|
let visibility = body["visibility"].to_string();
|
||||||
|
if !database::VISIBILITIES.contains(&visibility.as_str()) {
|
||||||
|
return webui::error(&format!("Unknown visibility '{}'", visibility));
|
||||||
|
}
|
||||||
|
let shared_with = body["shared_with"].clone();
|
||||||
|
if let Err(e) = validate_shared_users(&shared_with) {
|
||||||
|
return webui::error(&e);
|
||||||
|
}
|
||||||
|
|
||||||
|
database::set_visibility(music_id, &visibility, &shared_with);
|
||||||
|
// The download toggle only affects the webui browser, not the game catalog
|
||||||
|
if !body["downloads_disabled"].is_null() {
|
||||||
|
database::set_downloads_disabled(music_id, body["downloads_disabled"].as_bool().unwrap_or(false));
|
||||||
|
}
|
||||||
|
database::bump_revision();
|
||||||
|
|
||||||
|
send_json(object!{
|
||||||
|
result: "OK"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(req: HttpRequest, body: String) -> HttpResponse {
|
||||||
|
if disabled() {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
let Some(uid) = get_session_uid(&req) else {
|
||||||
|
return webui::error("Not logged in");
|
||||||
|
};
|
||||||
|
let body = jzon::parse(&body).unwrap_or(object!{});
|
||||||
|
let music_id = body["music_id"].as_i64().unwrap_or(0);
|
||||||
|
let Some(owner) = database::get_song_owner(music_id) else {
|
||||||
|
return webui::error("Song not found");
|
||||||
|
};
|
||||||
|
if owner != uid {
|
||||||
|
return webui::error("You can only delete your own songs");
|
||||||
|
}
|
||||||
|
let song = database::get_song(music_id).unwrap_or(object!{});
|
||||||
|
|
||||||
|
database::delete_song(music_id);
|
||||||
|
database::bump_revision();
|
||||||
|
// Global clear-rate stats for the dead live id (per-user score records are
|
||||||
|
// wiped lazily on each user's next userdata pull)
|
||||||
|
crate::router::clear_rate::purge_live(music_id);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(get_data_path(&format!("custom_songs/{}", music_id)));
|
||||||
|
// Audio is content-addressed and may be shared with another upload
|
||||||
|
for key in ["play", "select"] {
|
||||||
|
let md5 = song["sound"][key]["md5"].to_string();
|
||||||
|
if !md5.is_empty() && !database::audio_in_use(&md5, music_id) {
|
||||||
|
let _ = fs::remove_file(audio_file_path(&md5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send_json(object!{
|
||||||
|
result: "OK"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// WHY DID THE AI WRITE 400 LINES OF TESTS
|
||||||
|
/// Well I guess they can't hurt lets commit them anyway
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// 2 seconds of 44.1kHz 16-bit mono silence
|
||||||
|
fn test_wav() -> Vec<u8> {
|
||||||
|
let sample_rate: u32 = 44100;
|
||||||
|
let data_len: u32 = sample_rate * 2 * 2;
|
||||||
|
let mut rv = Vec::new();
|
||||||
|
rv.extend(b"RIFF");
|
||||||
|
rv.extend((36 + data_len).to_le_bytes());
|
||||||
|
rv.extend(b"WAVEfmt ");
|
||||||
|
rv.extend(16u32.to_le_bytes());
|
||||||
|
rv.extend(1u16.to_le_bytes());
|
||||||
|
rv.extend(1u16.to_le_bytes());
|
||||||
|
rv.extend(sample_rate.to_le_bytes());
|
||||||
|
rv.extend((sample_rate * 2).to_le_bytes());
|
||||||
|
rv.extend(2u16.to_le_bytes());
|
||||||
|
rv.extend(16u16.to_le_bytes());
|
||||||
|
rv.extend(b"data");
|
||||||
|
rv.extend(data_len.to_le_bytes());
|
||||||
|
rv.resize(rv.len() + data_len as usize, 0);
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 seconds of a 440Hz sine, encoded to ogg-vorbis in-process
|
||||||
|
fn test_ogg() -> Vec<u8> {
|
||||||
|
let samples: Vec<f32> = (0..44100 * 2)
|
||||||
|
.map(|i| (i as f32 * 440.0 * 2.0 * std::f32::consts::PI / 44100.0).sin() * 0.5)
|
||||||
|
.collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut builder = vorbis_rs::VorbisEncoderBuilder::new_with_serial(
|
||||||
|
std::num::NonZeroU32::new(44100).unwrap(),
|
||||||
|
std::num::NonZeroU8::new(1).unwrap(),
|
||||||
|
&mut out,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let mut encoder = builder.build().unwrap();
|
||||||
|
encoder.encode_audio_block([&samples]).unwrap();
|
||||||
|
encoder.finish().unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_png() -> Vec<u8> {
|
||||||
|
let mut rv = Vec::new();
|
||||||
|
image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(64, 32, |x, y| {
|
||||||
|
image::Rgba([(x * 4) as u8, (y * 8) as u8, 128, 255])
|
||||||
|
})).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).unwrap();
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_chart() -> Vec<u8> {
|
||||||
|
jzon::stringify(jzon::array![
|
||||||
|
{"timing_sec": 0.5, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 5},
|
||||||
|
{"timing_sec": 1.0, "notes_attribute": 1, "notes_level": 1, "effect": 3, "effect_value": 0.5, "position": 3},
|
||||||
|
{"timing_sec": 1.5, "notes_attribute": 1, "notes_level": 1, "effect": 4, "effect_value": 0.0, "position": 7}
|
||||||
|
]).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(fields: &mut HashMap<String, Vec<u8>>, key: &str, value: &str) {
|
||||||
|
fields.insert(String::from(key), value.as_bytes().to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a song, import the package as another user, and the served song
|
||||||
|
// must be identical apart from the assigned music_id - INCLUDING the audio
|
||||||
|
// md5s: ogg uploads are stored as-is and the preview encode is
|
||||||
|
// deterministic, so both cues carry identical bytes on both servers
|
||||||
|
#[test]
|
||||||
|
fn export_import_round_trip() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Round Trip");
|
||||||
|
field(&mut fields, "name_en", "Round Trip EN");
|
||||||
|
field(&mut fields, "kana", "ラウンドトリップ");
|
||||||
|
field(&mut fields, "artist", "Trip Artist");
|
||||||
|
field(&mut fields, "attribute", "2");
|
||||||
|
field(&mut fields, "band_category", "MUSE");
|
||||||
|
field(&mut fields, "bpm", "182.5");
|
||||||
|
field(&mut fields, "preview_start_sec", "0.5");
|
||||||
|
field(&mut fields, "preview_length_sec", "1.0");
|
||||||
|
field(&mut fields, "level_number_1", "7");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg());
|
||||||
|
fields.insert(String::from("chart_1"), test_chart());
|
||||||
|
let source_id = create_song(1111, &fields).unwrap();
|
||||||
|
|
||||||
|
let zip = package::build(source_id).unwrap();
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "visibility", "private");
|
||||||
|
package::expand(&zip, &mut fields).unwrap();
|
||||||
|
let imported_id = create_song(2222, &fields).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(source_id, imported_id);
|
||||||
|
assert_eq!(database::get_song_owner(imported_id), Some(2222));
|
||||||
|
|
||||||
|
let source = database::get_song(source_id).unwrap();
|
||||||
|
let imported = database::get_song(imported_id).unwrap();
|
||||||
|
for key in ["name", "name_en", "short_name", "kana", "artist", "artist_en", "band_category", "attribute", "bpm", "start_wait", "end_wait", "score", "multi_score", "mission_combo"] {
|
||||||
|
assert_eq!(jzon::stringify(source[key].clone()), jzon::stringify(imported[key].clone()), "{}", key);
|
||||||
|
}
|
||||||
|
for (a, b) in source["levels"].members().zip(imported["levels"].members()) {
|
||||||
|
for key in ["level", "level_number", "full_combo", "score_coeff"] {
|
||||||
|
assert_eq!(a[key], b[key], "{}", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(imported["levels"][0]["note_data_file_name"].to_string(), format!("{}_2_Custom{}", imported_id, imported_id));
|
||||||
|
for key in ["md5", "size", "duration_sec", "is_loop", "loop_start_sec", "loop_end_sec"] {
|
||||||
|
assert_eq!(source["sound"]["play"][key], imported["sound"]["play"][key], "play {}", key);
|
||||||
|
assert_eq!(source["sound"]["select"][key], imported["sound"]["select"][key], "select {}", key);
|
||||||
|
}
|
||||||
|
// The stored play ogg is the upload itself, byte for byte
|
||||||
|
assert_eq!(fs::read(audio_file_path(&source["sound"]["play"]["md5"].to_string())).unwrap(), test_ogg());
|
||||||
|
// The preview is the requested cut: 1 second starting at 0.5
|
||||||
|
assert!((source["sound"]["select"]["duration_sec"].as_f64().unwrap() - 1.0).abs() < 0.05);
|
||||||
|
|
||||||
|
// Charts and jackets are deterministic: byte-identical on both servers
|
||||||
|
for file in ["chart_1.json", "jacket.png", "jacket_blur.png", "original/chart_1.json", "original/jacket", "original/audio", "original/manifest.json"] {
|
||||||
|
assert_eq!(fs::read(song_path(source_id, file)).unwrap(), fs::read(song_path(imported_id, file)).unwrap(), "{}", file);
|
||||||
|
}
|
||||||
|
let md5 = imported["sound"]["play"]["md5"].to_string();
|
||||||
|
assert_eq!(fs::read(audio_file_path(&md5)).unwrap().len(), imported["sound"]["play"]["size"].as_usize().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// mp3/wav uploads still work: symphonia decodes them and the play cue is
|
||||||
|
// transcoded to ogg-vorbis in-process
|
||||||
|
#[test]
|
||||||
|
fn wav_uploads_are_transcoded() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Wav Song");
|
||||||
|
field(&mut fields, "artist", "Wav Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_wav());
|
||||||
|
fields.insert(String::from("chart_1"), test_chart());
|
||||||
|
let music_id = create_song(8888, &fields).unwrap();
|
||||||
|
|
||||||
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
assert!((song["sound"]["play"]["duration_sec"].as_f64().unwrap() - 2.0).abs() < 0.05);
|
||||||
|
// The stored cue really is ogg-vorbis now
|
||||||
|
let ogg = fs::read(audio_file_path(&song["sound"]["play"]["md5"].to_string())).unwrap();
|
||||||
|
assert!(ogg.starts_with(b"OggS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_audio_is_rejected() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut base = HashMap::new();
|
||||||
|
field(&mut base, "name", "Bad Audio");
|
||||||
|
field(&mut base, "artist", "Bad Artist");
|
||||||
|
field(&mut base, "attribute", "1");
|
||||||
|
base.insert(String::from("jacket"), test_png());
|
||||||
|
base.insert(String::from("chart_1"), test_chart());
|
||||||
|
|
||||||
|
// Garbage, garbage wearing an ogg header, and a truncated ogg
|
||||||
|
for bad in [vec![7u8; 4096], [b"OggS".to_vec(), vec![7u8; 4096]].concat(), test_ogg()[..200].to_vec()] {
|
||||||
|
let mut fields = base.clone();
|
||||||
|
fields.insert(String::from("audio"), bad);
|
||||||
|
let error = create_song(8888, &fields).unwrap_err();
|
||||||
|
assert!(error.contains("Could not read audio file") || error.contains("corrupt or truncated"), "{}", error);
|
||||||
|
}
|
||||||
|
// Too-short audio still has its own error
|
||||||
|
let samples: Vec<f32> = vec![0.0; 4410];
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut builder = vorbis_rs::VorbisEncoderBuilder::new_with_serial(
|
||||||
|
std::num::NonZeroU32::new(44100).unwrap(),
|
||||||
|
std::num::NonZeroU8::new(1).unwrap(),
|
||||||
|
&mut out,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let mut encoder = builder.build().unwrap();
|
||||||
|
encoder.encode_audio_block([&samples]).unwrap();
|
||||||
|
encoder.finish().unwrap();
|
||||||
|
let mut fields = base.clone();
|
||||||
|
fields.insert(String::from("audio"), out);
|
||||||
|
assert_eq!(create_song(8888, &fields).unwrap_err(), "Audio track is too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browse_respects_visibility() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let owner = 3333;
|
||||||
|
let friend = 4444;
|
||||||
|
let stranger = 5555;
|
||||||
|
|
||||||
|
let public_id = database::next_music_id();
|
||||||
|
database::insert_song(public_id, owner, &object!{music_id: public_id}, "public", &array![], false);
|
||||||
|
let private_id = database::next_music_id();
|
||||||
|
database::insert_song(private_id, owner, &object!{music_id: private_id}, "private", &array![], false);
|
||||||
|
let shared_id = database::next_music_id();
|
||||||
|
database::insert_song(shared_id, owner, &object!{music_id: shared_id}, "shared", &array![friend], false);
|
||||||
|
|
||||||
|
let has = |songs: &JsonValue, id: i64| songs.members().any(|data| data["music_id"] == id);
|
||||||
|
|
||||||
|
let anonymous = database::get_browse_songs(None);
|
||||||
|
assert!(has(&anonymous, public_id) && !has(&anonymous, private_id) && !has(&anonymous, shared_id));
|
||||||
|
let for_owner = database::get_browse_songs(Some(owner));
|
||||||
|
assert!(has(&for_owner, public_id) && has(&for_owner, private_id) && has(&for_owner, shared_id));
|
||||||
|
assert!(for_owner.members().find(|data| data["music_id"] == public_id).unwrap()["mine"].as_bool().unwrap());
|
||||||
|
let for_friend = database::get_browse_songs(Some(friend));
|
||||||
|
assert!(has(&for_friend, public_id) && !has(&for_friend, private_id) && has(&for_friend, shared_id));
|
||||||
|
let for_stranger = database::get_browse_songs(Some(stranger));
|
||||||
|
assert!(has(&for_stranger, public_id) && !has(&for_stranger, private_id) && !has(&for_stranger, shared_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_rules_are_enforced() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
let owner = 6666;
|
||||||
|
let stranger = 7777;
|
||||||
|
|
||||||
|
let locked_id = database::next_music_id();
|
||||||
|
database::insert_song(locked_id, owner, &object!{music_id: locked_id}, "public", &array![], true);
|
||||||
|
let open_id = database::next_music_id();
|
||||||
|
database::insert_song(open_id, owner, &object!{music_id: open_id}, "public", &array![], false);
|
||||||
|
let private_id = database::next_music_id();
|
||||||
|
database::insert_song(private_id, owner, &object!{music_id: private_id}, "private", &array![], false);
|
||||||
|
|
||||||
|
// Downloads disabled: everyone but the owner is denied
|
||||||
|
assert!(database::export_allowed(locked_id, Some(owner)).is_ok());
|
||||||
|
assert_eq!(database::export_allowed(locked_id, Some(stranger)), Err("The uploader has disabled downloads for this song"));
|
||||||
|
assert_eq!(database::export_allowed(locked_id, None), Err("The uploader has disabled downloads for this song"));
|
||||||
|
// Open public song: anyone, even anonymous
|
||||||
|
assert!(database::export_allowed(open_id, None).is_ok());
|
||||||
|
// Invisible songs don't admit they exist
|
||||||
|
assert_eq!(database::export_allowed(private_id, Some(stranger)), Err("Song not found"));
|
||||||
|
assert_eq!(database::export_allowed(9999999, Some(owner)), Err("Song not found"));
|
||||||
|
// The toggle is reversible
|
||||||
|
database::set_downloads_disabled(locked_id, false);
|
||||||
|
assert!(database::export_allowed(locked_id, Some(stranger)).is_ok());
|
||||||
|
|
||||||
|
// A row without stored originals (uploaded before export support) has
|
||||||
|
// a clear error instead of a broken zip
|
||||||
|
assert!(package::build(open_id).unwrap_err().contains("before export support"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// master_group_id must never be 0 (the client's group filter crashes on it)
|
||||||
|
// and must be the band's misc GroupMst id
|
||||||
|
#[test]
|
||||||
|
fn master_group_id_maps_per_band() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let expected = [
|
||||||
|
("MUSE", 199), ("AQOURS", 299), ("NIJIGAKU", 399),
|
||||||
|
("LIELLA", 499), ("HASUNOSORA", 599), ("YOHANE", 9999),
|
||||||
|
("OTHER", 9999), ("NONE", 9999)
|
||||||
|
];
|
||||||
|
for (band, group) in expected {
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Group Test");
|
||||||
|
field(&mut fields, "artist", "Group Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
field(&mut fields, "band_category", band);
|
||||||
|
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(1234, &fields).unwrap();
|
||||||
|
|
||||||
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
assert_eq!(song["master_group_id"], group, "band {}", band);
|
||||||
|
assert_ne!(song["master_group_id"], 0, "band {}", band);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing in the catalog ever serves a 0
|
||||||
|
for song in database::get_songs_for_user(1234).members() {
|
||||||
|
assert_ne!(song["master_group_id"], 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole feature is off unless --enable-custom-songs: endpoints 404 / go
|
||||||
|
// empty, and the webui config the client gates its nav on reports it off.
|
||||||
|
// When enabled everything works.
|
||||||
|
#[test]
|
||||||
|
fn feature_gate_hides_everything_when_disabled() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
// Disabled: representative endpoint behaves as if absent, no ids leak,
|
||||||
|
// and serverInfo tells the webui to hide the nav (header.js gates on it)
|
||||||
|
crate::runtime::set_enable_custom_songs(false);
|
||||||
|
assert!(disabled());
|
||||||
|
assert!(get_music_ids(1).is_empty());
|
||||||
|
let resp = actix_web::rt::System::new().block_on(async {
|
||||||
|
browse(actix_web::test::TestRequest::default().to_http_request()).await
|
||||||
|
});
|
||||||
|
assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND);
|
||||||
|
let info = webui_server_info();
|
||||||
|
assert_eq!(info["data"]["custom_songs"], false);
|
||||||
|
|
||||||
|
// Enabled: browse serves the catalog again and serverInfo advertises it
|
||||||
|
crate::runtime::set_enable_custom_songs(true);
|
||||||
|
assert!(!disabled());
|
||||||
|
let resp = actix_web::rt::System::new().block_on(async {
|
||||||
|
browse(actix_web::test::TestRequest::default().to_http_request()).await
|
||||||
|
});
|
||||||
|
assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
|
||||||
|
let info = webui_server_info();
|
||||||
|
assert_eq!(info["data"]["custom_songs"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The JSON body the webui's /api/webui/serverInfo handler returns
|
||||||
|
fn webui_server_info() -> JsonValue {
|
||||||
|
let resp = crate::router::webui::server_info(actix_web::test::TestRequest::default().to_http_request());
|
||||||
|
let body = actix_web::rt::System::new().block_on(async {
|
||||||
|
actix_web::body::to_bytes(resp.into_body()).await.unwrap()
|
||||||
|
});
|
||||||
|
jzon::parse(&String::from_utf8_lossy(&body)).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/router/custom_song/audio.rs
Normal file
183
src/router/custom_song/audio.rs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
use std::num::{NonZeroU8, NonZeroU32};
|
||||||
|
use symphonia::core::codecs::CodecParameters;
|
||||||
|
use symphonia::core::codecs::audio::AudioDecoderOptions;
|
||||||
|
use symphonia::core::errors::Error as SymphoniaError;
|
||||||
|
use symphonia::core::formats::TrackType;
|
||||||
|
use symphonia::core::formats::probe::Hint;
|
||||||
|
use symphonia::core::io::MediaSourceStream;
|
||||||
|
use vorbis_rs::{VorbisBitrateManagementStrategy, VorbisEncoderBuilder};
|
||||||
|
|
||||||
|
use super::{DEFAULT_PREVIEW_LENGTH_SEC, PREVIEW_FADE_SEC};
|
||||||
|
|
||||||
|
// The whole audio pipeline runs in-process: symphonia (pure Rust) decodes and
|
||||||
|
// validates uploads, vorbis_rs (libvorbis compiled into the binary - a library
|
||||||
|
// call, like rusqlite's bundled sqlite) encodes. No external processes are
|
||||||
|
// ever spawned and nothing needs to be on PATH.
|
||||||
|
//
|
||||||
|
// - ogg-vorbis uploads are stored AS-IS once they prove decodable, so the
|
||||||
|
// served play cue's md5 is stable across servers (export/import keeps it)
|
||||||
|
// - mp3/wav uploads are transcoded to ogg-vorbis once, at upload time
|
||||||
|
// - the select cue (menu preview) is cut + faded in samples and re-encoded
|
||||||
|
// Encodes keep the source sample rate and use a fixed Ogg stream serial, so
|
||||||
|
// they're deterministic: the same input always produces the same bytes
|
||||||
|
|
||||||
|
pub struct Cue {
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
pub md5: String,
|
||||||
|
pub duration_sec: f64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~ffmpeg's -q:a 6
|
||||||
|
const ENCODE_QUALITY: f32 = 0.6;
|
||||||
|
// "SIF2" - fixed so encoding is deterministic
|
||||||
|
const STREAM_SERIAL: i32 = 0x53494632;
|
||||||
|
const ENCODE_BLOCK_FRAMES: usize = 65536;
|
||||||
|
|
||||||
|
struct DecodedAudio {
|
||||||
|
// Planar f32, one Vec per channel. Channels past the first two are dropped
|
||||||
|
channels: Vec<Vec<f32>>,
|
||||||
|
sample_rate: u32
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodedAudio {
|
||||||
|
fn frames(&self) -> usize {
|
||||||
|
self.channels[0].len()
|
||||||
|
}
|
||||||
|
fn duration(&self) -> f64 {
|
||||||
|
self.frames() as f64 / self.sample_rate as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ogg_vorbis(bytes: &[u8]) -> bool {
|
||||||
|
// "OggS" capture pattern + the "\x01vorbis" identification header on the first page
|
||||||
|
bytes.starts_with(b"OggS") && bytes.len() > 64 && bytes[..64].windows(7).any(|w| w == b"\x01vorbis")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(bytes: &[u8]) -> Result<DecodedAudio, String> {
|
||||||
|
let stream = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.to_vec())), Default::default());
|
||||||
|
let mut format = symphonia::default::get_probe()
|
||||||
|
.probe(&Hint::new(), stream, Default::default(), Default::default())
|
||||||
|
.map_err(|_| String::from("Could not read audio file (expected ogg vorbis, mp3 or wav)"))?;
|
||||||
|
|
||||||
|
let track = format.default_track(TrackType::Audio).ok_or(String::from("Audio file has no audio track"))?;
|
||||||
|
let track_id = track.id;
|
||||||
|
let Some(CodecParameters::Audio(params)) = track.codec_params.clone() else {
|
||||||
|
return Err(String::from("Audio file has no audio track"));
|
||||||
|
};
|
||||||
|
let mut decoder = symphonia::default::get_codecs()
|
||||||
|
.make_audio_decoder(¶ms, &AudioDecoderOptions::default())
|
||||||
|
.map_err(|_| String::from("Could not read audio file (expected ogg vorbis, mp3 or wav)"))?;
|
||||||
|
|
||||||
|
let mut channels: Vec<Vec<f32>> = Vec::new();
|
||||||
|
let mut sample_rate = 0;
|
||||||
|
let mut interleaved: Vec<f32> = Vec::new();
|
||||||
|
loop {
|
||||||
|
let packet = match format.next_packet() {
|
||||||
|
Ok(Some(packet)) => packet,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(SymphoniaError::ResetRequired) => break,
|
||||||
|
Err(_) => return Err(String::from("Audio file is corrupt or truncated"))
|
||||||
|
};
|
||||||
|
if packet.track_id != track_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let decoded = match decoder.decode(&packet) {
|
||||||
|
Ok(decoded) => decoded,
|
||||||
|
// Decoders treat a bad packet as recoverable; skip it like they do
|
||||||
|
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||||
|
Err(_) => return Err(String::from("Audio file is corrupt or truncated"))
|
||||||
|
};
|
||||||
|
let count = decoded.spec().channels().count();
|
||||||
|
if channels.is_empty() {
|
||||||
|
sample_rate = decoded.spec().rate();
|
||||||
|
channels = vec![Vec::new(); std::cmp::min(count, 2)];
|
||||||
|
}
|
||||||
|
decoded.copy_to_vec_interleaved(&mut interleaved);
|
||||||
|
for (i, samples) in channels.iter_mut().enumerate() {
|
||||||
|
samples.extend(interleaved.iter().skip(i).step_by(count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if channels.is_empty() || channels[0].is_empty() || sample_rate == 0 {
|
||||||
|
return Err(String::from("Audio file is corrupt or truncated"));
|
||||||
|
}
|
||||||
|
Ok(DecodedAudio { channels, sample_rate })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(channels: &[&[f32]], sample_rate: u32) -> Result<Vec<u8>, String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut builder = VorbisEncoderBuilder::new_with_serial(
|
||||||
|
NonZeroU32::new(sample_rate).ok_or(String::from("Audio file is corrupt or truncated"))?,
|
||||||
|
NonZeroU8::new(channels.len() as u8).unwrap(),
|
||||||
|
&mut out,
|
||||||
|
STREAM_SERIAL
|
||||||
|
);
|
||||||
|
builder.bitrate_management_strategy(VorbisBitrateManagementStrategy::QualityVbr {
|
||||||
|
target_quality: ENCODE_QUALITY
|
||||||
|
});
|
||||||
|
let mut encoder = builder.build().map_err(|e| format!("Audio encode failed: {}", e))?;
|
||||||
|
|
||||||
|
let frames = channels[0].len();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < frames {
|
||||||
|
let end = std::cmp::min(i + ENCODE_BLOCK_FRAMES, frames);
|
||||||
|
let block: Vec<&[f32]> = channels.iter().map(|samples| &samples[i..end]).collect();
|
||||||
|
encoder.encode_audio_block(&block).map_err(|e| format!("Audio encode failed: {}", e))?;
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
encoder.finish().map_err(|e| format!("Audio encode failed: {}", e))?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cue(bytes: Vec<u8>, duration_sec: f64) -> Cue {
|
||||||
|
Cue {
|
||||||
|
md5: format!("{:x}", md5::compute(&bytes)),
|
||||||
|
bytes,
|
||||||
|
duration_sec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The play cue is the full track, the select cue is a preview cut with short
|
||||||
|
// fades. Both are stored content-addressed by the md5 of the final ogg bytes -
|
||||||
|
// the client validates md5(file) against the value served in the catalog
|
||||||
|
pub fn process(bytes: &[u8], preview_start_sec: Option<f64>, preview_length_sec: Option<f64>) -> Result<(Cue, Cue), String> {
|
||||||
|
let audio = decode(bytes)?;
|
||||||
|
let duration = audio.duration();
|
||||||
|
if duration <= 1.0 {
|
||||||
|
return Err(String::from("Audio track is too short"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let planar: Vec<&[f32]> = audio.channels.iter().map(|samples| samples.as_slice()).collect();
|
||||||
|
let play = if is_ogg_vorbis(bytes) {
|
||||||
|
// Already ogg-vorbis and proven decodable: keep the exact bytes
|
||||||
|
cue(bytes.to_vec(), duration)
|
||||||
|
} else {
|
||||||
|
cue(encode(&planar, audio.sample_rate)?, duration)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preview defaults: start 30% into the track, 30 seconds long
|
||||||
|
let mut start = preview_start_sec.unwrap_or(duration * 0.3);
|
||||||
|
if start < 0.0 || start >= duration {
|
||||||
|
start = duration * 0.3;
|
||||||
|
}
|
||||||
|
let length = preview_length_sec.unwrap_or(DEFAULT_PREVIEW_LENGTH_SEC).clamp(1.0, duration - start);
|
||||||
|
let start_frame = (start * audio.sample_rate as f64) as usize;
|
||||||
|
let end_frame = std::cmp::min(start_frame + (length * audio.sample_rate as f64) as usize, audio.frames());
|
||||||
|
|
||||||
|
let fade_frames = (PREVIEW_FADE_SEC * audio.sample_rate as f64) as usize;
|
||||||
|
let mut segment: Vec<Vec<f32>> = audio.channels.iter().map(|samples| samples[start_frame..end_frame].to_vec()).collect();
|
||||||
|
let frames = end_frame - start_frame;
|
||||||
|
if frames > fade_frames * 2 {
|
||||||
|
for samples in segment.iter_mut() {
|
||||||
|
for i in 0..fade_frames {
|
||||||
|
let gain = i as f32 / fade_frames as f32;
|
||||||
|
samples[i] *= gain;
|
||||||
|
samples[frames - 1 - i] *= gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let planar: Vec<&[f32]> = segment.iter().map(|samples| samples.as_slice()).collect();
|
||||||
|
let select = cue(encode(&planar, audio.sample_rate)?, frames as f64 / audio.sample_rate as f64);
|
||||||
|
|
||||||
|
Ok((play, select))
|
||||||
|
}
|
||||||
266
src/router/custom_song/chart.rs
Normal file
266
src/router/custom_song/chart.rs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
use jzon::{object, JsonValue};
|
||||||
|
|
||||||
|
// Transcodes a SIF1/NPPS4 beatmap (array of {timing_sec, effect, effect_value, position})
|
||||||
|
// into the SIF2 chart JSON the client deserializes into NoteData.
|
||||||
|
//
|
||||||
|
// Mapping rules:
|
||||||
|
// - line = position - 1 (both are right-to-left)
|
||||||
|
// - effect 1 (and 2, the "parallel" marker) -> type 1 (tap)
|
||||||
|
// - effect 3 (hold) -> head note (type 1) at timing_sec plus a SYNTHESIZED tail note
|
||||||
|
// (type 1, same line) at timing_sec + effect_value, linked through parent/child ids
|
||||||
|
// - effect 4 (star/token) -> type 3
|
||||||
|
// - effect 11/12/13 (swing) and anything unknown -> plain type 1 tap for v1.
|
||||||
|
// Slider chains are a later feature.
|
||||||
|
// - notes_attribute / notes_level are dropped (SIF2 has no per-note attribute)
|
||||||
|
// - ids are sequential from 1 in time order. num is the spawn group: the dummy
|
||||||
|
// header occupies 100, real groups count up from 101, and notes that hit
|
||||||
|
// simultaneously (equal timing_sec, which covers SIF1 effect 2 pairs) share one num.
|
||||||
|
// - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client
|
||||||
|
// deserializes it verbatim.
|
||||||
|
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
|
||||||
|
// line (the game counts a same-lane hold as one combo for the chain)
|
||||||
|
|
||||||
|
struct WorkNote {
|
||||||
|
time: f64,
|
||||||
|
line: i64,
|
||||||
|
kind: i64,
|
||||||
|
// Index into the work list of the hold head this tail belongs to
|
||||||
|
head: Option<usize>
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sif_note(data: &JsonValue, index: usize) -> Result<(f64, i64, f64, i64), String> {
|
||||||
|
let timing = data["timing_sec"].as_f64().ok_or(format!("Note {}: missing timing_sec", index))?;
|
||||||
|
let effect = data["effect"].as_i64().ok_or(format!("Note {}: missing effect", index))?;
|
||||||
|
let effect_value = data["effect_value"].as_f64().unwrap_or(0.0);
|
||||||
|
let position = data["position"].as_i64().ok_or(format!("Note {}: missing position", index))?;
|
||||||
|
|
||||||
|
if !(1..=9).contains(&position) {
|
||||||
|
return Err(format!("Note {}: position {} is outside 1-9", index, position));
|
||||||
|
}
|
||||||
|
if timing < 0.0 {
|
||||||
|
return Err(format!("Note {}: negative timing_sec {}", index, timing));
|
||||||
|
}
|
||||||
|
if effect == 3 && effect_value <= 0.0 {
|
||||||
|
return Err(format!("Note {}: hold with effect_value {} (must be > 0)", index, effect_value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((timing, effect, effect_value, position))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the chart JSON and its max_combo_count (== the difficulty's full_combo)
|
||||||
|
pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
||||||
|
if !beatmap.is_array() || beatmap.is_empty() {
|
||||||
|
return Err(String::from("Chart is not a JSON array of notes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut work: Vec<WorkNote> = Vec::new();
|
||||||
|
for (i, data) in beatmap.members().enumerate() {
|
||||||
|
let (timing, effect, effect_value, position) = parse_sif_note(data, i)?;
|
||||||
|
|
||||||
|
for other in beatmap.members().take(i) {
|
||||||
|
if other["timing_sec"].as_f64() == Some(timing) && other["position"].as_i64() == Some(position) && other["effect"].as_i64() != Some(effect) {
|
||||||
|
return Err(format!("Note {}: duplicate timing {} on position {} with a different effect", i, timing, position));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let head = work.len();
|
||||||
|
work.push(WorkNote {
|
||||||
|
time: timing,
|
||||||
|
line: position - 1,
|
||||||
|
kind: if effect == 4 { 3 } else { 1 },
|
||||||
|
head: None
|
||||||
|
});
|
||||||
|
if effect == 3 {
|
||||||
|
work.push(WorkNote {
|
||||||
|
time: timing + effect_value,
|
||||||
|
line: position - 1,
|
||||||
|
kind: 1,
|
||||||
|
head: Some(head)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequential ids in time order. Stable sort keeps input order on ties
|
||||||
|
let mut order: Vec<usize> = (0..work.len()).collect();
|
||||||
|
order.sort_by(|a, b| work[*a].time.partial_cmp(&work[*b].time).unwrap());
|
||||||
|
|
||||||
|
let mut ids = vec![0i64; work.len()];
|
||||||
|
let mut nums = vec![0i64; work.len()];
|
||||||
|
let mut num = 100;
|
||||||
|
let mut last_time = f64::NEG_INFINITY;
|
||||||
|
for (i, index) in order.iter().enumerate() {
|
||||||
|
ids[*index] = (i + 1) as i64;
|
||||||
|
// Simultaneous notes share a spawn group
|
||||||
|
if work[*index].time != last_time {
|
||||||
|
num += 1;
|
||||||
|
last_time = work[*index].time;
|
||||||
|
}
|
||||||
|
nums[*index] = num;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tail_of = vec![0usize; work.len()];
|
||||||
|
for (i, note) in work.iter().enumerate() {
|
||||||
|
if let Some(head) = note.head {
|
||||||
|
tail_of[head] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut notes = jzon::array![{
|
||||||
|
"id": 0, "num": 100, "line": 0, "time": 0.0, "type": 0,
|
||||||
|
"parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0,
|
||||||
|
"force_sync_group_id": 0
|
||||||
|
}];
|
||||||
|
let mut max_combo_count = 0;
|
||||||
|
for index in order.iter() {
|
||||||
|
let note = &work[*index];
|
||||||
|
let tail = tail_of[*index];
|
||||||
|
let is_head = tail != 0;
|
||||||
|
|
||||||
|
// Same-lane hold heads don't count toward the combo, their tail does
|
||||||
|
if !(is_head && work[tail].line == note.line) {
|
||||||
|
max_combo_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.push(object!{
|
||||||
|
"id": ids[*index],
|
||||||
|
"num": nums[*index],
|
||||||
|
"line": note.line,
|
||||||
|
"time": note.time,
|
||||||
|
"type": note.kind,
|
||||||
|
"parent_id": if let Some(head) = note.head { ids[head] } else { 0 },
|
||||||
|
"child_id": if is_head { ids[tail] } else { 0 },
|
||||||
|
"child_num": if is_head { nums[tail] } else { 0 },
|
||||||
|
"child_line": if is_head { work[tail].line } else { 0 },
|
||||||
|
"force_sync_group_id": 0
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((object!{
|
||||||
|
"max_lane": 9,
|
||||||
|
"sound_name": "",
|
||||||
|
"max_combo_count": max_combo_count,
|
||||||
|
"notes": notes
|
||||||
|
}, max_combo_count))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sif_note(timing_sec: f64, position: i64, effect: i64, effect_value: f64) -> JsonValue {
|
||||||
|
object!{
|
||||||
|
"timing_sec": timing_sec,
|
||||||
|
"notes_attribute": 1,
|
||||||
|
"notes_level": 1,
|
||||||
|
"effect": effect,
|
||||||
|
"effect_value": effect_value,
|
||||||
|
"position": position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_taps() {
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.0, 1, 1, 2.0),
|
||||||
|
sif_note(2.0, 5, 1, 2.0),
|
||||||
|
sif_note(3.0, 9, 1, 2.0)
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(combo, 3);
|
||||||
|
assert_eq!(chart["max_combo_count"], 3);
|
||||||
|
assert_eq!(chart["max_lane"], 9);
|
||||||
|
assert_eq!(chart["notes"].len(), 4);
|
||||||
|
// Dummy header is verbatim
|
||||||
|
assert_eq!(chart["notes"][0]["id"], 0);
|
||||||
|
assert_eq!(chart["notes"][0]["num"], 100);
|
||||||
|
assert_eq!(chart["notes"][0]["type"], 0);
|
||||||
|
// Real notes: sequential ids, monotonic nums, right-to-left lines
|
||||||
|
assert_eq!(chart["notes"][1]["id"], 1);
|
||||||
|
assert_eq!(chart["notes"][1]["num"], 101);
|
||||||
|
assert_eq!(chart["notes"][1]["line"], 0);
|
||||||
|
assert_eq!(chart["notes"][1]["type"], 1);
|
||||||
|
assert_eq!(chart["notes"][2]["num"], 102);
|
||||||
|
assert_eq!(chart["notes"][2]["line"], 4);
|
||||||
|
assert_eq!(chart["notes"][3]["id"], 3);
|
||||||
|
assert_eq!(chart["notes"][3]["num"], 103);
|
||||||
|
assert_eq!(chart["notes"][3]["line"], 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hold_head_and_tail() {
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.0, 3, 3, 2.5)
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
// The synthesized same-lane tail counts, the head does not
|
||||||
|
assert_eq!(combo, 1);
|
||||||
|
assert_eq!(chart["notes"].len(), 3);
|
||||||
|
let head = &chart["notes"][1];
|
||||||
|
let tail = &chart["notes"][2];
|
||||||
|
assert_eq!(head["id"], 1);
|
||||||
|
assert_eq!(head["child_id"], 2);
|
||||||
|
assert_eq!(head["child_num"], tail["num"].clone());
|
||||||
|
assert_eq!(head["child_line"], 2);
|
||||||
|
assert_eq!(head["parent_id"], 0);
|
||||||
|
assert_eq!(tail["id"], 2);
|
||||||
|
assert_eq!(tail["parent_id"], 1);
|
||||||
|
assert_eq!(tail["child_id"], 0);
|
||||||
|
assert_eq!(tail["line"], 2);
|
||||||
|
assert_eq!(tail["time"].as_f64().unwrap(), 3.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parallel_pair() {
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.0, 2, 2, 2.0),
|
||||||
|
sif_note(1.0, 8, 2, 2.0)
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
// Simultaneous notes share a spawn group and both count
|
||||||
|
assert_eq!(combo, 2);
|
||||||
|
assert_eq!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone());
|
||||||
|
assert_eq!(chart["notes"][1]["type"], 1);
|
||||||
|
assert_eq!(chart["notes"][2]["type"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed() {
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.0, 5, 1, 2.0), // tap
|
||||||
|
sif_note(2.0, 3, 3, 1.5), // hold: head at 2.0, tail at 3.5
|
||||||
|
sif_note(2.5, 7, 4, 0.0), // star
|
||||||
|
sif_note(3.5, 1, 2, 2.0), // parallel with the hold tail
|
||||||
|
sif_note(4.0, 9, 11, 0.0) // swing -> plain tap for v1
|
||||||
|
];
|
||||||
|
let (chart, combo) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
// 6 real notes, minus the same-lane hold head
|
||||||
|
assert_eq!(combo, 5);
|
||||||
|
assert_eq!(chart["notes"].len(), 7);
|
||||||
|
// Time order: tap(1.0), head(2.0), star(2.5), tail(3.5), parallel(3.5), swing(4.0)
|
||||||
|
assert_eq!(chart["notes"][2]["child_id"], 4);
|
||||||
|
assert_eq!(chart["notes"][3]["type"], 3);
|
||||||
|
assert_eq!(chart["notes"][4]["parent_id"], 2);
|
||||||
|
// The tail and the parallel tap at 3.5 share a spawn group
|
||||||
|
assert_eq!(chart["notes"][4]["num"], chart["notes"][5]["num"].clone());
|
||||||
|
assert_eq!(chart["notes"][6]["type"], 1);
|
||||||
|
// Ids stay sequential in time order
|
||||||
|
for (i, data) in chart["notes"].members().enumerate() {
|
||||||
|
assert_eq!(data["id"], i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_bad_charts() {
|
||||||
|
assert!(transcode(&jzon::array![sif_note(1.0, 0, 1, 2.0)]).is_err());
|
||||||
|
assert!(transcode(&jzon::array![sif_note(1.0, 10, 1, 2.0)]).is_err());
|
||||||
|
assert!(transcode(&jzon::array![sif_note(-1.0, 5, 1, 2.0)]).is_err());
|
||||||
|
assert!(transcode(&jzon::array![sif_note(1.0, 5, 3, 0.0)]).is_err());
|
||||||
|
assert!(transcode(&jzon::array![sif_note(1.0, 5, 1, 2.0), sif_note(1.0, 5, 3, 2.0)]).is_err());
|
||||||
|
assert!(transcode(&jzon::object!{}).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
90
src/router/custom_song/package.rs
Normal file
90
src/router/custom_song/package.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Cursor, Read, Seek, Write};
|
||||||
|
use zip::write::SimpleFileOptions;
|
||||||
|
|
||||||
|
use super::{song_path, LEVEL_COUNT};
|
||||||
|
|
||||||
|
// Export packages carry the ORIGINAL upload artifacts. SIF1 is the canonical
|
||||||
|
// interchange format - the transcoded NoteData charts are derived data and are
|
||||||
|
// never exported. Importing a package on another ew server replays the exact
|
||||||
|
// same upload pipeline the multipart form uses. Layout of the zip:
|
||||||
|
// manifest.json upload metadata, same schema as the multipart fields
|
||||||
|
// jacket original jacket image bytes (png/jpg)
|
||||||
|
// audio original audio bytes (ogg/mp3/wav)
|
||||||
|
// chart_{level}.json SIF1-schema charts, level 1..4 (only uploaded levels)
|
||||||
|
// visibility/shared_with/downloads_disabled are per-server settings and are
|
||||||
|
// deliberately NOT part of the package.
|
||||||
|
|
||||||
|
pub fn build(music_id: i64) -> Result<Vec<u8>, String> {
|
||||||
|
// Songs uploaded before export support have no original artifacts on disk
|
||||||
|
let manifest = fs::read(song_path(music_id, "original/manifest.json"))
|
||||||
|
.map_err(|_| String::from("This song was uploaded before export support and can't be downloaded"))?;
|
||||||
|
|
||||||
|
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
let options = SimpleFileOptions::default();
|
||||||
|
let mut add = |name: &str, bytes: &[u8]| -> Result<(), String> {
|
||||||
|
zip.start_file(name, options).map_err(|e| e.to_string())?;
|
||||||
|
zip.write_all(bytes).map_err(|e| e.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
add("manifest.json", &manifest)?;
|
||||||
|
for name in ["jacket", "audio"] {
|
||||||
|
let bytes = fs::read(song_path(music_id, &format!("original/{}", name))).map_err(|e| e.to_string())?;
|
||||||
|
add(name, &bytes)?;
|
||||||
|
}
|
||||||
|
for level in 1..=LEVEL_COUNT {
|
||||||
|
let Ok(bytes) = fs::read(song_path(music_id, &format!("original/chart_{}.json", level))) else { continue; };
|
||||||
|
add(&format!("chart_{}.json", level), &bytes)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
|
||||||
|
let mut file = archive.by_name(name).ok()?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes).ok()?;
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expands a package into the same field map the upload form produces - the zip
|
||||||
|
// contents map 1:1 onto the multipart fields, so an import is just an upload
|
||||||
|
// with its fields sourced from the package. Fields already present in the map
|
||||||
|
// that the package also carries are overwritten (the package's metadata wins);
|
||||||
|
// visibility/shared_with/downloads_disabled aren't packaged and stay untouched
|
||||||
|
pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(), String> {
|
||||||
|
let mut archive = zip::ZipArchive::new(Cursor::new(package)).map_err(|_| String::from("Package is not a valid zip file"))?;
|
||||||
|
|
||||||
|
let manifest = read_entry(&mut archive, "manifest.json").ok_or(String::from("Package has no manifest.json"))?;
|
||||||
|
let manifest = jzon::parse(&String::from_utf8_lossy(&manifest)).map_err(|_| String::from("Package manifest is not valid JSON"))?;
|
||||||
|
if manifest["format"].as_i64() != Some(1) {
|
||||||
|
return Err(String::from("Unsupported package format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in ["name", "name_en", "short_name", "kana", "artist", "artist_en", "band_category", "attribute", "bpm", "preview_start_sec", "preview_length_sec"] {
|
||||||
|
if !manifest[key].is_null() {
|
||||||
|
fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for data in manifest["levels"].members() {
|
||||||
|
let Some(level) = data["level"].as_i64() else { continue; };
|
||||||
|
if !data["level_number"].is_null() {
|
||||||
|
fields.insert(format!("level_number_{}", level), data["level_number"].to_string().into_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.insert(String::from("jacket"), read_entry(&mut archive, "jacket").ok_or(String::from("Package has no jacket"))?);
|
||||||
|
fields.insert(String::from("audio"), read_entry(&mut archive, "audio").ok_or(String::from("Package has no audio"))?);
|
||||||
|
let mut has_chart = false;
|
||||||
|
for level in 1..=LEVEL_COUNT {
|
||||||
|
if let Some(chart) = read_entry(&mut archive, &format!("chart_{}.json", level)) {
|
||||||
|
fields.insert(format!("chart_{}", level), chart);
|
||||||
|
has_chart = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_chart {
|
||||||
|
return Err(String::from("Package has no charts"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -26,10 +26,10 @@ static ASSET_TABLE: &[(&str, AssetHashes)] = &[
|
|||||||
("JP", AssetHashes {
|
("JP", AssetHashes {
|
||||||
version_android: "4c921d2443335e574a82e04ec9ea243c",
|
version_android: "4c921d2443335e574a82e04ec9ea243c",
|
||||||
version_ios: "4c921d2443335e574a82e04ec9ea243c",
|
version_ios: "4c921d2443335e574a82e04ec9ea243c",
|
||||||
version_windows: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
version_windows: "4c921d2443335e574a82e04ec9ea243c",
|
||||||
android: "67f8f261c16b3cca63e520a25aad6c1c",
|
android: "67f8f261c16b3cca63e520a25aad6c1c",
|
||||||
ios: "b8975be8300013a168d061d3fdcd4a16",
|
ios: "b8975be8300013a168d061d3fdcd4a16",
|
||||||
windows: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
windows: "eb59fcba5ff6987eff1969dbd97eccde",
|
||||||
}),
|
}),
|
||||||
("GL", AssetHashes {
|
("GL", AssetHashes {
|
||||||
version_android: "5260ff15dff8ba0c00ad91400f515f55",
|
version_android: "5260ff15dff8ba0c00ad91400f515f55",
|
||||||
|
|||||||
@@ -61,9 +61,14 @@ async fn deck(req: HttpRequest, body: String) -> impl Responder {
|
|||||||
async fn user(req: HttpRequest) -> impl Responder {
|
async fn user(req: HttpRequest) -> impl Responder {
|
||||||
let key = global::get_login(req.headers(), "");
|
let key = global::get_login(req.headers(), "");
|
||||||
let mut user = userdata::get_acc(&key);
|
let mut user = userdata::get_acc(&key);
|
||||||
|
|
||||||
user["lottery_list"] = array![];
|
user["lottery_list"] = array![];
|
||||||
|
|
||||||
|
// Custom songs visible to this user are unlocked
|
||||||
|
for id in crate::router::custom_song::get_music_ids(user["user"]["id"].as_i64().unwrap()).members() {
|
||||||
|
user["master_music_ids"].push(id.as_i64().unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
global::api(&req, Some(user))
|
global::api(&req, Some(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use rand::RngExt;
|
|||||||
|
|
||||||
use crate::router::global;
|
use crate::router::global;
|
||||||
use crate::router::items;
|
use crate::router::items;
|
||||||
|
use crate::database::custom_song;
|
||||||
use crate::sql::SQLite;
|
use crate::sql::SQLite;
|
||||||
use crate::include_file;
|
use crate::include_file;
|
||||||
|
|
||||||
@@ -224,10 +225,53 @@ fn get_data(auth_key: &str, row: &str) -> JsonValue {
|
|||||||
jzon::parse(&result.unwrap()).unwrap()
|
jzon::parse(&result.unwrap()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deleted custom songs leave stale score/clear records behind. They're wiped
|
||||||
|
// lazily when the userdata is pulled: collect the user's own music-id-keyed
|
||||||
|
// rows in the custom range (official ids are never candidates) and drop the
|
||||||
|
// ones whose id no longer exists in the catalog. Custom ids are never reused,
|
||||||
|
// so the wipe is final. A song that still exists but isn't visible to this
|
||||||
|
// user is NOT wiped - existence is what's checked, not visibility
|
||||||
|
fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
|
||||||
|
// Feature off: never touch custom_songs.db, leave userdata untouched
|
||||||
|
if crate::router::custom_song::disabled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut candidates = array![];
|
||||||
|
for key in ["live_list", "live_mission_list"] {
|
||||||
|
for data in user[key].members() {
|
||||||
|
let id = data["master_live_id"].as_i64().unwrap_or(0);
|
||||||
|
if id >= custom_song::FIRST_MUSIC_ID && !candidates.contains(id) {
|
||||||
|
candidates.push(id).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let dead = custom_song::dead_music_ids(&candidates);
|
||||||
|
if dead.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for key in ["live_list", "live_mission_list"] {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < user[key].len() {
|
||||||
|
if dead.contains(user[key][i]["master_live_id"].as_i64().unwrap_or(0)) {
|
||||||
|
user[key].array_remove(i);
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_acc(auth_key: &str) -> JsonValue {
|
pub fn get_acc(auth_key: &str) -> JsonValue {
|
||||||
let mut user = get_data(auth_key, "userdata");
|
let mut user = get_data(auth_key, "userdata");
|
||||||
cleanup_account(&mut user);
|
cleanup_account(&mut user);
|
||||||
|
if remove_deleted_custom_songs(&mut user) {
|
||||||
|
save_data(auth_key, "userdata", user.clone());
|
||||||
|
}
|
||||||
|
|
||||||
items::lp_modification(&mut user, 0, false);
|
items::lp_modification(&mut user, 0, false);
|
||||||
user
|
user
|
||||||
}
|
}
|
||||||
@@ -589,3 +633,61 @@ pub fn purge_accounts() -> usize {
|
|||||||
crate::database::gree::setup();
|
crate::database::gree::setup();
|
||||||
dead_uids.len()
|
dead_uids.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// User plays a custom song -> the song is deleted -> the next userdata
|
||||||
|
// pull serves data without the dead scores and the stored rows are gone
|
||||||
|
#[test]
|
||||||
|
fn deleted_custom_song_records_are_wiped_on_pull() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let token = "userdata-test-token";
|
||||||
|
let mut user = get_acc(token);
|
||||||
|
|
||||||
|
let deleted_id = custom_song::next_music_id();
|
||||||
|
custom_song::insert_song(deleted_id, 1, &object!{music_id: deleted_id}, "public", &array![], false);
|
||||||
|
// Exists but isn't visible to this user - must survive the wipe
|
||||||
|
let private_id = custom_song::next_music_id();
|
||||||
|
custom_song::insert_song(private_id, 1, &object!{music_id: private_id}, "private", &array![], false);
|
||||||
|
|
||||||
|
for id in [1001, deleted_id, private_id] {
|
||||||
|
user["live_list"].push(object!{
|
||||||
|
master_live_id: id,
|
||||||
|
level: 4,
|
||||||
|
clear_count: 1,
|
||||||
|
high_score: 123456,
|
||||||
|
max_combo: 100
|
||||||
|
}).unwrap();
|
||||||
|
user["live_mission_list"].push(object!{
|
||||||
|
master_live_id: id,
|
||||||
|
clear_master_live_mission_ids: [1, 24]
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
save_acc(token, user);
|
||||||
|
|
||||||
|
// Both custom songs still exist: nothing gets wiped
|
||||||
|
let user = get_acc(token);
|
||||||
|
assert_eq!(user["live_list"].len(), 3);
|
||||||
|
assert_eq!(user["live_mission_list"].len(), 3);
|
||||||
|
|
||||||
|
custom_song::delete_song(deleted_id);
|
||||||
|
|
||||||
|
// The next pull drops the dead id's records and only those
|
||||||
|
let user = get_acc(token);
|
||||||
|
assert!(!user["live_list"].members().any(|data| data["master_live_id"] == deleted_id));
|
||||||
|
assert!(!user["live_mission_list"].members().any(|data| data["master_live_id"] == deleted_id));
|
||||||
|
// Official records are untouchable, invisible-but-alive songs survive
|
||||||
|
assert!(user["live_list"].members().any(|data| data["master_live_id"] == 1001));
|
||||||
|
assert!(user["live_list"].members().any(|data| data["master_live_id"] == private_id));
|
||||||
|
assert_eq!(user["live_list"].len(), 2);
|
||||||
|
assert_eq!(user["live_mission_list"].len(), 2);
|
||||||
|
|
||||||
|
// The wipe persisted to the database, not just the served copy
|
||||||
|
let stored = get_data(token, "userdata");
|
||||||
|
assert!(!stored["live_list"].members().any(|data| data["master_live_id"] == deleted_id));
|
||||||
|
assert!(!stored["live_mission_list"].members().any(|data| data["master_live_id"] == deleted_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ fn get_config() -> JsonValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_login_token(req: &HttpRequest) -> Option<String> {
|
pub fn get_login_token(req: &HttpRequest) -> Option<String> {
|
||||||
let blank_header = HeaderValue::from_static("");
|
let blank_header = HeaderValue::from_static("");
|
||||||
let cookies = req.headers().get("Cookie").unwrap_or(&blank_header).to_str().unwrap_or("");
|
let cookies = req.headers().get("Cookie").unwrap_or(&blank_header).to_str().unwrap_or("");
|
||||||
if cookies.is_empty() {
|
if cookies.is_empty() {
|
||||||
@@ -30,7 +30,7 @@ fn get_login_token(req: &HttpRequest) -> Option<String> {
|
|||||||
Some(cookies.split("ew_token=").last().unwrap_or("").split(';').collect::<Vec<_>>()[0].to_string())
|
Some(cookies.split("ew_token=").last().unwrap_or("").split(';').collect::<Vec<_>>()[0].to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error(msg: &str) -> HttpResponse {
|
pub fn error(msg: &str) -> HttpResponse {
|
||||||
let resp = object!{
|
let resp = object!{
|
||||||
result: "ERR",
|
result: "ERR",
|
||||||
message: msg
|
message: msg
|
||||||
@@ -53,7 +53,7 @@ pub fn login(_req: HttpRequest, body: String) -> HttpResponse {
|
|||||||
};
|
};
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
.insert_header(ContentType::json())
|
.insert_header(ContentType::json())
|
||||||
.insert_header(("Set-Cookie", format!("ew_token={}; SameSite=Strict; HttpOnly", token.unwrap())))
|
.insert_header(("Set-Cookie", format!("ew_token={}; SameSite=Strict; Path=/; HttpOnly", token.unwrap())))
|
||||||
.body(jzon::stringify(resp))
|
.body(jzon::stringify(resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +220,7 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
|
|||||||
result: "OK",
|
result: "OK",
|
||||||
data: {
|
data: {
|
||||||
account_import: get_config()["import"].as_bool().unwrap(),
|
account_import: get_config()["import"].as_bool().unwrap(),
|
||||||
|
custom_songs: !crate::router::custom_song::disabled(),
|
||||||
links: {
|
links: {
|
||||||
global: args.global_android,
|
global: args.global_android,
|
||||||
japan: args.japan_android,
|
japan: args.japan_android,
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ pub struct HostConfig {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub jp_android_asset_hash: String,
|
pub jp_android_asset_hash: String,
|
||||||
pub en_android_asset_hash: String,
|
pub en_android_asset_hash: String,
|
||||||
|
pub enable_custom_songs: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets an embedding app (or the tests) enable the opt-in custom songs feature
|
||||||
|
// without a command-line flag
|
||||||
|
pub fn set_enable_custom_songs(enabled: bool) {
|
||||||
|
HOST_CONFIG.write().unwrap().enable_custom_songs = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_running(running: bool) {
|
pub fn set_running(running: bool) {
|
||||||
@@ -174,4 +181,30 @@ pub fn overlay_args(args: &mut crate::options::Args) {
|
|||||||
}
|
}
|
||||||
overlay_str!(jp_android_asset_hash);
|
overlay_str!(jp_android_asset_hash);
|
||||||
overlay_str!(en_android_asset_hash);
|
overlay_str!(en_android_asset_hash);
|
||||||
|
// Overlay only ever enables the feature; a command-line --enable-custom-songs
|
||||||
|
// is never overridden back to off
|
||||||
|
if cfg.enable_custom_songs {
|
||||||
|
args.enable_custom_songs = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// idk why an ai put tests here but they are here now. Yay tests????
|
||||||
|
#[cfg(test)]
|
||||||
|
lazy_static! {
|
||||||
|
static ref TEST_DATA_DIR: String = {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ew-tests-{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir.to_str().unwrap().to_string()
|
||||||
|
};
|
||||||
|
static ref TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
let guard = crate::lock_onto_mutex!(TEST_LOCK);
|
||||||
|
update_data_path(&TEST_DATA_DIR);
|
||||||
|
// The feature is off by default; tests exercise it, so turn it on while holding the lock
|
||||||
|
set_enable_custom_songs(true);
|
||||||
|
guard
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user