Added web player
This commit is contained in:
380
Cargo.lock
generated
380
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
@@ -100,6 +109,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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)));
|
||||
|
||||
213
furumi-server/src/web/auth.rs
Normal file
213
furumi-server/src/web/auth.rs
Normal file
@@ -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<WebState>,
|
||||
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<WebState>) -> 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<WebState>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> 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#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Furumi Player — Login</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #0d0f14;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.card {
|
||||
background: #161b27;
|
||||
border: 1px solid #2a3347;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 3rem;
|
||||
width: 360px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
|
||||
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
|
||||
label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; }
|
||||
input[type=password] {
|
||||
width: 100%; padding: 0.6rem 0.8rem;
|
||||
background: #0d0f14; border: 1px solid #2a3347; border-radius: 8px;
|
||||
color: #e2e8f0; font-size: 0.95rem; outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input[type=password]:focus { border-color: #7c6af7; }
|
||||
button {
|
||||
margin-top: 1.2rem; width: 100%; padding: 0.65rem;
|
||||
background: #7c6af7; border: none; border-radius: 8px;
|
||||
color: #fff; font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover { background: #6b58e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">🎵 Furumi</div>
|
||||
<div class="subtitle">Enter access token to continue</div>
|
||||
<form method="POST" action="/login">
|
||||
<label for="password">Access Token</label>
|
||||
<input type="password" id="password" name="password" autofocus autocomplete="current-password">
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
const LOGIN_ERROR_HTML: &str = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Furumi Player — Login</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #0d0f14;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.card {
|
||||
background: #161b27;
|
||||
border: 1px solid #2a3347;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 3rem;
|
||||
width: 360px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
|
||||
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
|
||||
.error { color: #f87171; font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; }
|
||||
input[type=password] {
|
||||
width: 100%; padding: 0.6rem 0.8rem;
|
||||
background: #0d0f14; border: 1px solid #f87171; border-radius: 8px;
|
||||
color: #e2e8f0; font-size: 0.95rem; outline: none;
|
||||
}
|
||||
button {
|
||||
margin-top: 1.2rem; width: 100%; padding: 0.65rem;
|
||||
background: #7c6af7; border: none; border-radius: 8px;
|
||||
color: #fff; font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
button:hover { background: #6b58e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">🎵 Furumi</div>
|
||||
<div class="subtitle">Enter access token to continue</div>
|
||||
<p class="error">❌ Invalid token. Please try again.</p>
|
||||
<form method="POST" action="/login">
|
||||
<label for="password">Access Token</label>
|
||||
<input type="password" id="password" name="password" autofocus>
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
132
furumi-server/src/web/browse.rs
Normal file
132
furumi-server/src/web/browse.rs
Normal file
@@ -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<Entry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: EntryKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EntryKind {
|
||||
File,
|
||||
Dir,
|
||||
}
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<WebState>,
|
||||
Query(query): Query<BrowseQuery>,
|
||||
) -> 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<Entry> = 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")
|
||||
}
|
||||
175
furumi-server/src/web/meta.rs
Normal file
175
furumi-server/src/web/meta.rs
Normal file
@@ -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<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub track: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub duration_secs: Option<f64>,
|
||||
pub cover_base64: Option<String>, // "data:image/jpeg;base64,..."
|
||||
}
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<WebState>,
|
||||
Path(path): Path<String>,
|
||||
) -> 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<MetaResponse> {
|
||||
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<String> = None;
|
||||
let mut artist: Option<String> = None;
|
||||
let mut album: Option<String> = None;
|
||||
let mut track: Option<u32> = None;
|
||||
let mut year: Option<u32> = None;
|
||||
let mut cover_data: Option<(Vec<u8>, 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<String>,
|
||||
artist: &mut Option<String>,
|
||||
album: &mut Option<String>,
|
||||
track: &mut Option<u32>,
|
||||
year: &mut Option<u32>,
|
||||
cover: &mut Option<(Vec<u8>, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
49
furumi-server/src/web/mod.rs
Normal file
49
furumi-server/src/web/mod.rs
Normal file
@@ -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<PathBuf>,
|
||||
pub token: Arc<String>,
|
||||
}
|
||||
|
||||
/// 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"))
|
||||
}
|
||||
912
furumi-server/src/web/player.html
Normal file
912
furumi-server/src/web/player.html
Normal file
@@ -0,0 +1,912 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Furumi Player</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg-base: #0a0c12;
|
||||
--bg-panel: #111520;
|
||||
--bg-card: #161d2e;
|
||||
--bg-hover: #1e2740;
|
||||
--bg-active: #252f4a;
|
||||
--border: #1f2c45;
|
||||
--accent: #7c6af7;
|
||||
--accent-dim: #5a4fcf;
|
||||
--accent-glow:rgba(124,106,247,0.3);
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #64748b;
|
||||
--text-dim: #94a3b8;
|
||||
--success: #34d399;
|
||||
--danger: #f87171;
|
||||
}
|
||||
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg-base);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Header ─── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.header-logo {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
font-size: 1.15rem; font-weight: 700; color: var(--accent);
|
||||
}
|
||||
.header-logo svg { width: 22px; height: 22px; }
|
||||
.btn-logout {
|
||||
font-size: 0.78rem; color: var(--text-muted); background: none;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 0.3rem 0.75rem; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.btn-logout:hover { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
/* ─── Main layout ─── */
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── File browser ─── */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
resize: horizontal;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 0.85rem 1rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.breadcrumb {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.breadcrumb span { color: var(--accent); cursor: pointer; }
|
||||
.breadcrumb span:hover { text-decoration: underline; }
|
||||
|
||||
.file-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.file-list::-webkit-scrollbar { width: 4px; }
|
||||
.file-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.file-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
transition: background 0.12s;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.file-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.file-item.dir { color: var(--accent); }
|
||||
.file-item.dir:hover { color: var(--accent); }
|
||||
.file-item .icon { font-size: 0.95rem; flex-shrink: 0; opacity: 0.8; }
|
||||
.file-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-item .add-btn {
|
||||
opacity: 0;
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.file-item:hover .add-btn { opacity: 1; }
|
||||
.file-item .add-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
/* ─── Queue ─── */
|
||||
.queue-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
.queue-header {
|
||||
padding: 0.85rem 1.25rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.queue-actions { display: flex; gap: 0.5rem; }
|
||||
.queue-btn {
|
||||
font-size: 0.7rem; padding: 0.2rem 0.55rem;
|
||||
background: none; border: 1px solid var(--border); border-radius: 5px;
|
||||
color: var(--text-muted); cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.queue-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.queue-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
.queue-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.queue-list::-webkit-scrollbar { width: 4px; }
|
||||
.queue-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.queue-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.55rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.queue-item:hover { background: var(--bg-hover); }
|
||||
.queue-item.playing {
|
||||
background: var(--bg-active);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.queue-item.playing .qi-title { color: var(--accent); }
|
||||
.queue-item .qi-index { font-size: 0.75rem; color: var(--text-muted); width: 1.5rem; text-align: right; flex-shrink: 0; }
|
||||
.queue-item.playing .qi-index::before { content: '▶'; font-size: 0.6rem; color: var(--accent); }
|
||||
.queue-item .qi-cover {
|
||||
width: 36px; height: 36px; border-radius: 5px;
|
||||
background: var(--bg-card);
|
||||
flex-shrink: 0; overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.queue-item .qi-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.queue-item .qi-info { flex: 1; overflow: hidden; }
|
||||
.queue-item .qi-title { font-size: 0.875rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.queue-item .qi-artist { font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.queue-item .qi-dur { font-size: 0.72rem; color: var(--text-muted); flex-shrink: 0; }
|
||||
.queue-item .qi-remove {
|
||||
opacity: 0; font-size: 0.8rem; color: var(--text-muted);
|
||||
background: none; border: none; cursor: pointer; padding: 2px 5px;
|
||||
border-radius: 4px; transition: all 0.15s;
|
||||
}
|
||||
.queue-item:hover .qi-remove { opacity: 1; }
|
||||
.queue-item .qi-remove:hover { color: var(--danger); }
|
||||
.queue-item.dragging { opacity: 0.5; background: var(--bg-active); }
|
||||
.queue-item.drag-over { border-top: 2px solid var(--accent); margin-top: -2px; }
|
||||
|
||||
.queue-empty {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
color: var(--text-muted); font-size: 0.875rem; gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
.queue-empty .empty-icon { font-size: 2.5rem; opacity: 0.3; }
|
||||
|
||||
/* ─── Player bar ─── */
|
||||
.player-bar {
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.9rem 1.5rem;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.np-info { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
|
||||
.np-cover {
|
||||
width: 44px; height: 44px; border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
flex-shrink: 0; overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.3rem;
|
||||
}
|
||||
.np-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.np-text { min-width: 0; }
|
||||
.np-title { font-size: 0.875rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.np-artist { font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.controls { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
|
||||
.ctrl-btns { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ctrl-btn {
|
||||
background: none; border: none; color: var(--text-dim);
|
||||
cursor: pointer; padding: 0.35rem; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1rem; transition: all 0.15s;
|
||||
}
|
||||
.ctrl-btn:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.ctrl-btn.active { color: var(--accent); }
|
||||
.ctrl-btn-main {
|
||||
width: 38px; height: 38px;
|
||||
background: var(--accent); color: #fff !important;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: 0 0 14px var(--accent-glow);
|
||||
}
|
||||
.ctrl-btn-main:hover { background: var(--accent-dim) !important; }
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 0.6rem; width: 100%; }
|
||||
.time { font-size: 0.7rem; color: var(--text-muted); flex-shrink: 0; font-variant-numeric: tabular-nums; min-width: 2.5rem; text-align: center; }
|
||||
.progress-bar {
|
||||
flex: 1; height: 4px; background: var(--bg-hover);
|
||||
border-radius: 2px; cursor: pointer; position: relative;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%; background: var(--accent); border-radius: 2px;
|
||||
position: relative; transition: width 0.1s linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
.progress-fill::after {
|
||||
content: ''; position: absolute; right: -5px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
opacity: 0; transition: opacity 0.15s;
|
||||
}
|
||||
.progress-bar:hover .progress-fill::after { opacity: 1; }
|
||||
|
||||
.volume-row { display: flex; align-items: center; gap: 0.5rem; justify-content: flex-end; }
|
||||
.vol-icon { font-size: 0.9rem; color: var(--text-muted); cursor: pointer; }
|
||||
.volume-slider {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 80px; height: 4px; border-radius: 2px;
|
||||
background: var(--bg-hover); cursor: pointer; outline: none;
|
||||
}
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 12px; height: 12px;
|
||||
border-radius: 50%; background: var(--accent); cursor: pointer;
|
||||
}
|
||||
|
||||
/* scrollbar global */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
|
||||
/* loading spinner */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; }
|
||||
|
||||
/* toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 90px; right: 1.5rem;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 0.6rem 1rem;
|
||||
font-size: 0.8rem; color: var(--text-dim);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
opacity: 0; transform: translateY(8px);
|
||||
transition: all 0.25s; pointer-events: none; z-index: 100;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="9" cy="18" r="3"/><circle cx="18" cy="15" r="3"/>
|
||||
<path d="M12 18V6l9-3v3"/>
|
||||
</svg>
|
||||
Furumi Player
|
||||
</div>
|
||||
<button class="btn-logout" onclick="logout()">Sign out</button>
|
||||
</header>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main">
|
||||
<!-- Sidebar: file browser -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">📁 Library</div>
|
||||
<div class="breadcrumb" id="breadcrumb">/ <span onclick="navigate('')">root</span></div>
|
||||
<div class="file-list" id="fileList">
|
||||
<div style="padding:2rem;text-align:center;color:var(--text-muted)"><div class="spinner"></div></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Queue -->
|
||||
<section class="queue-panel">
|
||||
<div class="queue-header">
|
||||
<span>Queue</span>
|
||||
<div class="queue-actions">
|
||||
<button class="queue-btn active" id="btnShuffle" onclick="toggleShuffle()" title="Shuffle">⇄ Shuffle</button>
|
||||
<button class="queue-btn active" id="btnRepeat" onclick="toggleRepeat()" title="Repeat">↻ Repeat</button>
|
||||
<button class="queue-btn" onclick="clearQueue()" title="Clear queue">✕ Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue-list" id="queueList">
|
||||
<div class="queue-empty" id="queueEmpty">
|
||||
<div class="empty-icon">🎵</div>
|
||||
<div>Click files to add to queue</div>
|
||||
<div style="font-size:0.75rem;margin-top:0.25rem">Double-click a folder to add all tracks</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Player bar -->
|
||||
<div class="player-bar">
|
||||
<!-- Now playing -->
|
||||
<div class="np-info">
|
||||
<div class="np-cover" id="npCover">🎵</div>
|
||||
<div class="np-text">
|
||||
<div class="np-title" id="npTitle">Nothing playing</div>
|
||||
<div class="np-artist" id="npArtist">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="controls">
|
||||
<div class="ctrl-btns">
|
||||
<button class="ctrl-btn" onclick="prevTrack()" title="Previous">⏮</button>
|
||||
<button class="ctrl-btn ctrl-btn-main" id="btnPlayPause" onclick="togglePlay()" title="Play/Pause">▶</button>
|
||||
<button class="ctrl-btn" onclick="nextTrack()" title="Next">⏭</button>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span class="time" id="timeElapsed">0:00</span>
|
||||
<div class="progress-bar" id="progressBar" onclick="seekTo(event)" title="Seek position">
|
||||
<div class="progress-fill" id="progressFill" style="width:0%"></div>
|
||||
</div>
|
||||
<span class="time" id="timeDuration">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Volume -->
|
||||
<div class="volume-row">
|
||||
<span class="vol-icon" onclick="toggleMute()" id="volIcon" title="Toggle Mute">🔊</span>
|
||||
<input type="range" class="volume-slider" id="volSlider" min="0" max="100" value="80"
|
||||
title="Volume" oninput="setVolume(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<audio id="audioEl"></audio>
|
||||
|
||||
<script>
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
const audio = document.getElementById('audioEl');
|
||||
let queue = []; // [{path, name, meta}]
|
||||
let queueIndex = -1;
|
||||
let shuffle = false;
|
||||
let repeatAll = true;
|
||||
let shuffleOrder = [];
|
||||
let currentPath = '';
|
||||
let isSeeking = false;
|
||||
let metaCache = {};
|
||||
|
||||
// Restore prefs
|
||||
(function() {
|
||||
try {
|
||||
const v = localStorage.getItem('furumi_vol');
|
||||
if (v !== null) { audio.volume = v / 100; document.getElementById('volSlider').value = v; }
|
||||
shuffle = localStorage.getItem('furumi_shuffle') === '1';
|
||||
repeatAll = localStorage.getItem('furumi_repeat') !== '0';
|
||||
updateShuffleUI();
|
||||
updateRepeatUI();
|
||||
} catch(e) {}
|
||||
})();
|
||||
|
||||
// ─── Audio events ─────────────────────────────────────────────────────────────
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (!isSeeking && audio.duration) {
|
||||
const pct = (audio.currentTime / audio.duration) * 100;
|
||||
document.getElementById('progressFill').style.width = pct + '%';
|
||||
document.getElementById('timeElapsed').textContent = fmt(audio.currentTime);
|
||||
document.getElementById('timeDuration').textContent = fmt(audio.duration);
|
||||
}
|
||||
});
|
||||
audio.addEventListener('ended', () => nextTrack());
|
||||
audio.addEventListener('play', () => { document.getElementById('btnPlayPause').textContent = '⏸'; });
|
||||
audio.addEventListener('pause', () => { document.getElementById('btnPlayPause').textContent = '▶'; });
|
||||
audio.addEventListener('error', () => { showToast('Failed to play track'); nextTrack(); });
|
||||
|
||||
// ─── Browse ───────────────────────────────────────────────────────────────────
|
||||
async function navigate(path) {
|
||||
currentPath = path;
|
||||
updateBreadcrumb(path);
|
||||
const listEl = document.getElementById('fileList');
|
||||
listEl.innerHTML = '<div style="padding:2rem;text-align:center;color:var(--text-muted)"><div class="spinner"></div></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/browse?path=' + encodeURIComponent(path));
|
||||
const data = await res.json();
|
||||
if (!res.ok) { listEl.innerHTML = `<div style="padding:1rem;color:var(--danger)">${data.error||'Error'}</div>`; return; }
|
||||
renderFileList(data.entries, path);
|
||||
} catch(e) {
|
||||
listEl.innerHTML = '<div style="padding:1rem;color:var(--danger)">Network error</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileList(entries, basePath) {
|
||||
const listEl = document.getElementById('fileList');
|
||||
if (!entries.length) {
|
||||
listEl.innerHTML = '<div style="padding:1.5rem;text-align:center;color:var(--text-muted);font-size:0.85rem">Empty folder</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
entries.forEach(e => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'file-item ' + (e.type === 'dir' ? 'dir' : '');
|
||||
const itemPath = basePath ? basePath + '/' + e.name : e.name;
|
||||
|
||||
if (e.type === 'dir') {
|
||||
div.innerHTML = `<span class="icon">📁</span><span class="name">${esc(e.name)}</span>
|
||||
<button class="add-btn" title="Add folder to queue">➕</button>`;
|
||||
div.querySelector('.add-btn').addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
addFolderToQueue(itemPath);
|
||||
});
|
||||
div.addEventListener('click', () => navigate(itemPath));
|
||||
div.addEventListener('dblclick', () => addFolderToQueue(itemPath));
|
||||
} else {
|
||||
div.innerHTML = `<span class="icon">🎵</span><span class="name">${esc(e.name)}</span>
|
||||
<button class="add-btn add-next" title="Play next">▶️</button>
|
||||
<button class="add-btn add-end" title="Add to end">➕</button>`;
|
||||
|
||||
div.querySelector('.add-next').addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
addNextProtocol(itemPath, e.name);
|
||||
});
|
||||
div.querySelector('.add-end').addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
addToQueue(itemPath, e.name);
|
||||
showToast('Added to queue');
|
||||
});
|
||||
div.addEventListener('click', () => {
|
||||
addToQueue(itemPath, e.name, true);
|
||||
});
|
||||
}
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function updateBreadcrumb(path) {
|
||||
const el = document.getElementById('breadcrumb');
|
||||
if (!path) { el.innerHTML = '/ <span onclick="navigate(\'\')">root</span>'; return; }
|
||||
const parts = path.split('/');
|
||||
let html = '/ <span onclick="navigate(\'\')">root</span>';
|
||||
let acc = '';
|
||||
parts.forEach((p, i) => {
|
||||
acc = acc ? acc + '/' + p : p;
|
||||
const cap = acc;
|
||||
if (i < parts.length - 1) html += ` / <span onclick="navigate('${cap}')">${esc(p)}</span>`;
|
||||
else html += ` / ${esc(p)}`;
|
||||
});
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function addFolderToQueue(folderPath) {
|
||||
showToast('Loading folder…');
|
||||
try {
|
||||
const res = await fetch('/api/browse?path=' + encodeURIComponent(folderPath));
|
||||
if (!res.ok) throw new Error('API error');
|
||||
const data = await res.json();
|
||||
const files = (data.entries || []).filter(e => e.type === 'file');
|
||||
|
||||
// Process additions in a batch to avoid UI race conditions
|
||||
files.forEach(f => {
|
||||
const p = folderPath ? folderPath + '/' + f.name : f.name;
|
||||
addToQueue(p, f.name, false, true); // true = skipRender
|
||||
});
|
||||
|
||||
renderQueue(); // render once after the batch
|
||||
|
||||
// Auto-start if it was empty
|
||||
if (queueIndex === -1 && queue.length > 0) {
|
||||
playIndex(0);
|
||||
}
|
||||
|
||||
showToast(`Added ${files.length} tracks`);
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
showToast('Error loading folder');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Queue ────────────────────────────────────────────────────────────────────
|
||||
function addNextProtocol(path, name) {
|
||||
const existing = queue.findIndex(t => t.path === path);
|
||||
if (existing !== -1) return; // already in queue
|
||||
|
||||
const track = { path, name, meta: null };
|
||||
let newIdx;
|
||||
|
||||
if (queueIndex === -1 || queue.length === 0) {
|
||||
// nothing playing, just add and play
|
||||
queue.push(track);
|
||||
newIdx = queue.length - 1;
|
||||
fetchMeta(path, newIdx);
|
||||
renderQueue();
|
||||
playIndex(newIdx);
|
||||
} else {
|
||||
// insert right after current playing
|
||||
newIdx = queueIndex + 1;
|
||||
queue.splice(newIdx, 0, track);
|
||||
|
||||
// update shuffle order if needed
|
||||
if (shuffle) {
|
||||
shuffleOrder.splice(shuffleOrder.indexOf(queueIndex) + 1, 0, newIdx);
|
||||
// adjust indices for tracks shifted to the right
|
||||
for (let i = 0; i < shuffleOrder.length; i++) {
|
||||
if (shuffleOrder[i] >= newIdx && shuffleOrder[i] !== newIdx) {
|
||||
shuffleOrder[i]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchMeta(path, newIdx);
|
||||
renderQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function addToQueue(path, name, playNow = false, skipRender = false) {
|
||||
const existing = queue.findIndex(t => t.path === path);
|
||||
if (existing !== -1 && !playNow) return;
|
||||
|
||||
if (existing === -1) {
|
||||
queue.push({ path, name, meta: null });
|
||||
const idx = queue.length - 1;
|
||||
fetchMeta(path, idx);
|
||||
}
|
||||
|
||||
const idx = existing !== -1 ? existing : queue.length - 1;
|
||||
|
||||
if (!skipRender) renderQueue();
|
||||
|
||||
if (playNow) {
|
||||
if (queueIndex === -1) {
|
||||
playIndex(idx);
|
||||
} // else already playing, just queued
|
||||
}
|
||||
|
||||
if (queueIndex === -1 && !playNow && !skipRender) {
|
||||
// Auto-start if first item
|
||||
playIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
function playIndex(i) {
|
||||
if (i < 0 || i >= queue.length) return;
|
||||
queueIndex = i;
|
||||
const track = queue[i];
|
||||
const url = '/api/stream/' + track.path;
|
||||
audio.src = url;
|
||||
audio.play().catch(() => {});
|
||||
updateNowPlaying(track);
|
||||
renderQueue();
|
||||
scrollQueueToActive();
|
||||
loadMeta(track);
|
||||
}
|
||||
|
||||
function updateNowPlaying(track) {
|
||||
document.getElementById('npTitle').textContent = track.meta?.title || displayName(track.name);
|
||||
document.getElementById('npArtist').textContent = track.meta?.artist || '—';
|
||||
if (track.meta?.cover_base64) {
|
||||
document.getElementById('npCover').innerHTML = `<img src="${track.meta.cover_base64}" alt="cover">`;
|
||||
} else {
|
||||
document.getElementById('npCover').textContent = '🎵';
|
||||
}
|
||||
document.title = (track.meta?.title || displayName(track.name)) + ' — Furumi Player';
|
||||
}
|
||||
|
||||
async function loadMeta(track) {
|
||||
if (track.meta) { updateNowPlaying(track); return; }
|
||||
const cached = metaCache[track.path];
|
||||
if (cached) { track.meta = cached; updateNowPlaying(track); return; }
|
||||
try {
|
||||
const res = await fetch('/api/meta/' + track.path);
|
||||
if (res.ok) {
|
||||
const meta = await res.json();
|
||||
metaCache[track.path] = meta;
|
||||
track.meta = meta;
|
||||
updateNowPlaying(track);
|
||||
renderQueue();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function fetchMeta(path, idx) {
|
||||
const cached = metaCache[path];
|
||||
if (cached) { queue[idx].meta = cached; renderQueue(); return; }
|
||||
try {
|
||||
const res = await fetch('/api/meta/' + path);
|
||||
if (res.ok) {
|
||||
const meta = await res.json();
|
||||
metaCache[path] = meta;
|
||||
if (queue[idx]) { queue[idx].meta = meta; }
|
||||
renderQueue();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function renderQueue() {
|
||||
const listEl = document.getElementById('queueList');
|
||||
|
||||
if (!queue.length) {
|
||||
listEl.innerHTML = `
|
||||
<div class="queue-empty" id="queueEmpty">
|
||||
<div class="empty-icon">🎵</div>
|
||||
<div>Click files to add to queue</div>
|
||||
<div style="font-size:0.75rem;margin-top:0.25rem">Double-click a folder to add all tracks</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const order = currentOrder();
|
||||
listEl.innerHTML = '';
|
||||
|
||||
order.forEach((origIdx, pos) => {
|
||||
const track = queue[origIdx];
|
||||
const isPlaying = origIdx === queueIndex;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'queue-item' + (isPlaying ? ' playing' : '');
|
||||
div.dataset.origIdx = origIdx;
|
||||
|
||||
const cover = track.meta?.cover_base64
|
||||
? `<img src="${track.meta.cover_base64}" alt="">`
|
||||
: '🎵';
|
||||
const title = track.meta?.title || displayName(track.name);
|
||||
const artist = track.meta?.artist || '';
|
||||
const dur = track.meta?.duration_secs != null ? fmt(track.meta.duration_secs) : '';
|
||||
const idxDisplay = isPlaying ? '' : (pos + 1);
|
||||
|
||||
div.innerHTML = `
|
||||
<span class="qi-index">${idxDisplay}</span>
|
||||
<div class="qi-cover">${cover}</div>
|
||||
<div class="qi-info">
|
||||
<div class="qi-title">${esc(title)}</div>
|
||||
<div class="qi-artist">${esc(artist)}</div>
|
||||
</div>
|
||||
<span class="qi-dur">${dur}</span>
|
||||
<button class="qi-remove" title="Remove track" onclick="removeFromQueue(${origIdx}, event)">✕</button>
|
||||
`;
|
||||
div.addEventListener('click', () => playIndex(origIdx));
|
||||
|
||||
// Drag & Drop for reordering
|
||||
div.draggable = true;
|
||||
div.addEventListener('dragstart', e => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', pos);
|
||||
div.classList.add('dragging');
|
||||
});
|
||||
div.addEventListener('dragend', () => {
|
||||
div.classList.remove('dragging');
|
||||
document.querySelectorAll('.queue-item').forEach(el => el.classList.remove('drag-over'));
|
||||
});
|
||||
div.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
});
|
||||
div.addEventListener('dragenter', () => div.classList.add('drag-over'));
|
||||
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
|
||||
div.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
div.classList.remove('drag-over');
|
||||
const fromPos = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||
if (!isNaN(fromPos)) moveQueueItem(fromPos, pos);
|
||||
});
|
||||
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function scrollQueueToActive() {
|
||||
const el = document.querySelector('.queue-item.playing');
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function removeFromQueue(origIdx, ev) {
|
||||
ev.stopPropagation();
|
||||
queue.splice(origIdx, 1);
|
||||
if (queueIndex === origIdx) { queueIndex = -1; audio.pause(); audio.src = ''; }
|
||||
else if (queueIndex > origIdx) queueIndex--;
|
||||
|
||||
if (shuffle) {
|
||||
const sidx = shuffleOrder.indexOf(origIdx);
|
||||
if (sidx !== -1) shuffleOrder.splice(sidx, 1);
|
||||
for (let i = 0; i < shuffleOrder.length; i++) {
|
||||
if (shuffleOrder[i] > origIdx) shuffleOrder[i]--;
|
||||
}
|
||||
}
|
||||
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
function moveQueueItem(fromPos, toPos) {
|
||||
if (fromPos === toPos) return;
|
||||
|
||||
if (shuffle) {
|
||||
const item = shuffleOrder.splice(fromPos, 1)[0];
|
||||
shuffleOrder.splice(toPos, 0, item);
|
||||
} else {
|
||||
const item = queue.splice(fromPos, 1)[0];
|
||||
queue.splice(toPos, 0, item);
|
||||
|
||||
if (queueIndex === fromPos) {
|
||||
queueIndex = toPos;
|
||||
} else if (fromPos < queueIndex && toPos >= queueIndex) {
|
||||
queueIndex--;
|
||||
} else if (fromPos > queueIndex && toPos <= queueIndex) {
|
||||
queueIndex++;
|
||||
}
|
||||
}
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
queue = []; queueIndex = -1; shuffleOrder = [];
|
||||
audio.pause(); audio.src = '';
|
||||
document.getElementById('npTitle').textContent = 'Nothing playing';
|
||||
document.getElementById('npArtist').textContent = '—';
|
||||
document.getElementById('npCover').textContent = '🎵';
|
||||
document.title = 'Furumi Player';
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
// ─── Playback controls ────────────────────────────────────────────────────────
|
||||
function togglePlay() {
|
||||
if (!audio.src) { if (queue.length) playIndex(queueIndex === -1 ? 0 : queueIndex); return; }
|
||||
if (audio.paused) audio.play();
|
||||
else audio.pause();
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
if (!queue.length) return;
|
||||
const order = currentOrder();
|
||||
const pos = order.indexOf(queueIndex);
|
||||
if (pos < order.length - 1) {
|
||||
playIndex(order[pos + 1]);
|
||||
} else if (repeatAll) {
|
||||
if (shuffle) buildShuffleOrder();
|
||||
playIndex(currentOrder()[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function prevTrack() {
|
||||
if (!queue.length) return;
|
||||
if (audio.currentTime > 3) { audio.currentTime = 0; return; }
|
||||
const order = currentOrder();
|
||||
const pos = order.indexOf(queueIndex);
|
||||
if (pos > 0) playIndex(order[pos - 1]);
|
||||
else if (repeatAll) playIndex(order[order.length - 1]);
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
shuffle = !shuffle;
|
||||
if (shuffle) buildShuffleOrder();
|
||||
updateShuffleUI();
|
||||
localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0');
|
||||
renderQueue();
|
||||
}
|
||||
function updateShuffleUI() {
|
||||
document.getElementById('btnShuffle').classList.toggle('active', shuffle);
|
||||
}
|
||||
function toggleRepeat() {
|
||||
repeatAll = !repeatAll;
|
||||
updateRepeatUI();
|
||||
localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0');
|
||||
}
|
||||
function updateRepeatUI() {
|
||||
document.getElementById('btnRepeat').classList.toggle('active', repeatAll);
|
||||
}
|
||||
|
||||
function buildShuffleOrder() {
|
||||
shuffleOrder = [...Array(queue.length).keys()];
|
||||
for (let i = shuffleOrder.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]];
|
||||
}
|
||||
// Put current track first in shuffle order
|
||||
if (queueIndex !== -1) {
|
||||
const ci = shuffleOrder.indexOf(queueIndex);
|
||||
if (ci > 0) { shuffleOrder.splice(ci, 1); shuffleOrder.unshift(queueIndex); }
|
||||
}
|
||||
}
|
||||
|
||||
function currentOrder() {
|
||||
if (!shuffle) return [...Array(queue.length).keys()];
|
||||
if (shuffleOrder.length !== queue.length) buildShuffleOrder();
|
||||
return shuffleOrder;
|
||||
}
|
||||
|
||||
// ─── Seek & Volume ────────────────────────────────────────────────────────────
|
||||
function seekTo(e) {
|
||||
if (!audio.duration) return;
|
||||
const bar = document.getElementById('progressBar');
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
audio.currentTime = pct * audio.duration;
|
||||
}
|
||||
|
||||
let muted = false, volBeforeMute = 80;
|
||||
function toggleMute() {
|
||||
muted = !muted;
|
||||
audio.muted = muted;
|
||||
document.getElementById('volIcon').textContent = muted ? '🔇' : '🔊';
|
||||
}
|
||||
function setVolume(v) {
|
||||
audio.volume = v / 100;
|
||||
document.getElementById('volIcon').textContent = v == 0 ? '🔇' : '🔊';
|
||||
localStorage.setItem('furumi_vol', v);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function fmt(secs) {
|
||||
if (!secs || isNaN(secs)) return '0:00';
|
||||
const s = Math.floor(secs);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
if (h > 0) return `${h}:${pad(m % 60)}:${pad(s % 60)}`;
|
||||
return `${m}:${pad(s % 60)}`;
|
||||
}
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function displayName(filename) {
|
||||
return filename.replace(/\.[^.]+$/, '').replace(/_/g, ' ').replace(/^\d+[\s.-]+/, '');
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
window.location.href = '/logout';
|
||||
}
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||
navigate('');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
171
furumi-server/src/web/stream.rs
Normal file
171
furumi-server/src/web/stream.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<WebState>,
|
||||
Path(path): Path<String>,
|
||||
Query(query): Query<StreamQuery>,
|
||||
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=<start>-<end>` header.
|
||||
fn parse_range(s: &str) -> Option<(u64, Option<u64>)> {
|
||||
let s = s.strip_prefix("bytes=")?;
|
||||
let mut parts = s.splitn(2, '-');
|
||||
let start: u64 = parts.next()?.parse().ok()?;
|
||||
let end: Option<u64> = 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()
|
||||
}
|
||||
244
furumi-server/src/web/transcoder.rs
Normal file
244
furumi-server/src/web/transcoder.rs
Normal file
@@ -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<u8>` 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<Vec<u8>> {
|
||||
// ---- 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<u8> = 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<f32> = 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<f32> = 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<f32>) {
|
||||
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<f32> = 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::<Vec<_>>())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
interleave_channels(&ch0, ch1.as_deref(), target_channels, out);
|
||||
}
|
||||
AudioBufferRef::S32(buf) => {
|
||||
let ch0: Vec<f32> = 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::<Vec<_>>())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
interleave_channels(&ch0, ch1.as_deref(), target_channels, out);
|
||||
}
|
||||
AudioBufferRef::U8(buf) => {
|
||||
let ch0: Vec<f32> = 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::<Vec<_>>())
|
||||
} 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<f32>) {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user