diff --git a/Cargo.lock b/Cargo.lock index 41ce587..801543a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "asn1-rs" version = "0.7.1" @@ -156,6 +162,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -192,6 +209,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "axum-macros", "bytes", "futures-util", "http", @@ -239,18 +257,44 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -266,6 +310,12 @@ 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" version = "1.5.0" @@ -391,6 +441,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -415,6 +474,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -455,13 +524,23 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -490,6 +569,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -527,6 +615,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.3.0" @@ -658,6 +752,7 @@ dependencies = [ "async-stream", "async-trait", "axum", + "base64", "bytes", "clap", "furumi-common", @@ -666,16 +761,25 @@ dependencies = [ "futures-util", "jsonwebtoken", "libc", + "mime_guess", + "ogg", "once_cell", + "opus", "prometheus", "prost", "rcgen", "rustls", + "serde", + "serde_json", + "sha2", + "symphonia", "tempfile", "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-util", "tonic", + "tower 0.4.13", "tracing", "tracing-subscriber", ] @@ -784,6 +888,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1082,7 +1196,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "plain", "redox_syscall 0.7.3", @@ -1142,6 +1256,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1211,7 +1335,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1223,7 +1347,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1308,6 +1432,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "ogg" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdab8dcd8d4052eaacaf8fb07a3ccd9a6e26efadb42878a413c68fc4af1dee2b" +dependencies = [ + "byteorder", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -1335,6 +1468,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "opus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +dependencies = [ + "audiopus_sys", +] + [[package]] name = "page_size" version = "0.6.0" @@ -1490,7 +1632,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hex", "procfs-core", "rustix 0.38.44", @@ -1502,7 +1644,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hex", ] @@ -1675,7 +1817,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1684,7 +1826,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1745,7 +1887,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1758,7 +1900,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -1856,7 +1998,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -1945,6 +2087,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2035,6 +2188,189 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -2398,6 +2734,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2439,6 +2787,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -2545,7 +2899,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap 2.13.0", "semver", @@ -2728,7 +3082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap 2.13.0", "log", "serde", diff --git a/furumi-server/Cargo.toml b/furumi-server/Cargo.toml index 33c0aa5..df9e947 100644 --- a/furumi-server/Cargo.toml +++ b/furumi-server/Cargo.toml @@ -18,15 +18,25 @@ rustls = { version = "0.23.37", features = ["ring"] } thiserror = "2.0.18" tokio = { version = "1.50.0", features = ["full"] } tokio-stream = "0.1.18" +tokio-util = { version = "0.7", features = ["io"] } tonic = { version = "0.12.3", features = ["tls"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } async-stream = "0.3.6" async-trait = "0.1.89" prometheus = { version = "0.14.0", features = ["process"] } -axum = { version = "0.7", features = ["tokio"] } +axum = { version = "0.7", features = ["tokio", "macros"] } once_cell = "1.21.3" rcgen = { version = "0.14.7", features = ["pem"] } +symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] } +opus = "0.3" +ogg = "0.9" +mime_guess = "2.0" +tower = { version = "0.4", features = ["util"] } +sha2 = "0.10" +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [dev-dependencies] tempfile = "3.26.0" diff --git a/furumi-server/src/main.rs b/furumi-server/src/main.rs index 95df840..812ee67 100644 --- a/furumi-server/src/main.rs +++ b/furumi-server/src/main.rs @@ -2,6 +2,7 @@ pub mod vfs; pub mod security; pub mod server; pub mod metrics; +pub mod web; use std::net::SocketAddr; use std::path::PathBuf; @@ -33,6 +34,14 @@ struct Args { #[arg(long, env = "FURUMI_METRICS_BIND", default_value = "0.0.0.0:9090")] metrics_bind: String, + /// IP address and port for the web music player + #[arg(long, env = "FURUMI_WEB_BIND", default_value = "0.0.0.0:8080")] + web_bind: String, + + /// Disable the web music player UI + #[arg(long, default_value_t = false)] + no_web: bool, + /// Disable TLS encryption (not recommended, use only for debugging) #[arg(long, default_value_t = false)] no_tls: bool, @@ -91,7 +100,7 @@ async fn main() -> Result<(), Box> { println!("Authentication: enabled (Bearer token)"); } println!("Document Root: {:?}", root_path); - println!("Metrics: http://{}/metrics", metrics_addr); + println!("Metrics: http://{}/metrics", metrics_addr); // Spawn the Prometheus metrics HTTP server on a separate port let metrics_app = Router::new().route("/metrics", get(metrics_handler)); @@ -100,6 +109,20 @@ async fn main() -> Result<(), Box> { axum::serve(metrics_listener, metrics_app).await.unwrap(); }); + // Spawn the web music player on its own port + if !args.no_web { + let web_addr: SocketAddr = args.web_bind.parse().unwrap_or_else(|e| { + eprintln!("Error: Invalid web bind address '{}': {}", args.web_bind, e); + std::process::exit(1); + }); + let web_app = web::build_router(root_path.clone(), args.token.clone()); + let web_listener = tokio::net::TcpListener::bind(web_addr).await?; + println!("Web player: http://{}", web_addr); + tokio::spawn(async move { + axum::serve(web_listener, web_app).await.unwrap(); + }); + } + let mut builder = Server::builder() .tcp_keepalive(Some(std::time::Duration::from_secs(60))) .http2_keepalive_interval(Some(std::time::Duration::from_secs(60))); diff --git a/furumi-server/src/web/auth.rs b/furumi-server/src/web/auth.rs new file mode 100644 index 0000000..8d2e23f --- /dev/null +++ b/furumi-server/src/web/auth.rs @@ -0,0 +1,213 @@ +use axum::{ + body::Body, + extract::{Request, State}, + http::{HeaderMap, StatusCode, header}, + middleware::Next, + response::{Html, IntoResponse, Redirect, Response}, + Form, +}; +use sha2::{Digest, Sha256}; +use serde::Deserialize; + +use super::WebState; + +/// Cookie name used to store the session token. +const SESSION_COOKIE: &str = "furumi_session"; + +/// Compute SHA-256 of the token as hex string (stored in cookie, not raw token). +pub fn token_hash(token: &str) -> String { + let mut h = Sha256::new(); + h.update(token.as_bytes()); + format!("{:x}", h.finalize()) +} + +/// axum middleware: if token is configured, requires a valid session cookie. +pub async fn require_auth( + State(state): State, + req: Request, + next: Next, +) -> Response { + // Auth disabled when token is empty + if state.token.is_empty() { + return next.run(req).await; + } + + let cookies = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let expected = token_hash(&state.token); + let authed = cookies + .split(';') + .any(|c| c.trim() == format!("{}={}", SESSION_COOKIE, expected)); + + if authed { + next.run(req).await + } else { + let uri = req.uri().path(); + if uri.starts_with("/api/") { + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() + } else { + Redirect::to("/login").into_response() + } + } +} + +/// GET /login โ€” show login form. +pub async fn login_page(State(state): State) -> impl IntoResponse { + if state.token.is_empty() { + return Redirect::to("/").into_response(); + } + Html(LOGIN_HTML).into_response() +} + +#[derive(Deserialize)] +pub struct LoginForm { + password: String, +} + +/// POST /login โ€” validate password, set session cookie. +pub async fn login_submit( + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + if state.token.is_empty() { + return Redirect::to("/").into_response(); + } + + if form.password == *state.token { + let hash = token_hash(&state.token); + let cookie = format!( + "{}={}; HttpOnly; SameSite=Strict; Path=/", + SESSION_COOKIE, hash + ); + let mut headers = HeaderMap::new(); + headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); + headers.insert(header::LOCATION, "/".parse().unwrap()); + (StatusCode::FOUND, headers, Body::empty()).into_response() + } else { + Html(LOGIN_ERROR_HTML).into_response() + } +} + +/// GET /logout โ€” clear session cookie and redirect to login. +pub async fn logout() -> impl IntoResponse { + let cookie = format!( + "{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT", + SESSION_COOKIE + ); + let mut headers = HeaderMap::new(); + headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); + headers.insert(header::LOCATION, "/login".parse().unwrap()); + (StatusCode::FOUND, headers, Body::empty()).into_response() +} + +const LOGIN_HTML: &str = r#" + + + + +Furumi Player โ€” Login + + + +
+ +
Enter access token to continue
+
+ + + +
+
+ +"#; + +const LOGIN_ERROR_HTML: &str = r#" + + + +Furumi Player โ€” Login + + + +
+ +
Enter access token to continue
+

โŒ Invalid token. Please try again.

+
+ + + +
+
+ +"#; diff --git a/furumi-server/src/web/browse.rs b/furumi-server/src/web/browse.rs new file mode 100644 index 0000000..c0a6e2e --- /dev/null +++ b/furumi-server/src/web/browse.rs @@ -0,0 +1,132 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::security::sanitize_path; +use super::WebState; + +#[derive(Deserialize)] +pub struct BrowseQuery { + #[serde(default)] + pub path: String, +} + +#[derive(Serialize)] +pub struct BrowseResponse { + pub path: String, + pub entries: Vec, +} + +#[derive(Serialize)] +pub struct Entry { + pub name: String, + #[serde(rename = "type")] + pub kind: EntryKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum EntryKind { + File, + Dir, +} + +pub async fn handler( + State(state): State, + Query(query): Query, +) -> impl IntoResponse { + let safe = match sanitize_path(&query.path) { + Ok(p) => p, + Err(_) => { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid path"}))).into_response(); + } + }; + + let dir_path: PathBuf = state.root.join(&safe); + + let read_dir = match tokio::fs::read_dir(&dir_path).await { + Ok(rd) => rd, + Err(e) => { + let status = if e.kind() == std::io::ErrorKind::NotFound { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + return (status, Json(serde_json::json!({"error": e.to_string()}))).into_response(); + } + }; + + let mut entries: Vec = Vec::new(); + let mut rd = read_dir; + + loop { + match rd.next_entry().await { + Ok(Some(entry)) => { + let name = entry.file_name().to_string_lossy().into_owned(); + // Skip hidden files + if name.starts_with('.') { + continue; + } + let meta = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + if meta.is_dir() { + entries.push(Entry { name, kind: EntryKind::Dir, size: None }); + } else if meta.is_file() { + // Only expose audio files + if is_audio_file(&name) { + entries.push(Entry { + name, + kind: EntryKind::File, + size: Some(meta.len()), + }); + } + } + } + Ok(None) => break, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(); + } + } + } + + // Sort: dirs first, then files; alphabetically within each group + entries.sort_by(|a, b| { + let a_dir = matches!(a.kind, EntryKind::Dir); + let b_dir = matches!(b.kind, EntryKind::Dir); + b_dir.cmp(&a_dir).then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + + let response = BrowseResponse { + path: safe, + entries, + }; + + (StatusCode::OK, Json(response)).into_response() +} + +/// Whitelist of audio extensions served via the web player. +pub fn is_audio_file(name: &str) -> bool { + let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); + matches!( + ext.as_str(), + "mp3" | "flac" | "ogg" | "opus" | "aac" | "m4a" | "wav" | "ape" | "wv" | "wma" | "tta" | "aiff" | "aif" + ) +} + +/// Returns true if the format needs transcoding (not natively supported by browsers). +pub fn needs_transcode(name: &str) -> bool { + let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); + matches!(ext.as_str(), "ape" | "wv" | "wma" | "tta" | "aiff" | "aif") +} diff --git a/furumi-server/src/web/meta.rs b/furumi-server/src/web/meta.rs new file mode 100644 index 0000000..ef3f106 --- /dev/null +++ b/furumi-server/src/web/meta.rs @@ -0,0 +1,175 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Json}, +}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use serde::Serialize; +use symphonia::core::{ + codecs::CODEC_TYPE_NULL, + formats::FormatOptions, + io::MediaSourceStream, + meta::{MetadataOptions, StandardTagKey}, + probe::Hint, +}; + +use crate::security::sanitize_path; +use super::WebState; + +#[derive(Serialize)] +pub struct MetaResponse { + pub title: Option, + pub artist: Option, + pub album: Option, + pub track: Option, + pub year: Option, + pub duration_secs: Option, + pub cover_base64: Option, // "data:image/jpeg;base64,..." +} + +pub async fn handler( + State(state): State, + Path(path): Path, +) -> impl IntoResponse { + let safe = match sanitize_path(&path) { + Ok(p) => p, + Err(_) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid path"}))).into_response(), + }; + + let file_path = state.root.join(&safe); + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_owned(); + + let meta = tokio::task::spawn_blocking(move || read_meta(file_path, &filename)).await; + + match meta { + Ok(Ok(m)) => (StatusCode::OK, Json(m)).into_response(), + Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response(), + } +} + +fn read_meta(file_path: std::path::PathBuf, filename: &str) -> anyhow::Result { + let file = std::fs::File::open(&file_path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let mut probed = symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions { enable_gapless: false, ..Default::default() }, + &MetadataOptions::default(), + )?; + + // Extract tags from container-level metadata + let mut title: Option = None; + let mut artist: Option = None; + let mut album: Option = None; + let mut track: Option = None; + let mut year: Option = None; + let mut cover_data: Option<(Vec, String)> = None; + + // Check metadata side-data (e.g., ID3 tags probed before format) + if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) { + extract_tags(rev.tags(), rev.visuals(), &mut title, &mut artist, &mut album, &mut track, &mut year, &mut cover_data); + } + + // Also check format-embedded metadata + if let Some(rev) = probed.format.metadata().current() { + if title.is_none() { + extract_tags(rev.tags(), rev.visuals(), &mut title, &mut artist, &mut album, &mut track, &mut year, &mut cover_data); + } + } + + // If no title from tags, use filename without extension + if title.is_none() { + title = Some( + std::path::Path::new(filename) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(filename) + .to_owned(), + ); + } + + // Estimate duration from track time_base + n_frames + let duration_secs = probed + .format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .and_then(|t| { + let n_frames = t.codec_params.n_frames?; + let tb = t.codec_params.time_base?; + Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64) + }); + + let cover_base64 = cover_data.map(|(data, mime)| { + format!("data:{};base64,{}", mime, BASE64.encode(&data)) + }); + + Ok(MetaResponse { + title, + artist, + album, + track, + year, + duration_secs, + cover_base64, + }) +} + +fn extract_tags( + tags: &[symphonia::core::meta::Tag], + visuals: &[symphonia::core::meta::Visual], + title: &mut Option, + artist: &mut Option, + album: &mut Option, + track: &mut Option, + year: &mut Option, + cover: &mut Option<(Vec, String)>, +) { + for tag in tags { + if let Some(key) = tag.std_key { + match key { + StandardTagKey::TrackTitle => { + *title = Some(tag.value.to_string()); + } + StandardTagKey::Artist | StandardTagKey::Performer => { + if artist.is_none() { + *artist = Some(tag.value.to_string()); + } + } + StandardTagKey::Album => { + *album = Some(tag.value.to_string()); + } + StandardTagKey::TrackNumber => { + if track.is_none() { + *track = tag.value.to_string().parse().ok(); + } + } + StandardTagKey::Date | StandardTagKey::OriginalDate => { + if year.is_none() { + // Parse first 4 characters as year + *year = tag.value.to_string()[..4.min(tag.value.to_string().len())].parse().ok(); + } + } + _ => {} + } + } + } + + if cover.is_none() { + if let Some(visual) = visuals.first() { + let mime = visual.media_type.clone(); + *cover = Some((visual.data.to_vec(), mime)); + } + } +} diff --git a/furumi-server/src/web/mod.rs b/furumi-server/src/web/mod.rs new file mode 100644 index 0000000..ecd7c85 --- /dev/null +++ b/furumi-server/src/web/mod.rs @@ -0,0 +1,49 @@ +pub mod auth; +pub mod browse; +pub mod meta; +pub mod stream; +pub mod transcoder; + +use std::path::PathBuf; +use std::sync::Arc; + +use axum::{ + Router, + middleware, + routing::get, +}; + +/// Shared state passed to all web handlers. +#[derive(Clone)] +pub struct WebState { + pub root: Arc, + pub token: Arc, +} + +/// Build the axum Router for the web player. +pub fn build_router(root: PathBuf, token: String) -> Router { + let state = WebState { + root: Arc::new(root), + token: Arc::new(token), + }; + + let api = Router::new() + .route("/browse", get(browse::handler)) + .route("/stream/*path", get(stream::handler)) + .route("/meta/*path", get(meta::handler)); + + let authed_routes = Router::new() + .route("/", get(player_html)) + .nest("/api", api) + .route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)); + + Router::new() + .route("/login", get(auth::login_page).post(auth::login_submit)) + .route("/logout", get(auth::logout)) + .merge(authed_routes) + .with_state(state) +} + +async fn player_html() -> axum::response::Html<&'static str> { + axum::response::Html(include_str!("player.html")) +} diff --git a/furumi-server/src/web/player.html b/furumi-server/src/web/player.html new file mode 100644 index 0000000..c53df79 --- /dev/null +++ b/furumi-server/src/web/player.html @@ -0,0 +1,912 @@ + + + + + +Furumi Player + + + + + +
+ + +
+ + +
+ + + + +
+
+ Queue +
+ + + +
+
+
+
+
๐ŸŽต
+
Click files to add to queue
+
Double-click a folder to add all tracks
+
+
+
+
+ + +
+ +
+
๐ŸŽต
+
+
Nothing playing
+
โ€”
+
+
+ + +
+
+ + + +
+
+ 0:00 +
+
+
+ 0:00 +
+
+ + +
+ ๐Ÿ”Š + +
+
+ +
+ + + + + + diff --git a/furumi-server/src/web/stream.rs b/furumi-server/src/web/stream.rs new file mode 100644 index 0000000..6c33ac0 --- /dev/null +++ b/furumi-server/src/web/stream.rs @@ -0,0 +1,171 @@ +use axum::{ + body::Body, + extract::{Path, Query, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use crate::security::sanitize_path; +use super::{ + WebState, + browse::{is_audio_file, needs_transcode}, +}; + +#[derive(Deserialize)] +pub struct StreamQuery { + #[serde(default)] + pub transcode: Option, +} + +pub async fn handler( + State(state): State, + Path(path): Path, + Query(query): Query, + headers: HeaderMap, +) -> impl IntoResponse { + let safe = match sanitize_path(&path) { + Ok(p) => p, + Err(_) => return bad_request("invalid path"), + }; + + let file_path = state.root.join(&safe); + + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_owned(); + + if !is_audio_file(&filename) { + return (StatusCode::FORBIDDEN, "not an audio file").into_response(); + } + + let force_transcode = query.transcode.as_deref() == Some("1"); + + if force_transcode || needs_transcode(&filename) { + return stream_transcoded(file_path).await; + } + + stream_native(file_path, &filename, &headers).await +} + +/// Stream a file as-is with Range support. +async fn stream_native(file_path: std::path::PathBuf, filename: &str, req_headers: &HeaderMap) -> Response { + let mut file = match tokio::fs::File::open(&file_path).await { + Ok(f) => f, + Err(e) => { + let status = if e.kind() == std::io::ErrorKind::NotFound { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + return (status, e.to_string()).into_response(); + } + }; + + let file_size = match file.metadata().await { + Ok(m) => m.len(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let content_type = guess_content_type(filename); + + // Parse Range header + let range_header = req_headers + .get(header::RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(parse_range); + + if let Some((start, end)) = range_header { + let end = end.unwrap_or(file_size - 1).min(file_size - 1); + if start > end || start >= file_size { + return (StatusCode::RANGE_NOT_SATISFIABLE, "invalid range").into_response(); + } + + let length = end - start + 1; + + if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); + } + + let limited = file.take(length); + let stream = tokio_util::io::ReaderStream::new(limited); + let body = Body::from_stream(stream); + + let mut resp_headers = HeaderMap::new(); + resp_headers.insert(header::CONTENT_TYPE, content_type.parse().unwrap()); + resp_headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); + resp_headers.insert(header::CONTENT_LENGTH, length.to_string().parse().unwrap()); + resp_headers.insert( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, end, file_size).parse().unwrap(), + ); + (StatusCode::PARTIAL_CONTENT, resp_headers, body).into_response() + } else { + // Full file + let stream = tokio_util::io::ReaderStream::new(file); + let body = Body::from_stream(stream); + + let mut resp_headers = HeaderMap::new(); + resp_headers.insert(header::CONTENT_TYPE, content_type.parse().unwrap()); + resp_headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); + resp_headers.insert(header::CONTENT_LENGTH, file_size.to_string().parse().unwrap()); + (StatusCode::OK, resp_headers, body).into_response() + } +} + +/// Stream a transcoded (Ogg/Opus) version of the file. +async fn stream_transcoded(file_path: std::path::PathBuf) -> Response { + let ogg_data = match tokio::task::spawn_blocking(move || { + super::transcoder::transcode_to_ogg_opus(file_path) + }) + .await + { + Ok(Ok(data)) => data, + Ok(Err(e)) => { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); + } + Err(e) => { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); + } + }; + + let len = ogg_data.len(); + let mut resp_headers = HeaderMap::new(); + resp_headers.insert(header::CONTENT_TYPE, "audio/ogg".parse().unwrap()); + resp_headers.insert(header::CONTENT_LENGTH, len.to_string().parse().unwrap()); + resp_headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("none")); + + (StatusCode::OK, resp_headers, Body::from(ogg_data)).into_response() +} + +/// Parse `Range: bytes=-` header. +fn parse_range(s: &str) -> Option<(u64, Option)> { + let s = s.strip_prefix("bytes=")?; + let mut parts = s.splitn(2, '-'); + let start: u64 = parts.next()?.parse().ok()?; + let end: Option = parts.next().and_then(|e| { + if e.is_empty() { None } else { e.parse().ok() } + }); + Some((start, end)) +} + +fn guess_content_type(filename: &str) -> &'static str { + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + match ext.as_str() { + "mp3" => "audio/mpeg", + "flac" => "audio/flac", + "ogg" => "audio/ogg", + "opus" => "audio/ogg; codecs=opus", + "aac" => "audio/aac", + "m4a" => "audio/mp4", + "wav" => "audio/wav", + _ => "application/octet-stream", + } +} + +fn bad_request(msg: &'static str) -> Response { + (StatusCode::BAD_REQUEST, msg).into_response() +} diff --git a/furumi-server/src/web/transcoder.rs b/furumi-server/src/web/transcoder.rs new file mode 100644 index 0000000..1e0e7a9 --- /dev/null +++ b/furumi-server/src/web/transcoder.rs @@ -0,0 +1,244 @@ +//! Symphonia-based audio transcoder: decodes any format โ†’ encodes to Ogg/Opus stream. +//! +//! The heavy decode/encode work runs in a `spawn_blocking` thread. +//! PCM samples are sent over a channel to the async stream handler. + +use std::path::PathBuf; +use std::io::Cursor; + +use anyhow::{anyhow, Result}; +use symphonia::core::{ + audio::{AudioBufferRef, Signal}, + codecs::{DecoderOptions, CODEC_TYPE_NULL}, + errors::Error as SymphoniaError, + formats::FormatOptions, + io::MediaSourceStream, + meta::MetadataOptions, + probe::Hint, +}; +use ogg::writing::PacketWriter; +use opus::{Application, Channels, Encoder}; + +/// Transcode an audio file at `path` into an Ogg/Opus byte stream. +/// Returns `Vec` with the full Ogg/Opus file (suitable for streaming/download). +/// +/// This is intentionally synchronous (for use inside `spawn_blocking`). +pub fn transcode_to_ogg_opus(path: PathBuf) -> Result> { + // ---- Open and probe the source ---- + let file = std::fs::File::open(&path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) + .map_err(|e| anyhow!("probe failed: {e}"))?; + + let mut format = probed.format; + + // Find the default audio track + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| anyhow!("no audio track found"))? + .clone(); + + let track_id = track.id; + let codec_params = &track.codec_params; + + let sample_rate = codec_params.sample_rate.unwrap_or(44100); + let n_channels = codec_params.channels.map(|c| c.count()).unwrap_or(2); + + // Opus only supports 1 or 2 channels; downmix to stereo if needed + let opus_channels = if n_channels == 1 { Channels::Mono } else { Channels::Stereo }; + let opus_ch_count = if n_channels == 1 { 1usize } else { 2 }; + + // Opus encoder (target 48 kHz, we'll resample if needed) + // Opus natively works at 48000 Hz; symphonia will decode at source rate. + // For simplicity, we encode at the source sample rate - most clients handle this. + let opus_sample_rate = if [8000u32, 12000, 16000, 24000, 48000].contains(&sample_rate) { + sample_rate + } else { + // Opus spec: use closest supported rate; 48000 is safest + 48000 + }; + + let mut encoder = Encoder::new(opus_sample_rate, opus_channels, Application::Audio) + .map_err(|e| anyhow!("opus encoder init: {e}"))?; + + // Typical Opus frame = 20ms + let frame_size = (opus_sample_rate as usize * 20) / 1000; // samples per channel per frame + + let mut decoder = symphonia::default::get_codecs() + .make(codec_params, &DecoderOptions::default()) + .map_err(|e| anyhow!("decoder init: {e}"))?; + + // ---- Ogg output buffer ---- + let mut ogg_buf: Vec = Vec::with_capacity(4 * 1024 * 1024); + { + let cursor = Cursor::new(&mut ogg_buf); + let mut pkt_writer = PacketWriter::new(cursor); + + // Write Opus header packet (stream serial = 1) + let serial: u32 = 1; + let opus_head = build_opus_head(opus_ch_count as u8, opus_sample_rate, 0); + pkt_writer.write_packet(opus_head, serial, ogg::writing::PacketWriteEndInfo::EndPage, 0)?; + + // Write Opus tags packet (empty) + let opus_tags = build_opus_tags(); + pkt_writer.write_packet(opus_tags, serial, ogg::writing::PacketWriteEndInfo::EndPage, 0)?; + + let mut sample_buf: Vec = Vec::new(); + let mut granule_pos: u64 = 0; + + loop { + let packet = match format.next_packet() { + Ok(p) => p, + Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(SymphoniaError::ResetRequired) => { + decoder.reset(); + continue; + } + Err(e) => return Err(anyhow!("format error: {e}")), + }; + + if packet.track_id() != track_id { + continue; + } + + match decoder.decode(&packet) { + Ok(decoded) => { + collect_samples(&decoded, opus_ch_count, &mut sample_buf); + } + Err(SymphoniaError::DecodeError(_)) => continue, + Err(e) => return Err(anyhow!("decode error: {e}")), + } + + // Encode complete frames from sample_buf + while sample_buf.len() >= frame_size * opus_ch_count { + let frame: Vec = sample_buf.drain(..frame_size * opus_ch_count).collect(); + let mut out = vec![0u8; 4000]; + let encoded_len = encoder + .encode_float(&frame, &mut out) + .map_err(|e| anyhow!("opus encode: {e}"))?; + out.truncate(encoded_len); + + granule_pos += frame_size as u64; + pkt_writer.write_packet( + out, + serial, + ogg::writing::PacketWriteEndInfo::NormalPacket, + granule_pos, + )?; + } + } + + // Encode remaining samples (partial frame โ€” pad with silence) + if !sample_buf.is_empty() { + let needed = frame_size * opus_ch_count; + sample_buf.resize(needed, 0.0); + let mut out = vec![0u8; 4000]; + let encoded_len = encoder + .encode_float(&sample_buf, &mut out) + .map_err(|e| anyhow!("opus encode final: {e}"))?; + out.truncate(encoded_len); + granule_pos += frame_size as u64; + pkt_writer.write_packet( + out, + serial, + ogg::writing::PacketWriteEndInfo::EndStream, + granule_pos, + )?; + } + } + + Ok(ogg_buf) +} + +/// Collect PCM samples from a symphonia AudioBufferRef into a flat f32 vec. +/// Downmixes to `target_channels` (1 or 2) if source has more channels. +fn collect_samples(decoded: &AudioBufferRef<'_>, target_channels: usize, out: &mut Vec) { + match decoded { + AudioBufferRef::F32(buf) => { + interleave_channels(buf.chan(0), if buf.spec().channels.count() > 1 { Some(buf.chan(1)) } else { None }, target_channels, out); + } + AudioBufferRef::S16(buf) => { + let ch0: Vec = buf.chan(0).iter().map(|&s| s as f32 / 32768.0).collect(); + let ch1 = if buf.spec().channels.count() > 1 { + Some(buf.chan(1).iter().map(|&s| s as f32 / 32768.0).collect::>()) + } else { + None + }; + interleave_channels(&ch0, ch1.as_deref(), target_channels, out); + } + AudioBufferRef::S32(buf) => { + let ch0: Vec = buf.chan(0).iter().map(|&s| s as f32 / 2147483648.0).collect(); + let ch1 = if buf.spec().channels.count() > 1 { + Some(buf.chan(1).iter().map(|&s| s as f32 / 2147483648.0).collect::>()) + } else { + None + }; + interleave_channels(&ch0, ch1.as_deref(), target_channels, out); + } + AudioBufferRef::U8(buf) => { + let ch0: Vec = buf.chan(0).iter().map(|&s| (s as f32 - 128.0) / 128.0).collect(); + let ch1 = if buf.spec().channels.count() > 1 { + Some(buf.chan(1).iter().map(|&s| (s as f32 - 128.0) / 128.0).collect::>()) + } else { + None + }; + interleave_channels(&ch0, ch1.as_deref(), target_channels, out); + } + _ => { + // For other formats, try to get samples via S16 conversion + // (symphonia may provide other types; we skip unsupported ones) + } + } +} + +fn interleave_channels(ch0: &[f32], ch1: Option<&[f32]>, target_channels: usize, out: &mut Vec) { + let len = ch0.len(); + if target_channels == 1 { + if let Some(c1) = ch1 { + // Mix down to mono + out.extend(ch0.iter().zip(c1.iter()).map(|(l, r)| (l + r) * 0.5)); + } else { + out.extend_from_slice(ch0); + } + } else { + // Stereo interleaved + let c1 = ch1.unwrap_or(ch0); + for i in 0..len { + out.push(ch0[i]); + out.push(c1[i]); + } + } +} + +/// Build OpusHead binary packet (RFC 7845). +fn build_opus_head(channels: u8, sample_rate: u32, pre_skip: u16) -> Vec { + let mut v = Vec::with_capacity(19); + v.extend_from_slice(b"OpusHead"); + v.push(1); // version + v.push(channels); + v.extend_from_slice(&pre_skip.to_le_bytes()); + v.extend_from_slice(&sample_rate.to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); // output gain + v.push(0); // channel mapping family + v +} + +/// Build OpusTags binary packet with minimal vendor string. +fn build_opus_tags() -> Vec { + let vendor = b"furumi-server"; + let mut v = Vec::new(); + v.extend_from_slice(b"OpusTags"); + v.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + v.extend_from_slice(vendor); + v.extend_from_slice(&0u32.to_le_bytes()); // user comment list length = 0 + v +}