diff --git a/.gitignore b/.gitignore index 237d299..01dfd46 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ docker/data/ .idea/ ndk/ .DS_Store +custom_songs/ diff --git a/Cargo.lock b/Cargo.lock index 0c396d0..c8c7c20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand", + "rand 0.10.1", "sha1", "smallvec", "tokio", @@ -68,6 +68,44 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-multipart" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560e3dd4eae03837f86d1b6bf6222c508568eff36845ef5ebb3a0dff480e3f64" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "derive_more", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "rand 0.10.1", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8720bceaa6797fd8b2deab968d52e1120b2a8c30950939f6c8cdb42a910bc885" +dependencies = [ + "bytesize", + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "actix-router" version = "0.5.4" @@ -217,6 +255,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -294,6 +350,33 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "aotuv_lancer_vorbis_sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c22da1eae146e1ac2956da45bd1856b2f9ad788e6f7af535b861df497d726a1" +dependencies = [ + "cc", + "ogg_next_sys", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "argon2" version = "0.5.3" @@ -306,12 +389,70 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "base16ct" version = "1.0.0" @@ -330,12 +471,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "blake2" version = "0.10.6" @@ -402,18 +558,42 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytesize" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e78e506b9d7633710dab98996f22f95f3d0f488e8f1aa162830556ed9fc14d" + [[package]] name = "bytestring" version = "1.5.1" @@ -523,6 +703,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -598,6 +784,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.7.3" @@ -672,6 +889,40 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dary_heap" version = "0.3.9" @@ -765,6 +1016,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -774,6 +1031,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -794,6 +1071,7 @@ dependencies = [ name = "ew" version = "1.1.0" dependencies = [ + "actix-multipart", "actix-web", "aes", "argon2", @@ -802,8 +1080,10 @@ dependencies = [ "cc", "clap", "csv", + "futures-util", "hex", "hmac", + "image", "include-flate-codegen", "include_dir", "jni", @@ -816,17 +1096,41 @@ dependencies = [ "objc2", "objc2-foundation", "pem", - "rand", - "rand_core 0.6.4", + "rand 0.10.1", + "rand_core 0.10.1", "rsa", "rusqlite", "sha1", "sha2", + "symphonia", "ureq", "urlencoding", "uuid", + "vorbis_rs", + "zip", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -839,6 +1143,27 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -853,6 +1178,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -888,6 +1214,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -907,6 +1244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", @@ -959,6 +1297,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "glob" version = "0.3.3" @@ -984,6 +1332,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1170,6 +1529,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1191,6 +1556,46 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "impl-more" version = "0.1.9" @@ -1261,12 +1666,32 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1374,6 +1799,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.186" @@ -1404,6 +1835,16 @@ dependencies = [ "rle-decode-fast", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -1431,6 +1872,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1469,6 +1916,25 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md5" version = "0.8.0" @@ -1519,6 +1985,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "no_std_io2" version = "0.9.3" @@ -1528,12 +2010,77 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1582,6 +2129,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "ogg_next_sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c990730c2782b922815753a62af535e4205267df190dfbd8aa5d74e11d7dcc3" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1628,6 +2184,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pem" version = "3.0.6" @@ -1685,6 +2253,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1700,6 +2281,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1741,6 +2331,46 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.45" @@ -1762,6 +2392,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -1773,6 +2413,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1782,12 +2432,91 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1832,6 +2561,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -1905,6 +2640,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -2015,6 +2763,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2091,6 +2848,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "slab" version = "0.4.12" @@ -2163,6 +2929,116 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symphonia" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" +dependencies = [ + "bitflags", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "smallvec", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] + [[package]] name = "syn" version = "2.0.117" @@ -2185,6 +3061,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2225,6 +3114,20 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -2266,6 +3169,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.1" @@ -2327,6 +3245,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.0" @@ -2439,6 +3363,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2451,6 +3386,20 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vorbis_rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874075ad757c0f6031d4706ed75703a9ba6c711164a0b1dab55af86800cd618f" +dependencies = [ + "aotuv_lancer_vorbis_sys", + "errno", + "getrandom 0.2.17", + "ogg_next_sys", + "thiserror 2.0.18", + "tinyvec", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2573,6 +3522,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -2858,6 +3813,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.2" @@ -2881,6 +3842,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.7" @@ -2941,12 +3922,44 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" @@ -2974,3 +3987,27 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 9cb1f29..9f47b50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,8 +30,14 @@ mime_guess = "2.0.5" include_dir = "0.7.4" jzon = "0.12.5" csv = "1.3" -argon2 = "0.5.3" -rand_core = { version = "0.6", features = ["getrandom"] } +actix-multipart = "0.8" +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] jni = { version = "0.21", features = ["invocation", "default"], optional = true } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 68bc2dd..0657bd1 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,6 +13,7 @@ services: HIDDEN: false # Will disable the webui DISABLE_IMPORTS: false # Will disable account imports 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 #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) diff --git a/docker/start.sh b/docker/start.sh index a518cb7..449255b 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -13,6 +13,7 @@ args=( [ "${PURGE:-}" = "true" ] && args+=(--purge) [ "${DISABLE_IMPORTS:-}" = "true" ] && args+=(--disable-imports) [ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports) +[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs) add_opt() { local value="$1" flag="$2" diff --git a/src/database.rs b/src/database.rs index a867ec7..b3ea254 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1 +1,2 @@ -pub mod gree; \ No newline at end of file +pub mod gree; +pub mod custom_song; diff --git a/src/database/custom_song.rs b/src/database/custom_song.rs new file mode 100644 index 0000000..f632c9e --- /dev/null +++ b/src/database/custom_song.rs @@ -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::().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::().unwrap_or(0); + let max = DATABASE.lock_and_select("SELECT MAX(music_id) FROM songs", params!()).unwrap_or_default().parse::().unwrap_or(0); + std::cmp::max(std::cmp::max(issued, max), FIRST_MUSIC_ID - 1) + 1 +} + +pub fn insert_song(music_id: i64, owner_id: i64, song: &JsonValue, visibility: &str, shared_with: &JsonValue, downloads_disabled: bool) { + DATABASE.lock_and_exec("INSERT INTO songs (music_id, owner_id, song, visibility, downloads_disabled) VALUES (?1, ?2, ?3, ?4, ?5)", params!(music_id, owner_id, jzon::stringify(song.clone()), visibility, downloads_disabled as i64)); + DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_music_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_music_id=?1", params!(music_id)); + set_shared_users(music_id, shared_with); +} + +pub fn delete_song(music_id: i64) { + DATABASE.lock_and_exec("DELETE FROM songs WHERE music_id=?1", params!(music_id)); + DATABASE.lock_and_exec("DELETE FROM shared_users WHERE music_id=?1", params!(music_id)); +} + +pub fn get_song(music_id: i64) -> Option { + let song = DATABASE.lock_and_select("SELECT song FROM songs WHERE music_id=?1", params!(music_id)).ok()?; + jzon::parse(&song).ok() +} + +pub fn get_song_owner(music_id: i64) -> Option { + DATABASE.lock_and_select("SELECT owner_id FROM songs WHERE music_id=?1", params!(music_id)).ok()?.parse::().ok() +} + +pub fn set_visibility(music_id: i64, visibility: &str, shared_with: &JsonValue) { + DATABASE.lock_and_exec("UPDATE songs SET visibility=?1 WHERE music_id=?2", params!(visibility, music_id)); + set_shared_users(music_id, shared_with); +} + +pub fn set_downloads_disabled(music_id: i64, downloads_disabled: bool) { + DATABASE.lock_and_exec("UPDATE songs SET downloads_disabled=?1 WHERE music_id=?2", params!(downloads_disabled as i64, music_id)); +} + +fn get_downloads_disabled(music_id: i64) -> bool { + DATABASE.lock_and_select("SELECT downloads_disabled FROM songs WHERE music_id=?1", params!(music_id)).unwrap_or_default() == "1" +} + +fn set_shared_users(music_id: i64, shared_with: &JsonValue) { + DATABASE.lock_and_exec("DELETE FROM shared_users WHERE music_id=?1", params!(music_id)); + for id in shared_with.members() { + DATABASE.lock_and_exec("INSERT OR IGNORE INTO shared_users (music_id, user_id) VALUES (?1, ?2)", params!(music_id, id.as_i64().unwrap())); + } +} + +fn get_visibility(music_id: i64) -> String { + DATABASE.lock_and_select("SELECT visibility FROM songs WHERE music_id=?1", params!(music_id)).unwrap_or(String::from("public")) +} + +fn get_shared_users(music_id: i64) -> JsonValue { + DATABASE.lock_and_select_all("SELECT user_id FROM shared_users WHERE music_id=?1 ORDER BY user_id", params!(music_id)).unwrap_or(array![]) +} + +// The catalog a given user is allowed to see: public songs, their own songs, +// and songs shared with them +pub fn get_songs_for_user(user_id: i64) -> JsonValue { + let songs = DATABASE.lock_and_select_all(" + SELECT song FROM songs + WHERE visibility='public' OR owner_id=?1 + OR (visibility='shared' AND music_id IN (SELECT music_id FROM shared_users WHERE user_id=?1)) + ORDER BY music_id", params!(user_id)).unwrap_or(array![]); + let mut rv = array![]; + for data in songs.members() { + rv.push(jzon::parse(&data.to_string()).unwrap()).unwrap(); + } + rv +} + +// Song blobs plus the visibility fields, for the webui manage view +pub fn get_songs_by_owner(owner_id: i64) -> JsonValue { + let songs = DATABASE.lock_and_select_all("SELECT song FROM songs WHERE owner_id=?1 ORDER BY music_id", params!(owner_id)).unwrap_or(array![]); + let mut rv = array![]; + for data in songs.members() { + let mut song = jzon::parse(&data.to_string()).unwrap(); + let music_id = song["music_id"].as_i64().unwrap(); + song["visibility"] = get_visibility(music_id).into(); + song["shared_with"] = get_shared_users(music_id); + song["downloads_disabled"] = get_downloads_disabled(music_id).into(); + rv.push(song).unwrap(); + } + rv +} + +// The webui song browser: everything the viewer is allowed to see under the +// visibility rules, plus the fields the browser page needs. Anonymous viewers +// (no webui session) see the public catalog only +pub fn get_browse_songs(viewer: Option) -> JsonValue { + let viewer = viewer.unwrap_or(0); + let songs = DATABASE.lock_and_select_all(" + SELECT song FROM songs + WHERE visibility='public' OR owner_id=?1 + OR (visibility='shared' AND music_id IN (SELECT music_id FROM shared_users WHERE user_id=?1)) + ORDER BY music_id", params!(viewer)).unwrap_or(array![]); + let mut rv = array![]; + for data in songs.members() { + let mut song = jzon::parse(&data.to_string()).unwrap(); + let music_id = song["music_id"].as_i64().unwrap(); + let owner = get_song_owner(music_id).unwrap_or(0); + song["owner_id"] = owner.into(); + song["mine"] = (owner == viewer).into(); + song["downloads_disabled"] = get_downloads_disabled(music_id).into(); + rv.push(song).unwrap(); + } + rv +} + +// Whether this viewer may download the song's export package: they must be +// able to SEE it (same rules as the catalog - a private song 404s rather than +// admitting it exists), and downloads must be enabled unless they own it +pub fn export_allowed(music_id: i64, viewer: Option) -> Result<(), &'static str> { + let Some(owner) = get_song_owner(music_id) else { + return Err("Song not found"); + }; + let viewer = viewer.unwrap_or(0); + if owner == viewer { + return Ok(()); + } + let visible = match get_visibility(music_id).as_str() { + "public" => true, + "shared" => get_shared_users(music_id).contains(viewer), + _ => false + }; + if !visible { + return Err("Song not found"); + } + if get_downloads_disabled(music_id) { + return Err("The uploader has disabled downloads for this song"); + } + Ok(()) +} + +pub fn get_music_ids_for_user(user_id: i64) -> JsonValue { + DATABASE.lock_and_select_all(" + SELECT music_id FROM songs + WHERE visibility='public' OR owner_id=?1 + OR (visibility='shared' AND music_id IN (SELECT music_id FROM shared_users WHERE user_id=?1)) + ORDER BY music_id", params!(user_id)).unwrap_or(array![]) +} + +// Which of these candidate ids no longer exist in the catalog. Only the custom +// range is ever considered, so official songs can't come back from this. A song +// that's merely private/shared still has its row - only genuinely deleted ids +// (which are never reused) are returned +pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue { + let mut ids: Vec = Vec::new(); + for id in candidates.members() { + let Some(id) = id.as_i64() else { continue; }; + if id >= FIRST_MUSIC_ID && !ids.contains(&id) { + ids.push(id); + } + } + if ids.is_empty() { + return array![]; + } + let list = ids.iter().map(|id| id.to_string()).collect::>().join(","); + let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).unwrap_or(array![]); + let mut rv = array![]; + for id in ids { + if !alive.contains(id) { + rv.push(id).unwrap(); + } + } + rv +} + +// Audio files are content-addressed and may be shared between songs +pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> bool { + DATABASE.lock_and_select("SELECT music_id FROM songs WHERE music_id!=?1 AND song LIKE ?2", params!(ignored_music_id, format!("%{}%", md5))).is_ok() +} diff --git a/src/options.rs b/src/options.rs index eb3fe8c..33fd3f8 100644 --- a/src/options.rs +++ b/src/options.rs @@ -38,6 +38,9 @@ pub struct Args { #[arg(long, default_value_t = false, help = "Disable webui, act completely like the original server")] 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")] pub purge: bool, diff --git a/src/router.rs b/src/router.rs index d3ef5b6..72e099b 100644 --- a/src/router.rs +++ b/src/router.rs @@ -20,6 +20,7 @@ pub mod serial_code; pub mod web; pub mod card; pub mod shop; +pub mod custom_song; pub mod webui; pub mod clear_rate; pub mod exchange; @@ -141,6 +142,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) { ) .configure(card::routes) .configure(chat::routes) + .configure(custom_song::routes) .configure(debug::routes) .configure(event::routes) .configure(exchange::routes) @@ -166,4 +168,5 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) { actix_web::web::scope("/v1.0") .configure(gree::routes) ); + cfg.configure(custom_song::web_routes); } diff --git a/src/router/clear_rate.rs b/src/router/clear_rate.rs index dddc1ba..eb2cd93 100644 --- a/src/router/clear_rate.rs +++ b/src/router/clear_rate.rs @@ -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) { update_live_score(id, uid, score); match DATABASE.get_live_data(id) { @@ -190,7 +198,8 @@ fn get_json() -> JsonValue { expert: get_pass_percent(info.expert_failed, info.expert_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(); } object!{ diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs new file mode 100644 index 0000000..f1b8b86 --- /dev/null +++ b/src/router/custom_song.rs @@ -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::().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 { + 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>, 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>, 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>, key: &str) -> Option { + field_str(fields, key).parse::().ok() +} + +// Checkbox-style flag: "1", "true" or "on" +fn field_flag(fields: &HashMap>, 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 { + let mut rv = array![]; + for part in input.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let id = part.parse::().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, Vec), 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>) -> Result { + 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::().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)> = 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::().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::().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 { + 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 { + let samples: Vec = (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 { + 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 { + 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>, 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 = 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() + } +} diff --git a/src/router/custom_song/audio.rs b/src/router/custom_song/audio.rs new file mode 100644 index 0000000..6d99dfd --- /dev/null +++ b/src/router/custom_song/audio.rs @@ -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, + 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>, + 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 { + 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::new(); + let mut sample_rate = 0; + let mut interleaved: Vec = 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, 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, 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, preview_length_sec: Option) -> 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> = 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)) +} diff --git a/src/router/custom_song/chart.rs b/src/router/custom_song/chart.rs new file mode 100644 index 0000000..0b45c72 --- /dev/null +++ b/src/router/custom_song/chart.rs @@ -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 +} + +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 = 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 = (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()); + } + +} diff --git a/src/router/custom_song/package.rs b/src/router/custom_song/package.rs new file mode 100644 index 0000000..7a60e69 --- /dev/null +++ b/src/router/custom_song/package.rs @@ -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, 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(archive: &mut zip::ZipArchive, name: &str) -> Option> { + 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>) -> 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(()) +} diff --git a/src/router/global.rs b/src/router/global.rs index 393518d..cf1ea97 100644 --- a/src/router/global.rs +++ b/src/router/global.rs @@ -26,10 +26,10 @@ static ASSET_TABLE: &[(&str, AssetHashes)] = &[ ("JP", AssetHashes { version_android: "4c921d2443335e574a82e04ec9ea243c", version_ios: "4c921d2443335e574a82e04ec9ea243c", - version_windows: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + version_windows: "4c921d2443335e574a82e04ec9ea243c", android: "67f8f261c16b3cca63e520a25aad6c1c", ios: "b8975be8300013a168d061d3fdcd4a16", - windows: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + windows: "eb59fcba5ff6987eff1969dbd97eccde", }), ("GL", AssetHashes { version_android: "5260ff15dff8ba0c00ad91400f515f55", diff --git a/src/router/user.rs b/src/router/user.rs index a62f571..fa71817 100644 --- a/src/router/user.rs +++ b/src/router/user.rs @@ -61,9 +61,14 @@ async fn deck(req: HttpRequest, body: String) -> impl Responder { async fn user(req: HttpRequest) -> impl Responder { let key = global::get_login(req.headers(), ""); let mut user = userdata::get_acc(&key); - + 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)) } diff --git a/src/router/userdata/mod.rs b/src/router/userdata/mod.rs index dbe23c6..3210f36 100644 --- a/src/router/userdata/mod.rs +++ b/src/router/userdata/mod.rs @@ -7,6 +7,7 @@ use rand::RngExt; use crate::router::global; use crate::router::items; +use crate::database::custom_song; use crate::sql::SQLite; use crate::include_file; @@ -224,10 +225,53 @@ fn get_data(auth_key: &str, row: &str) -> JsonValue { 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 { let mut user = get_data(auth_key, "userdata"); 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); user } @@ -589,3 +633,61 @@ pub fn purge_accounts() -> usize { crate::database::gree::setup(); 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)); + } +} diff --git a/src/router/webui.rs b/src/router/webui.rs index 14493cd..d274fbd 100644 --- a/src/router/webui.rs +++ b/src/router/webui.rs @@ -21,7 +21,7 @@ fn get_config() -> JsonValue { } } -fn get_login_token(req: &HttpRequest) -> Option { +pub fn get_login_token(req: &HttpRequest) -> Option { let blank_header = HeaderValue::from_static(""); let cookies = req.headers().get("Cookie").unwrap_or(&blank_header).to_str().unwrap_or(""); if cookies.is_empty() { @@ -30,7 +30,7 @@ fn get_login_token(req: &HttpRequest) -> Option { Some(cookies.split("ew_token=").last().unwrap_or("").split(';').collect::>()[0].to_string()) } -fn error(msg: &str) -> HttpResponse { +pub fn error(msg: &str) -> HttpResponse { let resp = object!{ result: "ERR", message: msg @@ -53,7 +53,7 @@ pub fn login(_req: HttpRequest, body: String) -> HttpResponse { }; HttpResponse::Ok() .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)) } @@ -220,6 +220,7 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse { result: "OK", data: { account_import: get_config()["import"].as_bool().unwrap(), + custom_songs: !crate::router::custom_song::disabled(), links: { global: args.global_android, japan: args.japan_android, diff --git a/src/runtime.rs b/src/runtime.rs index 3b2b474..9c1df6e 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -23,6 +23,13 @@ pub struct HostConfig { pub port: u16, pub jp_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) { @@ -174,4 +181,30 @@ pub fn overlay_args(args: &mut crate::options::Args) { } overlay_str!(jp_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 }