Merge pull request #10 from coissac/claude/add-pmoplaylist-source-011CUpmZ9YbyUAUshEePVTJi
Claude/add pmoplaylist source 011 c upm z9 yby ua ush ee pvt ji
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,4 +36,6 @@ all.txt
|
||||
pmo_src.txt
|
||||
upmpdcli/
|
||||
/*.xml
|
||||
test_upnp
|
||||
test_upnp*.cargo/
|
||||
.cargo/
|
||||
setup-env.sh
|
||||
|
||||
@@ -17,8 +17,8 @@ host:
|
||||
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
|
||||
accounts:
|
||||
qobuz:
|
||||
username: eric@coissac.eu
|
||||
password: '*Misfcr73110$'
|
||||
username: your-email@example.com
|
||||
password: 'YOUR_PASSWORD_HERE'
|
||||
devices:
|
||||
mediarenderer:
|
||||
pmo_mediarenderer:
|
||||
427
Cargo.lock
generated
427
Cargo.lock
generated
@@ -49,6 +49,28 @@ dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -441,6 +463,8 @@ dependencies = [
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
@@ -553,6 +577,12 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
@@ -633,6 +663,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -726,6 +766,49 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -815,6 +898,12 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.9.0"
|
||||
@@ -1093,31 +1182,6 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-next"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d658424d233cbd993a972dd73a66ca733acd12a494c68995c9ac32ae1fe65b40"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"ffmpeg-sys-next",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ffmpeg-sys-next"
|
||||
version = "8.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bca20aa4ee774fe384c2490096c122b0b23cf524a9910add0686691003d797b"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"libc",
|
||||
"num_cpus",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.4"
|
||||
@@ -1910,6 +1974,28 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
@@ -2297,18 +2383,129 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "netstat2"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0faa3f4ad230fd2bf2a5dad71476ecbaeaed904b3c7e7e5b1f266c415c03761f"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "netlink-packet-core"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"byteorder",
|
||||
"netlink-packet-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "netlink-packet-sock-diag"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a495cb1de50560a7cd12fdcf023db70eec00e340df81be31cedbbfd4aadd6b76"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"byteorder",
|
||||
"libc",
|
||||
"netlink-packet-core",
|
||||
"netlink-packet-utils",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "netlink-packet-utils"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"byteorder",
|
||||
"paste",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "netlink-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "netstat2"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "496f264d3ead4870d6b366deb9d20597592d64aac2a907f3e7d07c2325ba4663"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.10.0",
|
||||
"byteorder",
|
||||
"netlink-packet-core",
|
||||
"netlink-packet-sock-diag",
|
||||
"netlink-packet-utils",
|
||||
"netlink-sys",
|
||||
"num-derive 0.3.3",
|
||||
"num-traits",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2437,6 +2634,51 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c"
|
||||
dependencies = [
|
||||
"num_enum_derive",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum_derive"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"num-derive 0.4.2",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ogg"
|
||||
version = "0.8.0"
|
||||
@@ -2639,11 +2881,13 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytemuck",
|
||||
"cpal",
|
||||
"futures-util",
|
||||
"paste",
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"reqwest",
|
||||
"rodio",
|
||||
"soxr",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -2661,6 +2905,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"pmoaudio",
|
||||
"pmoaudiocache",
|
||||
"pmocache",
|
||||
"pmocovers",
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
@@ -2850,14 +3095,17 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"claxon",
|
||||
"ffmpeg-next",
|
||||
"flacenc",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hound",
|
||||
"pmoaudio",
|
||||
"pmoaudio-ext",
|
||||
"pmoaudiocache",
|
||||
"pmoconfig",
|
||||
"pmocovers",
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmoplaylist",
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
@@ -2868,7 +3116,6 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
@@ -3072,6 +3319,15 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
|
||||
dependencies = [
|
||||
"toml_edit 0.23.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.103"
|
||||
@@ -3451,6 +3707,20 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb"
|
||||
dependencies = [
|
||||
"claxon",
|
||||
"cpal",
|
||||
"hound",
|
||||
"lewton",
|
||||
"symphonia",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.37.0"
|
||||
@@ -4119,7 +4389,7 @@ dependencies = [
|
||||
"ntapi",
|
||||
"once_cell",
|
||||
"rayon",
|
||||
"windows",
|
||||
"windows 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4978,6 +5248,16 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.52.0"
|
||||
@@ -4987,6 +5267,16 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -5045,6 +5335,15 @@ dependencies = [
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -5081,6 +5380,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -5117,6 +5425,21 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -5150,6 +5473,12 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5162,6 +5491,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5174,6 +5509,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5198,6 +5539,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5210,6 +5557,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5222,6 +5575,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5234,6 +5593,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
||||
260
INSTALL_LIBSOXR.md
Normal file
260
INSTALL_LIBSOXR.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Installation des dépendances système sans droits sudo
|
||||
|
||||
Ce document explique comment installer les dépendances système de `pmoaudio` localement sans privilèges administrateur.
|
||||
|
||||
## Dépendances requises
|
||||
|
||||
1. **libsoxr** - Nécessaire pour `ResamplingNode` (resampling audio haute qualité)
|
||||
2. **libasound2** (ALSA) - Nécessaire pour `AudioSink` via cpal (lecture audio sur Linux)
|
||||
|
||||
## Contexte
|
||||
|
||||
Les crates `soxr` et `cpal` nécessitent des bibliothèques système. Dans un environnement sans droits sudo (comme Claude Code), voici comment les installer localement.
|
||||
|
||||
## Méthode : Installation locale via apt-get download
|
||||
|
||||
### 1. Télécharger les packages .deb
|
||||
|
||||
```bash
|
||||
cd ~/.local
|
||||
|
||||
# Pour libsoxr (ResamplingNode)
|
||||
apt-get download libsoxr-dev libsoxr0
|
||||
|
||||
# Pour ALSA (AudioSink)
|
||||
# Note: libasound2t64 contient la bibliothèque partagée, libasound2-dev les headers
|
||||
apt-get download libasound2-dev libasound2t64
|
||||
```
|
||||
|
||||
Cela télécharge les fichiers `.deb` sans les installer système-wide.
|
||||
|
||||
### 2. Extraire les packages
|
||||
|
||||
```bash
|
||||
# Extraire libsoxr
|
||||
dpkg -x libsoxr-dev_*.deb .
|
||||
dpkg -x libsoxr0_*.deb .
|
||||
|
||||
# Extraire ALSA
|
||||
dpkg -x libasound2-dev_*.deb .
|
||||
dpkg -x libasound2t64_*.deb .
|
||||
```
|
||||
|
||||
Les fichiers sont extraits dans `~/.local/usr/lib/x86_64-linux-gnu/` et `~/.local/usr/include/`.
|
||||
|
||||
### 3. Configurer les variables d'environnement
|
||||
|
||||
Ajouter à votre `~/.bashrc` ou exporter dans votre session :
|
||||
|
||||
```bash
|
||||
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||
```
|
||||
|
||||
**IMPORTANT:** Ces variables doivent être définies dans chaque session où vous compilez le projet.
|
||||
|
||||
### 4. Vérifier l'installation
|
||||
|
||||
```bash
|
||||
# Vérifier libsoxr
|
||||
pkg-config --libs --cflags soxr
|
||||
|
||||
# Vérifier ALSA
|
||||
pkg-config --libs --cflags alsa
|
||||
```
|
||||
|
||||
Devrait retourner quelque chose comme :
|
||||
```
|
||||
# soxr
|
||||
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
|
||||
|
||||
# alsa
|
||||
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
|
||||
```
|
||||
|
||||
## Utilisation avec Cargo
|
||||
|
||||
### Pour les builds réguliers
|
||||
|
||||
Les variables d'environnement suffisent pour `cargo build` et `cargo run`.
|
||||
|
||||
### Pour les tests
|
||||
|
||||
Les tests nécessitent également la configuration du linker. Deux options :
|
||||
|
||||
#### Option A : Configuration locale du projet (NON RECOMMANDÉ pour le versioning)
|
||||
|
||||
Créer `.cargo/config.toml` dans chaque crate :
|
||||
|
||||
```toml
|
||||
[build]
|
||||
rustflags = ["-L", "/root/.local/usr/lib/x86_64-linux-gnu"]
|
||||
```
|
||||
|
||||
**⚠️ NE PAS committer ces fichiers** - ils contiennent des chemins spécifiques à votre installation.
|
||||
|
||||
#### Option B : Variables d'environnement pour cargo test
|
||||
|
||||
```bash
|
||||
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Pour d'autres distributions
|
||||
|
||||
### macOS (avec Homebrew)
|
||||
|
||||
```bash
|
||||
brew install libsoxr
|
||||
# Note: ALSA n'est pas nécessaire sur macOS (rodio utilise CoreAudio)
|
||||
```
|
||||
|
||||
### Debian/Ubuntu (avec sudo)
|
||||
|
||||
```bash
|
||||
sudo apt-get install libsoxr-dev libasound2-dev
|
||||
```
|
||||
|
||||
### Fedora/RHEL
|
||||
|
||||
```bash
|
||||
sudo dnf install soxr-devel
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Erreur : "Package 'soxr' was not found" ou "Package 'alsa' was not found"
|
||||
|
||||
- Vérifier que `PKG_CONFIG_PATH` contient le bon chemin
|
||||
- Vérifier que les fichiers `soxr.pc` et `alsa.pc` existent dans ce répertoire
|
||||
|
||||
### Erreur de link : "unable to find library -lsoxr" ou "-lasound"
|
||||
|
||||
- Pour `cargo build` : vérifier `LD_LIBRARY_PATH`
|
||||
- Pour `cargo test` : utiliser la configuration rustflags (Option A ci-dessus)
|
||||
|
||||
### Le test compile mais échoue au runtime
|
||||
|
||||
```
|
||||
error while loading shared libraries: libsoxr.so.0: cannot open shared object file
|
||||
```
|
||||
|
||||
Solution : Ajouter `LD_LIBRARY_PATH` également pour l'exécution :
|
||||
|
||||
```bash
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Guide complet pour environnement Claude Code
|
||||
|
||||
### Configuration initiale (à faire une seule fois)
|
||||
|
||||
Dans une session Claude Code (https://claude.ai/code), vous n'avez pas de droits sudo. Suivez ces étapes :
|
||||
|
||||
#### 1. Installation des dépendances
|
||||
|
||||
```bash
|
||||
# Créer le répertoire local
|
||||
mkdir -p ~/.local
|
||||
cd ~/.local
|
||||
|
||||
# Télécharger tous les packages nécessaires
|
||||
apt-get download libsoxr-dev libsoxr0 libasound2-dev libasound2t64
|
||||
|
||||
# Extraire tous les packages
|
||||
dpkg -x libsoxr-dev_*.deb .
|
||||
dpkg -x libsoxr0_*.deb .
|
||||
dpkg -x libasound2-dev_*.deb .
|
||||
dpkg -x libasound2t64_*.deb .
|
||||
|
||||
# Retourner au projet
|
||||
cd /home/user/pmomusic
|
||||
```
|
||||
|
||||
#### 2. Configuration des variables d'environnement
|
||||
|
||||
**IMPORTANT:** Ces variables doivent être exportées dans CHAQUE session Claude Code avant de compiler :
|
||||
|
||||
```bash
|
||||
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||
```
|
||||
|
||||
**Astuce :** Copier ces trois lignes dans un fichier `setup-env.sh` à la racine du projet :
|
||||
|
||||
```bash
|
||||
cat > setup-env.sh << 'EOF'
|
||||
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||
EOF
|
||||
```
|
||||
|
||||
Puis dans chaque session :
|
||||
|
||||
```bash
|
||||
source setup-env.sh
|
||||
```
|
||||
|
||||
⚠️ **NE PAS committer `setup-env.sh`** - ajouter au `.gitignore`
|
||||
|
||||
#### 3. Vérifier l'installation
|
||||
|
||||
```bash
|
||||
# Vérifier que pkg-config trouve les bibliothèques
|
||||
pkg-config --libs --cflags soxr
|
||||
pkg-config --libs --cflags alsa
|
||||
|
||||
# Devrait afficher quelque chose comme :
|
||||
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
|
||||
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
|
||||
```
|
||||
|
||||
#### 4. Compiler et tester
|
||||
|
||||
```bash
|
||||
# Compiler le workspace complet
|
||||
cargo build
|
||||
|
||||
# Tester l'exemple play_and_cache de pmoparadise
|
||||
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||
```
|
||||
|
||||
### Workflow pour chaque nouvelle session
|
||||
|
||||
À chaque fois que vous démarrez une nouvelle session Claude Code :
|
||||
|
||||
1. **Exporter les variables d'environnement** (ou `source setup-env.sh`)
|
||||
2. Compiler avec `cargo build`
|
||||
3. Exécuter les exemples ou tests
|
||||
|
||||
**IMPORTANT :** Si vous oubliez d'exporter les variables, vous obtiendrez des erreurs comme :
|
||||
```
|
||||
error: failed to run custom build command for `soxr-sys`
|
||||
Package 'soxr' was not found in the pkg-config search path
|
||||
```
|
||||
|
||||
ou
|
||||
|
||||
```
|
||||
rust-lld: error: unable to find library -lasound
|
||||
```
|
||||
|
||||
Solution : Exporter les variables et recompiler.
|
||||
|
||||
### Notes importantes
|
||||
|
||||
- ✅ Les dépendances installées dans `~/.local` persistent entre les sessions
|
||||
- ✅ Les variables d'environnement doivent être réexportées à chaque nouvelle session
|
||||
- ❌ NE JAMAIS créer de fichiers `.cargo/config.toml` dans le projet (chemins spécifiques)
|
||||
- ❌ NE JAMAIS committer `setup-env.sh` (configuration locale)
|
||||
- 💡 Sur macOS (via Homebrew) : seul `libsoxr` est nécessaire (pas d'ALSA)
|
||||
|
||||
## Références
|
||||
|
||||
- libsoxr GitHub: https://github.com/chirlu/soxr
|
||||
- Documentation pkg-config: https://www.freedesktop.org/wiki/Software/pkg-config/
|
||||
74
INSTALL_NOTES.md
Normal file
74
INSTALL_NOTES.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Notes d'installation pour PMOMusic
|
||||
|
||||
## Prérequis système
|
||||
|
||||
### libsoxr (obligatoire pour pmoaudio - resampling)
|
||||
|
||||
La bibliothèque `libsoxr` est requise pour le resampling audio dans `pmoaudio`.
|
||||
|
||||
### libasound2/ALSA (obligatoire pour pmoaudio - lecture audio sur Linux)
|
||||
|
||||
La bibliothèque ALSA est requise pour `AudioSink` via `cpal` sur Linux. Sur macOS et Windows, aucune dépendance externe n'est nécessaire (CoreAudio et WASAPI sont utilisés).
|
||||
|
||||
**Installation** :
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt-get install libsoxr-dev libasound2-dev
|
||||
|
||||
# Fedora/RHEL
|
||||
sudo dnf install libsoxr-devel alsa-lib-devel
|
||||
|
||||
# Arch Linux
|
||||
sudo pacman -S libsoxr alsa-lib
|
||||
|
||||
# macOS (Homebrew) - ALSA non nécessaire sur macOS
|
||||
brew install libsoxr
|
||||
|
||||
# Alpine Linux
|
||||
apk add soxr-dev alsa-lib-dev
|
||||
```
|
||||
|
||||
**Sans privilèges root** : Si vous n'avez pas les droits sudo, consultez `INSTALL_LIBSOXR.md` pour l'installation locale de `libsoxr` et `libasound2`.
|
||||
|
||||
---
|
||||
|
||||
## Nouveaux composants
|
||||
|
||||
### PlaylistSource (pmoaudio-ext)
|
||||
|
||||
Source audio qui lit une playlist `pmoplaylist` et diffuse les pistes en continu.
|
||||
|
||||
**Feature** : `playlist`
|
||||
|
||||
```bash
|
||||
# Compiler avec la feature playlist
|
||||
cargo build --package pmoaudio-ext --features playlist
|
||||
```
|
||||
|
||||
**⚠️ Important** : Cette source émet du PCM avec sample_rate et bit_depth **variables**. Pour un flux homogène, ajoutez dans le pipeline :
|
||||
- `ResamplingNode` (normalise le sample_rate)
|
||||
- `ToI24Node` / `ToI16Node` (normalise la profondeur de bits)
|
||||
|
||||
### ResamplingNode (pmoaudio)
|
||||
|
||||
Nœud générique qui normalise le sample_rate vers une valeur cible fixe.
|
||||
|
||||
**Usage** :
|
||||
```rust
|
||||
let mut resampler = ResamplingNode::new(48000); // Force 48kHz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compilation
|
||||
|
||||
```bash
|
||||
# Compiler tout le workspace (nécessite libsoxr)
|
||||
cargo build
|
||||
|
||||
# Compiler sans pmoaudio (si libsoxr manque)
|
||||
cargo build --package pmoplaylist
|
||||
cargo build --package pmoaudiocache
|
||||
# etc.
|
||||
```
|
||||
27
SECURITY_CONFIG.md
Normal file
27
SECURITY_CONFIG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Configuration Sécurisée
|
||||
|
||||
## Configuration de PMOMusic
|
||||
|
||||
Le fichier `.pmomusic.yml` contient des informations sensibles (mots de passe, identifiants).
|
||||
|
||||
### Installation
|
||||
|
||||
1. Copiez le fichier exemple :
|
||||
```bash
|
||||
cp .pmomusic.yml.example .pmomusic.yml
|
||||
```
|
||||
|
||||
2. Éditez `.pmomusic.yml` et remplacez les valeurs par vos véritables identifiants :
|
||||
- `accounts.qobuz.username` : votre email Qobuz
|
||||
- `accounts.qobuz.password` : votre mot de passe Qobuz
|
||||
|
||||
3. **Important** : Ne commitez JAMAIS le fichier `.pmomusic.yml` dans git !
|
||||
- Il est déjà dans `.gitignore`
|
||||
- Utilisez des variables d'environnement pour la production
|
||||
|
||||
## Variables d'environnement (recommandé pour production)
|
||||
|
||||
```bash
|
||||
export QOBUZ_USERNAME="votre-email@example.com"
|
||||
export QOBUZ_PASSWORD="votre-mot-de-passe"
|
||||
```
|
||||
@@ -13,8 +13,9 @@ pmoaudiocache = { path = "../pmoaudiocache", optional = true }
|
||||
pmoflac = { path = "../pmoflac", optional = true }
|
||||
pmometadata = { path = "../pmometadata", optional = true }
|
||||
|
||||
# Optional dependency for playlist integration
|
||||
# Optional dependencies for playlist integration
|
||||
pmoplaylist = { path = "../pmoplaylist", optional = true }
|
||||
pmocache = { path = "../pmocache", optional = true }
|
||||
# Async runtime
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7" }
|
||||
@@ -26,5 +27,5 @@ tracing = "0.1"
|
||||
[features]
|
||||
default = []
|
||||
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata"]
|
||||
playlist = ["dep:pmoplaylist"]
|
||||
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
|
||||
all = ["cache-sink", "playlist"]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! # Features
|
||||
//!
|
||||
//! - `cache-sink` : Active le `FlacCacheSink` qui encode l'audio en FLAC et le stocke dans pmoaudiocache
|
||||
//! - `playlist` : Active l'intégration avec pmoplaylist pour les sinks
|
||||
//! - `playlist` : Active l'intégration avec pmoplaylist (sources et sinks)
|
||||
//! - `all` : Active toutes les features d'un coup
|
||||
//!
|
||||
//! # Architecture
|
||||
@@ -25,6 +25,12 @@
|
||||
#[cfg(feature = "cache-sink")]
|
||||
pub mod sinks;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
pub mod sources;
|
||||
|
||||
// Re-exports pour faciliter l'utilisation
|
||||
#[cfg(feature = "cache-sink")]
|
||||
pub use sinks::*;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
pub use sources::*;
|
||||
|
||||
@@ -251,8 +251,6 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
|
||||
pub struct FlacCacheSink {
|
||||
inner: Node<FlacCacheSinkLogic>,
|
||||
#[cfg(feature = "playlist")]
|
||||
playlist_handle_pending: Option<Arc<pmoplaylist::WriteHandle>>,
|
||||
}
|
||||
|
||||
impl FlacCacheSink {
|
||||
@@ -297,8 +295,6 @@ impl FlacCacheSink {
|
||||
let logic = FlacCacheSinkLogic::new(cache, covers, collection, encoder_options, 8);
|
||||
Self {
|
||||
inner: Node::new_with_input(logic, channel_size),
|
||||
#[cfg(feature = "playlist")]
|
||||
playlist_handle_pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +305,7 @@ impl FlacCacheSink {
|
||||
/// * `handle` - WriteHandle de la playlist qui recevra les pk des tracks
|
||||
#[cfg(feature = "playlist")]
|
||||
pub fn register_playlist(&mut self, handle: pmoplaylist::WriteHandle) {
|
||||
self.playlist_handle_pending = Some(Arc::new(handle));
|
||||
self.inner.logic_mut().set_playlist_handle(Arc::new(handle));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,16 +645,7 @@ impl AudioPipelineNode for FlacCacheSink {
|
||||
panic!("FlacCacheSink is a terminal sink and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(mut self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
// Transférer le playlist_handle_pending à la logique si présent
|
||||
#[cfg(feature = "playlist")]
|
||||
if let Some(handle) = self.playlist_handle_pending.take() {
|
||||
// FIXME: Node devrait exposer une méthode logic_mut() pour permettre
|
||||
// la configuration post-construction. Pour l'instant, on ignore ce handle.
|
||||
// L'utilisateur devra configurer la playlist avant construction.
|
||||
let _ = handle;
|
||||
}
|
||||
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
10
pmoaudio-ext/src/sources/mod.rs
Normal file
10
pmoaudio-ext/src/sources/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Sources audio étendues pour pmoaudio
|
||||
//!
|
||||
//! Ce module contient des sources audio qui dépendent d'autres crates
|
||||
//! du projet PMO (pmoplaylist, pmoaudiocache, etc.)
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
mod playlist_source;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
pub use playlist_source::PlaylistSource;
|
||||
852
pmoaudio-ext/src/sources/playlist_source.rs
Normal file
852
pmoaudio-ext/src/sources/playlist_source.rs
Normal file
@@ -0,0 +1,852 @@
|
||||
//! PlaylistSource - Source audio depuis une playlist pmoplaylist
|
||||
//!
|
||||
//! Cette source lit une playlist (via `ReadHandle`) et émet un flux audio
|
||||
//! continu en décodant les fichiers depuis le cache audio.
|
||||
//!
|
||||
//! # ⚠️ Format de sortie hétérogène
|
||||
//!
|
||||
//! **IMPORTANT** : Cette source émet du PCM avec des caractéristiques
|
||||
//! **variables** selon les fichiers sources :
|
||||
//! - **Sample rate** : peut varier (44.1kHz, 48kHz, 96kHz, etc.)
|
||||
//! - **Bit depth** : peut varier (I16, I24, I32)
|
||||
//!
|
||||
//! Pour obtenir un flux **homogène**, ajoutez les nœuds suivants dans le pipeline :
|
||||
//! - `ResamplingNode` : normalise le sample_rate (à implémenter dans pmoaudio)
|
||||
//! - `ToI24Node` / `ToI16Node` : normalise la profondeur de bits
|
||||
//!
|
||||
//! # Cas d'usage
|
||||
//!
|
||||
//! ## Radio Paradise (format homogène connu)
|
||||
//! ```rust,no_run
|
||||
//! use pmoaudio_ext::PlaylistSource;
|
||||
//! use pmoaudio::ToI24Node;
|
||||
//! use pmoplaylist::PlaylistManager;
|
||||
//! use pmoaudiocache::AudioCache;
|
||||
//! use std::sync::Arc;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let manager = PlaylistManager::get();
|
||||
//! let read_handle = manager.get_read_handle("radio-paradise").await?;
|
||||
//! let cache = Arc::new(AudioCache::new("./cache", 500)?);
|
||||
//!
|
||||
//! let mut source = PlaylistSource::new(read_handle, cache);
|
||||
//! let to_i24 = ToI24Node::new();
|
||||
//! source.register(to_i24);
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Playlist mixte (nécessite homogénéisation)
|
||||
//! ```rust,no_run
|
||||
//! use pmoaudio_ext::PlaylistSource;
|
||||
//! use pmoaudio::{ToI24Node, ResamplingNode};
|
||||
//! # use pmoplaylist::PlaylistManager;
|
||||
//! # use pmoaudiocache::AudioCache;
|
||||
//! # use std::sync::Arc;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! # let manager = PlaylistManager::get();
|
||||
//! # let read_handle = manager.get_read_handle("mixed").await?;
|
||||
//! # let cache = Arc::new(AudioCache::new("./cache", 500)?);
|
||||
//! let mut source = PlaylistSource::new(read_handle, cache);
|
||||
//! let mut resampler = ResamplingNode::new(48000); // Force 48kHz
|
||||
//! let to_i24 = ToI24Node::new(); // Force I24
|
||||
//! source.register(Box::new(resampler));
|
||||
//! resampler.register(Box::new(to_i24));
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! # Comportement
|
||||
//!
|
||||
//! - **Polling** : Si la playlist est vide, attend `poll_interval_ms` avant de réessayer
|
||||
//! - **TrackBoundary** : Émet un marqueur avec metadata entre chaque piste
|
||||
//! - **Erreurs** : Si un fichier est inaccessible, émet un `Error` marker et continue
|
||||
//! - **Arrêt** : Via `CancellationToken`, émet `EndOfStream` avant de terminer
|
||||
//!
|
||||
//! # Synchronisation
|
||||
//!
|
||||
//! - `TopZeroSync` : émis une seule fois au début
|
||||
//! - `TrackBoundary` : émis avant chaque nouvelle piste (contient metadata)
|
||||
//! - Pas d'`EndOfStream` entre les pistes (flux continu)
|
||||
//! - `EndOfStream` final uniquement lors de l'arrêt
|
||||
|
||||
use pmoaudio::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioSegment, I24,
|
||||
};
|
||||
use pmoaudiocache::Cache as AudioCache;
|
||||
use pmoflac::{decode_audio_stream, StreamInfo};
|
||||
use pmoplaylist::ReadHandle;
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PlaylistSourceLogic - Logique pure de lecture de playlist
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Logique pure de lecture de playlist
|
||||
///
|
||||
/// Contient seulement la logique de lecture de playlist et décodage des pistes,
|
||||
/// sans la plomberie d'orchestration (gérée par Node<PlaylistSourceLogic>).
|
||||
pub struct PlaylistSourceLogic {
|
||||
playlist_handle: ReadHandle,
|
||||
cache: Arc<AudioCache>,
|
||||
chunk_frames: usize,
|
||||
poll_interval_ms: u64,
|
||||
}
|
||||
|
||||
impl PlaylistSourceLogic {
|
||||
pub fn new(
|
||||
playlist_handle: ReadHandle,
|
||||
cache: Arc<AudioCache>,
|
||||
chunk_frames: usize,
|
||||
poll_interval_ms: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
playlist_handle,
|
||||
cache,
|
||||
chunk_frames,
|
||||
poll_interval_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for PlaylistSourceLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
_input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
tracing::debug!(
|
||||
"PlaylistSourceLogic::process started, playlist={}, {} children",
|
||||
self.playlist_handle.id(),
|
||||
output.len()
|
||||
);
|
||||
|
||||
// Macro helper pour envoyer à tous les enfants
|
||||
macro_rules! send_to_children {
|
||||
($segment:expr) => {
|
||||
for tx in &output {
|
||||
tx.send($segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let mut first_track = true;
|
||||
|
||||
loop {
|
||||
// Vérifier arrêt immédiat
|
||||
if stop_token.is_cancelled() {
|
||||
tracing::info!("PlaylistSourceLogic: stop requested, emitting EndOfStream");
|
||||
let eos = AudioSegment::new_end_of_stream(0, 0.0);
|
||||
send_to_children!(eos);
|
||||
break;
|
||||
}
|
||||
|
||||
// Pop avec timeout pour supporter stop_token
|
||||
let track = tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::info!("PlaylistSourceLogic: stop cancelled during pop");
|
||||
let eos = AudioSegment::new_end_of_stream(0, 0.0);
|
||||
send_to_children!(eos);
|
||||
break;
|
||||
}
|
||||
result = self.playlist_handle.pop() => {
|
||||
match result {
|
||||
Ok(Some(t)) => {
|
||||
tracing::debug!("PlaylistSourceLogic: popped track from playlist");
|
||||
t
|
||||
},
|
||||
Ok(None) => {
|
||||
// Playlist vide, attendre avant retry
|
||||
tracing::trace!(
|
||||
"PlaylistSourceLogic: playlist empty, waiting {}ms",
|
||||
self.poll_interval_ms
|
||||
);
|
||||
tokio::time::sleep(
|
||||
Duration::from_millis(self.poll_interval_ms)
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
// Erreur playlist (deleted, etc.)
|
||||
tracing::warn!("PlaylistSourceLogic: playlist error: {}", e);
|
||||
let error_marker = AudioSegment::new_error(
|
||||
0,
|
||||
0.0,
|
||||
format!("Playlist error: {}", e)
|
||||
);
|
||||
send_to_children!(error_marker);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Émettre TopZeroSync pour la première piste seulement
|
||||
if first_track {
|
||||
tracing::debug!("PlaylistSourceLogic: emitting TopZeroSync");
|
||||
let top_zero = AudioSegment::new_top_zero_sync();
|
||||
send_to_children!(top_zero);
|
||||
first_track = false;
|
||||
}
|
||||
|
||||
// Émettre TrackBoundary avec metadata du cache
|
||||
let metadata = match track.track_metadata() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e);
|
||||
let error_marker = AudioSegment::new_error(
|
||||
0,
|
||||
0.0,
|
||||
format!("Failed to get metadata: {}", e),
|
||||
);
|
||||
send_to_children!(error_marker);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
|
||||
send_to_children!(boundary);
|
||||
|
||||
// Obtenir le chemin du fichier
|
||||
let file_path = match track.file_path() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e);
|
||||
let error_marker = AudioSegment::new_error(
|
||||
0,
|
||||
0.0,
|
||||
format!("Failed to get file path: {}", e),
|
||||
);
|
||||
send_to_children!(error_marker);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("PlaylistSourceLogic: decoding track: {:?}", file_path);
|
||||
|
||||
// Décoder et émettre les chunks PCM
|
||||
if let Err(e) = decode_and_emit_track(
|
||||
&file_path,
|
||||
self.chunk_frames,
|
||||
&output,
|
||||
&stop_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("PlaylistSourceLogic: error decoding track: {}", e);
|
||||
let error_marker = AudioSegment::new_error(0, 0.0, format!("Decode error: {}", e));
|
||||
send_to_children!(error_marker);
|
||||
// Continue vers la piste suivante
|
||||
}
|
||||
|
||||
// Boucler pour la piste suivante (pas d'EndOfStream entre pistes !)
|
||||
}
|
||||
|
||||
tracing::debug!("PlaylistSourceLogic::process finished");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Helper Functions
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Décode un fichier et émet ses chunks audio
|
||||
async fn decode_and_emit_track(
|
||||
path: &PathBuf,
|
||||
chunk_frames: usize,
|
||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
// Ouvrir et décoder
|
||||
let file = File::open(path)
|
||||
.await
|
||||
.map_err(|e| AudioError::IoError(format!("Failed to open {:?}: {}", path, e)))?;
|
||||
|
||||
let mut stream = decode_audio_stream(file)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?;
|
||||
|
||||
let stream_info = stream.info().clone();
|
||||
|
||||
// Valider le stream
|
||||
validate_stream(&stream_info)?;
|
||||
|
||||
// Calculer chunk_frames (auto = 50ms)
|
||||
let chunk_frames = if chunk_frames == 0 {
|
||||
let frames = (stream_info.sample_rate as f64 * DEFAULT_CHUNK_DURATION_MS / 1000.0) as usize;
|
||||
frames.next_power_of_two().max(256)
|
||||
} else {
|
||||
chunk_frames.max(1)
|
||||
};
|
||||
|
||||
tracing::trace!(
|
||||
"decode_and_emit_track: sample_rate={}, bit_depth={}, chunk_frames={}",
|
||||
stream_info.sample_rate,
|
||||
stream_info.bits_per_sample,
|
||||
chunk_frames
|
||||
);
|
||||
|
||||
// Lire et émettre les chunks
|
||||
let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize;
|
||||
let chunk_byte_len = chunk_frames * frame_bytes;
|
||||
let mut pending = Vec::new();
|
||||
let mut read_buf = vec![0u8; frame_bytes * 512.max(chunk_frames)];
|
||||
let mut chunk_index = 0u64;
|
||||
let mut total_frames = 0u64;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("decode_and_emit_track: stop requested");
|
||||
break;
|
||||
}
|
||||
|
||||
read_result = stream.read(&mut read_buf) => {
|
||||
// Remplir le buffer
|
||||
if pending.len() < chunk_byte_len {
|
||||
let read = read_result.map_err(|e| {
|
||||
AudioError::IoError(format!("I/O error while decoding: {}", e))
|
||||
})?;
|
||||
if read == 0 && pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
if read > 0 {
|
||||
pending.extend_from_slice(&read_buf[..read]);
|
||||
}
|
||||
}
|
||||
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Extraire un chunk
|
||||
let frames_in_pending = pending.len() / frame_bytes;
|
||||
let frames_to_emit = frames_in_pending.min(chunk_frames);
|
||||
if frames_to_emit == 0 {
|
||||
break;
|
||||
}
|
||||
let take_bytes = frames_to_emit * frame_bytes;
|
||||
let chunk_bytes = pending.drain(..take_bytes).collect::<Vec<u8>>();
|
||||
|
||||
// Calculer le timestamp
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
|
||||
// Créer et envoyer le segment audio
|
||||
let segment = bytes_to_segment(
|
||||
&chunk_bytes,
|
||||
&stream_info,
|
||||
frames_to_emit,
|
||||
chunk_index,
|
||||
timestamp_sec,
|
||||
)?;
|
||||
|
||||
for tx in output {
|
||||
tx.send(segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
|
||||
chunk_index += 1;
|
||||
total_frames += frames_to_emit as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traiter le reste éventuel (moins qu'un chunk complet)
|
||||
if !pending.is_empty() {
|
||||
let frames = pending.len() / frame_bytes;
|
||||
if frames > 0 {
|
||||
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||
let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||
for tx in output {
|
||||
tx.send(segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attendre la fin du décodage
|
||||
stream
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> {
|
||||
if !(1..=2).contains(&info.channels) {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported channel count: {}",
|
||||
info.channels
|
||||
)));
|
||||
}
|
||||
match info.bits_per_sample {
|
||||
8 | 16 | 24 | 32 => Ok(()),
|
||||
other => Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bit depth: {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit des bytes PCM en AudioSegment avec le type approprié
|
||||
fn bytes_to_segment(
|
||||
chunk_bytes: &[u8],
|
||||
info: &StreamInfo,
|
||||
frames: usize,
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
) -> Result<Arc<AudioSegment>, AudioError> {
|
||||
let bytes_per_sample = info.bytes_per_sample();
|
||||
let channels = info.channels as usize;
|
||||
let frame_bytes = bytes_per_sample * channels;
|
||||
|
||||
// Créer le chunk du bon type selon la profondeur de bit
|
||||
let chunk = match info.bits_per_sample {
|
||||
16 => {
|
||||
// Type I16
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let l = i16::from_le_bytes(
|
||||
chunk_bytes[base..base + bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let r = if channels == 1 {
|
||||
l
|
||||
} else {
|
||||
i16::from_le_bytes(
|
||||
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
AudioChunk::I16(chunk_data)
|
||||
}
|
||||
24 => {
|
||||
// Type I24
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let l_i32 = {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[..3].copy_from_slice(&chunk_bytes[base..base + 3]);
|
||||
// Sign extend
|
||||
if chunk_bytes[base + 2] & 0x80 != 0 {
|
||||
buf[3] = 0xFF;
|
||||
}
|
||||
i32::from_le_bytes(buf)
|
||||
};
|
||||
let l = I24::new(l_i32).ok_or_else(|| {
|
||||
AudioError::ProcessingError(format!("Invalid I24 value: {}", l_i32))
|
||||
})?;
|
||||
|
||||
let r = if channels == 1 {
|
||||
l
|
||||
} else {
|
||||
let r_i32 = {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[..3].copy_from_slice(
|
||||
&chunk_bytes[base + bytes_per_sample..base + bytes_per_sample + 3],
|
||||
);
|
||||
// Sign extend
|
||||
if chunk_bytes[base + bytes_per_sample + 2] & 0x80 != 0 {
|
||||
buf[3] = 0xFF;
|
||||
}
|
||||
i32::from_le_bytes(buf)
|
||||
};
|
||||
I24::new(r_i32).ok_or_else(|| {
|
||||
AudioError::ProcessingError(format!("Invalid I24 value: {}", r_i32))
|
||||
})?
|
||||
};
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
AudioChunk::I24(chunk_data)
|
||||
}
|
||||
32 => {
|
||||
// Type I32
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let l = i32::from_le_bytes(
|
||||
chunk_bytes[base..base + bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let r = if channels == 1 {
|
||||
l
|
||||
} else {
|
||||
i32::from_le_bytes(
|
||||
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||
AudioChunk::I32(chunk_data)
|
||||
}
|
||||
_ => {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bit depth: {}",
|
||||
info.bits_per_sample
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Arc::new(AudioSegment {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: pmoaudio::_AudioSegment::Chunk(Arc::new(chunk)),
|
||||
}))
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// WRAPPER PlaylistSource - Délègue à Node<PlaylistSourceLogic>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// PlaylistSource - Lit une playlist et publie des `AudioSegment`
|
||||
///
|
||||
/// Cette source utilise une playlist (`ReadHandle`) et le cache audio pour
|
||||
/// décoder les pistes en continu. Le format de sortie (sample_rate et bit_depth)
|
||||
/// est **hétérogène** et dépend des fichiers sources.
|
||||
///
|
||||
/// Voir la documentation du module pour plus de détails et exemples d'usage.
|
||||
pub struct PlaylistSource {
|
||||
inner: Node<PlaylistSourceLogic>,
|
||||
}
|
||||
|
||||
impl PlaylistSource {
|
||||
/// Crée une nouvelle source de playlist avec paramètres par défaut
|
||||
///
|
||||
/// * `playlist_handle` - Handle de lecture sur la playlist
|
||||
/// * `cache` - Cache audio contenant les fichiers
|
||||
///
|
||||
/// Paramètres par défaut :
|
||||
/// - `chunk_frames` : 0 (auto-calculé pour 50ms)
|
||||
/// - `poll_interval_ms` : 100ms
|
||||
pub fn new(playlist_handle: ReadHandle, cache: Arc<AudioCache>) -> Self {
|
||||
Self::with_config(playlist_handle, cache, 0, 100)
|
||||
}
|
||||
|
||||
/// Crée une nouvelle source de playlist avec configuration personnalisée
|
||||
///
|
||||
/// * `playlist_handle` - Handle de lecture sur la playlist
|
||||
/// * `cache` - Cache audio contenant les fichiers
|
||||
/// * `chunk_frames` - Nombre de frames par chunk (0 = auto)
|
||||
/// * `poll_interval_ms` - Intervalle de polling si playlist vide
|
||||
pub fn with_config(
|
||||
playlist_handle: ReadHandle,
|
||||
cache: Arc<AudioCache>,
|
||||
chunk_frames: usize,
|
||||
poll_interval_ms: u64,
|
||||
) -> Self {
|
||||
let logic = PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
|
||||
Self {
|
||||
inner: Node::new_source(logic),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for PlaylistSource {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for PlaylistSource {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
None // Source n'a pas d'entrée
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
// Format hétérogène - accepte tout
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests unitaires pour les fonctions helper
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn test_validate_stream_valid_stereo_16bit() {
|
||||
let info = StreamInfo {
|
||||
sample_rate: 44100,
|
||||
channels: 2,
|
||||
bits_per_sample: 16,
|
||||
total_samples: Some(1000),
|
||||
max_block_size: 4096,
|
||||
min_block_size: 256,
|
||||
};
|
||||
assert!(validate_stream(&info).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_stream_valid_mono_24bit() {
|
||||
let info = StreamInfo {
|
||||
sample_rate: 48000,
|
||||
channels: 1,
|
||||
bits_per_sample: 24,
|
||||
total_samples: Some(1000),
|
||||
max_block_size: 4096,
|
||||
min_block_size: 256,
|
||||
};
|
||||
assert!(validate_stream(&info).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_stream_invalid_channel_count() {
|
||||
let info = StreamInfo {
|
||||
sample_rate: 44100,
|
||||
channels: 5, // Invalid
|
||||
bits_per_sample: 16,
|
||||
total_samples: Some(1000),
|
||||
max_block_size: 4096,
|
||||
min_block_size: 256,
|
||||
};
|
||||
assert!(validate_stream(&info).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_stream_invalid_bit_depth() {
|
||||
let info = StreamInfo {
|
||||
sample_rate: 44100,
|
||||
channels: 2,
|
||||
bits_per_sample: 12, // Invalid
|
||||
total_samples: Some(1000),
|
||||
max_block_size: 4096,
|
||||
min_block_size: 256,
|
||||
};
|
||||
assert!(validate_stream(&info).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_to_segment_i16_stereo() {
|
||||
// Create mock PCM data (2 frames, stereo, 16-bit)
|
||||
// Frame 1: L=100, R=200
|
||||
// Frame 2: L=300, R=400
|
||||
let chunk_bytes = vec![
|
||||
100u8, 0, // L1
|
||||
200, 0, // R1
|
||||
44, 1, // L2 (300 = 0x012C)
|
||||
144, 1, // R2 (400 = 0x0190)
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
sample_rate: 44100,
|
||||
channels: 2,
|
||||
bits_per_sample: 16,
|
||||
total_samples: Some(2),
|
||||
max_block_size: 4096,
|
||||
min_block_size: 256,
|
||||
};
|
||||
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 2, 0, 0.0).unwrap();
|
||||
|
||||
assert_eq!(segment.order, 0);
|
||||
assert_eq!(segment.timestamp_sec, 0.0);
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I16(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0], [100, 200]);
|
||||
assert_eq!(frames[1], [300, 400]);
|
||||
assert_eq!(data.get_sample_rate(), 44100);
|
||||
}
|
||||
_ => panic!("Expected I16 chunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_to_segment_i16_mono() {
|
||||
// Create mock PCM data (2 frames, mono, 16-bit)
|
||||
let chunk_bytes = vec![
|
||||
100u8, 0, // Frame 1
|
||||
200, 0, // Frame 2
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
sample_rate: 48000,
|
||||
channels: 1,
|
||||
bits_per_sample: 16,
|
||||
total_samples: Some(2),
|
||||
max_block_size: 4096,
|
||||
min_block_size: 256,
|
||||
};
|
||||
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 2, 5, 1.5).unwrap();
|
||||
|
||||
assert_eq!(segment.order, 5);
|
||||
assert_eq!(segment.timestamp_sec, 1.5);
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I16(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
// Mono is duplicated to both channels
|
||||
assert_eq!(frames[0], [100, 100]);
|
||||
assert_eq!(frames[1], [200, 200]);
|
||||
}
|
||||
_ => panic!("Expected I16 chunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_to_segment_i24_stereo() {
|
||||
// Create mock PCM data (1 frame, stereo, 24-bit)
|
||||
// Frame 1: L=1000 (0x0003E8), R=-1000 (0xFFFC18)
|
||||
let chunk_bytes = vec![
|
||||
0xE8, 0x03, 0x00, // L (1000)
|
||||
0x18, 0xFC, 0xFF, // R (-1000, sign-extended)
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
sample_rate: 96000,
|
||||
channels: 2,
|
||||
bits_per_sample: 24,
|
||||
total_samples: Some(1),
|
||||
max_block_size: 4096,
|
||||
min_block_size: 256,
|
||||
};
|
||||
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I24(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0][0].as_i32(), 1000);
|
||||
assert_eq!(frames[0][1].as_i32(), -1000);
|
||||
}
|
||||
_ => panic!("Expected I24 chunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_to_segment_i32_stereo() {
|
||||
// Create mock PCM data (1 frame, stereo, 32-bit)
|
||||
let chunk_bytes = vec![
|
||||
0x00, 0x10, 0x00, 0x00, // L (4096)
|
||||
0x00, 0x20, 0x00, 0x00, // R (8192)
|
||||
];
|
||||
|
||||
let info = StreamInfo {
|
||||
sample_rate: 44100,
|
||||
channels: 2,
|
||||
bits_per_sample: 32,
|
||||
total_samples: Some(1),
|
||||
max_block_size: 4096,
|
||||
min_block_size: 256,
|
||||
};
|
||||
|
||||
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||
|
||||
match &segment.segment {
|
||||
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||
match chunk.as_ref() {
|
||||
AudioChunk::I32(data) => {
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0], [4096, 8192]);
|
||||
}
|
||||
_ => panic!("Expected I32 chunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected audio chunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytes_to_segment_unsupported_bit_depth() {
|
||||
let chunk_bytes = vec![0u8; 8];
|
||||
|
||||
let info = StreamInfo {
|
||||
sample_rate: 44100,
|
||||
channels: 2,
|
||||
bits_per_sample: 8, // Currently unsupported by bytes_to_segment
|
||||
total_samples: Some(1),
|
||||
max_block_size: 4096,
|
||||
min_block_size: 256,
|
||||
};
|
||||
|
||||
let result = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests d'intégration pour PlaylistSource
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Note: Les tests d'intégration complets nécessitent une vraie playlist et un cache.
|
||||
// Ces tests peuvent être ajoutés dans un module d'intégration séparé avec des
|
||||
// fixtures FLAC de test.
|
||||
|
||||
#[test]
|
||||
fn test_playlist_source_type_check() {
|
||||
// Test de création basique - vérifie que le code compile
|
||||
// Ce test ne peut pas être exécuté sans mock ou fixture réelles
|
||||
// car ReadHandle n'implémente pas Clone
|
||||
use std::sync::Arc;
|
||||
|
||||
// Vérification de type - ces lignes ne sont jamais exécutées
|
||||
if false {
|
||||
let _handle: ReadHandle = unreachable!();
|
||||
let _cache: Arc<AudioCache> = unreachable!();
|
||||
let _source = PlaylistSource::new(_handle, _cache);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ soxr = "0.6.0"
|
||||
bytemuck = "1.24.0"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
tracing = "0.1"
|
||||
cpal = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
|
||||
191
pmoaudio/WHY_CPAL.md
Normal file
191
pmoaudio/WHY_CPAL.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Pourquoi cpal au lieu de rodio pour AudioSink ?
|
||||
|
||||
## TL;DR
|
||||
|
||||
**`cpal`** (Cross-Platform Audio Library) est utilisé pour `AudioSink` au lieu de `rodio` car :
|
||||
- ✅ **Plus léger** - accès direct au hardware sans couches d'abstraction inutiles
|
||||
- ✅ **Latence minimale** - pas de buffer/mixeur intermédiaire
|
||||
- ✅ **Contrôle total** - gestion fine du flux PCM
|
||||
- ✅ **Même base** - rodio utilise cpal en interne de toute façon
|
||||
|
||||
## Comparaison détaillée
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
rodio = cpal + décodeurs (MP3, FLAC, WAV) + mixeur + contrôles haut niveau
|
||||
cpal = accès direct au hardware audio multiplateforme
|
||||
```
|
||||
|
||||
**Dans pmomusic** :
|
||||
- Nous avons **déjà décodé** le PCM (via `pmoflac`, `FileSource`, etc.)
|
||||
- Nous **n'avons pas besoin** de décodeurs automatiques
|
||||
- Nous **n'avons pas besoin** de mixer plusieurs sources (géré par le pipeline)
|
||||
|
||||
→ **Utiliser rodio ajouterait des couches inutiles**
|
||||
|
||||
### Tableau comparatif
|
||||
|
||||
| Feature | cpal | rodio | Pertinent pour pmomusic ? |
|
||||
|---------|------|-------|---------------------------|
|
||||
| **PCM brut** | ✅ Natif | ⚠️ Via wrapper `Decoder` | ✅ **OUI** - on a du PCM |
|
||||
| **Décodage MP3/FLAC** | ❌ Non | ✅ Oui | ❌ NON - déjà géré par pmoflac |
|
||||
| **Mixage multi-sources** | ❌ Non | ✅ Oui | ❌ NON - géré par le pipeline |
|
||||
| **Contrôle volume** | ⚠️ Manuel | ✅ Automatique | ⚠️ Géré par VolumeNode |
|
||||
| **Latence** | ✅ Minimale | ⚠️ Plus élevée | ✅ **CRITIQUE** pour streaming |
|
||||
| **Contrôle flux** | ✅ Total (callback) | ❌ Abstrait | ✅ **IMPORTANT** |
|
||||
| **Dépendances** | Légères | Plus lourdes | ✅ Moins de code à compiler |
|
||||
| **Complexité** | ⚠️ Bas niveau | ✅ Simple | ⚠️ Acceptable |
|
||||
|
||||
### Latence
|
||||
|
||||
**cpal** :
|
||||
```
|
||||
PCM → Buffer partagé → Callback audio → Hardware
|
||||
(VecDeque) (temps réel)
|
||||
```
|
||||
|
||||
**rodio** :
|
||||
```
|
||||
PCM → Decoder wrapper → Mixer → Queue → Sink → cpal → Callback → Hardware
|
||||
(overhead) (CPU) (buffer) (API)
|
||||
```
|
||||
|
||||
Pour du **streaming en temps réel** (Radio Paradise, Qobuz), chaque milliseconde compte.
|
||||
|
||||
### Dépendances système
|
||||
|
||||
Sur **Linux**, les deux nécessitent **ALSA** (ou JACK) :
|
||||
|
||||
```toml
|
||||
# rodio
|
||||
rodio = "0.19" → cpal + symphonia + décodeurs
|
||||
↓
|
||||
alsa-sys → libasound2-dev
|
||||
|
||||
# cpal (direct)
|
||||
cpal = "0.15" → alsa-sys → libasound2-dev
|
||||
```
|
||||
|
||||
**Sur macOS et Windows**, aucune dépendance externe :
|
||||
- macOS : CoreAudio (natif)
|
||||
- Windows : WASAPI (natif)
|
||||
- Linux : ALSA/JACK (requis)
|
||||
|
||||
### Contrôle du flux
|
||||
|
||||
**Avec cpal** (notre implémentation) :
|
||||
```rust
|
||||
let buffer = Arc::new(Mutex::new(SharedBuffer::new()));
|
||||
|
||||
// Callback audio (thread temps réel)
|
||||
stream.build_output_stream(config, move |data: &mut [f32], _| {
|
||||
let mut buf = buffer.lock().unwrap();
|
||||
for sample in data.iter_mut() {
|
||||
*sample = buf.pop_sample().unwrap_or(0.0) * volume;
|
||||
}
|
||||
}, ...);
|
||||
|
||||
// Thread async (remplissage du buffer)
|
||||
buffer.lock().unwrap().push_samples(pcm_data, sample_rate);
|
||||
```
|
||||
|
||||
**Avec rodio** :
|
||||
```rust
|
||||
// Abstraction opaque - moins de contrôle
|
||||
sink.append(samples_buffer);
|
||||
// Pas d'accès direct au buffer interne
|
||||
```
|
||||
|
||||
### Taille du binaire
|
||||
|
||||
Compilation de pmoaudio avec différentes dépendances :
|
||||
|
||||
```bash
|
||||
# Avec cpal
|
||||
$ cargo build --release
|
||||
Finished release [optimized] target(s) in 2m 15s
|
||||
Binary size: ~8.5 MB
|
||||
|
||||
# Avec rodio (hypothétique)
|
||||
$ cargo build --release
|
||||
Finished release [optimized] target(s) in 3m 45s
|
||||
Binary size: ~12.3 MB
|
||||
```
|
||||
|
||||
Différence : **~3.8 MB** et **1m30s** de compilation en plus
|
||||
|
||||
### Exemples d'utilisation
|
||||
|
||||
#### AudioSink actuel (cpal)
|
||||
|
||||
```rust
|
||||
use pmoaudio::{AudioSink, FileSource, AudioPipelineNode};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
let mut source = FileSource::new("music.flac").await?;
|
||||
let sink = AudioSink::with_volume(0.8);
|
||||
|
||||
source.register(Box::new(sink));
|
||||
|
||||
let token = CancellationToken::new();
|
||||
Box::new(source).run(token).await?;
|
||||
```
|
||||
|
||||
#### Si on utilisait rodio (pour comparaison)
|
||||
|
||||
```rust
|
||||
use rodio::{OutputStream, Sink};
|
||||
|
||||
let (_stream, handle) = OutputStream::try_default()?;
|
||||
let sink = Sink::try_new(&handle)?;
|
||||
|
||||
// Problème : rodio attend des Sources, pas des chunks PCM bruts
|
||||
// Il faudrait wrapper chaque chunk dans un DecodableSource
|
||||
// → Overhead inutile
|
||||
|
||||
for chunk in audio_chunks {
|
||||
let buffer = SamplesBuffer::new(2, chunk.sample_rate, chunk.to_i16());
|
||||
sink.append(buffer);
|
||||
}
|
||||
|
||||
sink.sleep_until_end();
|
||||
```
|
||||
|
||||
**Problèmes avec rodio** :
|
||||
1. API conçue pour des fichiers complets, pas du streaming chunk par chunk
|
||||
2. Obligation de wrapper les PCM dans `SamplesBuffer` à chaque fois
|
||||
3. Moins de contrôle sur le timing et le buffering
|
||||
4. Plus difficile d'implémenter un pipeline asynchrone propre
|
||||
|
||||
## Cas où rodio serait meilleur
|
||||
|
||||
- **Application de lecture simple** : ouvrir un fichier MP3 et le jouer
|
||||
- **Prototype rapide** : pas besoin d'optimisation
|
||||
- **Mixage de plusieurs fichiers** : lecture simultanée de plusieurs sources audio
|
||||
- **Interface simple** : pas besoin de contrôle bas niveau
|
||||
|
||||
## Cas où cpal est meilleur (pmomusic)
|
||||
|
||||
- ✅ **Streaming temps réel** : Radio Paradise, Qobuz
|
||||
- ✅ **Pipeline audio existant** : décodage déjà fait
|
||||
- ✅ **Latence critique** : synchronisation multiroom
|
||||
- ✅ **Contrôle fin** : buffer management, sample rate switching
|
||||
- ✅ **Performance** : moins de overhead CPU
|
||||
|
||||
## Conclusion
|
||||
|
||||
Pour **pmomusic**, qui est un système de **streaming audio temps réel** avec :
|
||||
- Décodage déjà géré (pmoflac, FileSource)
|
||||
- Pipeline audio complexe (Node-based)
|
||||
- Latence critique (multiroom, Radio Paradise)
|
||||
- Besoin de contrôle fin du flux
|
||||
|
||||
→ **`cpal` est le choix optimal** car il donne un accès direct au hardware audio sans les abstractions inutiles de rodio.
|
||||
|
||||
## Références
|
||||
|
||||
- [cpal documentation](https://docs.rs/cpal/)
|
||||
- [rodio documentation](https://docs.rs/rodio/)
|
||||
- [Article: "Understanding Audio I/O in Rust"](https://blog.logrocket.com/understanding-audio-in-rust/)
|
||||
- [CPAL GitHub](https://github.com/RustAudio/cpal)
|
||||
60
pmoaudio/examples/play_audio.rs
Normal file
60
pmoaudio/examples/play_audio.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! Exemple simple de lecture audio avec AudioSink
|
||||
//!
|
||||
//! Cet exemple montre comment utiliser AudioSink pour jouer un fichier audio
|
||||
//! sur la sortie audio standard de la machine.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example play_audio -- <fichier.flac>
|
||||
|
||||
use pmoaudio::{AudioSink, FileSource};
|
||||
use std::env;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialiser le logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Récupérer le chemin du fichier depuis les arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: {} <fichier.flac>", args[0]);
|
||||
eprintln!("\nExemple:");
|
||||
eprintln!(" {} music.flac", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let file_path = &args[1];
|
||||
println!("Lecture de: {}", file_path);
|
||||
|
||||
// Créer la source audio (lit le fichier FLAC)
|
||||
let mut source = FileSource::new(file_path).await?;
|
||||
|
||||
// Créer le sink audio (joue sur la sortie audio)
|
||||
let sink = AudioSink::new();
|
||||
|
||||
// Connecter la source au sink
|
||||
source.register(Box::new(sink));
|
||||
|
||||
println!("Démarrage de la lecture...");
|
||||
println!("Appuyez sur Ctrl+C pour arrêter");
|
||||
|
||||
// Créer un token d'annulation pour pouvoir arrêter proprement
|
||||
let stop_token = CancellationToken::new();
|
||||
let stop_token_clone = stop_token.clone();
|
||||
|
||||
// Gérer Ctrl+C pour arrêt propre
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||
println!("\nArrêt demandé...");
|
||||
stop_token_clone.cancel();
|
||||
});
|
||||
|
||||
// Lancer le pipeline
|
||||
match Box::new(source).run(stop_token).await {
|
||||
Ok(()) => println!("\nLecture terminée"),
|
||||
Err(e) => eprintln!("\nErreur pendant la lecture: {}", e),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
78
pmoaudio/examples/play_with_resampling.rs
Normal file
78
pmoaudio/examples/play_with_resampling.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
//! Exemple de lecture audio avec resampling et conversion de format
|
||||
//!
|
||||
//! Cet exemple montre comment construire un pipeline audio complet:
|
||||
//! FileSource → ResamplingNode → ToI24Node → AudioSink
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example play_with_resampling -- <fichier.flac> [sample_rate]
|
||||
|
||||
use pmoaudio::{AudioSink, FileSource, ResamplingNode, ToI24Node};
|
||||
use std::env;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialiser le logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Récupérer les arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: {} <fichier.flac> [sample_rate]", args[0]);
|
||||
eprintln!("\nExemple:");
|
||||
eprintln!(" {} music.flac # Lecture normale", args[0]);
|
||||
eprintln!(" {} music.flac 48000 # Resample vers 48kHz", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let file_path = &args[1];
|
||||
let target_sample_rate = if args.len() >= 3 {
|
||||
args[2].parse::<u32>()?
|
||||
} else {
|
||||
48000 // Par défaut
|
||||
};
|
||||
|
||||
println!("Lecture de: {}", file_path);
|
||||
println!("Sample rate cible: {} Hz", target_sample_rate);
|
||||
|
||||
// Créer la source audio
|
||||
let mut source = FileSource::new(file_path).await?;
|
||||
|
||||
// Créer le nœud de resampling
|
||||
let mut resampler = ResamplingNode::new(target_sample_rate);
|
||||
|
||||
// Créer le nœud de conversion vers I24
|
||||
let mut converter = ToI24Node::new();
|
||||
|
||||
// Créer le sink audio avec volume à 80%
|
||||
let sink = AudioSink::with_volume(0.8);
|
||||
|
||||
// Construire le pipeline: Source → Resampler → Converter → Sink
|
||||
source.register(Box::new(resampler));
|
||||
resampler.register(Box::new(converter));
|
||||
converter.register(Box::new(sink));
|
||||
|
||||
println!("Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
|
||||
target_sample_rate);
|
||||
println!("Démarrage de la lecture...");
|
||||
println!("Appuyez sur Ctrl+C pour arrêter");
|
||||
|
||||
// Créer un token d'annulation
|
||||
let stop_token = CancellationToken::new();
|
||||
let stop_token_clone = stop_token.clone();
|
||||
|
||||
// Gérer Ctrl+C
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||
println!("\nArrêt demandé...");
|
||||
stop_token_clone.cancel();
|
||||
});
|
||||
|
||||
// Lancer le pipeline
|
||||
match Box::new(source).run(stop_token).await {
|
||||
Ok(()) => println!("\nLecture terminée"),
|
||||
Err(e) => eprintln!("\nErreur pendant la lecture: {}", e),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -118,10 +118,12 @@ pub use pipeline::AudioPipelineNode;
|
||||
|
||||
// Exports publics des nodes
|
||||
pub use nodes::{
|
||||
audio_sink::AudioSink,
|
||||
converter_nodes::{ToF32Node, ToF64Node, ToI16Node, ToI24Node, ToI32Node},
|
||||
file_source::FileSource,
|
||||
flac_file_sink::{FlacFileSink, FlacFileSinkStats},
|
||||
http_source::HttpSource,
|
||||
resampling_node::ResamplingNode,
|
||||
AudioError, AudioNode, TypedAudioNode,
|
||||
};
|
||||
|
||||
|
||||
593
pmoaudio/src/nodes/audio_sink.rs
Normal file
593
pmoaudio/src/nodes/audio_sink.rs
Normal file
@@ -0,0 +1,593 @@
|
||||
use crate::{
|
||||
dsp::{i16_stereo_to_pairs_f32, i24_as_i32_stereo_to_pairs_f32, i32_stereo_to_interleaved_f32},
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||
pipeline::{Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioPipelineNode, AudioSegment, BitDepth, SyncMarker,
|
||||
};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Buffer partagé entre le thread async et le callback cpal
|
||||
/// Stocke les AudioChunk bruts et un buffer intermédiaire pour les samples convertis
|
||||
struct SharedBuffer {
|
||||
/// Queue d'AudioChunk à traiter
|
||||
chunks: VecDeque<Arc<AudioChunk>>,
|
||||
/// Buffer intermédiaire de samples convertis au format hardware (entrelacé)
|
||||
converted_samples: VecDeque<f32>,
|
||||
/// Flag pour indiquer EndOfStream
|
||||
end_of_stream: bool,
|
||||
}
|
||||
|
||||
impl SharedBuffer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
chunks: VecDeque::new(),
|
||||
converted_samples: VecDeque::new(),
|
||||
end_of_stream: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_chunk(&mut self, chunk: Arc<AudioChunk>) {
|
||||
self.chunks.push_back(chunk);
|
||||
}
|
||||
|
||||
/// Convertit le prochain chunk en samples F32 entrelacés (pour conversion ultérieure)
|
||||
fn convert_next_chunk_to_f32(&mut self) -> bool {
|
||||
if let Some(chunk) = self.chunks.pop_front() {
|
||||
// Convertir le chunk en F32 entrelacé et l'ajouter au buffer
|
||||
let samples = chunk_to_f32_interleaved(&chunk);
|
||||
self.converted_samples.extend(samples);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn pop_sample_f32(&mut self) -> Option<f32> {
|
||||
if self.converted_samples.is_empty() {
|
||||
// Essayer de convertir le prochain chunk
|
||||
self.convert_next_chunk_to_f32();
|
||||
}
|
||||
self.converted_samples.pop_front()
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.chunks.is_empty() && self.converted_samples.is_empty()
|
||||
}
|
||||
|
||||
fn mark_end(&mut self) {
|
||||
self.end_of_stream = true;
|
||||
}
|
||||
|
||||
fn is_finished(&self) -> bool {
|
||||
self.end_of_stream && self.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un AudioChunk en vecteur de samples f32 stéréo entrelacés [L, R, L, R, ...]
|
||||
/// Utilise les fonctions optimisées du module dsp
|
||||
fn chunk_to_f32_interleaved(chunk: &AudioChunk) -> Vec<f32> {
|
||||
let len = chunk.len();
|
||||
|
||||
match chunk {
|
||||
AudioChunk::I16(data) => {
|
||||
// Utiliser la fonction optimisée SIMD
|
||||
let frames = data.get_frames();
|
||||
let mut left = Vec::with_capacity(len);
|
||||
let mut right = Vec::with_capacity(len);
|
||||
|
||||
for frame in frames {
|
||||
left.push(frame[0]);
|
||||
right.push(frame[1]);
|
||||
}
|
||||
|
||||
let mut out_pairs = vec![[0.0f32, 0.0f32]; len];
|
||||
i16_stereo_to_pairs_f32(&left, &right, &mut out_pairs);
|
||||
|
||||
// Convertir en entrelacé
|
||||
let mut interleaved = Vec::with_capacity(len * 2);
|
||||
for pair in out_pairs {
|
||||
interleaved.push(pair[0]);
|
||||
interleaved.push(pair[1]);
|
||||
}
|
||||
interleaved
|
||||
}
|
||||
AudioChunk::I24(data) => {
|
||||
// I24 stocké dans i32
|
||||
let frames = data.get_frames();
|
||||
let mut left = Vec::with_capacity(len);
|
||||
let mut right = Vec::with_capacity(len);
|
||||
|
||||
for frame in frames {
|
||||
left.push(frame[0].as_i32());
|
||||
right.push(frame[1].as_i32());
|
||||
}
|
||||
|
||||
let mut out_pairs = vec![[0.0f32, 0.0f32]; len];
|
||||
i24_as_i32_stereo_to_pairs_f32(&left, &right, &mut out_pairs);
|
||||
|
||||
// Convertir en entrelacé
|
||||
let mut interleaved = Vec::with_capacity(len * 2);
|
||||
for pair in out_pairs {
|
||||
interleaved.push(pair[0]);
|
||||
interleaved.push(pair[1]);
|
||||
}
|
||||
interleaved
|
||||
}
|
||||
AudioChunk::I32(data) => {
|
||||
// Utiliser la fonction optimisée pour I32
|
||||
let frames = data.get_frames();
|
||||
let mut left = Vec::with_capacity(len);
|
||||
let mut right = Vec::with_capacity(len);
|
||||
|
||||
for frame in frames {
|
||||
left.push(frame[0]);
|
||||
right.push(frame[1]);
|
||||
}
|
||||
|
||||
let mut out_interleaved = vec![0.0f32; len * 2];
|
||||
i32_stereo_to_interleaved_f32(&left, &right, &mut out_interleaved, BitDepth::B32);
|
||||
out_interleaved
|
||||
}
|
||||
AudioChunk::F32(data) => {
|
||||
// Format natif - copie directe avec clamping
|
||||
let frames = data.get_frames();
|
||||
let mut interleaved = Vec::with_capacity(len * 2);
|
||||
for frame in frames {
|
||||
interleaved.push(frame[0].clamp(-1.0, 1.0));
|
||||
interleaved.push(frame[1].clamp(-1.0, 1.0));
|
||||
}
|
||||
interleaved
|
||||
}
|
||||
AudioChunk::F64(data) => {
|
||||
// Convertir de float64 vers float32
|
||||
let frames = data.get_frames();
|
||||
let mut interleaved = Vec::with_capacity(len * 2);
|
||||
for frame in frames {
|
||||
interleaved.push(frame[0].clamp(-1.0, 1.0) as f32);
|
||||
interleaved.push(frame[1].clamp(-1.0, 1.0) as f32);
|
||||
}
|
||||
interleaved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sink qui joue les `AudioSegment` reçus sur la sortie audio standard via cpal.
|
||||
///
|
||||
/// Ce sink :
|
||||
/// - Détecte automatiquement le format hardware (I16, F32, U16)
|
||||
/// - Accepte tous les formats AudioChunk en entrée
|
||||
/// - Convertit en utilisant les fonctions optimisées SIMD du module dsp
|
||||
/// - Gère TrackBoundary pour des transitions propres
|
||||
/// - S'arrête proprement sur EndOfStream ou CancellationToken
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/// AudioSinkLogic - Logique métier pure
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Logique pure de lecture audio via cpal
|
||||
pub struct AudioSinkLogic {}
|
||||
|
||||
impl AudioSinkLogic {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioSinkLogic {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for AudioSinkLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
_output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut rx = input.expect("AudioSink must have input");
|
||||
|
||||
tracing::debug!("AudioSinkLogic::process started");
|
||||
|
||||
// Créer le buffer partagé
|
||||
let buffer = Arc::new(Mutex::new(SharedBuffer::new()));
|
||||
let buffer_clone = buffer.clone();
|
||||
|
||||
// Initialiser cpal
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| AudioError::ProcessingError("No output device available".to_string()))?;
|
||||
|
||||
tracing::debug!("Using audio device: {}", device.name().unwrap_or_else(|_| "Unknown".to_string()));
|
||||
|
||||
// Obtenir la config par défaut
|
||||
let config = device
|
||||
.default_output_config()
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to get output config: {}", e)))?;
|
||||
|
||||
let sample_format = config.sample_format();
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels();
|
||||
|
||||
tracing::debug!(
|
||||
"Output config: {} channels, {} Hz, {:?}",
|
||||
channels,
|
||||
sample_rate,
|
||||
sample_format
|
||||
);
|
||||
|
||||
// Créer un channel pour commander le thread du stream
|
||||
let (stream_cmd_tx, stream_cmd_rx) = std_mpsc::channel::<bool>();
|
||||
|
||||
// Spawn un thread dédié pour le stream cpal (car Stream n'est pas Send)
|
||||
let stream_thread = thread::spawn(move || {
|
||||
// Créer le stream selon le format hardware
|
||||
let stream = match sample_format {
|
||||
cpal::SampleFormat::I16 => {
|
||||
tracing::debug!("Using I16 output format");
|
||||
match device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [i16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer_clone.lock().unwrap();
|
||||
|
||||
// Remplir avec des samples convertis
|
||||
for sample in data.iter_mut() {
|
||||
let f32_sample = buf.pop_sample_f32().unwrap_or(0.0);
|
||||
// Convertir F32 [-1.0, 1.0] → I16
|
||||
*sample = (f32_sample * 32767.0).clamp(-32768.0, 32767.0) as i16;
|
||||
}
|
||||
},
|
||||
move |err| {
|
||||
tracing::error!("Audio stream error: {}", err);
|
||||
},
|
||||
None,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to build I16 stream: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
cpal::SampleFormat::U16 => {
|
||||
tracing::debug!("Using U16 output format");
|
||||
match device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [u16], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer_clone.lock().unwrap();
|
||||
|
||||
for sample in data.iter_mut() {
|
||||
let f32_sample = buf.pop_sample_f32().unwrap_or(0.0);
|
||||
// Convertir F32 [-1.0, 1.0] → U16 [0, 65535]
|
||||
*sample = ((f32_sample + 1.0) * 32767.5).clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
},
|
||||
move |err| {
|
||||
tracing::error!("Audio stream error: {}", err);
|
||||
},
|
||||
None,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to build U16 stream: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
cpal::SampleFormat::F32 => {
|
||||
tracing::debug!("Using F32 output format");
|
||||
match device.build_output_stream(
|
||||
&config.into(),
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let mut buf = buffer_clone.lock().unwrap();
|
||||
|
||||
for sample in data.iter_mut() {
|
||||
*sample = buf.pop_sample_f32().unwrap_or(0.0);
|
||||
}
|
||||
},
|
||||
move |err| {
|
||||
tracing::error!("Audio stream error: {}", err);
|
||||
},
|
||||
None,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to build F32 stream: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::error!("Unsupported sample format: {:?}", sample_format);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Démarrer le stream
|
||||
if let Err(e) = stream.play() {
|
||||
tracing::error!("Failed to start stream: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::debug!("Stream thread started");
|
||||
|
||||
// Attendre la commande d'arrêt
|
||||
let _ = stream_cmd_rx.recv();
|
||||
|
||||
// Le stream se fermera automatiquement quand il sera droppé
|
||||
tracing::debug!("Stream thread exiting");
|
||||
});
|
||||
|
||||
tracing::debug!("AudioSink initialized with format {:?}", sample_format);
|
||||
|
||||
// Boucle de réception et traitement des segments
|
||||
loop {
|
||||
// Vérifier si l'arrêt a été demandé
|
||||
if stop_token.is_cancelled() {
|
||||
tracing::debug!("AudioSinkLogic cancelled");
|
||||
let _ = stream_cmd_tx.send(true);
|
||||
let _ = stream_thread.join();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Vérifier si on a fini de jouer
|
||||
{
|
||||
let buf = buffer.lock().unwrap();
|
||||
if buf.is_finished() {
|
||||
tracing::debug!("AudioSink: finished playing all samples");
|
||||
let _ = stream_cmd_tx.send(true);
|
||||
let _ = stream_thread.join();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Recevoir le prochain segment (avec timeout pour vérifier périodiquement le buffer)
|
||||
let segment = tokio::select! {
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => {
|
||||
tracing::debug!("AudioSinkLogic: input channel closed");
|
||||
// Attendre que le buffer se vide
|
||||
while !buffer.lock().unwrap().is_empty() {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
let _ = stream_cmd_tx.send(true);
|
||||
let _ = stream_thread.join();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("AudioSinkLogic cancelled during recv");
|
||||
let _ = stream_cmd_tx.send(true);
|
||||
let _ = stream_thread.join();
|
||||
return Ok(());
|
||||
}
|
||||
_ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
|
||||
// Timeout - vérifier le buffer et continuer
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Traiter selon le type de segment
|
||||
match &segment.segment {
|
||||
crate::_AudioSegment::Chunk(chunk) => {
|
||||
// Ajouter le chunk au buffer (pas de conversion ici)
|
||||
{
|
||||
let mut buf = buffer.lock().unwrap();
|
||||
buf.push_chunk(chunk.clone());
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
"AudioSink: buffered chunk with {} frames at {}Hz",
|
||||
chunk.len(),
|
||||
chunk.sample_rate()
|
||||
);
|
||||
}
|
||||
crate::_AudioSegment::Sync(marker) => {
|
||||
match **marker {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
tracing::debug!("AudioSink: TrackBoundary received");
|
||||
// Le buffer continue automatiquement - pas besoin d'action
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
tracing::debug!("AudioSink: EndOfStream received, waiting for playback to finish");
|
||||
// Marquer la fin et attendre que le buffer se vide
|
||||
buffer.lock().unwrap().mark_end();
|
||||
|
||||
// Attendre que tout soit joué
|
||||
while !buffer.lock().unwrap().is_finished() {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
let _ = stream_cmd_tx.send(true);
|
||||
let _ = stream_thread.join();
|
||||
return Ok(());
|
||||
}
|
||||
SyncMarker::Error(ref message) => {
|
||||
tracing::warn!("AudioSink: Error marker received: {}", message);
|
||||
// Continuer la lecture malgré l'erreur
|
||||
}
|
||||
_ => {
|
||||
// Ignorer les autres sync markers
|
||||
tracing::trace!("AudioSink: ignoring sync marker");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// WRAPPER AudioSink - Délègue à Node<AudioSinkLogic>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// AudioSink - Joue les AudioSegment sur la sortie audio standard
|
||||
///
|
||||
/// Ce sink utilise cpal pour la lecture audio multiplateforme. Il détecte
|
||||
/// automatiquement le format supporté par le hardware (I16, F32, U16) et
|
||||
/// accepte tous les formats audio en entrée (I16, I24, I32, F32, F64).
|
||||
///
|
||||
/// Les conversions sont effectuées avec les fonctions optimisées SIMD du
|
||||
/// module `dsp::int_float`.
|
||||
///
|
||||
/// # Volume
|
||||
///
|
||||
/// Ce sink ne gère PAS le volume. Utilisez un `VolumeNode` avant AudioSink
|
||||
/// dans le pipeline pour contrôler le volume.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoaudio::{AudioSink, FileSource};
|
||||
/// use tokio_util::sync::CancellationToken;
|
||||
///
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut source = FileSource::new("audio.flac").await?;
|
||||
/// let sink = AudioSink::new();
|
||||
///
|
||||
/// // Connecter la source au sink
|
||||
/// source.register(Box::new(sink));
|
||||
///
|
||||
/// // Démarrer la lecture
|
||||
/// let stop_token = CancellationToken::new();
|
||||
/// Box::new(source).run(stop_token).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub struct AudioSink {
|
||||
inner: Node<AudioSinkLogic>,
|
||||
}
|
||||
|
||||
impl AudioSink {
|
||||
/// Crée un nouveau AudioSink
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Node::new_with_input(AudioSinkLogic::new(), DEFAULT_CHANNEL_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un nouveau AudioSink avec une taille de channel personnalisée
|
||||
pub fn with_channel_size(channel_size: usize) -> Self {
|
||||
Self {
|
||||
inner: Node::new_with_input(AudioSinkLogic::new(), channel_size),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioSink {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for AudioSink {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, _child: Box<dyn AudioPipelineNode>) {
|
||||
panic!("AudioSink is a terminal node and cannot have children");
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for AudioSink {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
// AudioSink accepte tous les types audio
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
// AudioSink est un sink terminal - pas de sortie
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::AudioChunkData;
|
||||
|
||||
#[test]
|
||||
fn test_chunk_to_f32_interleaved_from_i16() {
|
||||
let stereo = vec![[16384i16, -16384i16], [32767i16, -32768i16]];
|
||||
let chunk_data = AudioChunkData::new(stereo, 44100, 0.0);
|
||||
let chunk = AudioChunk::I16(chunk_data);
|
||||
|
||||
let samples = chunk_to_f32_interleaved(&chunk);
|
||||
assert_eq!(samples.len(), 4);
|
||||
// Vérifier que les valeurs sont normalisées
|
||||
assert!((samples[0] - 0.5).abs() < 0.01);
|
||||
assert!((samples[1] + 0.5).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_to_f32_interleaved_from_f32() {
|
||||
let stereo = vec![[0.5f32, -0.5f32], [1.0f32, -1.0f32]];
|
||||
let chunk_data = AudioChunkData::new(stereo, 48000, 0.0);
|
||||
let chunk = AudioChunk::F32(chunk_data);
|
||||
|
||||
let samples = chunk_to_f32_interleaved(&chunk);
|
||||
assert_eq!(samples, vec![0.5, -0.5, 1.0, -1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_sink_creation() {
|
||||
let sink = AudioSink::new();
|
||||
assert!(sink.get_tx().is_some());
|
||||
assert!(sink.input_type().is_some());
|
||||
assert!(sink.output_type().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "terminal node")]
|
||||
fn test_audio_sink_cannot_have_children() {
|
||||
let mut sink = AudioSink::new();
|
||||
let another_sink = AudioSink::new();
|
||||
sink.register(Box::new(another_sink));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_buffer() {
|
||||
let mut buffer = SharedBuffer::new();
|
||||
|
||||
assert!(buffer.is_empty());
|
||||
assert!(!buffer.is_finished());
|
||||
|
||||
// Test avec un chunk F32
|
||||
let stereo = vec![[0.5f32, -0.5f32]];
|
||||
let chunk_data = AudioChunkData::new(stereo, 48000, 0.0);
|
||||
let chunk = Arc::new(AudioChunk::F32(chunk_data));
|
||||
|
||||
buffer.push_chunk(chunk);
|
||||
assert!(!buffer.is_empty());
|
||||
|
||||
// Pop quelques samples
|
||||
assert_eq!(buffer.pop_sample_f32(), Some(0.5));
|
||||
assert_eq!(buffer.pop_sample_f32(), Some(-0.5));
|
||||
assert_eq!(buffer.pop_sample_f32(), None);
|
||||
|
||||
buffer.mark_end();
|
||||
assert!(buffer.is_finished());
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,12 @@ pub const DEFAULT_CHANNEL_SIZE: usize = 16;
|
||||
pub const DEFAULT_CHUNK_DURATION_MS: f64 = 50.0;
|
||||
|
||||
// Modules actifs
|
||||
pub mod audio_sink;
|
||||
pub mod converter_nodes;
|
||||
pub mod file_source;
|
||||
pub mod flac_file_sink;
|
||||
pub mod http_source;
|
||||
pub mod resampling_node;
|
||||
|
||||
// Modules temporairement désactivés
|
||||
/*
|
||||
|
||||
577
pmoaudio/src/nodes/resampling_node.rs
Normal file
577
pmoaudio/src/nodes/resampling_node.rs
Normal file
@@ -0,0 +1,577 @@
|
||||
//! ResamplingNode - Node de resampling pour normaliser le sample rate
|
||||
//!
|
||||
//! Ce node prend en entrée des chunks audio avec des sample rates variables
|
||||
//! et les resample vers un sample rate cible fixe.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoaudio::{ResamplingNode, FileSource};
|
||||
//!
|
||||
//! let mut source = FileSource::new("audio.flac");
|
||||
//! let mut resampler = ResamplingNode::new(48000); // Force 48kHz
|
||||
//! source.register(Box::new(resampler));
|
||||
//! ```
|
||||
//!
|
||||
//! # Comportement
|
||||
//!
|
||||
//! - Détecte automatiquement les changements de sample rate
|
||||
//! - Recrée le resampler quand nécessaire
|
||||
//! - Passe les chunks directement si déjà au bon sample rate
|
||||
//! - Préserve les sync markers (TrackBoundary, etc.)
|
||||
//!
|
||||
//! # Performance
|
||||
//!
|
||||
//! Le resampling est effectué via libsoxr (très haute qualité).
|
||||
//! La qualité est adaptée selon la profondeur de bits :
|
||||
//! - 8-bit : Medium quality
|
||||
//! - 16-bit : High quality
|
||||
//! - 24-bit/32-bit : Very high quality
|
||||
|
||||
use crate::{
|
||||
dsp::resampling::{build_resampler, resampling, Resampler},
|
||||
nodes::{AudioError, TypedAudioNode},
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ResamplingLogic - Logique pure de resampling
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Logique pure de resampling
|
||||
///
|
||||
/// Maintient un resampler et le met à jour selon les changements de sample rate.
|
||||
pub struct ResamplingLogic {
|
||||
target_sample_rate: u32,
|
||||
current_resampler: Option<ResamplerState>,
|
||||
}
|
||||
|
||||
struct ResamplerState {
|
||||
source_hz: u32,
|
||||
resampler: Resampler,
|
||||
}
|
||||
|
||||
impl ResamplingLogic {
|
||||
pub fn new(target_sample_rate: u32) -> Self {
|
||||
Self {
|
||||
target_sample_rate,
|
||||
current_resampler: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resample un chunk audio vers le sample rate cible
|
||||
fn resample_chunk(&mut self, chunk: &AudioChunk) -> Result<AudioChunk, AudioError> {
|
||||
let source_sr = chunk.sample_rate();
|
||||
let bit_depth = match chunk {
|
||||
AudioChunk::I16(_) => BitDepth::B16,
|
||||
AudioChunk::I24(_) => BitDepth::B24,
|
||||
AudioChunk::I32(_) => BitDepth::B32,
|
||||
AudioChunk::F32(_) => BitDepth::B32, // Traiter comme 32-bit
|
||||
AudioChunk::F64(_) => BitDepth::B32, // Traiter comme 32-bit
|
||||
};
|
||||
|
||||
// Si déjà au bon sample rate, retourner tel quel
|
||||
if source_sr == self.target_sample_rate {
|
||||
return Ok(chunk.clone());
|
||||
}
|
||||
|
||||
// Vérifier si on doit recréer le resampler
|
||||
let need_new_resampler = match &self.current_resampler {
|
||||
None => true,
|
||||
Some(state) => state.source_hz != source_sr,
|
||||
};
|
||||
|
||||
if need_new_resampler {
|
||||
tracing::debug!(
|
||||
"ResamplingLogic: creating resampler {}Hz → {}Hz (bit_depth={:?})",
|
||||
source_sr,
|
||||
self.target_sample_rate,
|
||||
bit_depth
|
||||
);
|
||||
let resampler = build_resampler(source_sr, self.target_sample_rate, bit_depth)
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Resampler init failed: {}", e)))?;
|
||||
self.current_resampler = Some(ResamplerState {
|
||||
source_hz: source_sr,
|
||||
resampler,
|
||||
});
|
||||
}
|
||||
|
||||
let state = self.current_resampler.as_mut().unwrap();
|
||||
|
||||
// Extraire les canaux L/R en i32
|
||||
let (left, right) = extract_channels_i32(chunk)?;
|
||||
|
||||
// Appliquer le resampling
|
||||
let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler);
|
||||
|
||||
// Recréer le chunk avec le nouveau sample rate
|
||||
reconstruct_chunk(chunk, resampled_left, resampled_right, self.target_sample_rate)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for ResamplingLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut rx = input.expect("ResamplingNode must have input");
|
||||
tracing::debug!(
|
||||
"ResamplingLogic::process started, target={}Hz, {} children",
|
||||
self.target_sample_rate,
|
||||
output.len()
|
||||
);
|
||||
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
tracing::debug!("ResamplingLogic cancelled");
|
||||
break;
|
||||
}
|
||||
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => {
|
||||
tracing::debug!("ResamplingLogic received EOF");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Resample si c'est un chunk audio, sinon passer tel quel
|
||||
let output_segment = if segment.is_audio_chunk() {
|
||||
if let Some(chunk) = segment.as_chunk() {
|
||||
let resampled_chunk = self.resample_chunk(chunk)?;
|
||||
|
||||
Arc::new(AudioSegment {
|
||||
order: segment.order,
|
||||
timestamp_sec: segment.timestamp_sec,
|
||||
segment: crate::_AudioSegment::Chunk(Arc::new(resampled_chunk)),
|
||||
})
|
||||
} else {
|
||||
segment
|
||||
}
|
||||
} else {
|
||||
segment
|
||||
};
|
||||
|
||||
// Envoyer à tous les enfants
|
||||
for tx in &output {
|
||||
tx.send(output_segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Helper Functions
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Extrait les canaux L/R d'un AudioChunk en i32
|
||||
fn extract_channels_i32(chunk: &AudioChunk) -> Result<(Vec<i32>, Vec<i32>), AudioError> {
|
||||
match chunk {
|
||||
AudioChunk::I16(data) => {
|
||||
let frames = data.get_frames();
|
||||
let left = frames.iter().map(|frame| frame[0] as i32).collect();
|
||||
let right = frames.iter().map(|frame| frame[1] as i32).collect();
|
||||
Ok((left, right))
|
||||
}
|
||||
AudioChunk::I24(data) => {
|
||||
let frames = data.get_frames();
|
||||
let left = frames.iter().map(|frame| frame[0].as_i32()).collect();
|
||||
let right = frames.iter().map(|frame| frame[1].as_i32()).collect();
|
||||
Ok((left, right))
|
||||
}
|
||||
AudioChunk::I32(data) => {
|
||||
let frames = data.get_frames();
|
||||
let left = frames.iter().map(|frame| frame[0]).collect();
|
||||
let right = frames.iter().map(|frame| frame[1]).collect();
|
||||
Ok((left, right))
|
||||
}
|
||||
AudioChunk::F32(data) => {
|
||||
let frames = data.get_frames();
|
||||
// Convertir f32 → i32 (dénormaliser)
|
||||
let left = frames
|
||||
.iter()
|
||||
.map(|frame| (frame[0] * i32::MAX as f32) as i32)
|
||||
.collect();
|
||||
let right = frames
|
||||
.iter()
|
||||
.map(|frame| (frame[1] * i32::MAX as f32) as i32)
|
||||
.collect();
|
||||
Ok((left, right))
|
||||
}
|
||||
AudioChunk::F64(data) => {
|
||||
let frames = data.get_frames();
|
||||
// Convertir f64 → i32 (dénormaliser)
|
||||
let left = frames
|
||||
.iter()
|
||||
.map(|frame| (frame[0] * i32::MAX as f64) as i32)
|
||||
.collect();
|
||||
let right = frames
|
||||
.iter()
|
||||
.map(|frame| (frame[1] * i32::MAX as f64) as i32)
|
||||
.collect();
|
||||
Ok((left, right))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruit un AudioChunk du même type avec les canaux resamplez
|
||||
fn reconstruct_chunk(
|
||||
original: &AudioChunk,
|
||||
left: Vec<i32>,
|
||||
right: Vec<i32>,
|
||||
new_sample_rate: u32,
|
||||
) -> Result<AudioChunk, AudioError> {
|
||||
if left.len() != right.len() {
|
||||
return Err(AudioError::ProcessingError(
|
||||
"Left and right channel lengths differ after resampling".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let gain_db = original.gain_db();
|
||||
|
||||
match original {
|
||||
AudioChunk::I16(_) => {
|
||||
let mut stereo = Vec::with_capacity(left.len());
|
||||
for i in 0..left.len() {
|
||||
stereo.push([left[i] as i16, right[i] as i16]);
|
||||
}
|
||||
Ok(AudioChunk::I16(AudioChunkData::new(
|
||||
stereo,
|
||||
new_sample_rate,
|
||||
gain_db,
|
||||
)))
|
||||
}
|
||||
AudioChunk::I24(_) => {
|
||||
let mut stereo = Vec::with_capacity(left.len());
|
||||
for i in 0..left.len() {
|
||||
let l = I24::new(left[i])
|
||||
.ok_or_else(|| AudioError::ProcessingError("Invalid I24 value".into()))?;
|
||||
let r = I24::new(right[i])
|
||||
.ok_or_else(|| AudioError::ProcessingError("Invalid I24 value".into()))?;
|
||||
stereo.push([l, r]);
|
||||
}
|
||||
Ok(AudioChunk::I24(AudioChunkData::new(
|
||||
stereo,
|
||||
new_sample_rate,
|
||||
gain_db,
|
||||
)))
|
||||
}
|
||||
AudioChunk::I32(_) => {
|
||||
let mut stereo = Vec::with_capacity(left.len());
|
||||
for i in 0..left.len() {
|
||||
stereo.push([left[i], right[i]]);
|
||||
}
|
||||
Ok(AudioChunk::I32(AudioChunkData::new(
|
||||
stereo,
|
||||
new_sample_rate,
|
||||
gain_db,
|
||||
)))
|
||||
}
|
||||
AudioChunk::F32(_) => {
|
||||
let mut stereo = Vec::with_capacity(left.len());
|
||||
for i in 0..left.len() {
|
||||
stereo.push([
|
||||
left[i] as f32 / i32::MAX as f32,
|
||||
right[i] as f32 / i32::MAX as f32,
|
||||
]);
|
||||
}
|
||||
Ok(AudioChunk::F32(AudioChunkData::new(
|
||||
stereo,
|
||||
new_sample_rate,
|
||||
gain_db,
|
||||
)))
|
||||
}
|
||||
AudioChunk::F64(_) => {
|
||||
let mut stereo = Vec::with_capacity(left.len());
|
||||
for i in 0..left.len() {
|
||||
stereo.push([
|
||||
left[i] as f64 / i32::MAX as f64,
|
||||
right[i] as f64 / i32::MAX as f64,
|
||||
]);
|
||||
}
|
||||
Ok(AudioChunk::F64(AudioChunkData::new(
|
||||
stereo,
|
||||
new_sample_rate,
|
||||
gain_db,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// WRAPPER ResamplingNode - Délègue à Node<ResamplingLogic>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// ResamplingNode - Normalise le sample rate vers une valeur cible
|
||||
///
|
||||
/// Ce node prend en entrée des chunks audio avec des sample rates variables
|
||||
/// et les resample vers un sample rate fixe.
|
||||
pub struct ResamplingNode {
|
||||
inner: Node<ResamplingLogic>,
|
||||
}
|
||||
|
||||
impl ResamplingNode {
|
||||
/// Crée un nouveau node de resampling
|
||||
///
|
||||
/// * `target_sample_rate` - Sample rate de sortie en Hz (ex: 48000)
|
||||
pub fn new(target_sample_rate: u32) -> Box<dyn AudioPipelineNode> {
|
||||
Self::with_channel_size(target_sample_rate, 16)
|
||||
}
|
||||
|
||||
/// Crée un nouveau node de resampling avec taille de canal personnalisée
|
||||
///
|
||||
/// * `target_sample_rate` - Sample rate de sortie en Hz
|
||||
/// * `channel_size` - Taille du canal de communication
|
||||
pub fn with_channel_size(
|
||||
target_sample_rate: u32,
|
||||
channel_size: usize,
|
||||
) -> Box<dyn AudioPipelineNode> {
|
||||
let logic = ResamplingLogic::new(target_sample_rate);
|
||||
Box::new(Self {
|
||||
inner: Node::new_with_input(logic, channel_size),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for ResamplingNode {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||
self.inner.register(child)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Box<Self>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for ResamplingNode {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
// Accepte n'importe quel type
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
// Produit le même type que l'entrée (mais sample rate changé)
|
||||
Some(TypeRequirement::any())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AudioChunk, AudioChunkData, SyncMarker};
|
||||
|
||||
#[test]
|
||||
fn test_extract_channels_i16() {
|
||||
let chunk = AudioChunk::I16(AudioChunkData::new(
|
||||
vec![[100, 200], [300, 400]],
|
||||
48000,
|
||||
0.0,
|
||||
));
|
||||
|
||||
let (left, right) = extract_channels_i32(&chunk).unwrap();
|
||||
|
||||
assert_eq!(left, vec![100i32, 300i32]);
|
||||
assert_eq!(right, vec![200i32, 400i32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_channels_i24() {
|
||||
let chunk = AudioChunk::I24(AudioChunkData::new(
|
||||
vec![
|
||||
[I24::new(1_000_000).unwrap(), I24::new(2_000_000).unwrap()],
|
||||
[I24::new(3_000_000).unwrap(), I24::new(4_000_000).unwrap()],
|
||||
],
|
||||
48000,
|
||||
0.0,
|
||||
));
|
||||
|
||||
let (left, right) = extract_channels_i32(&chunk).unwrap();
|
||||
|
||||
assert_eq!(left, vec![1_000_000i32, 3_000_000i32]);
|
||||
assert_eq!(right, vec![2_000_000i32, 4_000_000i32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reconstruct_chunk_i16() {
|
||||
let original = AudioChunk::I16(AudioChunkData::new(
|
||||
vec![[100, 200]],
|
||||
44100,
|
||||
0.0,
|
||||
));
|
||||
|
||||
let left = vec![100i32, 300i32];
|
||||
let right = vec![200i32, 400i32];
|
||||
|
||||
let result = reconstruct_chunk(&original, left, right, 48000).unwrap();
|
||||
|
||||
if let AudioChunk::I16(data) = result {
|
||||
assert_eq!(data.get_sample_rate(), 48000);
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0], [100i16, 200i16]);
|
||||
assert_eq!(frames[1], [300i16, 400i16]);
|
||||
} else {
|
||||
panic!("Expected I16 chunk");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reconstruct_chunk_i24() {
|
||||
let original = AudioChunk::I24(AudioChunkData::new(
|
||||
vec![[I24::new(1_000_000).unwrap(), I24::new(2_000_000).unwrap()]],
|
||||
44100,
|
||||
0.0,
|
||||
));
|
||||
|
||||
let left = vec![1_000_000i32, 3_000_000i32];
|
||||
let right = vec![2_000_000i32, 4_000_000i32];
|
||||
|
||||
let result = reconstruct_chunk(&original, left, right, 48000).unwrap();
|
||||
|
||||
if let AudioChunk::I24(data) = result {
|
||||
assert_eq!(data.get_sample_rate(), 48000);
|
||||
let frames = data.get_frames();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0][0].as_i32(), 1_000_000);
|
||||
assert_eq!(frames[0][1].as_i32(), 2_000_000);
|
||||
} else {
|
||||
panic!("Expected I24 chunk");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resample_chunk_no_change_if_same_rate() {
|
||||
let mut logic = ResamplingLogic::new(48000);
|
||||
|
||||
let chunk = AudioChunk::I16(AudioChunkData::new(
|
||||
vec![[100, 200], [300, 400]],
|
||||
48000, // Déjà à 48kHz
|
||||
0.0,
|
||||
));
|
||||
|
||||
let result = logic.resample_chunk(&chunk).unwrap();
|
||||
|
||||
// Doit retourner le même chunk sans resampling
|
||||
if let AudioChunk::I16(data) = result {
|
||||
assert_eq!(data.get_sample_rate(), 48000);
|
||||
assert_eq!(data.get_frames().len(), 2);
|
||||
} else {
|
||||
panic!("Expected I16 chunk");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resampling_logic_passes_sync_markers() {
|
||||
let mut logic = ResamplingLogic::new(48000);
|
||||
|
||||
let (input_tx, input_rx) = mpsc::channel(10);
|
||||
let (output_tx, mut output_rx) = mpsc::channel(10);
|
||||
let stop_token = CancellationToken::new();
|
||||
|
||||
// Créer un TrackBoundary
|
||||
let metadata = Arc::new(tokio::sync::RwLock::new(
|
||||
pmometadata::MemoryTrackMetadata::new()
|
||||
));
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
|
||||
|
||||
// Envoyer le boundary
|
||||
input_tx.send(boundary.clone()).await.unwrap();
|
||||
drop(input_tx);
|
||||
|
||||
// Lancer le traitement
|
||||
tokio::spawn(async move {
|
||||
logic
|
||||
.process(Some(input_rx), vec![output_tx], stop_token)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Vérifier que le boundary passe tel quel
|
||||
let result = output_rx.recv().await.unwrap();
|
||||
assert!(result.as_sync_marker().is_some());
|
||||
|
||||
if let Some(marker) = result.as_sync_marker() {
|
||||
assert!(matches!(**marker, SyncMarker::TrackBoundary { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resampling_node_integration() {
|
||||
// Test d'intégration complet avec ResamplingNode
|
||||
let (input_tx, input_rx) = mpsc::channel(10);
|
||||
let (output_tx, mut output_rx) = mpsc::channel(10);
|
||||
let stop_token = CancellationToken::new();
|
||||
|
||||
let mut logic = ResamplingLogic::new(48000);
|
||||
|
||||
// Créer un chunk à 44.1kHz
|
||||
let chunk_44k = AudioChunk::I16(AudioChunkData::new(
|
||||
vec![[1000, 2000]; 100], // 100 frames
|
||||
44100,
|
||||
0.0,
|
||||
));
|
||||
|
||||
let segment = Arc::new(AudioSegment {
|
||||
order: 0,
|
||||
timestamp_sec: 0.0,
|
||||
segment: crate::_AudioSegment::Chunk(Arc::new(chunk_44k)),
|
||||
});
|
||||
|
||||
input_tx.send(segment).await.unwrap();
|
||||
drop(input_tx);
|
||||
|
||||
// Lancer le traitement
|
||||
tokio::spawn(async move {
|
||||
logic
|
||||
.process(Some(input_rx), vec![output_tx], stop_token)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Vérifier le résultat
|
||||
let result = output_rx.recv().await.unwrap();
|
||||
assert!(result.is_audio_chunk());
|
||||
|
||||
if let Some(chunk) = result.as_chunk() {
|
||||
// Le chunk doit être I16 (même type)
|
||||
assert!(matches!(chunk.as_ref(), AudioChunk::I16(_)));
|
||||
|
||||
// Le sample rate doit être 48000
|
||||
assert_eq!(chunk.sample_rate(), 48000);
|
||||
|
||||
// Le nombre de frames doit avoir changé (ratio ~1.088)
|
||||
// 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz
|
||||
if let AudioChunk::I16(data) = chunk.as_ref() {
|
||||
let frames = data.get_frames().len();
|
||||
assert!(frames >= 105 && frames <= 115, "Expected ~109 frames, got {}", frames);
|
||||
}
|
||||
} else {
|
||||
panic!("Expected audio chunk");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -482,6 +482,15 @@ impl<L: NodeLogic> Node<L> {
|
||||
pub fn logic(&self) -> &L {
|
||||
&self.logic
|
||||
}
|
||||
|
||||
/// Retourne une référence mutable vers la logique métier du nœud
|
||||
///
|
||||
/// Permet de configurer la logique après construction mais avant run().
|
||||
/// Utile pour définir des options qui ne peuvent pas être connues
|
||||
/// au moment de la construction du nœud.
|
||||
pub fn logic_mut(&mut self) -> &mut L {
|
||||
&mut self.logic
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use anyhow::Result;
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
|
||||
@@ -51,12 +51,8 @@ symphonia = { version = "0.5", features = ["all"] }
|
||||
# Audio decoding - claxon for FLAC streaming
|
||||
claxon = "0.4"
|
||||
|
||||
# FFmpeg for progressive streaming (decoding + encoding) - optional
|
||||
ffmpeg-next = { version = "8.0", optional = true }
|
||||
|
||||
# Per-track feature dependencies
|
||||
hound = { version = "3.5", optional = true }
|
||||
tempfile = { version = "3.8", optional = true }
|
||||
# pmoaudio-ext with playlist support (optional for examples)
|
||||
pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist"] }
|
||||
|
||||
# Common music source traits
|
||||
pmosource = { path = "../pmosource" }
|
||||
@@ -74,12 +70,16 @@ pmoserver = { path = "../pmoserver", optional = true }
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
axum = { version = "0.8.4", optional = true }
|
||||
|
||||
# pmoaudio node support
|
||||
pmoaudio = { path = "../pmoaudio", optional = true }
|
||||
pmoflac = { path = "../pmoflac", optional = true }
|
||||
pmometadata = { path = "../pmometadata", optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["metadata-only", "pmoconfig"]
|
||||
# Mode métadonnées seules (pas de décodage FLAC)
|
||||
metadata-only = []
|
||||
# Active l'extraction par-track (WAV export, etc.)
|
||||
per-track = ["dep:hound", "dep:tempfile"]
|
||||
# Active l'API REST pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
|
||||
# Feature pour activer le support serveur (cache registry)
|
||||
@@ -88,8 +88,10 @@ server = ["pmosource/server", "pmoconfig"]
|
||||
pmoconfig = ["dep:pmoconfig"]
|
||||
# Feature cache (deprecated - toujours actif maintenant)
|
||||
cache = []
|
||||
# Active le streaming progressif avec FFmpeg (latence réduite)
|
||||
ffmpeg = ["dep:ffmpeg-next"]
|
||||
# Active le support pmoaudio node (RadioParadiseStreamSource)
|
||||
pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util"]
|
||||
# Active le support complet avec playlist (pour les exemples avancés)
|
||||
full = ["pmoaudio", "dep:pmoaudio-ext"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests
|
||||
@@ -104,17 +106,3 @@ pmoaudiocache = { path = "../pmoaudiocache" }
|
||||
[[example]]
|
||||
name = "now_playing"
|
||||
path = "examples/now_playing.rs"
|
||||
|
||||
[[example]]
|
||||
name = "stream_block"
|
||||
path = "examples/stream_block.rs"
|
||||
|
||||
[[example]]
|
||||
name = "extract_track"
|
||||
path = "examples/extract_track.rs"
|
||||
required-features = ["per-track"]
|
||||
|
||||
[[example]]
|
||||
name = "with_cache"
|
||||
path = "examples/with_cache.rs"
|
||||
required-features = ["cache"]
|
||||
|
||||
244
pmoparadise/RADIO_PARADISE_STREAM_SOURCE.md
Normal file
244
pmoparadise/RADIO_PARADISE_STREAM_SOURCE.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# RadioParadiseStreamSource - Documentation Technique
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
`RadioParadiseStreamSource` est un nœud source pour `pmoaudio` qui télécharge et décode les blocs FLAC de Radio Paradise en temps réel, avec gestion automatique des transitions entre pistes (TrackBoundary).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Pattern Node<L>
|
||||
|
||||
Suit l'architecture séparée logique/pipeline de `pmoaudio` :
|
||||
|
||||
```
|
||||
RadioParadiseStreamSource (wrapper)
|
||||
└── Node<RadioParadiseStreamSourceLogic>
|
||||
└── RadioParadiseStreamSourceLogic (logique métier)
|
||||
```
|
||||
|
||||
### RadioParadiseStreamSourceLogic
|
||||
|
||||
Responsabilités :
|
||||
- **File d'attente** : `VecDeque<EventId>` pour les blocks à télécharger
|
||||
- **Cache anti-redondance** : `VecDeque<EventId>` pour 10 blocs récents (FIFO)
|
||||
- **Téléchargement** : Fetch bloc FLAC (bitrate=4 uniquement)
|
||||
- **Décodage** : Stream FLAC via `pmoflac::decode_audio_stream`
|
||||
- **Timing** : Calcul précis pour insertion TrackBoundary
|
||||
|
||||
## Flux d'exécution
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 1. Attente block ID (timeout 3s) │
|
||||
│ └─> VecDeque::pop_front() │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 2. Vérification cache │
|
||||
│ └─> VecDeque::contains(&event_id) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 3. Téléchargement métadonnées │
|
||||
│ └─> client.get_block(event_id) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 4. Téléchargement FLAC (bitrate=4) │
|
||||
│ └─> client.download_block_file(&block, 4) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 5. Décodage streaming │
|
||||
│ └─> pmoflac::decode_audio_stream(reader) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 6. Découpage en chunks │
|
||||
│ └─> pcm_to_audio_chunk(pcm, sr, bps) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 7. Insertion TrackBoundary (timing sample-based) │
|
||||
│ └─> elapsed_ms = (total_samples * 1000) / sr │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Timing TrackBoundary
|
||||
|
||||
### Algorithme
|
||||
|
||||
```rust
|
||||
let elapsed_ms = (total_samples * 1000) / sample_rate as u64;
|
||||
|
||||
if elapsed_ms >= song.elapsed {
|
||||
// Envoyer TrackBoundary AVANT le chunk (même order)
|
||||
send_track_boundary(*order, song, block).await;
|
||||
}
|
||||
```
|
||||
|
||||
### Exemple concret
|
||||
|
||||
Bloc FLAC contenant 3 chansons :
|
||||
- Song 0 : `elapsed = 0ms`
|
||||
- Song 1 : `elapsed = 180000ms` (3min)
|
||||
- Song 2 : `elapsed = 420000ms` (7min)
|
||||
|
||||
Timeline :
|
||||
```
|
||||
0ms 180000ms 420000ms
|
||||
│ │ │
|
||||
Song 0 TrackBoundary TrackBoundary
|
||||
└─> Song 1 └─> Song 2
|
||||
```
|
||||
|
||||
## SyncMarker Order
|
||||
|
||||
**Règle** : TrackBoundary a le **même order** que le chunk suivant.
|
||||
|
||||
```rust
|
||||
// TrackBoundary order = 42
|
||||
AudioSegment::new_sync(42, SyncMarker::TrackBoundary { ... })
|
||||
|
||||
// Chunk suivant order = 42
|
||||
AudioSegment::new_audio(42, AudioChunk::I16(...))
|
||||
```
|
||||
|
||||
## Gestion du cache
|
||||
|
||||
### Stratégie FIFO simple
|
||||
|
||||
```rust
|
||||
const RECENT_BLOCKS_CACHE_SIZE: usize = 10;
|
||||
|
||||
fn mark_block_downloaded(&mut self, event_id: EventId) {
|
||||
// Retirer tous les éléments excédentaires (garantit <= CACHE_SIZE)
|
||||
while self.recent_blocks.len() >= RECENT_BLOCKS_CACHE_SIZE {
|
||||
self.recent_blocks.pop_front();
|
||||
}
|
||||
|
||||
// Puis ajouter le nouveau bloc
|
||||
self.recent_blocks.push_back(event_id);
|
||||
}
|
||||
```
|
||||
|
||||
**Avantages VecDeque** :
|
||||
- ✅ Ordre FIFO garanti (le plus ancien est toujours retiré)
|
||||
- ✅ Simple et prévisible
|
||||
- ✅ Robuste : `while` garantit exactement 10 éléments max, même en cas d'état anormal
|
||||
- ✅ Ne dépasse jamais la capacité pré-allouée (retire avant d'ajouter)
|
||||
- ✅ Pour 10 éléments, `contains()` en O(n) reste très performant
|
||||
|
||||
## Support FLAC
|
||||
|
||||
### Formats supportés
|
||||
|
||||
- **16-bit** : `AudioChunk::I16`
|
||||
- **24-bit** : `AudioChunk::I24`
|
||||
|
||||
### Conversion PCM
|
||||
|
||||
```rust
|
||||
match bits_per_sample {
|
||||
16 => {
|
||||
let samples: Vec<i16> = pcm_data
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
|
||||
.collect();
|
||||
AudioChunk::I16(...)
|
||||
}
|
||||
24 => {
|
||||
let samples: Vec<I24> = pcm_data
|
||||
.chunks_exact(3)
|
||||
.map(|chunk| {
|
||||
let value = i32::from_le_bytes([chunk[0], chunk[1], chunk[2], 0]) >> 8;
|
||||
I24::from_i32(value)
|
||||
})
|
||||
.collect();
|
||||
AudioChunk::I24(...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Métadonnées
|
||||
|
||||
### TrackMetadata
|
||||
|
||||
Champs extraits de `Song` :
|
||||
- `title` : Titre de la chanson
|
||||
- `artist` : Artiste
|
||||
- `album` : Album (optionnel)
|
||||
- `year` : Année (optionnel)
|
||||
- `cover_url` : URL de la pochette (async via tokio::spawn)
|
||||
|
||||
### Gestion asynchrone du cover
|
||||
|
||||
```rust
|
||||
tokio::spawn(async move {
|
||||
if let Ok(mut meta) = metadata_clone.write().await {
|
||||
let _ = meta.set_cover_url(Some(cover_url)).await;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Publique
|
||||
|
||||
### Création
|
||||
|
||||
```rust
|
||||
pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```rust
|
||||
pub fn push_block_id(&mut self, event_id: EventId)
|
||||
```
|
||||
|
||||
Ajoute un block ID à télécharger dans la file d'attente.
|
||||
|
||||
### Exécution
|
||||
|
||||
```rust
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError>
|
||||
```
|
||||
|
||||
Hérite de `AudioPipelineNode`.
|
||||
|
||||
## Exemple d'utilisation
|
||||
|
||||
Voir `examples/radio_paradise_stream.rs` pour :
|
||||
- Utilisation basique
|
||||
- Intégration avec nowplaying stream
|
||||
- Connexion à un sink
|
||||
|
||||
## Constantes
|
||||
|
||||
```rust
|
||||
const BLOCK_ID_TIMEOUT_SECS: u64 = 3; // Timeout attente nouveau block
|
||||
const RECENT_BLOCKS_CACHE_SIZE: usize = 10; // Taille cache anti-redondance
|
||||
```
|
||||
|
||||
## Dépendances
|
||||
|
||||
- `pmoaudio` : Pipeline audio, types AudioChunk/AudioSegment
|
||||
- `pmoflac` : Décodage FLAC streaming
|
||||
- `pmometadata` : Métadonnées pistes
|
||||
- `futures-util` : StreamExt pour le décodage
|
||||
- `tokio` : Runtime async
|
||||
- `tokio-util` : StreamReader, CancellationToken
|
||||
|
||||
## Feature gate
|
||||
|
||||
```toml
|
||||
[features]
|
||||
pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util"]
|
||||
```
|
||||
|
||||
Activer avec : `cargo build -p pmoparadise --features pmoaudio`
|
||||
205
pmoparadise/examples/download_block.rs
Normal file
205
pmoparadise/examples/download_block.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! Télécharge un bloc complet de Radio Paradise et sauvegarde toutes les pistes en FLAC
|
||||
//!
|
||||
//! Ce programme démontre l'utilisation de la chaîne :
|
||||
//! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC de Radio Paradise
|
||||
//! 2. FlacFileSink - Sauvegarde automatiquement chaque piste dans un fichier FLAC séparé
|
||||
//!
|
||||
//! La nouvelle architecture AudioPipelineNode permet de :
|
||||
//! - Télécharger et décoder automatiquement les blocs FLAC de Radio Paradise
|
||||
//! - Détecter les limites de pistes (TrackBoundary)
|
||||
//! - Sauvegarder automatiquement chaque piste dans un fichier séparé
|
||||
//! - Gérer proprement l'arrêt du pipeline avec un CancellationToken
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example download_block -- <channel_id>
|
||||
//!
|
||||
//! Exemple:
|
||||
//! cargo run --example download_block -- 0 # Main Mix
|
||||
//! cargo run --example download_block -- 1 # Mellow Mix
|
||||
//! cargo run --example download_block -- 2 # Rock Mix
|
||||
//! cargo run --example download_block -- 3 # World/Etc Mix
|
||||
|
||||
use pmoaudio::{AudioPipelineNode, FlacFileSink};
|
||||
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
|
||||
use std::env;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialiser tracing pour le debug
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive(tracing::Level::INFO.into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// Récupérer les arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() != 2 {
|
||||
eprintln!("Usage: {} <channel_id>", args[0]);
|
||||
eprintln!();
|
||||
eprintln!("Downloads a complete Radio Paradise block and saves all tracks as FLAC files.");
|
||||
eprintln!();
|
||||
eprintln!("Channel IDs:");
|
||||
eprintln!(" 0 - Main Mix (eclectic, diverse mix)");
|
||||
eprintln!(" 1 - Mellow Mix (smooth, chilled music)");
|
||||
eprintln!(" 2 - Rock Mix (classic & modern rock)");
|
||||
eprintln!(" 3 - World/Etc Mix (global sounds)");
|
||||
eprintln!();
|
||||
eprintln!("Example:");
|
||||
eprintln!(" {} 0 # Download Main Mix", args[0]);
|
||||
eprintln!(" {} 2 # Download Rock Mix", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let channel_id: u8 = match args[1].parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if channel_id > 3 {
|
||||
eprintln!("Error: channel_id must be between 0 and 3");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!("=== Radio Paradise Block Downloader ===");
|
||||
println!();
|
||||
println!("Channel ID: {}", channel_id);
|
||||
println!();
|
||||
|
||||
// Créer le client Radio Paradise pour le channel spécifié
|
||||
println!("Fetching current block metadata...");
|
||||
let client = RadioParadiseClient::builder()
|
||||
.channel(channel_id)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
// Récupérer le bloc actuel
|
||||
let block = client.get_block(None).await?;
|
||||
|
||||
println!("Block Information:");
|
||||
println!(" Event ID: {}", block.event);
|
||||
println!(" Songs: {}", block.song_count());
|
||||
println!(
|
||||
" Duration: {:.1} minutes",
|
||||
block.length as f64 / 60000.0
|
||||
);
|
||||
println!();
|
||||
|
||||
// Afficher la liste des pistes
|
||||
println!("Tracklist:");
|
||||
for (index, song) in block.songs_ordered() {
|
||||
println!(
|
||||
" {:2}. {} - {} ({})",
|
||||
index + 1,
|
||||
song.artist,
|
||||
song.title,
|
||||
song.album.as_deref().unwrap_or("Unknown Album")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Créer le répertoire de sortie
|
||||
let output_dir = format!("./rp_channel_{}block{}", channel_id, block.event);
|
||||
std::fs::create_dir_all(&output_dir)?;
|
||||
println!("Output directory: {}", output_dir);
|
||||
println!();
|
||||
|
||||
// Créer le pipeline: RadioParadiseStreamSource → FlacFileSink
|
||||
let mut source = RadioParadiseStreamSource::new(client);
|
||||
|
||||
// Ajouter le bloc à télécharger
|
||||
source.push_block_id(block.event);
|
||||
|
||||
// Créer le sink qui sauvegarde chaque piste dans un fichier séparé
|
||||
let base_path = format!("{}/track.flac", output_dir);
|
||||
let sink = FlacFileSink::new(&base_path);
|
||||
|
||||
// Construire la chaîne: source → sink
|
||||
source.register(Box::new(sink));
|
||||
|
||||
// Créer un token d'arrêt
|
||||
let stop_token = CancellationToken::new();
|
||||
|
||||
// Gérer Ctrl+C pour arrêt propre
|
||||
let stop_token_clone = stop_token.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
println!("\n\nReceived Ctrl+C, stopping...");
|
||||
stop_token_clone.cancel();
|
||||
});
|
||||
|
||||
// Lancer tout le pipeline
|
||||
println!("Downloading and processing block...");
|
||||
println!("Press Ctrl+C to stop.");
|
||||
println!();
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let result = Box::new(source).run(stop_token).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// Vérifier le résultat
|
||||
match result {
|
||||
Ok(()) => {
|
||||
println!();
|
||||
println!(
|
||||
"✓ Download completed successfully in {:.2}s",
|
||||
elapsed.as_secs_f64()
|
||||
);
|
||||
println!(" Output directory: {}", output_dir);
|
||||
println!();
|
||||
|
||||
// Afficher les fichiers créés
|
||||
let entries = std::fs::read_dir(&output_dir)?;
|
||||
let mut files: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s == "flac")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
files.sort_by_key(|e| e.path());
|
||||
|
||||
println!("Files created:");
|
||||
for (i, entry) in files.iter().enumerate() {
|
||||
let path = entry.path();
|
||||
let metadata = std::fs::metadata(&path)?;
|
||||
let size_mb = metadata.len() as f64 / (1024.0 * 1024.0);
|
||||
println!(
|
||||
" {:2}. {} ({:.2} MB)",
|
||||
i + 1,
|
||||
path.file_name().unwrap().to_string_lossy(),
|
||||
size_mb
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Calculer la taille totale
|
||||
let total_size: u64 = files
|
||||
.iter()
|
||||
.filter_map(|e| std::fs::metadata(e.path()).ok())
|
||||
.map(|m| m.len())
|
||||
.sum();
|
||||
println!(
|
||||
"Total size: {:.2} MB",
|
||||
total_size as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!();
|
||||
eprintln!("✗ Download error: {}", e);
|
||||
eprintln!();
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
//! Example: Extract individual tracks from a FLAC block (requires `per-track` feature)
|
||||
//!
|
||||
//! This example demonstrates:
|
||||
//! - Per-track extraction from FLAC blocks
|
||||
//! - Exporting tracks to WAV files
|
||||
//! - Alternative player-based seeking (recommended)
|
||||
//!
|
||||
//! **Warning**: This approach downloads and decodes entire blocks.
|
||||
//! For most use cases, player-based seeking is more efficient.
|
||||
//!
|
||||
//! Run with: cargo run --example extract_track --features per-track
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
use pmoparadise::{RadioParadiseClient, Result};
|
||||
#[cfg(feature = "per-track")]
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize logging
|
||||
#[cfg(feature = "logging")]
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
println!("Radio Paradise - Per-Track Extraction Demo");
|
||||
println!("===========================================\n");
|
||||
|
||||
println!("WARNING: This feature downloads entire blocks (50-100MB)");
|
||||
println!(" and performs CPU-intensive FLAC decoding.");
|
||||
println!(" For most use cases, player-based seeking is better.\n");
|
||||
|
||||
// Create client
|
||||
let client = RadioParadiseClient::new().await?;
|
||||
|
||||
// Get current block
|
||||
let block = client.get_block(None).await?;
|
||||
|
||||
println!("Block Information:");
|
||||
println!(" Event: {}", block.event);
|
||||
println!(" Songs: {}", block.song_count());
|
||||
println!(" URL: {}\n", block.url);
|
||||
|
||||
// Display all tracks
|
||||
println!("Available Tracks:");
|
||||
for (index, song) in block.songs_ordered() {
|
||||
println!(
|
||||
" {}. {} - {} ({:.1}s)",
|
||||
index,
|
||||
song.artist,
|
||||
song.title,
|
||||
song.duration as f64 / 1000.0
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Extract first track
|
||||
let track_index = 0;
|
||||
if let Some((_, song)) = block.songs_ordered().first() {
|
||||
println!("Extracting Track {}:", track_index);
|
||||
println!(" Artist: {}", song.artist);
|
||||
println!(" Title: {}", song.title);
|
||||
println!(" Album: {}\n", song.album);
|
||||
|
||||
println!("Downloading and decoding... (this may take a while)");
|
||||
|
||||
// Open track stream
|
||||
let mut track_stream = client.open_track_stream(&block, track_index).await?;
|
||||
|
||||
println!("Track Metadata:");
|
||||
println!(" Sample Rate: {} Hz", track_stream.metadata.sample_rate);
|
||||
println!(" Channels: {}", track_stream.metadata.channels);
|
||||
println!(
|
||||
" Bits Per Sample: {}",
|
||||
track_stream.metadata.bits_per_sample
|
||||
);
|
||||
println!(" Total Samples: {}", track_stream.metadata.total_samples);
|
||||
println!();
|
||||
|
||||
// Export to WAV
|
||||
let output_path = Path::new("track.wav");
|
||||
println!("Exporting to {:?}...", output_path);
|
||||
track_stream.export_wav(output_path)?;
|
||||
println!("✓ Export complete!\n");
|
||||
}
|
||||
|
||||
// Show alternative: player-based seeking
|
||||
println!("RECOMMENDED ALTERNATIVE: Player-Based Seeking");
|
||||
println!("=============================================\n");
|
||||
|
||||
for (index, song) in block.songs_ordered().into_iter().take(3) {
|
||||
let (start, duration) = client.track_position_seconds(&block, index)?;
|
||||
println!("Track {}: {} - {}", index, song.artist, song.title);
|
||||
println!(" mpv command:");
|
||||
println!(
|
||||
" mpv --start={:.3} --length={:.3} '{}'",
|
||||
start, duration, block.url
|
||||
);
|
||||
println!(" ffmpeg command (extract to file):");
|
||||
println!(
|
||||
" ffmpeg -ss {:.3} -t {:.3} -i '{}' -c copy track_{}.flac",
|
||||
start, duration, block.url, index
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("These methods are much more efficient as they:");
|
||||
println!(" - Don't download the entire block");
|
||||
println!(" - Use the player's optimized seeking");
|
||||
println!(" - Start playback immediately");
|
||||
println!(" - Preserve original quality (with -c copy)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "per-track"))]
|
||||
fn main() {
|
||||
eprintln!("ERROR: This example requires the 'per-track' feature.");
|
||||
eprintln!("Run with: cargo run --example extract_track --features per-track");
|
||||
std::process::exit(1);
|
||||
}
|
||||
276
pmoparadise/examples/play_and_cache.rs
Normal file
276
pmoparadise/examples/play_and_cache.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! Télécharge un bloc Radio Paradise, le cache, et le joue en même temps
|
||||
//!
|
||||
//! Ce programme démontre l'utilisation complète de la chaîne :
|
||||
//! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC
|
||||
//! 2. FlacCacheSink - Cache chaque piste en FLAC et alimente une playlist
|
||||
//! 3. PlaylistSource - Lit la playlist pendant le téléchargement
|
||||
//! 4. AudioSink - Joue l'audio sur la sortie standard
|
||||
//!
|
||||
//! Architecture :
|
||||
//! ```text
|
||||
//! Pipeline 1 (Download & Cache):
|
||||
//! RadioParadiseStreamSource → FlacCacheSink (avec playlist abonnée)
|
||||
//!
|
||||
//! Pipeline 2 (Playback):
|
||||
//! PlaylistSource (lit la playlist) → AudioSink (joue l'audio)
|
||||
//! ```
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example play_and_cache --features full -- <channel_id>
|
||||
//!
|
||||
//! Exemple:
|
||||
//! cargo run --example play_and_cache --features full -- 0 # Main Mix
|
||||
//! cargo run --example play_and_cache --features full -- 2 # Rock Mix
|
||||
|
||||
use pmoaudio::{AudioPipelineNode, AudioSink};
|
||||
use pmoaudio_ext::{FlacCacheSink, PlaylistSource};
|
||||
use pmoaudiocache::Cache as AudioCache;
|
||||
use pmocovers::Cache as CoverCache;
|
||||
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
|
||||
use pmoplaylist::Manager as PlaylistManager;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialiser tracing avec beaucoup de logs
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive(tracing::Level::DEBUG.into())
|
||||
.add_directive("pmoaudio=debug".parse()?)
|
||||
.add_directive("pmoaudio_ext=debug".parse()?)
|
||||
.add_directive("pmoplaylist=debug".parse()?)
|
||||
.add_directive("pmoparadise=debug".parse()?)
|
||||
.add_directive("pmoaudiocache=debug".parse()?)
|
||||
)
|
||||
.init();
|
||||
|
||||
tracing::info!("=== Radio Paradise Play & Cache ===");
|
||||
|
||||
// Récupérer les arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() != 2 {
|
||||
eprintln!("Usage: {} <channel_id>", args[0]);
|
||||
eprintln!();
|
||||
eprintln!("Downloads a Radio Paradise block, caches it, and plays it simultaneously.");
|
||||
eprintln!();
|
||||
eprintln!("Channel IDs:");
|
||||
eprintln!(" 0 - Main Mix (eclectic, diverse mix)");
|
||||
eprintln!(" 1 - Mellow Mix (smooth, chilled music)");
|
||||
eprintln!(" 2 - Rock Mix (classic & modern rock)");
|
||||
eprintln!(" 3 - World/Etc Mix (global sounds)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let channel_id: u8 = match args[1].parse() {
|
||||
Ok(id) if id <= 3 => id,
|
||||
_ => {
|
||||
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Channel ID: {}", channel_id);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Initialiser les caches et le gestionnaire de playlist
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let base_dir = std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string());
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
|
||||
tracing::info!("Initializing caches in: {}", base_dir);
|
||||
|
||||
// Créer le cache audio
|
||||
let audio_cache_dir = format!("{}/audio_cache", base_dir);
|
||||
std::fs::create_dir_all(&audio_cache_dir)?;
|
||||
let audio_cache = Arc::new(AudioCache::new(
|
||||
&audio_cache_dir,
|
||||
1000, // 1000 MB limit
|
||||
)?);
|
||||
tracing::debug!("Audio cache initialized at: {}", audio_cache_dir);
|
||||
|
||||
// Créer le cache de covers
|
||||
let cover_cache_dir = format!("{}/cover_cache", base_dir);
|
||||
std::fs::create_dir_all(&cover_cache_dir)?;
|
||||
let cover_cache = Arc::new(CoverCache::new(
|
||||
&cover_cache_dir,
|
||||
100, // 100 MB limit
|
||||
)?);
|
||||
tracing::debug!("Cover cache initialized at: {}", cover_cache_dir);
|
||||
|
||||
// Utiliser le gestionnaire de playlist singleton
|
||||
tracing::info!("Getting playlist manager...");
|
||||
let playlist_manager = pmoplaylist::PlaylistManager();
|
||||
tracing::debug!("Playlist manager obtained");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Créer la playlist pour ce channel
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let playlist_id = format!("radio-paradise-ch{}", channel_id);
|
||||
tracing::info!("Creating playlist: {}", playlist_id);
|
||||
|
||||
// Créer la playlist (ou la vider si elle existe)
|
||||
let mut writer = playlist_manager.create_persistent_playlist(playlist_id.clone()).await?;
|
||||
writer.set_title(format!("Radio Paradise - Channel {}", channel_id)).await?;
|
||||
writer.flush().await?; // Vider la playlist si elle existait
|
||||
tracing::debug!("Playlist created and flushed");
|
||||
|
||||
// Créer le reader pour la lecture
|
||||
let reader = playlist_manager.get_read_handle(&playlist_id).await?;
|
||||
tracing::debug!("Read handle created");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Récupérer les infos du bloc à télécharger
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
tracing::info!("Fetching current block metadata...");
|
||||
let client = RadioParadiseClient::builder()
|
||||
.channel(channel_id)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let block = client.get_block(None).await?;
|
||||
|
||||
tracing::info!("Block Information:");
|
||||
tracing::info!(" Event ID: {}", block.event);
|
||||
tracing::info!(" Songs: {}", block.song_count());
|
||||
tracing::info!(" Duration: {:.1} minutes", block.length as f64 / 60000.0);
|
||||
tracing::info!("");
|
||||
|
||||
tracing::info!("Tracklist:");
|
||||
for (index, song) in block.songs_ordered() {
|
||||
tracing::info!(
|
||||
" {:2}. {} - {} ({})",
|
||||
index + 1,
|
||||
song.artist,
|
||||
song.title,
|
||||
song.album.as_deref().unwrap_or("Unknown Album")
|
||||
);
|
||||
}
|
||||
tracing::info!("");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Pipeline 1: Téléchargement et cache
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
tracing::info!("Creating download pipeline...");
|
||||
|
||||
// Créer la source Radio Paradise
|
||||
let mut download_source = RadioParadiseStreamSource::new(client);
|
||||
download_source.push_block_id(block.event);
|
||||
tracing::debug!("RadioParadiseStreamSource created with block {}", block.event);
|
||||
|
||||
// Créer le sink de cache FLAC
|
||||
let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone());
|
||||
cache_sink.register_playlist(writer);
|
||||
tracing::debug!("FlacCacheSink created and registered with playlist");
|
||||
|
||||
// Connecter source → sink
|
||||
download_source.register(Box::new(cache_sink));
|
||||
tracing::info!("Download pipeline connected: RadioParadiseStreamSource → FlacCacheSink");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Pipeline 2: Lecture depuis la playlist
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
tracing::info!("Creating playback pipeline...");
|
||||
|
||||
// Créer la source playlist
|
||||
let mut playlist_source = PlaylistSource::new(reader, audio_cache.clone());
|
||||
tracing::debug!("PlaylistSource created");
|
||||
|
||||
// Créer le sink audio avec volume à 80%
|
||||
let audio_sink = AudioSink::with_volume(0.8);
|
||||
tracing::debug!("AudioSink created with volume 0.8");
|
||||
|
||||
// Connecter playlist → audio
|
||||
playlist_source.register(Box::new(audio_sink));
|
||||
tracing::info!("Playback pipeline connected: PlaylistSource → AudioSink");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Lancer les deux pipelines en parallèle
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
tracing::info!("");
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("Starting both pipelines...");
|
||||
tracing::info!("Pipeline 1: Downloading and caching");
|
||||
tracing::info!("Pipeline 2: Playing from playlist");
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("");
|
||||
|
||||
let stop_token = CancellationToken::new();
|
||||
let stop_token_download = stop_token.clone();
|
||||
let stop_token_playback = stop_token.clone();
|
||||
|
||||
// Gérer Ctrl+C
|
||||
let stop_token_ctrl_c = stop_token.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
tracing::warn!("Received Ctrl+C, stopping...");
|
||||
stop_token_ctrl_c.cancel();
|
||||
});
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Lancer les deux pipelines en parallèle
|
||||
let download_handle = tokio::spawn(async move {
|
||||
tracing::info!("[DOWNLOAD] Pipeline starting...");
|
||||
let result = Box::new(download_source).run(stop_token_download).await;
|
||||
match &result {
|
||||
Ok(()) => tracing::info!("[DOWNLOAD] Pipeline completed successfully"),
|
||||
Err(e) => tracing::error!("[DOWNLOAD] Pipeline error: {}", e),
|
||||
}
|
||||
result
|
||||
});
|
||||
|
||||
let playback_handle = tokio::spawn(async move {
|
||||
// Attendre un peu que le premier track soit disponible
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
tracing::info!("[PLAYBACK] Pipeline starting...");
|
||||
let result = Box::new(playlist_source).run(stop_token_playback).await;
|
||||
match &result {
|
||||
Ok(()) => tracing::info!("[PLAYBACK] Pipeline completed successfully"),
|
||||
Err(e) => tracing::error!("[PLAYBACK] Pipeline error: {}", e),
|
||||
}
|
||||
result
|
||||
});
|
||||
|
||||
// Attendre les deux pipelines
|
||||
let (download_result, playback_result) = tokio::join!(download_handle, playback_handle);
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// Vérifier les résultats
|
||||
match (download_result, playback_result) {
|
||||
(Ok(Ok(())), Ok(Ok(()))) => {
|
||||
tracing::info!("");
|
||||
tracing::info!("========================================");
|
||||
tracing::info!("✓ Both pipelines completed successfully");
|
||||
tracing::info!(" Total time: {:.2}s", elapsed.as_secs_f64());
|
||||
tracing::info!("========================================");
|
||||
}
|
||||
(download_res, playback_res) => {
|
||||
tracing::error!("");
|
||||
tracing::error!("========================================");
|
||||
if let Err(e) = download_res {
|
||||
tracing::error!("✗ Download pipeline error: {:?}", e);
|
||||
} else if let Ok(Err(e)) = download_res {
|
||||
tracing::error!("✗ Download pipeline error: {}", e);
|
||||
}
|
||||
if let Err(e) = playback_res {
|
||||
tracing::error!("✗ Playback pipeline error: {:?}", e);
|
||||
} else if let Ok(Err(e)) = playback_res {
|
||||
tracing::error!("✗ Playback pipeline error: {}", e);
|
||||
}
|
||||
tracing::error!("========================================");
|
||||
return Err("Pipeline error".into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Example showing how to access and save the Radio Paradise source image
|
||||
//!
|
||||
//! This example demonstrates:
|
||||
//! - Getting source information via the MusicSource trait
|
||||
//! - Accessing the embedded WebP image
|
||||
//! - Optionally saving it to a file
|
||||
|
||||
use pmoaudiocache::cache as audio_cache;
|
||||
use pmocovers::cache as covers_cache;
|
||||
use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
|
||||
use pmosource::MusicSource;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create the client and source
|
||||
let client = RadioParadiseClient::new().await?;
|
||||
|
||||
// Build lightweight caches under the system temp dir for this example
|
||||
let base_dir = std::env::temp_dir().join(format!(
|
||||
"pmoparadise_show_source_image_{}",
|
||||
std::process::id()
|
||||
));
|
||||
let covers_dir = base_dir.join("covers");
|
||||
let audio_dir = base_dir.join("audio");
|
||||
std::fs::create_dir_all(&covers_dir)?;
|
||||
std::fs::create_dir_all(&audio_dir)?;
|
||||
|
||||
let cover_cache = Arc::new(covers_cache::new_cache(
|
||||
covers_dir.to_string_lossy().as_ref(),
|
||||
32,
|
||||
)?);
|
||||
let audio_cache = Arc::new(audio_cache::new_cache(
|
||||
audio_dir.to_string_lossy().as_ref(),
|
||||
32,
|
||||
)?);
|
||||
|
||||
let source = RadioParadiseSource::new_default(client, cover_cache, audio_cache);
|
||||
|
||||
// Display source information
|
||||
println!("Music Source Information");
|
||||
println!("========================");
|
||||
println!("Name: {}", source.name());
|
||||
println!("ID: {}", source.id());
|
||||
println!("Image MIME type: {}", source.default_image_mime_type());
|
||||
|
||||
// Get the embedded image
|
||||
let image_data = source.default_image();
|
||||
println!("Embedded image size: {} bytes", image_data.len());
|
||||
|
||||
// Verify WebP format
|
||||
if image_data.len() >= 12 {
|
||||
let is_webp = &image_data[0..4] == b"RIFF" && &image_data[8..12] == b"WEBP";
|
||||
println!("Valid WebP format: {}", is_webp);
|
||||
}
|
||||
|
||||
// Optional: save to file
|
||||
if std::env::args().any(|arg| arg == "--save") {
|
||||
let filename = format!("{}_default.webp", source.id());
|
||||
let mut file = fs::File::create(&filename)?;
|
||||
file.write_all(image_data)?;
|
||||
println!("\nImage saved to: {}", filename);
|
||||
println!("You can view it with: open {}", filename);
|
||||
} else {
|
||||
println!("\nTo save the image to disk, run with: --save");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
//! Example: Stream a Radio Paradise block with prefetching
|
||||
//!
|
||||
//! This example demonstrates:
|
||||
//! - Streaming block audio data
|
||||
//! - Writing to a file or piping to a player
|
||||
//! - Prefetching the next block for gapless playback
|
||||
//! - Continuous playback loop
|
||||
//!
|
||||
//! Run with: cargo run --example stream_block
|
||||
//!
|
||||
//! To play directly with mpv:
|
||||
//! cargo run --example stream_block | mpv --no-cache --demuxer=+lavf -
|
||||
|
||||
use futures::StreamExt;
|
||||
use pmoparadise::{RadioParadiseClient, Result};
|
||||
use std::io::Write;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize logging (optional)
|
||||
#[cfg(feature = "logging")]
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
eprintln!("Radio Paradise - Block Streaming Demo");
|
||||
eprintln!("======================================\n");
|
||||
|
||||
// Create client
|
||||
let mut client = RadioParadiseClient::builder().build().await?;
|
||||
|
||||
eprintln!("Client configured for FLAC streaming\n");
|
||||
|
||||
// Get current block
|
||||
let current_block = client.get_block(None).await?;
|
||||
|
||||
eprintln!("Current Block:");
|
||||
eprintln!(" Event: {}", current_block.event);
|
||||
eprintln!(" Songs: {}", current_block.song_count());
|
||||
eprintln!(
|
||||
" Duration: {:.1} minutes",
|
||||
current_block.length as f64 / 60000.0
|
||||
);
|
||||
eprintln!(" URL: {}\n", current_block.url);
|
||||
|
||||
// Display tracklist
|
||||
eprintln!("Tracklist:");
|
||||
for (index, song) in current_block.songs_ordered() {
|
||||
eprintln!(" {}. {} - {}", index + 1, song.artist, song.title);
|
||||
}
|
||||
eprintln!();
|
||||
|
||||
// Prefetch next block in advance
|
||||
eprintln!("Prefetching next block...");
|
||||
client.prefetch_next(¤t_block).await?;
|
||||
eprintln!(
|
||||
"Next block prefetched: {}\n",
|
||||
client.next_block_url().unwrap()
|
||||
);
|
||||
|
||||
// Stream the block
|
||||
eprintln!("Streaming block... (writing to stdout)");
|
||||
eprintln!("Tip: Pipe to a player like: cargo run --example stream_block | mpv -\n");
|
||||
|
||||
let mut stream = client.stream_block_from_metadata(¤t_block).await?;
|
||||
let mut total_bytes = 0u64;
|
||||
let mut stdout = std::io::stdout();
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result?;
|
||||
total_bytes += chunk.len() as u64;
|
||||
|
||||
// Write to stdout (can be piped to a player)
|
||||
stdout.write_all(&chunk)?;
|
||||
stdout.flush()?;
|
||||
|
||||
// Progress indicator (to stderr so it doesn't interfere with piped audio)
|
||||
if total_bytes % (1024 * 1024) == 0 {
|
||||
eprintln!(
|
||||
" Downloaded: {:.1} MB",
|
||||
total_bytes as f64 / 1024.0 / 1024.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("\nBlock streaming complete!");
|
||||
eprintln!(
|
||||
"Total downloaded: {:.2} MB",
|
||||
total_bytes as f64 / 1024.0 / 1024.0
|
||||
);
|
||||
|
||||
// In a real application, you would now:
|
||||
// 1. Get the next block using prefetched metadata
|
||||
// 2. Stream it seamlessly
|
||||
// 3. Prefetch the following block
|
||||
// 4. Repeat for continuous playback
|
||||
|
||||
eprintln!("\nFor continuous playback, you would now stream the next block:");
|
||||
eprintln!(" Event: {}", current_block.end_event);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Test progressive streaming implementation
|
||||
//!
|
||||
//! This example tests the streaming implementation and measures performance
|
||||
//!
|
||||
//! Run with:
|
||||
//! ```bash
|
||||
//! RUST_LOG=info cargo run --example test_streaming
|
||||
//! ```
|
||||
|
||||
use pmoparadise::RadioParadiseClient;
|
||||
use std::time::Instant;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize tracing with timestamps
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(false)
|
||||
.with_thread_ids(false)
|
||||
.with_level(true)
|
||||
.init();
|
||||
|
||||
println!("🎵 Testing Progressive FLAC Streaming");
|
||||
println!("=====================================\n");
|
||||
|
||||
// Create the Radio Paradise client
|
||||
println!("📡 Connecting to Radio Paradise...");
|
||||
let client = RadioParadiseClient::new().await?;
|
||||
println!("✅ Connected!\n");
|
||||
|
||||
// Get current block
|
||||
println!("🎧 Fetching current block metadata...");
|
||||
let block = client.get_block(None).await?;
|
||||
|
||||
println!("\n📊 Block Information:");
|
||||
println!(" Event ID: {}", block.event);
|
||||
println!(" Songs: {}", block.song_count());
|
||||
println!(" Duration: ~{} seconds\n", block.length / 1000);
|
||||
|
||||
// List songs
|
||||
println!("🎵 Songs in this block:");
|
||||
for (idx, song) in block.songs_ordered() {
|
||||
println!(
|
||||
" {}. {} - {} ({}s at {}s)",
|
||||
idx + 1,
|
||||
song.artist,
|
||||
song.title,
|
||||
song.duration / 1000,
|
||||
song.elapsed / 1000
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Now test the streaming decoder
|
||||
println!("⚡ Starting progressive streaming test...");
|
||||
println!(" (This will download and decode the block progressively)");
|
||||
println!();
|
||||
|
||||
let start_time = Instant::now();
|
||||
let block_url = block.url.parse()?;
|
||||
let http_stream = client.stream_block(&block_url).await?;
|
||||
|
||||
use pmoparadise::streaming::StreamingPCMDecoder;
|
||||
|
||||
// Decode in a blocking task
|
||||
let decode_task = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<(u64, usize)>> {
|
||||
let mut decoder = StreamingPCMDecoder::new(http_stream)?;
|
||||
|
||||
println!(
|
||||
" 🎼 Stream info: {}Hz, {} channels, {} bits",
|
||||
decoder.sample_rate(),
|
||||
decoder.channels(),
|
||||
decoder.bits_per_sample()
|
||||
);
|
||||
|
||||
let mut chunk_times = Vec::new();
|
||||
let mut chunk_count = 0;
|
||||
|
||||
while let Some(chunk) = decoder.decode_chunk()? {
|
||||
chunk_count += 1;
|
||||
chunk_times.push((chunk.position_ms, chunk.samples.len()));
|
||||
|
||||
if chunk_count % 50 == 0 {
|
||||
println!(
|
||||
" 📦 Chunk {} at {}ms ({} samples)",
|
||||
chunk_count,
|
||||
chunk.position_ms,
|
||||
chunk.samples.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(chunk_times)
|
||||
});
|
||||
|
||||
let chunk_times = decode_task
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Join error: {}", e))??;
|
||||
let total_time = start_time.elapsed();
|
||||
|
||||
println!("\n✅ Streaming Complete!");
|
||||
println!("\n📈 Performance Metrics:");
|
||||
println!(" Total chunks decoded: {}", chunk_times.len());
|
||||
println!(" Total time: {:.2}s", total_time.as_secs_f64());
|
||||
|
||||
if let Some((first_pos, _)) = chunk_times.first() {
|
||||
println!(" First chunk at: {}ms", first_pos);
|
||||
}
|
||||
|
||||
if let Some((last_pos, _)) = chunk_times.last() {
|
||||
println!(
|
||||
" Last chunk at: {}ms (~{:.1}s)",
|
||||
last_pos,
|
||||
last_pos / 1000
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n💡 Analysis:");
|
||||
println!(" With the old approach (download all first):");
|
||||
println!(" - Would need to wait for full download (~12-16s)");
|
||||
println!(" - Then decode all samples");
|
||||
println!(" - Total: ~15-20s before first track");
|
||||
println!();
|
||||
println!(" With progressive streaming:");
|
||||
println!(" - First chunks arrive in ~2-3s");
|
||||
println!(" - First track (3min) ready in ~6-8s");
|
||||
println!(" - Improvement: ~2x faster! ⚡");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//! Example demonstrating Radio Paradise with cache support
|
||||
//!
|
||||
//! This example shows how to use the RadioParadiseSource with pmocovers
|
||||
//! and pmoaudiocache to cache both cover images and audio tracks.
|
||||
//!
|
||||
//! Run with:
|
||||
//! ```bash
|
||||
//! cargo run --example with_cache --features cache
|
||||
//! ```
|
||||
|
||||
use pmoaudiocache::AudioCache;
|
||||
use pmocovers::Cache as CoverCache;
|
||||
use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
|
||||
use pmosource::MusicSource;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
println!("🎵 Radio Paradise with Cache Support");
|
||||
println!("=====================================\n");
|
||||
|
||||
// Create the Radio Paradise client
|
||||
println!("📡 Connecting to Radio Paradise...");
|
||||
let client = RadioParadiseClient::new().await?;
|
||||
println!("✅ Connected!\n");
|
||||
|
||||
// Initialize caches
|
||||
println!("💾 Initializing caches...");
|
||||
let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?);
|
||||
let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?);
|
||||
println!("✅ Caches initialized!\n");
|
||||
|
||||
// Create the source with caching enabled
|
||||
let source = RadioParadiseSource::new_with_cache(
|
||||
client.clone(),
|
||||
"http://localhost:8080",
|
||||
50,
|
||||
Some(cover_cache.clone()),
|
||||
Some(audio_cache.clone()),
|
||||
);
|
||||
|
||||
println!("📻 Source: {}", source.name());
|
||||
println!("🆔 ID: {}", source.id());
|
||||
println!("📝 Supports FIFO: {}\n", source.supports_fifo());
|
||||
|
||||
// Fetch current playing information
|
||||
println!("🎧 Fetching current track information...");
|
||||
let now_playing = client.now_playing().await?;
|
||||
let block = Arc::new(now_playing.block.clone());
|
||||
|
||||
println!("\n🎵 Now Playing:");
|
||||
println!(" Event: {}", block.event);
|
||||
if let Some(song) = &now_playing.current_song {
|
||||
println!(" Title: {}", song.title);
|
||||
println!(" Artist: {}", song.artist);
|
||||
println!(" Album: {}", song.album);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Add current song to the source
|
||||
println!("➕ Adding current track to FIFO with caching...");
|
||||
if let Some(song) = &now_playing.current_song {
|
||||
source
|
||||
.add_song(
|
||||
block.clone(),
|
||||
song,
|
||||
now_playing.current_song_index.unwrap_or(0),
|
||||
)
|
||||
.await?;
|
||||
println!("✅ Track added and caching started!");
|
||||
println!(" - Cover image will be cached to: ./cache/covers/");
|
||||
println!(" - Audio will be cached to: ./cache/audio/\n");
|
||||
}
|
||||
|
||||
// Wait a bit for caching to start
|
||||
println!("⏳ Waiting for cache operations to complete...");
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// Get items from FIFO
|
||||
println!("\n📋 Items in FIFO:");
|
||||
let items = source.get_items(0, 10).await?;
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
println!(
|
||||
" {}. {} - {}",
|
||||
i + 1,
|
||||
item.artist.as_deref().unwrap_or("Unknown"),
|
||||
item.title
|
||||
);
|
||||
|
||||
// Show resolved URI (will use cached version if available)
|
||||
if let Ok(uri) = source.resolve_uri(&item.id).await {
|
||||
println!(" URI: {}", uri);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n✨ Example complete!");
|
||||
println!("\n💡 Tips:");
|
||||
println!(" - Run the example again to see faster loading from cache");
|
||||
println!(" - Check ./cache/covers/ for cached cover images");
|
||||
println!(" - Check ./cache/audio/ for cached FLAC files");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
143
pmoparadise/src/channels.rs
Normal file
143
pmoparadise/src/channels.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! Radio Paradise channel definitions
|
||||
//!
|
||||
//! This module defines the available Radio Paradise channels and their metadata.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Logical identifier for a Radio Paradise channel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ParadiseChannelKind {
|
||||
Main,
|
||||
Mellow,
|
||||
Rock,
|
||||
Eclectic,
|
||||
}
|
||||
|
||||
impl ParadiseChannelKind {
|
||||
pub const fn id(self) -> u8 {
|
||||
match self {
|
||||
Self::Main => 0,
|
||||
Self::Mellow => 1,
|
||||
Self::Rock => 2,
|
||||
Self::Eclectic => 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "main",
|
||||
Self::Mellow => "mellow",
|
||||
Self::Rock => "rock",
|
||||
Self::Eclectic => "eclectic",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "Main Mix",
|
||||
Self::Mellow => "Mellow Mix",
|
||||
Self::Rock => "Rock Mix",
|
||||
Self::Eclectic => "Eclectic Mix",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "Eclectic mix of rock, world, electronica, and more",
|
||||
Self::Mellow => "Mellower, less aggressive music",
|
||||
Self::Rock => "Heavier, more guitar-driven music",
|
||||
Self::Eclectic => "Curated worldwide selection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ParadiseChannelKind {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"main" | "0" => Ok(Self::Main),
|
||||
"mellow" | "1" => Ok(Self::Mellow),
|
||||
"rock" | "2" => Ok(Self::Rock),
|
||||
"eclectic" | "3" => Ok(Self::Eclectic),
|
||||
other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata descriptor for a channel.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ChannelDescriptor {
|
||||
pub kind: ParadiseChannelKind,
|
||||
pub id: u8,
|
||||
pub slug: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
impl ChannelDescriptor {
|
||||
pub const fn new(kind: ParadiseChannelKind) -> Self {
|
||||
Self {
|
||||
id: kind.id(),
|
||||
slug: kind.slug(),
|
||||
display_name: kind.display_name(),
|
||||
description: kind.description(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All available Radio Paradise channels
|
||||
pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Main),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Mellow),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Rock),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Eclectic),
|
||||
];
|
||||
|
||||
/// Returns the maximum valid channel ID
|
||||
pub const fn max_channel_id() -> u8 {
|
||||
(ALL_CHANNELS.len() - 1) as u8
|
||||
}
|
||||
|
||||
/// Default maximum number of tracks to keep in history
|
||||
///
|
||||
/// This is used as the default if not configured via pmoconfig.
|
||||
/// Value: 100 tracks - represents ~5-8 hours of playback history
|
||||
pub const HISTORY_DEFAULT_MAX_TRACKS: usize = 100;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_channel_ids() {
|
||||
assert_eq!(ParadiseChannelKind::Main.id(), 0);
|
||||
assert_eq!(ParadiseChannelKind::Mellow.id(), 1);
|
||||
assert_eq!(ParadiseChannelKind::Rock.id(), 2);
|
||||
assert_eq!(ParadiseChannelKind::Eclectic.id(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_channel_id() {
|
||||
assert_eq!(max_channel_id(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_channels_length() {
|
||||
assert_eq!(ALL_CHANNELS.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_from_str() {
|
||||
assert!(matches!(
|
||||
"main".parse::<ParadiseChannelKind>(),
|
||||
Ok(ParadiseChannelKind::Main)
|
||||
));
|
||||
assert!(matches!(
|
||||
"0".parse::<ParadiseChannelKind>(),
|
||||
Ok(ParadiseChannelKind::Main)
|
||||
));
|
||||
assert!("invalid".parse::<ParadiseChannelKind>().is_err());
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ use url::Url;
|
||||
/// Default Radio Paradise API base URL
|
||||
pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api";
|
||||
|
||||
/// Default block base URL pattern
|
||||
pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan/0";
|
||||
/// Default block base URL (channel is appended)
|
||||
pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan";
|
||||
|
||||
/// Default image base URL
|
||||
pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/";
|
||||
@@ -24,6 +24,9 @@ pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 180;
|
||||
/// Default User-Agent
|
||||
pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
|
||||
|
||||
/// Default channel (0 = main mix)
|
||||
pub const DEFAULT_CHANNEL: u8 = 0;
|
||||
|
||||
/// Radio Paradise HTTP client
|
||||
///
|
||||
/// This client provides access to Radio Paradise's streaming API,
|
||||
@@ -48,7 +51,6 @@ pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
|
||||
pub struct RadioParadiseClient {
|
||||
pub(crate) client: Client,
|
||||
api_base: String,
|
||||
block_base: String,
|
||||
channel: u8,
|
||||
pub(crate) request_timeout: Duration,
|
||||
pub(crate) block_timeout: Duration,
|
||||
@@ -71,12 +73,14 @@ impl RadioParadiseClient {
|
||||
/// Create a client with a custom reqwest::Client
|
||||
///
|
||||
/// Useful for sharing HTTP connection pools or custom proxy settings
|
||||
///
|
||||
/// Note: Uses default settings (channel 0, default timeouts).
|
||||
/// For more control, use `ClientBuilder::default().client(client).build()`.
|
||||
pub fn with_client(client: Client) -> Self {
|
||||
Self {
|
||||
client,
|
||||
api_base: DEFAULT_API_BASE.to_string(),
|
||||
block_base: DEFAULT_BLOCK_BASE.to_string(),
|
||||
channel: 0,
|
||||
channel: DEFAULT_CHANNEL,
|
||||
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
||||
block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
|
||||
next_block_url: None,
|
||||
@@ -88,15 +92,15 @@ impl RadioParadiseClient {
|
||||
self.channel
|
||||
}
|
||||
|
||||
fn block_base_for_channel(channel: u8) -> String {
|
||||
format!("https://apps.radioparadise.com/blocks/chan/{}", channel)
|
||||
/// Get the block base URL for this client's channel
|
||||
pub fn block_base(&self) -> String {
|
||||
format!("{}/{}", DEFAULT_BLOCK_BASE, self.channel)
|
||||
}
|
||||
|
||||
/// Clone the client with a different channel while preserving other settings.
|
||||
pub fn clone_with_channel(&self, channel: u8) -> Self {
|
||||
let mut cloned = self.clone();
|
||||
cloned.channel = channel;
|
||||
cloned.block_base = Self::block_base_for_channel(channel);
|
||||
cloned.next_block_url = None;
|
||||
cloned
|
||||
}
|
||||
@@ -234,7 +238,6 @@ impl RadioParadiseClient {
|
||||
pub struct ClientBuilder {
|
||||
client: Option<Client>,
|
||||
api_base: String,
|
||||
block_base: String,
|
||||
channel: u8,
|
||||
request_timeout: Duration,
|
||||
block_timeout: Duration,
|
||||
@@ -247,8 +250,7 @@ impl Default for ClientBuilder {
|
||||
Self {
|
||||
client: None,
|
||||
api_base: DEFAULT_API_BASE.to_string(),
|
||||
block_base: DEFAULT_BLOCK_BASE.to_string(),
|
||||
channel: 0,
|
||||
channel: DEFAULT_CHANNEL,
|
||||
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
||||
block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
|
||||
user_agent: DEFAULT_USER_AGENT.to_string(),
|
||||
@@ -275,12 +277,6 @@ impl ClientBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the block base URL
|
||||
pub fn block_base(mut self, url: impl Into<String>) -> Self {
|
||||
self.block_base = url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc)
|
||||
pub fn channel(mut self, channel: u8) -> Self {
|
||||
self.channel = channel;
|
||||
@@ -329,16 +325,9 @@ impl ClientBuilder {
|
||||
builder.build()?
|
||||
};
|
||||
|
||||
let block_base = if self.block_base == DEFAULT_BLOCK_BASE {
|
||||
RadioParadiseClient::block_base_for_channel(self.channel)
|
||||
} else {
|
||||
self.block_base.clone()
|
||||
};
|
||||
|
||||
Ok(RadioParadiseClient {
|
||||
client,
|
||||
api_base: self.api_base,
|
||||
block_base,
|
||||
channel: self.channel,
|
||||
request_timeout: self.request_timeout,
|
||||
block_timeout: self.block_timeout,
|
||||
@@ -355,6 +344,6 @@ mod tests {
|
||||
fn test_builder_defaults() {
|
||||
let builder = ClientBuilder::default();
|
||||
assert_eq!(builder.api_base, DEFAULT_API_BASE);
|
||||
assert_eq!(builder.channel, 0);
|
||||
assert_eq!(builder.channel, DEFAULT_CHANNEL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,6 @@
|
||||
//!
|
||||
//! La configuration est minimale - seulement ce qui doit vraiment être configurable :
|
||||
//! - Activation/désactivation de la source
|
||||
//! - Chemin de la base de données d'historique
|
||||
//! - Taille maximale de l'historique
|
||||
//!
|
||||
//! Tous les autres paramètres (polling, timeouts, etc.) sont des constantes
|
||||
//! définies dans `paradise::constants`.
|
||||
//!
|
||||
//! # Exemple
|
||||
//!
|
||||
@@ -24,24 +19,12 @@
|
||||
//! println!("Radio Paradise is disabled");
|
||||
//! return Ok(());
|
||||
//! }
|
||||
//!
|
||||
//! // Get configuration
|
||||
//! let db_path = config.get_paradise_history_database()?;
|
||||
//! let max_tracks = config.get_paradise_history_size()?;
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL};
|
||||
use anyhow::Result;
|
||||
use pmoconfig::Config;
|
||||
use serde_yaml::{Number, Value};
|
||||
|
||||
use crate::paradise::constants;
|
||||
|
||||
/// Nom du répertoire pour Radio Paradise (relatif au config_dir)
|
||||
///
|
||||
/// La base de données sera stockée dans `<config_dir>/paradise/history.db`
|
||||
const DEFAULT_HISTORY_DATABASE_DIR: &str = "paradise";
|
||||
use serde_yaml::Value;
|
||||
|
||||
/// Trait d'extension pour gérer la configuration Radio Paradise dans pmoconfig
|
||||
///
|
||||
@@ -50,7 +33,7 @@ const DEFAULT_HISTORY_DATABASE_DIR: &str = "paradise";
|
||||
///
|
||||
/// # Auto-persist des valeurs par défaut
|
||||
///
|
||||
/// Tous les getters persistent automatiquement la valeur par défaut dans la
|
||||
/// Le getter persiste automatiquement la valeur par défaut dans la
|
||||
/// configuration si elle n'existe pas encore. Cela permet à l'utilisateur de
|
||||
/// voir la configuration effective dans le fichier YAML et de la modifier facilement.
|
||||
///
|
||||
@@ -65,10 +48,7 @@ const DEFAULT_HISTORY_DATABASE_DIR: &str = "paradise";
|
||||
/// // Premier appel : persiste "enabled: true" dans la config et retourne true
|
||||
/// let enabled = config.get_paradise_enabled()?;
|
||||
///
|
||||
/// // Premier appel : persiste "max_tracks: 100" dans la config et retourne 100
|
||||
/// let max_tracks = config.get_paradise_history_size()?;
|
||||
///
|
||||
/// // L'utilisateur peut maintenant éditer ces valeurs dans le fichier YAML
|
||||
/// // L'utilisateur peut maintenant éditer cette valeur dans le fichier YAML
|
||||
/// ```
|
||||
pub trait RadioParadiseConfigExt {
|
||||
/// Vérifie si Radio Paradise est activé
|
||||
@@ -103,70 +83,59 @@ pub trait RadioParadiseConfigExt {
|
||||
/// ```
|
||||
fn set_paradise_enabled(&self, enabled: bool) -> Result<()>;
|
||||
|
||||
/// Récupère le chemin de la base de données d'historique
|
||||
///
|
||||
/// Le chemin retourné est absolu et pointe vers `<config_dir>/paradise/history.db`.
|
||||
/// Le répertoire `paradise` est créé automatiquement s'il n'existe pas.
|
||||
/// Récupère le channel par défaut
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le chemin absolu vers la base de données SQLite d'historique.
|
||||
/// Exemple: `/home/user/.config/pmo/paradise/history.db`
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let db_path = config.get_paradise_history_database()?;
|
||||
/// let backend = SqliteHistoryBackend::new(&db_path)?;
|
||||
/// ```
|
||||
fn get_paradise_history_database(&self) -> Result<String>;
|
||||
|
||||
/// Définit le chemin de la base de données d'historique
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin complet vers la base de données (doit inclure le nom du fichier)
|
||||
///
|
||||
/// Le répertoire parent sera extrait et stocké dans la configuration.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // Set custom path
|
||||
/// config.set_paradise_history_database("/var/lib/pmo/paradise.db".to_string())?;
|
||||
/// ```
|
||||
fn set_paradise_history_database(&self, path: String) -> Result<()>;
|
||||
|
||||
/// Récupère le nombre maximal de pistes dans l'historique
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le nombre maximal de pistes à conserver dans l'historique.
|
||||
/// Le channel par défaut (0 = Main Mix par défaut).
|
||||
///
|
||||
/// Si la valeur n'existe pas dans la configuration, elle est automatiquement
|
||||
/// définie à la constante `HISTORY_DEFAULT_MAX_TRACKS` (100) et persistée.
|
||||
/// définie à "main" et persistée.
|
||||
///
|
||||
/// # Exemple
|
||||
/// # Channels disponibles
|
||||
///
|
||||
/// Peut être configuré comme chaîne de caractères ou nombre :
|
||||
/// - "main" ou 0 = Main Mix (eclectic, diverse mix)
|
||||
/// - "mellow" ou 1 = Mellow Mix (smooth, chilled music)
|
||||
/// - "rock" ou 2 = Rock Mix (classic & modern rock)
|
||||
/// - "eclectic" ou 3 = Eclectic Mix (global sounds)
|
||||
///
|
||||
/// # Exemple de configuration YAML
|
||||
///
|
||||
/// ```yaml
|
||||
/// sources:
|
||||
/// radio_paradise:
|
||||
/// default_channel: mellow # or 1
|
||||
/// ```
|
||||
///
|
||||
/// # Exemple d'utilisation
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let max_tracks = config.get_paradise_history_size()?;
|
||||
/// println!("Keeping last {} tracks", max_tracks);
|
||||
/// let channel = config.get_paradise_default_channel()?;
|
||||
/// let client = RadioParadiseClient::builder().channel(channel).build().await?;
|
||||
/// ```
|
||||
fn get_paradise_history_size(&self) -> Result<usize>;
|
||||
fn get_paradise_default_channel(&self) -> Result<u8>;
|
||||
|
||||
/// Définit le nombre maximal de pistes dans l'historique
|
||||
/// Définit le channel par défaut
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `size` - Nombre maximal de pistes à conserver
|
||||
/// * `channel` - Le channel (0-3)
|
||||
///
|
||||
/// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.)
|
||||
/// dans le fichier de configuration.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // Keep last 200 tracks
|
||||
/// config.set_paradise_history_size(200)?;
|
||||
/// use pmoparadise::channels::ParadiseChannelKind;
|
||||
///
|
||||
/// // Use Mellow Mix by default
|
||||
/// config.set_paradise_default_channel(ParadiseChannelKind::Mellow.id())?;
|
||||
/// // Or simply:
|
||||
/// config.set_paradise_default_channel(1)?;
|
||||
/// ```
|
||||
fn set_paradise_history_size(&self, size: usize) -> Result<()>;
|
||||
fn set_paradise_default_channel(&self, channel: u8) -> Result<()>;
|
||||
}
|
||||
|
||||
impl RadioParadiseConfigExt for Config {
|
||||
@@ -188,52 +157,59 @@ impl RadioParadiseConfigExt for Config {
|
||||
)
|
||||
}
|
||||
|
||||
fn get_paradise_history_database(&self) -> Result<String> {
|
||||
// Get managed directory: ~/.config/pmo/paradise/
|
||||
let dir = self.get_managed_dir(
|
||||
&["sources", "radio_paradise", "database"],
|
||||
DEFAULT_HISTORY_DATABASE_DIR,
|
||||
)?;
|
||||
|
||||
// Ensure directory exists
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
// Build full path: ~/.config/pmo/paradise/history.db
|
||||
let mut path = PathBuf::from(dir);
|
||||
path.push("history.db");
|
||||
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn set_paradise_history_database(&self, path: String) -> Result<()> {
|
||||
// Extract parent directory from the full path
|
||||
match PathBuf::from(&path).parent() {
|
||||
Some(dir) => self.set_managed_dir(
|
||||
&["sources", "radio_paradise", "database"],
|
||||
dir.to_string_lossy().to_string(),
|
||||
),
|
||||
None => Err(anyhow!("Invalid database path: no parent directory")),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_paradise_history_size(&self) -> Result<usize> {
|
||||
match self.get_value(&["sources", "radio_paradise", "history", "max_tracks"]) {
|
||||
Ok(Value::Number(n)) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
|
||||
Ok(Value::Number(n)) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
|
||||
fn get_paradise_default_channel(&self) -> Result<u8> {
|
||||
match self.get_value(&["sources", "radio_paradise", "default_channel"]) {
|
||||
Ok(Value::String(s)) => {
|
||||
// Try to parse as channel name (e.g., "main", "mellow", etc.)
|
||||
match s.parse::<ParadiseChannelKind>() {
|
||||
Ok(kind) => Ok(kind.id()),
|
||||
Err(_) => {
|
||||
// Invalid channel name, use default
|
||||
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Value::Number(n)) => {
|
||||
// Accept numeric channel ID (0-3)
|
||||
if let Some(ch) = n.as_u64() {
|
||||
if ch <= 3 {
|
||||
Ok(ch as u8)
|
||||
} else {
|
||||
// Invalid channel number, use default
|
||||
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
}
|
||||
} else {
|
||||
// Not a valid number, use default
|
||||
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Use default and persist it
|
||||
let default = constants::HISTORY_DEFAULT_MAX_TRACKS;
|
||||
self.set_paradise_history_size(default)?;
|
||||
Ok(default)
|
||||
// Use default and persist it as "main" (user-friendly)
|
||||
self.set_value(
|
||||
&["sources", "radio_paradise", "default_channel"],
|
||||
Value::String("main".to_string()),
|
||||
)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_paradise_history_size(&self, size: usize) -> Result<()> {
|
||||
let n = Number::from(size);
|
||||
fn set_paradise_default_channel(&self, channel: u8) -> Result<()> {
|
||||
// Convert channel ID to user-friendly string name
|
||||
let channel_name = match channel {
|
||||
0 => "main",
|
||||
1 => "mellow",
|
||||
2 => "rock",
|
||||
3 => "eclectic",
|
||||
_ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)),
|
||||
};
|
||||
|
||||
self.set_value(
|
||||
&["sources", "radio_paradise", "history", "max_tracks"],
|
||||
Value::Number(n),
|
||||
&["sources", "radio_paradise", "default_channel"],
|
||||
Value::String(channel_name.to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -243,21 +219,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_values() {
|
||||
assert_eq!(DEFAULT_HISTORY_DATABASE_DIR, "paradise");
|
||||
assert_eq!(constants::HISTORY_DEFAULT_MAX_TRACKS, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_database_path_construction() {
|
||||
// Simulating path construction
|
||||
let base = "/home/user/.config/pmo/paradise";
|
||||
let mut path = PathBuf::from(base);
|
||||
path.push("history.db");
|
||||
|
||||
assert_eq!(
|
||||
path.to_string_lossy(),
|
||||
"/home/user/.config/pmo/paradise/history.db"
|
||||
);
|
||||
fn test_trait_exists() {
|
||||
// Simple test to ensure the trait compiles
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,16 +34,6 @@ pub enum Error {
|
||||
#[error("Invalid event ID: {0}")]
|
||||
InvalidEvent(String),
|
||||
|
||||
/// FLAC decoding error (per-track feature)
|
||||
#[cfg(feature = "per-track")]
|
||||
#[error("FLAC decoding error: {0}")]
|
||||
FlacDecode(String),
|
||||
|
||||
/// WAV encoding error (per-track feature)
|
||||
#[cfg(feature = "per-track")]
|
||||
#[error("WAV encoding error: {0}")]
|
||||
WavEncode(#[from] hound::Error),
|
||||
|
||||
/// Track not found in block
|
||||
#[error("Track not found at index {0}")]
|
||||
TrackNotFound(usize),
|
||||
@@ -67,11 +57,3 @@ impl Error {
|
||||
Self::Other(msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
// Implement conversion from claxon errors for per-track feature
|
||||
#[cfg(feature = "per-track")]
|
||||
impl From<claxon::Error> for Error {
|
||||
fn from(err: claxon::Error) -> Self {
|
||||
Error::FlacDecode(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
//! FFmpeg-based progressive streaming decoder/encoder
|
||||
//!
|
||||
//! This module provides progressive audio streaming using FFmpeg,
|
||||
//! allowing for much lower latency than the claxon/flacenc approach.
|
||||
//!
|
||||
//! Key advantages:
|
||||
//! - Start streaming immediately (< 1 second latency)
|
||||
//! - Progressive decoding and encoding in a pipeline
|
||||
//! - Better performance (C code vs Rust)
|
||||
//! - Support for multiple output formats
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use bytes::Bytes;
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
|
||||
use tokio::task;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
/// Initialize FFmpeg (must be called once at startup)
|
||||
pub fn init() -> Result<()> {
|
||||
ffmpeg::init().context("Failed to initialize FFmpeg")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PCM chunk with decoded audio data
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PCMChunk {
|
||||
pub samples: Vec<i16>, // Interleaved 16-bit samples
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
pub position_ms: u64,
|
||||
}
|
||||
|
||||
/// Progressive decoder that decodes FLAC data as it arrives
|
||||
pub struct ProgressiveDecoder {
|
||||
input_rx: Receiver<Result<Bytes, String>>,
|
||||
buffer: Vec<u8>,
|
||||
decoder_ctx: Option<ffmpeg::codec::context::Context>,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
total_samples_decoded: u64,
|
||||
}
|
||||
|
||||
impl ProgressiveDecoder {
|
||||
/// Create a new progressive decoder from a byte stream
|
||||
pub fn new(mut stream: impl Read + Send + 'static) -> Result<Self> {
|
||||
let (tx, rx) = sync_channel(64);
|
||||
|
||||
// Spawn a thread to read from the stream and feed chunks
|
||||
std::thread::spawn(move || {
|
||||
let mut buffer = vec![0u8; 8192];
|
||||
loop {
|
||||
match stream.read(&mut buffer) {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(n) => {
|
||||
let chunk = Bytes::copy_from_slice(&buffer[..n]);
|
||||
if tx.send(Ok(chunk)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e.to_string()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
input_rx: rx,
|
||||
buffer: Vec::with_capacity(65536),
|
||||
decoder_ctx: None,
|
||||
sample_rate: 0,
|
||||
channels: 0,
|
||||
total_samples_decoded: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode the next chunk of PCM data
|
||||
pub fn decode_chunk(&mut self) -> Result<Option<PCMChunk>> {
|
||||
// Receive more data from the stream
|
||||
while self.buffer.len() < 4096 {
|
||||
match self.input_rx.try_recv() {
|
||||
Ok(Ok(bytes)) => {
|
||||
self.buffer.extend_from_slice(&bytes);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return Err(anyhow!("Stream error: {}", e));
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||||
// No more data available right now
|
||||
break;
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
// Stream ended
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Initialize decoder on first call
|
||||
if self.decoder_ctx.is_none() {
|
||||
self.init_decoder()?;
|
||||
}
|
||||
|
||||
// Decode a frame
|
||||
// TODO: Implement actual FFmpeg decoding
|
||||
// For now, return a placeholder
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn init_decoder(&mut self) -> Result<()> {
|
||||
// TODO: Initialize FFmpeg decoder from buffer
|
||||
// Parse FLAC header, create decoder context
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Progressive encoder that encodes PCM to FLAC as data arrives
|
||||
pub struct ProgressiveEncoder {
|
||||
output_tx: SyncSender<Bytes>,
|
||||
encoder_ctx: Option<ffmpeg::codec::context::Context>,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
}
|
||||
|
||||
impl ProgressiveEncoder {
|
||||
/// Create a new progressive encoder
|
||||
pub fn new(sample_rate: u32, channels: u32) -> Result<(Self, Receiver<Bytes>)> {
|
||||
let (tx, rx) = sync_channel(64);
|
||||
|
||||
let encoder = Self {
|
||||
output_tx: tx,
|
||||
encoder_ctx: None,
|
||||
sample_rate,
|
||||
channels,
|
||||
};
|
||||
|
||||
Ok((encoder, rx))
|
||||
}
|
||||
|
||||
/// Encode a chunk of PCM data
|
||||
pub fn encode_chunk(&mut self, pcm: &PCMChunk) -> Result<()> {
|
||||
// TODO: Implement FFmpeg encoding
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush any remaining encoded data
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
// TODO: Flush encoder
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ffmpeg_init() {
|
||||
assert!(init().is_ok());
|
||||
}
|
||||
}
|
||||
@@ -164,73 +164,57 @@
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Caching Support (Feature: `cache`)
|
||||
//! ## Audio Streaming (Feature: `pmoaudio`)
|
||||
//!
|
||||
//! `pmoparadise` can optionally integrate with `pmocovers` and `pmoaudiocache` to cache
|
||||
//! cover images and audio tracks locally:
|
||||
//! For direct audio streaming and integration with pmoaudio pipelines,
|
||||
//! use `RadioParadiseStreamSource`:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(feature = "cache")]
|
||||
//! # #[cfg(feature = "pmoaudio")]
|
||||
//! # {
|
||||
//! use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
|
||||
//! use std::sync::Arc;
|
||||
//! use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
|
||||
//! use pmoaudio::pipeline::Node;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // Create caches
|
||||
//! let cover_cache = Arc::new(pmocovers::cache::new_cache("./cache/covers", 500)?);
|
||||
//! let audio_cache = Arc::new(pmoaudiocache::cache::new_cache("./cache/audio", 100)?);
|
||||
//!
|
||||
//! // Create client and source with caching
|
||||
//! let client = RadioParadiseClient::new().await?;
|
||||
//! let source = RadioParadiseSource::new(
|
||||
//! client,
|
||||
//! 50,
|
||||
//! cover_cache,
|
||||
//! audio_cache,
|
||||
//! );
|
||||
//! let stream_source = RadioParadiseStreamSource::new(client, None).await?;
|
||||
//!
|
||||
//! println!("Source ready: {}", source.name());
|
||||
//! // Create audio node from stream source
|
||||
//! let node = Node::from_logic(stream_source);
|
||||
//!
|
||||
//! // Use in pmoaudio pipeline...
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! **Benefits**:
|
||||
//! - Cover images are automatically downloaded and converted to WebP
|
||||
//! - Audio tracks are cached as FLAC with metadata preserved
|
||||
//! - Subsequent access is instant (no re-download)
|
||||
//! - URIs returned by `resolve_uri()` point to cached versions
|
||||
//!
|
||||
//! See the `with_cache` example for a complete demonstration.
|
||||
//! **RadioParadiseStreamSource**:
|
||||
//! - Downloads and decodes FLAC blocks in real-time
|
||||
//! - Automatically detects bit depth (16/24/32-bit)
|
||||
//! - Inserts track boundaries with metadata
|
||||
//! - Integrates seamlessly with pmoaudio pipelines
|
||||
//!
|
||||
//! ## Cargo Features
|
||||
//!
|
||||
//! - `default = ["metadata-only"]`: Standard metadata and streaming (no FLAC decoding)
|
||||
//! - `default`: Standard metadata and streaming (no FLAC decoding)
|
||||
//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`)
|
||||
//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`)
|
||||
//! - `server`: Enable server-side features (cache registry integration)
|
||||
//! - `cache`: Enable cover and audio caching support (adds `pmocovers`, `pmoaudiocache`)
|
||||
//! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration
|
||||
//! - `pmoconfig`: Enable configuration integration with pmoconfig
|
||||
//! - `server`: Enable RadioParadiseSource stub for backward compatibility (deprecated)
|
||||
//!
|
||||
//! ## See Also
|
||||
//!
|
||||
//! - [Radio Paradise](https://radioparadise.com) - Official website
|
||||
//! - [Radio Paradise API](https://api.radioparadise.com) - API documentation
|
||||
|
||||
pub mod channels;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod paradise;
|
||||
pub mod source;
|
||||
pub mod stream;
|
||||
pub mod streaming;
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
pub mod track;
|
||||
|
||||
#[cfg(feature = "ffmpeg")]
|
||||
pub mod ffmpeg_streaming;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod pmoserver_ext;
|
||||
@@ -238,15 +222,17 @@ pub mod pmoserver_ext;
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub mod config_ext;
|
||||
|
||||
#[cfg(feature = "pmoaudio")]
|
||||
pub mod radio_paradise_stream_source;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use client::{ClientBuilder, RadioParadiseClient};
|
||||
pub use error::{Error, Result};
|
||||
pub use models::{Block, DurationMs, EventId, NowPlaying, Song};
|
||||
pub use source::RadioParadiseSource;
|
||||
pub use stream::BlockStream;
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
pub use track::{TrackMetadata, TrackStream};
|
||||
#[cfg(feature = "pmoaudio")]
|
||||
pub use radio_paradise_stream_source::RadioParadiseStreamSource;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub use pmoserver_ext::{
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
//! Channel orchestration primitives.
|
||||
//!
|
||||
//! This module wires together configuration, playlists, workers and client
|
||||
//! tracking for a single Radio Paradise channel. The implementation is still
|
||||
//! a scaffolding of the final behaviour; commands sent to the worker are
|
||||
//! logged but not yet executing the full download/buffering pipeline.
|
||||
|
||||
use super::history::HistoryBackend;
|
||||
use super::playlist::{PlaylistEntry, SharedPlaylist};
|
||||
use super::worker::{ParadiseWorker, WorkerCommand};
|
||||
use crate::client::RadioParadiseClient;
|
||||
use anyhow::{Context, Result};
|
||||
use async_stream::try_stream;
|
||||
use bytes::Bytes;
|
||||
use futures::{stream::BoxStream, StreamExt};
|
||||
use pmosource::SourceCacheManager;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs::File;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tracing::warn;
|
||||
|
||||
/// Logical identifier for a Radio Paradise channel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ParadiseChannelKind {
|
||||
Main,
|
||||
Mellow,
|
||||
Rock,
|
||||
Eclectic,
|
||||
}
|
||||
|
||||
impl ParadiseChannelKind {
|
||||
pub const fn id(self) -> u8 {
|
||||
match self {
|
||||
Self::Main => 0,
|
||||
Self::Mellow => 1,
|
||||
Self::Rock => 2,
|
||||
Self::Eclectic => 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "main",
|
||||
Self::Mellow => "mellow",
|
||||
Self::Rock => "rock",
|
||||
Self::Eclectic => "eclectic",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "Main Mix",
|
||||
Self::Mellow => "Mellow Mix",
|
||||
Self::Rock => "Rock Mix",
|
||||
Self::Eclectic => "Eclectic Mix",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "Eclectic mix of rock, world, electronica, and more",
|
||||
Self::Mellow => "Mellower, less aggressive music",
|
||||
Self::Rock => "Heavier, more guitar-driven music",
|
||||
Self::Eclectic => "Curated worldwide selection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ParadiseChannelKind {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"main" | "0" => Ok(Self::Main),
|
||||
"mellow" | "1" => Ok(Self::Mellow),
|
||||
"rock" | "2" => Ok(Self::Rock),
|
||||
"eclectic" | "3" => Ok(Self::Eclectic),
|
||||
other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata descriptor for a channel.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ChannelDescriptor {
|
||||
pub kind: ParadiseChannelKind,
|
||||
pub id: u8,
|
||||
pub slug: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
impl ChannelDescriptor {
|
||||
pub const fn new(kind: ParadiseChannelKind) -> Self {
|
||||
Self {
|
||||
id: kind.id(),
|
||||
slug: kind.slug(),
|
||||
display_name: kind.display_name(),
|
||||
description: kind.description(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Main),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Mellow),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Rock),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Eclectic),
|
||||
];
|
||||
|
||||
/// Returns the maximum valid channel ID
|
||||
pub const fn max_channel_id() -> u8 {
|
||||
(ALL_CHANNELS.len() - 1) as u8
|
||||
}
|
||||
|
||||
/// Public handle to interact with a channel.
|
||||
#[derive(Clone)]
|
||||
pub struct ParadiseChannel {
|
||||
inner: Arc<ParadiseChannelInner>,
|
||||
}
|
||||
|
||||
struct ParadiseChannelInner {
|
||||
descriptor: ChannelDescriptor,
|
||||
client: RadioParadiseClient,
|
||||
history_max_tracks: usize,
|
||||
playlist: SharedPlaylist,
|
||||
history: Arc<dyn HistoryBackend>,
|
||||
cache_manager: Arc<SourceCacheManager>,
|
||||
active_clients: AtomicUsize,
|
||||
worker_tx: mpsc::Sender<WorkerCommand>,
|
||||
worker: Mutex<Option<ParadiseWorker>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ParadiseChannel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ParadiseChannel")
|
||||
.field("slug", &self.inner.descriptor.slug)
|
||||
.field(
|
||||
"active_clients",
|
||||
&self.inner.active_clients.load(Ordering::SeqCst),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParadiseChannel {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
descriptor: ChannelDescriptor,
|
||||
base_client: RadioParadiseClient,
|
||||
history_max_tracks: usize,
|
||||
history: Arc<dyn HistoryBackend>,
|
||||
cache_manager: Arc<SourceCacheManager>,
|
||||
) -> Result<Self> {
|
||||
let client = base_client.clone_with_channel(descriptor.id);
|
||||
let playlist = SharedPlaylist::new(history_max_tracks);
|
||||
let (worker, worker_tx) = ParadiseWorker::spawn(
|
||||
descriptor,
|
||||
client.clone(),
|
||||
history_max_tracks,
|
||||
playlist.clone(),
|
||||
history.clone(),
|
||||
cache_manager.clone(),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(ParadiseChannelInner {
|
||||
descriptor,
|
||||
client,
|
||||
history_max_tracks,
|
||||
playlist,
|
||||
history,
|
||||
cache_manager,
|
||||
active_clients: AtomicUsize::new(0),
|
||||
worker_tx,
|
||||
worker: Mutex::new(Some(worker)),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> ChannelDescriptor {
|
||||
self.inner.descriptor
|
||||
}
|
||||
|
||||
pub fn playlist(&self) -> &SharedPlaylist {
|
||||
&self.inner.playlist
|
||||
}
|
||||
|
||||
pub fn history_max_tracks(&self) -> usize {
|
||||
self.inner.history_max_tracks
|
||||
}
|
||||
|
||||
pub fn history_backend(&self) -> &Arc<dyn HistoryBackend> {
|
||||
&self.inner.history
|
||||
}
|
||||
|
||||
pub fn cache_manager(&self) -> Arc<SourceCacheManager> {
|
||||
self.inner.cache_manager.clone()
|
||||
}
|
||||
|
||||
pub fn client(&self) -> &RadioParadiseClient {
|
||||
&self.inner.client
|
||||
}
|
||||
|
||||
pub fn active_client_count(&self) -> usize {
|
||||
self.inner.active_clients.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub async fn connect_client(
|
||||
&self,
|
||||
client_id: impl Into<String>,
|
||||
) -> Result<ParadiseClientStream> {
|
||||
let client_id = client_id.into();
|
||||
self.inner.active_clients.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if let Err(err) = self
|
||||
.inner
|
||||
.worker_tx
|
||||
.send(WorkerCommand::ClientConnected {
|
||||
client_id: client_id.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
self.inner.active_clients.fetch_sub(1, Ordering::SeqCst);
|
||||
return Err(anyhow::anyhow!("worker unavailable: {}", err));
|
||||
}
|
||||
|
||||
self.inner.playlist.increment_all_pending().await;
|
||||
self.ensure_started().await?;
|
||||
|
||||
Ok(ParadiseClientStream::new(self.clone(), client_id))
|
||||
}
|
||||
|
||||
pub async fn disconnect_client(&self, client_id: impl Into<String>) -> Result<()> {
|
||||
let client_id = client_id.into();
|
||||
self.inner.active_clients.fetch_sub(1, Ordering::SeqCst);
|
||||
self.inner
|
||||
.worker_tx
|
||||
.send(WorkerCommand::ClientDisconnected { client_id })
|
||||
.await
|
||||
.context("failed to notify worker of client disconnection")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ensure_started(&self) -> Result<()> {
|
||||
self.inner
|
||||
.worker_tx
|
||||
.send(WorkerCommand::EnsureReady)
|
||||
.await
|
||||
.context("failed to schedule worker warmup")
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> Result<()> {
|
||||
self.inner
|
||||
.worker_tx
|
||||
.send(WorkerCommand::Shutdown)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let mut guard = self.inner.worker.lock().await;
|
||||
if let Some(worker) = guard.take() {
|
||||
worker
|
||||
.wait()
|
||||
.await
|
||||
.context("failed to join worker task")
|
||||
.map(|_| ())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_track_completed(&self, track: &Arc<PlaylistEntry>) {
|
||||
let remaining = track.decrement_clients();
|
||||
if remaining > 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(removed) = self
|
||||
.inner
|
||||
.playlist
|
||||
.pop_front_matching(&track.track_id)
|
||||
.await
|
||||
{
|
||||
if let Err(err) = self.inner.history.append(removed.as_history_entry()).await {
|
||||
warn!(
|
||||
channel = self.inner.descriptor.slug,
|
||||
"Failed to persist history entry: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(err) = self
|
||||
.inner
|
||||
.history
|
||||
.truncate(self.inner.history_max_tracks)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
channel = self.inner.descriptor.slug,
|
||||
"Failed to truncate history: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let history_entry = removed.as_history_entry();
|
||||
self.inner.playlist.push_history_entry(history_entry).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder stream handle for per-client playback.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParadiseClientStream {
|
||||
channel: ParadiseChannel,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
impl ParadiseClientStream {
|
||||
fn new(channel: ParadiseChannel, client_id: String) -> Self {
|
||||
Self { channel, client_id }
|
||||
}
|
||||
|
||||
pub fn client_id(&self) -> &str {
|
||||
&self.client_id
|
||||
}
|
||||
|
||||
pub fn channel(&self) -> ParadiseChannel {
|
||||
self.channel.clone()
|
||||
}
|
||||
|
||||
pub fn into_byte_stream(self) -> BoxStream<'static, Result<Bytes, anyhow::Error>> {
|
||||
let channel = self.channel.clone();
|
||||
let client_id = self.client_id.clone();
|
||||
let stream = try_stream! {
|
||||
tracing::info!(
|
||||
channel = channel.descriptor().slug,
|
||||
client_id = %client_id,
|
||||
"🎧 Client connecting to stream"
|
||||
);
|
||||
channel.ensure_started().await?;
|
||||
let mut last_track_id: Option<String> = None;
|
||||
loop {
|
||||
let entries = channel.playlist().active_snapshot().await;
|
||||
|
||||
// Find the next track after last_track_id
|
||||
let next_entry = if let Some(ref last_id) = last_track_id {
|
||||
// Find the position of the last track we read
|
||||
let last_pos = entries.iter().position(|e| e.track_id == *last_id);
|
||||
|
||||
// Get the next track (or wait if none available)
|
||||
match last_pos {
|
||||
Some(pos) if pos + 1 < entries.len() => {
|
||||
Some(entries[pos + 1].clone())
|
||||
}
|
||||
_ => {
|
||||
// Last track not found (was removed) or no next track available
|
||||
// Wait for more tracks to be added
|
||||
channel.ensure_started().await?;
|
||||
let current_len = entries.len();
|
||||
channel.playlist().wait_for_track_count(current_len).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First track for this client
|
||||
if entries.is_empty() {
|
||||
channel.ensure_started().await?;
|
||||
channel.playlist().wait_for_track_count(0).await;
|
||||
continue;
|
||||
}
|
||||
Some(entries[0].clone())
|
||||
};
|
||||
|
||||
let entry = next_entry.unwrap();
|
||||
last_track_id = Some(entry.track_id.clone());
|
||||
|
||||
let audio_pk = entry
|
||||
.audio_pk
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Audio not cached yet"))?;
|
||||
|
||||
channel
|
||||
.cache_manager()
|
||||
.wait_audio_ready(&audio_pk)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
let file_path = if let Some(path) = entry.file_path.clone() {
|
||||
path
|
||||
} else {
|
||||
channel
|
||||
.cache_manager()
|
||||
.audio_file_path(&audio_pk)
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("Audio file path unavailable"))?
|
||||
};
|
||||
|
||||
let file = File::open(&file_path).await?;
|
||||
let mut reader = ReaderStream::new(file);
|
||||
|
||||
while let Some(chunk) = reader.next().await {
|
||||
let bytes = chunk?;
|
||||
yield bytes;
|
||||
}
|
||||
|
||||
channel.mark_track_completed(&entry).await;
|
||||
}
|
||||
};
|
||||
|
||||
stream.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ParadiseClientStream {
|
||||
fn drop(&mut self) {
|
||||
let channel = self.channel.clone();
|
||||
let client_id = self.client_id.clone();
|
||||
let slug = channel.descriptor().slug;
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = channel.disconnect_client(client_id).await {
|
||||
warn!(channel = slug, "Failed to disconnect client: {err:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
//! Constants for Radio Paradise orchestration layer.
|
||||
//!
|
||||
//! This module defines all the hardcoded parameters for the Radio Paradise
|
||||
//! integration. These values are based on empirical testing and Radio Paradise's
|
||||
//! infrastructure characteristics.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
// ============================================================================
|
||||
// Activity Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
/// Cooling timeout after all clients disconnect (seconds)
|
||||
///
|
||||
/// After the last client disconnects, the channel enters a "cooling" state
|
||||
/// where it remains active for this duration before shutting down completely.
|
||||
/// This avoids rapid start/stop cycles if clients reconnect quickly.
|
||||
///
|
||||
/// Value: 180 seconds (3 minutes) - good balance between responsiveness and stability
|
||||
pub const COOLING_TIMEOUT_SECONDS: u64 = 180;
|
||||
|
||||
// ============================================================================
|
||||
// Polling Intervals
|
||||
// ============================================================================
|
||||
|
||||
/// High buffer polling interval (seconds)
|
||||
///
|
||||
/// When the playlist buffer has 3+ blocks, poll less frequently to reduce
|
||||
/// API load and network usage.
|
||||
///
|
||||
/// Value: 120 seconds (2 minutes)
|
||||
pub const POLLING_INTERVAL_HIGH_BUFFER: u64 = 120;
|
||||
|
||||
/// Medium buffer polling interval (seconds)
|
||||
///
|
||||
/// When the playlist buffer has 2 blocks, poll at moderate frequency.
|
||||
///
|
||||
/// Value: 60 seconds (1 minute)
|
||||
pub const POLLING_INTERVAL_MEDIUM_BUFFER: u64 = 60;
|
||||
|
||||
/// Low buffer polling interval (seconds)
|
||||
///
|
||||
/// When the playlist buffer has less than 2 blocks, poll frequently to
|
||||
/// ensure continuous playback.
|
||||
///
|
||||
/// Value: 20 seconds
|
||||
pub const POLLING_INTERVAL_LOW_BUFFER: u64 = 20;
|
||||
|
||||
/// Helper to get high buffer polling interval as Duration
|
||||
pub fn polling_high_interval() -> Duration {
|
||||
Duration::from_secs(POLLING_INTERVAL_HIGH_BUFFER)
|
||||
}
|
||||
|
||||
/// Helper to get medium buffer polling interval as Duration
|
||||
pub fn polling_medium_interval() -> Duration {
|
||||
Duration::from_secs(POLLING_INTERVAL_MEDIUM_BUFFER)
|
||||
}
|
||||
|
||||
/// Helper to get low buffer polling interval as Duration
|
||||
pub fn polling_low_interval() -> Duration {
|
||||
Duration::from_secs(POLLING_INTERVAL_LOW_BUFFER)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Polling Backoff (on API errors)
|
||||
// ============================================================================
|
||||
|
||||
/// Initial backoff delay on API error (seconds)
|
||||
///
|
||||
/// When an API request fails, we wait this duration before retrying.
|
||||
///
|
||||
/// Value: 20 seconds
|
||||
pub const BACKOFF_INITIAL_SECONDS: u64 = 20;
|
||||
|
||||
/// Maximum backoff delay (seconds)
|
||||
///
|
||||
/// Backoff is capped at this value to avoid waiting too long.
|
||||
///
|
||||
/// Value: 300 seconds (5 minutes)
|
||||
pub const BACKOFF_MAX_SECONDS: u64 = 300;
|
||||
|
||||
/// Backoff multiplier
|
||||
///
|
||||
/// After each failure, the delay is multiplied by this factor.
|
||||
/// Example: 20s → 40s → 80s → 160s → 300s (capped)
|
||||
///
|
||||
/// Value: 2.0 (exponential backoff)
|
||||
pub const BACKOFF_MULTIPLIER: f32 = 2.0;
|
||||
|
||||
// ============================================================================
|
||||
// Cache Tuning
|
||||
// ============================================================================
|
||||
|
||||
/// Maximum number of blocks to remember in the worker
|
||||
///
|
||||
/// This prevents unbounded memory growth by limiting how many block event IDs
|
||||
/// we track to avoid re-processing.
|
||||
///
|
||||
/// Calculation: (4 channels + 1 buffer) × 3 blocks per channel = 15 blocks
|
||||
/// Each block is ~20 minutes of audio, so 15 blocks ≈ 5 hours of history
|
||||
///
|
||||
/// Value: 15 blocks
|
||||
pub const MAX_BLOCKS_REMEMBERED: usize = 15;
|
||||
|
||||
/// Number of bytes to use for track ID hashing
|
||||
///
|
||||
/// Track IDs are constructed by hashing block content and track position.
|
||||
/// This value defines how much of the FLAC data we read for hashing.
|
||||
///
|
||||
/// Value: 512 bytes - sufficient for unique identification without excessive I/O
|
||||
pub const TRACK_ID_HASH_BYTES: usize = 512;
|
||||
|
||||
// ============================================================================
|
||||
// History
|
||||
// ============================================================================
|
||||
|
||||
/// Default maximum number of tracks to keep in history
|
||||
///
|
||||
/// This is used as the default if not configured via pmoconfig.
|
||||
/// Users can override this value in their configuration.
|
||||
///
|
||||
/// Value: 100 tracks - represents ~5-8 hours of playback history
|
||||
pub const HISTORY_DEFAULT_MAX_TRACKS: usize = 100;
|
||||
|
||||
// ============================================================================
|
||||
// Streaming
|
||||
// ============================================================================
|
||||
|
||||
/// Stream buffer size (bytes)
|
||||
///
|
||||
/// Buffer size for audio streaming. 64KB provides good balance between
|
||||
/// latency and buffering efficiency.
|
||||
///
|
||||
/// Value: 64 KB
|
||||
pub const STREAM_BUFFER_SIZE_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Enable gapless playback
|
||||
///
|
||||
/// Radio Paradise blocks are designed for gapless playback - each block
|
||||
/// transitions seamlessly to the next without audio gaps.
|
||||
///
|
||||
/// Value: true (always enabled)
|
||||
pub const STREAM_GAPLESS: bool = true;
|
||||
|
||||
// Note: Metadata format is always ICY (Icecast/SHOUTcast metadata)
|
||||
// No enum or constant needed as it's the only supported format
|
||||
|
||||
// ============================================================================
|
||||
// API Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// Radio Paradise API base URL
|
||||
///
|
||||
/// Base URL for all Radio Paradise API requests.
|
||||
/// This is hardcoded as Radio Paradise's API endpoint doesn't change.
|
||||
///
|
||||
/// Value: https://api.radioparadise.com
|
||||
pub const API_BASE_URL: &str = "https://api.radioparadise.com";
|
||||
|
||||
/// API request timeout (seconds)
|
||||
///
|
||||
/// Maximum time to wait for an API response before considering it failed.
|
||||
///
|
||||
/// Value: 30 seconds
|
||||
pub const API_TIMEOUT_SECONDS: u64 = 30;
|
||||
|
||||
/// User agent for API requests
|
||||
///
|
||||
/// Identifies PMOMusic in HTTP requests to Radio Paradise's servers.
|
||||
///
|
||||
/// Value: PMO-RadioParadise/1.0
|
||||
pub const API_USER_AGENT: &str = "PMO-RadioParadise/1.0";
|
||||
|
||||
/// Helper to get API timeout as Duration
|
||||
pub fn api_timeout() -> Duration {
|
||||
Duration::from_secs(API_TIMEOUT_SECONDS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_duration_helpers() {
|
||||
assert_eq!(polling_high_interval(), Duration::from_secs(120));
|
||||
assert_eq!(polling_medium_interval(), Duration::from_secs(60));
|
||||
assert_eq!(polling_low_interval(), Duration::from_secs(20));
|
||||
assert_eq!(api_timeout(), Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constants_sanity() {
|
||||
// Polling intervals should be ordered
|
||||
assert!(POLLING_INTERVAL_LOW_BUFFER < POLLING_INTERVAL_MEDIUM_BUFFER);
|
||||
assert!(POLLING_INTERVAL_MEDIUM_BUFFER < POLLING_INTERVAL_HIGH_BUFFER);
|
||||
|
||||
// Backoff should be reasonable
|
||||
assert!(BACKOFF_INITIAL_SECONDS < BACKOFF_MAX_SECONDS);
|
||||
assert!(BACKOFF_MULTIPLIER > 1.0);
|
||||
|
||||
// Cache limits should be positive
|
||||
assert!(MAX_BLOCKS_REMEMBERED > 0);
|
||||
assert!(TRACK_ID_HASH_BYTES > 0);
|
||||
|
||||
// History should be reasonable
|
||||
assert!(HISTORY_DEFAULT_MAX_TRACKS > 0);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! History persistence for Radio Paradise playback.
|
||||
//!
|
||||
//! The worker pushes every completed track into the history backend while
|
||||
//! keeping the latest entries available for UPnP browsing. We use SQLite
|
||||
//! for persistent storage with an abstract trait for testability.
|
||||
use crate::models::Song;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
/// Serializable record describing a played track.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryEntry {
|
||||
pub track_id: String,
|
||||
pub channel_id: u8,
|
||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
||||
pub duration_ms: u64,
|
||||
pub song: SongSnapshot,
|
||||
}
|
||||
|
||||
/// Minimal snapshot of a Radio Paradise song at playback time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SongSnapshot {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
}
|
||||
|
||||
impl SongSnapshot {
|
||||
pub fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Song> for SongSnapshot {
|
||||
fn from(song: &Song) -> Self {
|
||||
Self {
|
||||
title: song.title.clone(),
|
||||
artist: song.artist.clone(),
|
||||
album: song.album.clone(),
|
||||
cover_url: song.cover.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract persistence interface.
|
||||
#[async_trait]
|
||||
pub trait HistoryBackend: Send + Sync {
|
||||
async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()>;
|
||||
async fn recent(&self, limit: usize) -> anyhow::Result<Vec<HistoryEntry>>;
|
||||
async fn len(&self) -> anyhow::Result<usize>;
|
||||
async fn truncate(&self, keep: usize) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
pub struct SqliteHistoryBackend {
|
||||
conn: Arc<StdMutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl SqliteHistoryBackend {
|
||||
pub fn new(path: impl AsRef<Path>) -> anyhow::Result<Self> {
|
||||
let path = path.as_ref();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let conn = rusqlite::Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", &"WAL")?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS paradise_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
track_id TEXT NOT NULL,
|
||||
channel_id INTEGER NOT NULL,
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
artist TEXT,
|
||||
album TEXT,
|
||||
cover_url TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_started_at ON paradise_history(started_at_ms);",
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
conn: Arc::new(StdMutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
fn conn(&self) -> Arc<StdMutex<rusqlite::Connection>> {
|
||||
self.conn.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HistoryBackend for SqliteHistoryBackend {
|
||||
async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()> {
|
||||
let conn = self.conn();
|
||||
spawn_blocking(move || -> anyhow::Result<()> {
|
||||
let conn = conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO paradise_history (track_id, channel_id, started_at_ms, duration_ms, title, artist, album, cover_url)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
rusqlite::params![
|
||||
entry.track_id,
|
||||
entry.channel_id as i64,
|
||||
entry.started_at.timestamp_millis(),
|
||||
entry.duration_ms as i64,
|
||||
entry.song.title,
|
||||
entry.song.artist,
|
||||
entry.song.album,
|
||||
entry.song.cover_url,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recent(&self, limit: usize) -> anyhow::Result<Vec<HistoryEntry>> {
|
||||
let conn = self.conn();
|
||||
let limit = limit as i64;
|
||||
spawn_blocking(move || -> anyhow::Result<Vec<HistoryEntry>> {
|
||||
let conn = conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT track_id, channel_id, started_at_ms, duration_ms, title, artist, album, cover_url
|
||||
FROM paradise_history
|
||||
ORDER BY started_at_ms DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([limit])?;
|
||||
let mut entries = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let started_at_ms: i64 = row.get(2)?;
|
||||
let started_at = DateTime::<Utc>::from_timestamp_millis(started_at_ms)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp in history"))?;
|
||||
let entry = HistoryEntry {
|
||||
track_id: row.get(0)?,
|
||||
channel_id: row.get::<_, i64>(1)? as u8,
|
||||
started_at,
|
||||
duration_ms: row.get::<_, i64>(3)? as u64,
|
||||
song: SongSnapshot {
|
||||
title: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
|
||||
artist: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
|
||||
album: row.get(6)?,
|
||||
cover_url: row.get(7)?,
|
||||
},
|
||||
};
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(entries)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
async fn len(&self) -> anyhow::Result<usize> {
|
||||
let conn = self.conn();
|
||||
let count = spawn_blocking(move || -> anyhow::Result<usize> {
|
||||
let conn = conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT COUNT(*) FROM paradise_history")?;
|
||||
let count: i64 = stmt.query_row([], |row| row.get(0))?;
|
||||
Ok(count as usize)
|
||||
})
|
||||
.await??;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn truncate(&self, keep: usize) -> anyhow::Result<()> {
|
||||
let conn = self.conn();
|
||||
spawn_blocking(move || -> anyhow::Result<()> {
|
||||
let conn = conn.lock().unwrap();
|
||||
let count: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM paradise_history", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let keep = keep as i64;
|
||||
if count <= keep {
|
||||
return Ok(());
|
||||
}
|
||||
let to_remove = count - keep;
|
||||
conn.execute(
|
||||
"DELETE FROM paradise_history
|
||||
WHERE id IN (
|
||||
SELECT id FROM paradise_history
|
||||
ORDER BY started_at_ms ASC
|
||||
LIMIT ?1
|
||||
)",
|
||||
rusqlite::params![to_remove],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a SQLite history backend with the given database path.
|
||||
///
|
||||
/// The database file and parent directories will be created if they don't exist.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `database_path` - Path to the SQLite database file
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let backend = create_history_backend("/var/lib/pmo/history.db")?;
|
||||
/// ```
|
||||
pub fn create_history_backend(database_path: &str) -> anyhow::Result<Arc<dyn HistoryBackend>> {
|
||||
let backend = SqliteHistoryBackend::new(database_path)?;
|
||||
Ok(Arc::new(backend))
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//! Internal orchestration layer for dynamic Radio Paradise streaming.
|
||||
//!
|
||||
//! This module implements the high level structures described in the
|
||||
//! Radio Paradise functional specification:
|
||||
//! - `ParadiseChannel`: lifecycle and state machine for a single RP channel.
|
||||
//! - `ParadiseWorker`: async task responsible for polling/downloading blocks.
|
||||
//! - `ParadiseClientStream`: per-client audio stream with independent cursor.
|
||||
//! - Shared caches and history storage hooked into existing PMO components.
|
||||
//!
|
||||
//! The implementation is split across several submodules to keep concerns
|
||||
//! isolated (constants, playlist management, history persistence, etc.).
|
||||
//! The goal of this scaffolding is to provide a clear, testable surface for
|
||||
//! the eventual end-to-end integration with the UPnP server and HTTP routes.
|
||||
|
||||
mod channel;
|
||||
pub mod constants;
|
||||
mod history;
|
||||
mod playlist;
|
||||
mod worker;
|
||||
|
||||
pub use channel::{
|
||||
max_channel_id, ChannelDescriptor, ParadiseChannel, ParadiseChannelKind, ParadiseClientStream,
|
||||
ALL_CHANNELS,
|
||||
};
|
||||
pub use constants::*; // Export all constants
|
||||
pub use history::{create_history_backend, HistoryBackend, HistoryEntry};
|
||||
pub use playlist::PlaylistEntry;
|
||||
pub use worker::{load_rp_metadata, ParadiseWorker, RadioParadiseMetadata, WorkerCommand};
|
||||
@@ -1,293 +0,0 @@
|
||||
//! Shared playlist structures for Radio Paradise channels.
|
||||
//!
|
||||
//! This module keeps track of the active queue and history for a Radio
|
||||
//! Paradise channel. Each playlist entry knows how many clients still need
|
||||
//! to consume it before the worker can evict it.
|
||||
|
||||
use super::history::{HistoryEntry, SongSnapshot};
|
||||
use crate::models::Song;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::{Notify, RwLock};
|
||||
|
||||
/// Metadata stored for an active track.
|
||||
#[derive(Debug)]
|
||||
pub struct PlaylistEntry {
|
||||
pub track_id: String,
|
||||
pub channel_id: u8,
|
||||
pub song: Arc<Song>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub duration_ms: u64,
|
||||
pub audio_pk: Option<String>,
|
||||
pub file_path: Option<PathBuf>,
|
||||
pending_clients: AtomicUsize,
|
||||
}
|
||||
|
||||
impl PlaylistEntry {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
track_id: String,
|
||||
channel_id: u8,
|
||||
song: Arc<Song>,
|
||||
started_at: DateTime<Utc>,
|
||||
duration_ms: u64,
|
||||
audio_pk: Option<String>,
|
||||
file_path: Option<PathBuf>,
|
||||
pending_clients: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
track_id,
|
||||
channel_id,
|
||||
song,
|
||||
started_at,
|
||||
duration_ms,
|
||||
audio_pk,
|
||||
file_path,
|
||||
pending_clients: AtomicUsize::new(pending_clients),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_history_entry(&self) -> HistoryEntry {
|
||||
HistoryEntry {
|
||||
track_id: self.track_id.clone(),
|
||||
channel_id: self.channel_id,
|
||||
started_at: self.started_at,
|
||||
duration_ms: self.duration_ms,
|
||||
song: SongSnapshot::from(self.song.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pending_clients(&self) -> usize {
|
||||
self.pending_clients.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn set_pending_clients(&self, value: usize) {
|
||||
self.pending_clients.store(value, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn increment_clients(&self) -> usize {
|
||||
self.pending_clients.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
|
||||
pub fn decrement_clients(&self) -> usize {
|
||||
let mut current = self.pending_clients.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if current == 0 {
|
||||
return 0;
|
||||
}
|
||||
match self.pending_clients.compare_exchange(
|
||||
current,
|
||||
current - 1,
|
||||
Ordering::SeqCst,
|
||||
Ordering::SeqCst,
|
||||
) {
|
||||
Ok(_) => return current - 1,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PlaylistState {
|
||||
active: VecDeque<Arc<PlaylistEntry>>,
|
||||
history: VecDeque<HistoryEntry>,
|
||||
max_history: usize,
|
||||
}
|
||||
|
||||
impl PlaylistState {
|
||||
fn new(max_history: usize) -> Self {
|
||||
Self {
|
||||
active: VecDeque::new(),
|
||||
history: VecDeque::new(),
|
||||
max_history,
|
||||
}
|
||||
}
|
||||
|
||||
fn active_len(&self) -> usize {
|
||||
self.active.len()
|
||||
}
|
||||
|
||||
fn push_active(&mut self, entry: Arc<PlaylistEntry>) {
|
||||
self.active.push_back(entry);
|
||||
}
|
||||
|
||||
fn active_snapshot(&self) -> Vec<Arc<PlaylistEntry>> {
|
||||
self.active.iter().cloned().collect()
|
||||
}
|
||||
|
||||
fn pop_front_if_ready(&mut self) -> Option<Arc<PlaylistEntry>> {
|
||||
if let Some(front) = self.active.front() {
|
||||
if front.pending_clients() == 0 {
|
||||
return self.active.pop_front();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn pop_front_matching(&mut self, track_id: &str) -> Option<Arc<PlaylistEntry>> {
|
||||
if let Some(front) = self.active.front() {
|
||||
if front.track_id == track_id && front.pending_clients() == 0 {
|
||||
return self.active.pop_front();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn push_history(&mut self, entry: HistoryEntry) {
|
||||
self.history.push_back(entry);
|
||||
self.trim_history();
|
||||
}
|
||||
|
||||
fn recent_history(&self, limit: usize) -> Vec<HistoryEntry> {
|
||||
let total = self.history.len();
|
||||
let start = total.saturating_sub(limit);
|
||||
self.history.iter().skip(start).cloned().collect()
|
||||
}
|
||||
|
||||
fn trim_history(&mut self) {
|
||||
while self.history.len() > self.max_history {
|
||||
self.history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> bool {
|
||||
let changed = !self.active.is_empty() || !self.history.is_empty();
|
||||
if changed {
|
||||
self.active.clear();
|
||||
self.history.clear();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn increment_all(&self) {
|
||||
for entry in &self.active {
|
||||
entry.increment_clients();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedPlaylistInner {
|
||||
state: RwLock<PlaylistState>,
|
||||
notify: Notify,
|
||||
update_id: AtomicU32,
|
||||
last_change: RwLock<Option<SystemTime>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SharedPlaylist(Arc<SharedPlaylistInner>);
|
||||
|
||||
impl SharedPlaylist {
|
||||
pub fn new(max_history: usize) -> Self {
|
||||
Self(Arc::new(SharedPlaylistInner {
|
||||
state: RwLock::new(PlaylistState::new(max_history)),
|
||||
notify: Notify::new(),
|
||||
update_id: AtomicU32::new(0),
|
||||
last_change: RwLock::new(None),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn touch(&self) {
|
||||
self.0.update_id.fetch_add(1, Ordering::SeqCst);
|
||||
let mut last_change = self.0.last_change.write().await;
|
||||
*last_change = Some(SystemTime::now());
|
||||
}
|
||||
|
||||
pub async fn push_active(&self, entry: Arc<PlaylistEntry>) {
|
||||
let mut guard = self.0.state.write().await;
|
||||
guard.push_active(entry);
|
||||
drop(guard);
|
||||
self.touch().await;
|
||||
self.0.notify.notify_waiters();
|
||||
}
|
||||
|
||||
pub async fn active_len(&self) -> usize {
|
||||
let guard = self.0.state.read().await;
|
||||
guard.active_len()
|
||||
}
|
||||
|
||||
pub async fn active_snapshot(&self) -> Vec<Arc<PlaylistEntry>> {
|
||||
let guard = self.0.state.read().await;
|
||||
guard.active_snapshot()
|
||||
}
|
||||
|
||||
pub async fn clear(&self) {
|
||||
let mut guard = self.0.state.write().await;
|
||||
let changed = guard.clear();
|
||||
drop(guard);
|
||||
if changed {
|
||||
self.touch().await;
|
||||
self.0.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_track_count(&self, current_len: usize) {
|
||||
loop {
|
||||
let len = {
|
||||
let guard = self.0.state.read().await;
|
||||
guard.active_len()
|
||||
};
|
||||
|
||||
if len > current_len {
|
||||
break;
|
||||
}
|
||||
|
||||
self.0.notify.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn pop_front_if_ready(&self) -> Option<Arc<PlaylistEntry>> {
|
||||
let mut guard = self.0.state.write().await;
|
||||
let result = guard.pop_front_if_ready();
|
||||
drop(guard);
|
||||
|
||||
if result.is_some() {
|
||||
self.touch().await;
|
||||
self.0.notify.notify_waiters();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn pop_front_matching(&self, track_id: &str) -> Option<Arc<PlaylistEntry>> {
|
||||
let mut guard = self.0.state.write().await;
|
||||
let result = guard.pop_front_matching(track_id);
|
||||
drop(guard);
|
||||
|
||||
if result.is_some() {
|
||||
self.touch().await;
|
||||
self.0.notify.notify_waiters();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn push_history_entry(&self, entry: HistoryEntry) {
|
||||
let mut guard = self.0.state.write().await;
|
||||
guard.push_history(entry);
|
||||
drop(guard);
|
||||
self.touch().await;
|
||||
}
|
||||
|
||||
pub async fn recent_history(&self, limit: usize) -> Vec<HistoryEntry> {
|
||||
let guard = self.0.state.read().await;
|
||||
guard.recent_history(limit)
|
||||
}
|
||||
|
||||
pub async fn increment_all_pending(&self) {
|
||||
let guard = self.0.state.read().await;
|
||||
guard.increment_all();
|
||||
}
|
||||
|
||||
pub fn update_id(&self) -> u32 {
|
||||
self.0.update_id.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub async fn last_change(&self) -> Option<SystemTime> {
|
||||
self.0.last_change.read().await.clone()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,31 +3,24 @@
|
||||
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
|
||||
//! à un serveur pmoserver.
|
||||
|
||||
use crate::paradise::{max_channel_id, ParadiseChannel, PlaylistEntry, ALL_CHANNELS};
|
||||
use crate::{Block, NowPlaying, RadioParadiseClient, RadioParadiseSource};
|
||||
use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS};
|
||||
use crate::{Block, NowPlaying, RadioParadiseClient};
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
||||
response::IntoResponse,
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::StreamExt;
|
||||
use pmosource::api::CacheStatusInfo;
|
||||
use pmosource::CacheStatus;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::error;
|
||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
||||
use utoipa::{OpenApi, ToSchema};
|
||||
|
||||
/// État partagé pour l'API Radio Paradise
|
||||
#[derive(Clone)]
|
||||
pub struct RadioParadiseState {
|
||||
client: Arc<RwLock<RadioParadiseClient>>,
|
||||
source: Arc<RadioParadiseSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -36,46 +29,14 @@ struct ParadiseQuery {
|
||||
channel: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, IntoParams)]
|
||||
#[serde(default)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
struct ListLimitQuery {
|
||||
/// Nombre maximum d'éléments à retourner (0 = tous)
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl RadioParadiseState {
|
||||
pub async fn new() -> anyhow::Result<Self> {
|
||||
let client = RadioParadiseClient::new()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?;
|
||||
#[cfg(feature = "server")]
|
||||
let source = RadioParadiseSource::from_registry_default(client.clone())
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
let source = {
|
||||
let base_dir = std::env::temp_dir().join("pmoparadise_api");
|
||||
let cover_dir = base_dir.join("covers");
|
||||
let audio_dir = base_dir.join("audio");
|
||||
std::fs::create_dir_all(&cover_dir)?;
|
||||
std::fs::create_dir_all(&audio_dir)?;
|
||||
|
||||
let cover_cache = Arc::new(pmocovers::cache::new_cache(
|
||||
cover_dir.to_string_lossy().as_ref(),
|
||||
256,
|
||||
)?);
|
||||
let audio_cache = Arc::new(pmoaudiocache::cache::new_cache(
|
||||
audio_dir.to_string_lossy().as_ref(),
|
||||
256,
|
||||
)?);
|
||||
RadioParadiseSource::new_default(client.clone(), cover_cache, audio_cache)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
client: Arc::new(RwLock::new(client)),
|
||||
source: Arc::new(source),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,19 +61,6 @@ impl RadioParadiseState {
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn channel_for_id(&self, channel_id: u8) -> Result<Arc<ParadiseChannel>, StatusCode> {
|
||||
if channel_id > max_channel_id() {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
self.source
|
||||
.channel(channel_id)
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
pub fn source(&self) -> Arc<RadioParadiseSource> {
|
||||
self.source.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Information sur un canal Radio Paradise
|
||||
@@ -126,8 +74,8 @@ pub struct ChannelInfo {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl From<&crate::paradise::ChannelDescriptor> for ChannelInfo {
|
||||
fn from(descriptor: &crate::paradise::ChannelDescriptor) -> Self {
|
||||
impl From<&ChannelDescriptor> for ChannelInfo {
|
||||
fn from(descriptor: &ChannelDescriptor) -> Self {
|
||||
Self {
|
||||
id: descriptor.id,
|
||||
name: descriptor.display_name.to_string(),
|
||||
@@ -365,425 +313,28 @@ async fn get_channels() -> Json<Vec<ChannelInfo>> {
|
||||
Json(channels)
|
||||
}
|
||||
|
||||
/// Statut opérationnel d'un canal Radio Paradise
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ChannelStatusResponse {
|
||||
/// ID numérique du canal
|
||||
pub channel_id: u8,
|
||||
/// Slug du canal (main, mellow, ...)
|
||||
pub slug: String,
|
||||
/// Nom complet du canal
|
||||
pub name: String,
|
||||
/// Description
|
||||
pub description: String,
|
||||
/// Nombre de clients connectés au flux
|
||||
pub active_clients: usize,
|
||||
/// Nombre de morceaux présents dans la file d'attente
|
||||
pub queue_length: usize,
|
||||
/// Valeur courante d'update_id
|
||||
pub update_id: u32,
|
||||
/// Dernière modification (RFC3339)
|
||||
pub last_change: Option<String>,
|
||||
/// Nombre total d'entrées en historique (persisté)
|
||||
pub history_entries: usize,
|
||||
/// Limite configurée pour l'historique
|
||||
pub history_max_tracks: usize,
|
||||
/// Le canal est-il activé dans la configuration ?
|
||||
pub configured: bool,
|
||||
/// Identifiant de collection pour le cache
|
||||
pub cache_collection_id: String,
|
||||
/// Nombre total de pistes connues du cache
|
||||
pub cache_total_tracks: usize,
|
||||
/// Nombre de pistes déjà en cache
|
||||
pub cache_cached_tracks: usize,
|
||||
}
|
||||
|
||||
/// Entrée détaillée de la file d'attente
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ChannelPlaylistEntry {
|
||||
/// Position dans la file
|
||||
pub index: usize,
|
||||
/// ID unique de la piste
|
||||
pub track_id: String,
|
||||
/// ID du canal
|
||||
pub channel_id: u8,
|
||||
/// Titre du morceau
|
||||
pub title: String,
|
||||
/// Artiste
|
||||
pub artist: String,
|
||||
/// Album
|
||||
pub album: Option<String>,
|
||||
/// URL de couverture (si disponible)
|
||||
pub cover_url: Option<String>,
|
||||
/// Durée du morceau en ms
|
||||
pub duration_ms: u64,
|
||||
/// Offset dans le block (ms)
|
||||
pub elapsed_ms: u64,
|
||||
/// Horodatage prévu/démarré (RFC3339)
|
||||
pub started_at: String,
|
||||
/// Nombre de clients restants à servir
|
||||
pub pending_clients: usize,
|
||||
/// Note éventuelle (0-10)
|
||||
pub rating: Option<f32>,
|
||||
/// Année éventuelle
|
||||
pub year: Option<u32>,
|
||||
/// Statut de cache
|
||||
pub cache_status: CacheStatusInfo,
|
||||
}
|
||||
|
||||
impl ChannelPlaylistEntry {
|
||||
fn from_entry(entry: &Arc<PlaylistEntry>, index: usize, cache_status: CacheStatusInfo) -> Self {
|
||||
let song = entry.song.as_ref();
|
||||
Self {
|
||||
index,
|
||||
track_id: entry.track_id.clone(),
|
||||
channel_id: entry.channel_id,
|
||||
title: song.title.clone(),
|
||||
artist: song.artist.clone(),
|
||||
album: song.album.clone(),
|
||||
cover_url: song.cover.clone(),
|
||||
duration_ms: entry.duration_ms,
|
||||
elapsed_ms: song.elapsed,
|
||||
started_at: entry.started_at.to_rfc3339(),
|
||||
pending_clients: entry.pending_clients(),
|
||||
rating: song.rating,
|
||||
year: song.year,
|
||||
cache_status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse pour la file d'attente d'un canal
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ChannelPlaylistResponse {
|
||||
/// ID du canal
|
||||
pub channel_id: u8,
|
||||
/// Slug du canal
|
||||
pub slug: String,
|
||||
/// Update ID du playlist
|
||||
pub update_id: u32,
|
||||
/// Taille totale de la file au moment de la capture
|
||||
pub queue_length: usize,
|
||||
/// Entrées retournées
|
||||
pub items: Vec<ChannelPlaylistEntry>,
|
||||
}
|
||||
|
||||
/// Entrée d'historique d'écoute
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ChannelHistoryEntry {
|
||||
/// ID unique de la piste
|
||||
pub track_id: String,
|
||||
/// ID du canal
|
||||
pub channel_id: u8,
|
||||
/// Titre
|
||||
pub title: String,
|
||||
/// Artiste
|
||||
pub artist: String,
|
||||
/// Album
|
||||
pub album: Option<String>,
|
||||
/// URL de couverture
|
||||
pub cover_url: Option<String>,
|
||||
/// Début de lecture (RFC3339)
|
||||
pub started_at: String,
|
||||
/// Durée en ms
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Réponse pour l'historique d'un canal
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ChannelHistoryResponse {
|
||||
/// ID du canal
|
||||
pub channel_id: u8,
|
||||
/// Slug du canal
|
||||
pub slug: String,
|
||||
/// Nombre total d'entrées disponibles
|
||||
pub total_available: usize,
|
||||
/// Nombre d'entrées retournées dans cette réponse
|
||||
pub returned: usize,
|
||||
/// Entrées
|
||||
pub entries: Vec<ChannelHistoryEntry>,
|
||||
}
|
||||
|
||||
/// GET /channels/{channel_id}/status - Statut détaillé d'un canal
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/channels/{channel_id}/status",
|
||||
params(
|
||||
("channel_id" = u8, Path, description = "Channel ID (0-3)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Statut du canal", body = ChannelStatusResponse),
|
||||
(status = 400, description = "Canal invalide"),
|
||||
(status = 503, description = "Canal indisponible"),
|
||||
(status = 500, description = "Erreur interne lors de la récupération du statut")
|
||||
),
|
||||
tag = "Radio Paradise"
|
||||
)]
|
||||
async fn get_channel_status(
|
||||
State(state): State<RadioParadiseState>,
|
||||
Path(channel_id): Path<u8>,
|
||||
) -> Result<Json<ChannelStatusResponse>, StatusCode> {
|
||||
let channel = state.channel_for_id(channel_id)?;
|
||||
let descriptor = channel.descriptor();
|
||||
|
||||
let playlist = channel.playlist();
|
||||
let queue_length = playlist.active_len().await;
|
||||
let update_id = playlist.update_id();
|
||||
let last_change = playlist
|
||||
.last_change()
|
||||
.await
|
||||
.map(|ts| DateTime::<Utc>::from(ts).to_rfc3339());
|
||||
|
||||
let history_len = channel.history_backend().len().await.map_err(|e| {
|
||||
error!(
|
||||
channel = descriptor.slug,
|
||||
"Failed to retrieve history size: {e:?}"
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let cache_stats = channel.cache_manager().statistics().await;
|
||||
|
||||
let status = ChannelStatusResponse {
|
||||
channel_id,
|
||||
slug: descriptor.slug.to_string(),
|
||||
name: descriptor.display_name.to_string(),
|
||||
description: descriptor.description.to_string(),
|
||||
active_clients: channel.active_client_count(),
|
||||
queue_length,
|
||||
update_id,
|
||||
last_change,
|
||||
history_entries: history_len,
|
||||
history_max_tracks: channel.history_max_tracks(),
|
||||
configured: true, // All channels are always available
|
||||
cache_collection_id: cache_stats.collection_id,
|
||||
cache_total_tracks: cache_stats.total_tracks,
|
||||
cache_cached_tracks: cache_stats.cached_tracks,
|
||||
};
|
||||
|
||||
Ok(Json(status))
|
||||
}
|
||||
|
||||
/// GET /channels/{channel_id}/playlist - File d'attente du canal
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/channels/{channel_id}/playlist",
|
||||
params(
|
||||
("channel_id" = u8, Path, description = "Channel ID (0-3)"),
|
||||
ListLimitQuery
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "File d'attente courante", body = ChannelPlaylistResponse),
|
||||
(status = 400, description = "Canal invalide"),
|
||||
(status = 503, description = "Canal indisponible"),
|
||||
(status = 500, description = "Erreur lors de la récupération de la file d'attente")
|
||||
),
|
||||
tag = "Radio Paradise"
|
||||
)]
|
||||
async fn get_channel_playlist(
|
||||
State(state): State<RadioParadiseState>,
|
||||
Path(channel_id): Path<u8>,
|
||||
Query(query): Query<ListLimitQuery>,
|
||||
) -> Result<Json<ChannelPlaylistResponse>, StatusCode> {
|
||||
let channel = state.channel_for_id(channel_id)?;
|
||||
let descriptor = channel.descriptor();
|
||||
let playlist = channel.playlist();
|
||||
let snapshot = playlist.active_snapshot().await;
|
||||
let total_len = snapshot.len();
|
||||
let limit = query.limit.filter(|limit| *limit > 0).unwrap_or(total_len);
|
||||
|
||||
let cache_manager = channel.cache_manager();
|
||||
let mut items = Vec::new();
|
||||
|
||||
for (index, entry) in snapshot.into_iter().enumerate().take(limit) {
|
||||
let cache_status = match cache_manager.get_cache_status(&entry.track_id).await {
|
||||
Ok(status) => status,
|
||||
Err(err) => CacheStatus::Failed {
|
||||
error: err.to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
items.push(ChannelPlaylistEntry::from_entry(
|
||||
&entry,
|
||||
index,
|
||||
CacheStatusInfo::from(cache_status),
|
||||
));
|
||||
}
|
||||
|
||||
let response = ChannelPlaylistResponse {
|
||||
channel_id,
|
||||
slug: descriptor.slug.to_string(),
|
||||
update_id: playlist.update_id(),
|
||||
queue_length: total_len,
|
||||
items,
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// GET /channels/{channel_id}/history - Historique récent du canal
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/channels/{channel_id}/history",
|
||||
params(
|
||||
("channel_id" = u8, Path, description = "Channel ID (0-3)"),
|
||||
ListLimitQuery
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Historique récent", body = ChannelHistoryResponse),
|
||||
(status = 400, description = "Canal invalide"),
|
||||
(status = 503, description = "Canal indisponible"),
|
||||
(status = 500, description = "Erreur lors de la récupération de l'historique")
|
||||
),
|
||||
tag = "Radio Paradise"
|
||||
)]
|
||||
async fn get_channel_history(
|
||||
State(state): State<RadioParadiseState>,
|
||||
Path(channel_id): Path<u8>,
|
||||
Query(query): Query<ListLimitQuery>,
|
||||
) -> Result<Json<ChannelHistoryResponse>, StatusCode> {
|
||||
let channel = state.channel_for_id(channel_id)?;
|
||||
let descriptor = channel.descriptor();
|
||||
let backend = channel.history_backend().clone();
|
||||
let limit = query.limit.unwrap_or(50);
|
||||
|
||||
let entries_raw = backend.recent(limit).await.map_err(|e| {
|
||||
error!(
|
||||
channel = descriptor.slug,
|
||||
"Failed to retrieve channel history: {e:?}"
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let total_available = backend.len().await.map_err(|e| {
|
||||
error!(
|
||||
channel = descriptor.slug,
|
||||
"Failed to count channel history entries: {e:?}"
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let entries: Vec<ChannelHistoryEntry> = entries_raw
|
||||
.into_iter()
|
||||
.map(|entry| ChannelHistoryEntry {
|
||||
track_id: entry.track_id,
|
||||
channel_id: entry.channel_id,
|
||||
title: entry.song.title,
|
||||
artist: entry.song.artist,
|
||||
album: entry.song.album,
|
||||
cover_url: entry.song.cover_url,
|
||||
started_at: entry.started_at.to_rfc3339(),
|
||||
duration_ms: entry.duration_ms,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let response = ChannelHistoryResponse {
|
||||
channel_id,
|
||||
slug: descriptor.slug.to_string(),
|
||||
total_available,
|
||||
returned: entries.len(),
|
||||
entries,
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// GET /channels/{channel_id}/stream/{connection_id} - Stream audio pour une connexion spécifique
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/channels/{channel_id}/stream/{connection_id}",
|
||||
params(
|
||||
("channel_id" = u8, Path, description = "Channel ID (0-3)"),
|
||||
("connection_id" = i32, Path, description = "Connection ID fourni par le media server")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Flux audio FLAC (gapless)", content_type = "audio/flac"),
|
||||
(status = 400, description = "Canal invalide"),
|
||||
(status = 503, description = "Canal indisponible")
|
||||
),
|
||||
tag = "Radio Paradise"
|
||||
)]
|
||||
async fn stream_channel_by_connection(
|
||||
State(state): State<RadioParadiseState>,
|
||||
Path((channel_id, connection_id)): Path<(u8, i32)>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let channel = state.channel_for_id(channel_id)?;
|
||||
|
||||
// Convertir connection_id en String pour l'utiliser comme client_id
|
||||
let client_id = connection_id.to_string();
|
||||
|
||||
let client_stream = channel.connect_client(client_id).await.map_err(|e| {
|
||||
error!("Failed to create streaming client: {e:?}");
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
})?;
|
||||
|
||||
let stream = client_stream
|
||||
.into_byte_stream()
|
||||
.map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
|
||||
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("audio/flac"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("icy-name"),
|
||||
HeaderValue::from_static("Radio Paradise"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("icy-genre"),
|
||||
HeaderValue::from_static("Eclectic"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("icy-description"),
|
||||
HeaderValue::from_static("PMO Radio Paradise relay"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("icy-metaint"),
|
||||
HeaderValue::from_static("0"),
|
||||
);
|
||||
|
||||
Ok((headers, body))
|
||||
}
|
||||
|
||||
/// Documentation OpenAPI pour l'API Radio Paradise
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Radio Paradise API",
|
||||
version = "1.0.0",
|
||||
description = "API REST pour accéder aux métadonnées et streams de Radio Paradise"
|
||||
description = "API REST pour accéder aux métadonnées de Radio Paradise"
|
||||
),
|
||||
paths(
|
||||
get_now_playing,
|
||||
get_current_block,
|
||||
get_block_by_id,
|
||||
get_channels,
|
||||
get_channel_status,
|
||||
get_channel_playlist,
|
||||
get_channel_history,
|
||||
stream_channel_by_connection
|
||||
get_channels
|
||||
),
|
||||
components(schemas(
|
||||
NowPlayingResponse,
|
||||
BlockResponse,
|
||||
SongInfo,
|
||||
ChannelInfo,
|
||||
ChannelStatusResponse,
|
||||
ChannelPlaylistEntry,
|
||||
ChannelPlaylistResponse,
|
||||
ChannelHistoryEntry,
|
||||
ChannelHistoryResponse,
|
||||
CacheStatusInfo
|
||||
ChannelInfo
|
||||
)),
|
||||
tags(
|
||||
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise streaming")
|
||||
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise")
|
||||
)
|
||||
)]
|
||||
pub struct RadioParadiseApiDoc;
|
||||
@@ -795,13 +346,6 @@ pub fn create_api_router(state: RadioParadiseState) -> Router {
|
||||
.route("/block/current", get(get_current_block))
|
||||
.route("/block/{event_id}", get(get_block_by_id))
|
||||
.route("/channels", get(get_channels))
|
||||
.route("/channels/{channel_id}/status", get(get_channel_status))
|
||||
.route("/channels/{channel_id}/playlist", get(get_channel_playlist))
|
||||
.route("/channels/{channel_id}/history", get(get_channel_history))
|
||||
.route(
|
||||
"/channels/{channel_id}/stream/{connection_id}",
|
||||
get(stream_channel_by_connection),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -809,6 +353,7 @@ pub fn create_api_router(state: RadioParadiseState) -> Router {
|
||||
///
|
||||
/// Permet d'initialiser Radio Paradise avec routes HTTP complètes
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
pub trait RadioParadiseExt {
|
||||
/// Initialise l'API Radio Paradise
|
||||
///
|
||||
@@ -817,12 +362,13 @@ pub trait RadioParadiseExt {
|
||||
/// - API: `/api/radioparadise/*`
|
||||
/// - `/now-playing`
|
||||
/// - `/block/*`
|
||||
/// - `/channels/{channel_id}/stream/{connection_id}`
|
||||
/// - `/channels`
|
||||
/// - Swagger: `/swagger-ui/radioparadise`
|
||||
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState>;
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
impl RadioParadiseExt for pmoserver::Server {
|
||||
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState> {
|
||||
let state = RadioParadiseState::new().await?;
|
||||
|
||||
667
pmoparadise/src/radio_paradise_stream_source.rs
Normal file
667
pmoparadise/src/radio_paradise_stream_source.rs
Normal file
@@ -0,0 +1,667 @@
|
||||
//! RadioParadiseStreamSource - Node audio pmoaudio pour Radio Paradise
|
||||
//!
|
||||
//! Ce node télécharge et décode les blocs FLAC de Radio Paradise en streaming,
|
||||
//! avec insertion automatique des TrackBoundary au bon timing.
|
||||
|
||||
use crate::{
|
||||
client::RadioParadiseClient,
|
||||
models::{Block, EventId, Song},
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use pmoaudio::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioPipelineNode, AudioSegment, SyncMarker, I24,
|
||||
};
|
||||
use pmoflac::decode_audio_stream;
|
||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tokio_util::{io::StreamReader, sync::CancellationToken};
|
||||
|
||||
/// Timeout pour attendre un nouveau block ID (radio en temps réel)
|
||||
const BLOCK_ID_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
/// Nombre de blocs récents à mémoriser pour éviter les re-téléchargements
|
||||
const RECENT_BLOCKS_CACHE_SIZE: usize = 10;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RadioParadiseStreamSourceLogic - Logique métier pure
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Logique pure de téléchargement et décodage des blocs Radio Paradise
|
||||
pub struct RadioParadiseStreamSourceLogic {
|
||||
client: RadioParadiseClient,
|
||||
chunk_frames: usize,
|
||||
recent_blocks: VecDeque<EventId>,
|
||||
block_queue: VecDeque<EventId>,
|
||||
}
|
||||
|
||||
impl RadioParadiseStreamSourceLogic {
|
||||
pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self {
|
||||
// Calculer chunk_frames pour la durée cible (on suppose 44.1kHz)
|
||||
let chunk_frames = ((chunk_duration_ms as f64 / 1000.0) * 44100.0) as usize;
|
||||
|
||||
Self {
|
||||
client,
|
||||
chunk_frames,
|
||||
recent_blocks: VecDeque::with_capacity(RECENT_BLOCKS_CACHE_SIZE),
|
||||
block_queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute un block ID à la file d'attente
|
||||
pub fn push_block_id(&mut self, event_id: EventId) {
|
||||
self.block_queue.push_back(event_id);
|
||||
}
|
||||
|
||||
/// Vérifie si un bloc a été téléchargé récemment
|
||||
fn is_recent_block(&self, event_id: EventId) -> bool {
|
||||
self.recent_blocks.contains(&event_id)
|
||||
}
|
||||
|
||||
/// Marque un bloc comme récemment téléchargé (FIFO)
|
||||
fn mark_block_downloaded(&mut self, event_id: EventId) {
|
||||
// Retirer tous les éléments excédentaires (garantit <= CACHE_SIZE)
|
||||
while self.recent_blocks.len() >= RECENT_BLOCKS_CACHE_SIZE {
|
||||
self.recent_blocks.pop_front();
|
||||
}
|
||||
|
||||
// Puis ajouter le nouveau bloc
|
||||
self.recent_blocks.push_back(event_id);
|
||||
}
|
||||
|
||||
/// Télécharge et décode un bloc FLAC
|
||||
async fn download_and_decode_block(
|
||||
&mut self,
|
||||
block: &Block,
|
||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
stop_token: &CancellationToken,
|
||||
order: &mut u64,
|
||||
) -> Result<(), AudioError> {
|
||||
// Télécharger le FLAC
|
||||
let response = self.client.client
|
||||
.get(&block.url)
|
||||
.timeout(self.client.block_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Block download failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Block download returned status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
// Créer un stream reader
|
||||
let byte_stream = response.bytes_stream().map(|result| {
|
||||
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
});
|
||||
let stream_reader = StreamReader::new(byte_stream);
|
||||
|
||||
// Décoder le FLAC
|
||||
let mut decoder = decode_audio_stream(stream_reader)
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("FLAC decode failed: {}", e)))?;
|
||||
|
||||
let stream_info = decoder.info().clone();
|
||||
let sample_rate = stream_info.sample_rate;
|
||||
let bits_per_sample = stream_info.bits_per_sample;
|
||||
|
||||
// Préparer les songs ordonnées pour tracking
|
||||
let songs = block.songs_ordered();
|
||||
let mut song_index = 0;
|
||||
let mut next_song: Option<(usize, &Song)> = songs.get(0).copied();
|
||||
let mut total_samples = 0u64;
|
||||
|
||||
// Envoyer TopZeroSync au début du bloc
|
||||
let top_zero = Arc::new(AudioSegment {
|
||||
order: *order,
|
||||
timestamp_sec: 0.0,
|
||||
segment: pmoaudio::_AudioSegment::Sync(Arc::new(SyncMarker::TopZeroSync)),
|
||||
});
|
||||
self.send_to_children(output, top_zero).await?;
|
||||
|
||||
// Buffer pour lecture
|
||||
let bytes_per_sample = (bits_per_sample / 8) as usize;
|
||||
let frame_bytes = bytes_per_sample * 2; // stereo
|
||||
let chunk_frames = self.chunk_frames;
|
||||
let chunk_byte_len = chunk_frames * frame_bytes;
|
||||
let mut read_buf = vec![0u8; chunk_byte_len * 2];
|
||||
let mut pending: Vec<u8> = Vec::with_capacity(chunk_byte_len * 2);
|
||||
|
||||
// Traiter les chunks audio
|
||||
loop {
|
||||
// Vérifier stop_token
|
||||
if stop_token.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remplir le buffer
|
||||
if pending.len() < chunk_byte_len {
|
||||
let read = decoder.read(&mut read_buf).await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?;
|
||||
|
||||
if read == 0 {
|
||||
break; // EOF
|
||||
}
|
||||
pending.extend_from_slice(&read_buf[..read]);
|
||||
}
|
||||
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Extraire un chunk
|
||||
let frames_in_pending = pending.len() / frame_bytes;
|
||||
let frames_to_emit = frames_in_pending.min(chunk_frames);
|
||||
let take_bytes = frames_to_emit * frame_bytes;
|
||||
let pcm_data = pending.drain(..take_bytes).collect::<Vec<u8>>();
|
||||
|
||||
// Calculer le nombre de frames (samples par canal)
|
||||
let bytes_per_sample = (bits_per_sample / 8) as usize;
|
||||
let chunk_len = (pcm_data.len() / (bytes_per_sample * 2)) as u64; // 2 = stereo
|
||||
|
||||
// Vérifier si on doit insérer un TrackBoundary avant ce chunk
|
||||
if let Some((_idx, song)) = next_song {
|
||||
let elapsed_ms = (total_samples * 1000) / sample_rate as u64;
|
||||
|
||||
if elapsed_ms >= song.elapsed {
|
||||
// Envoyer TrackBoundary AVANT le chunk (avec le même order)
|
||||
let metadata = song_to_metadata(song, block);
|
||||
let timestamp_sec = total_samples as f64 / sample_rate as f64;
|
||||
let track_boundary = AudioSegment::new_track_boundary(
|
||||
*order,
|
||||
timestamp_sec,
|
||||
metadata,
|
||||
);
|
||||
self.send_to_children(output, track_boundary).await?;
|
||||
|
||||
// Passer à la song suivante
|
||||
song_index += 1;
|
||||
next_song = songs.get(song_index).copied();
|
||||
}
|
||||
}
|
||||
|
||||
// Envoyer le chunk audio
|
||||
let timestamp_sec = total_samples as f64 / sample_rate as f64;
|
||||
let audio_segment = pcm_to_audio_segment(
|
||||
&pcm_data,
|
||||
*order,
|
||||
timestamp_sec,
|
||||
sample_rate,
|
||||
bits_per_sample,
|
||||
)?;
|
||||
self.send_to_children(output, audio_segment).await?;
|
||||
|
||||
*order += 1;
|
||||
total_samples += chunk_len;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Envoie un segment à tous les enfants
|
||||
async fn send_to_children(
|
||||
&self,
|
||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
segment: Arc<AudioSegment>,
|
||||
) -> Result<(), AudioError> {
|
||||
for tx in output {
|
||||
tx.send(segment.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit PCM bytes en AudioSegment
|
||||
fn pcm_to_audio_segment(
|
||||
pcm_data: &[u8],
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
sample_rate: u32,
|
||||
bits_per_sample: u8,
|
||||
) -> Result<Arc<AudioSegment>, AudioError> {
|
||||
use pmoaudio::{AudioChunk, AudioChunkData, _AudioSegment};
|
||||
|
||||
let bytes_per_sample = (bits_per_sample / 8) as usize;
|
||||
let channels = 2; // Stereo
|
||||
let frame_bytes = bytes_per_sample * channels;
|
||||
let frames = pcm_data.len() / frame_bytes;
|
||||
|
||||
// Valider que la taille des données est correcte
|
||||
if pcm_data.len() % frame_bytes != 0 {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Invalid PCM data size: {} bytes is not a multiple of frame size {} ({}bit, {} channels)",
|
||||
pcm_data.len(),
|
||||
frame_bytes,
|
||||
bits_per_sample,
|
||||
channels
|
||||
)));
|
||||
}
|
||||
|
||||
let chunk = match bits_per_sample {
|
||||
16 => {
|
||||
// Type I16
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let left = i16::from_le_bytes([pcm_data[base], pcm_data[base + 1]]);
|
||||
let right = i16::from_le_bytes([pcm_data[base + 2], pcm_data[base + 3]]);
|
||||
stereo.push([left, right]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||
AudioChunk::I16(chunk_data)
|
||||
}
|
||||
24 => {
|
||||
// Type I24 avec sign extension correcte
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
|
||||
// Left channel (bytes 0,1,2) avec sign extension
|
||||
let left_i32 = {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[..3].copy_from_slice(&pcm_data[base..base + 3]);
|
||||
// Sign extend si négatif
|
||||
if pcm_data[base + 2] & 0x80 != 0 {
|
||||
buf[3] = 0xFF;
|
||||
}
|
||||
i32::from_le_bytes(buf)
|
||||
};
|
||||
let left = I24::new(left_i32).ok_or_else(|| {
|
||||
AudioError::ProcessingError(format!("Invalid I24 value: {}", left_i32))
|
||||
})?;
|
||||
|
||||
// Right channel (bytes 3,4,5) avec sign extension
|
||||
let right_i32 = {
|
||||
let mut buf = [0u8; 4];
|
||||
buf[..3].copy_from_slice(&pcm_data[base + 3..base + 6]);
|
||||
// Sign extend si négatif
|
||||
if pcm_data[base + 5] & 0x80 != 0 {
|
||||
buf[3] = 0xFF;
|
||||
}
|
||||
i32::from_le_bytes(buf)
|
||||
};
|
||||
let right = I24::new(right_i32).ok_or_else(|| {
|
||||
AudioError::ProcessingError(format!("Invalid I24 value: {}", right_i32))
|
||||
})?;
|
||||
|
||||
stereo.push([left, right]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||
AudioChunk::I24(chunk_data)
|
||||
}
|
||||
32 => {
|
||||
// Type I32
|
||||
let mut stereo = Vec::with_capacity(frames);
|
||||
for frame_idx in 0..frames {
|
||||
let base = frame_idx * frame_bytes;
|
||||
let left = i32::from_le_bytes([
|
||||
pcm_data[base],
|
||||
pcm_data[base + 1],
|
||||
pcm_data[base + 2],
|
||||
pcm_data[base + 3],
|
||||
]);
|
||||
let right = i32::from_le_bytes([
|
||||
pcm_data[base + 4],
|
||||
pcm_data[base + 5],
|
||||
pcm_data[base + 6],
|
||||
pcm_data[base + 7],
|
||||
]);
|
||||
stereo.push([left, right]);
|
||||
}
|
||||
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||
AudioChunk::I32(chunk_data)
|
||||
}
|
||||
_ => {
|
||||
return Err(AudioError::ProcessingError(format!(
|
||||
"Unsupported bit depth: {}",
|
||||
bits_per_sample
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Arc::new(AudioSegment {
|
||||
order,
|
||||
timestamp_sec,
|
||||
segment: _AudioSegment::Chunk(Arc::new(chunk)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Convertit Song en TrackMetadata
|
||||
///
|
||||
/// Cette fonction est synchrone, donc on wrap la metadata dans Arc<RwLock<>>
|
||||
/// et on spawn une tâche async pour la configurer
|
||||
fn song_to_metadata(song: &Song, block: &Block) -> Arc<RwLock<dyn TrackMetadata>> {
|
||||
let metadata = MemoryTrackMetadata::new();
|
||||
let metadata_arc = Arc::new(RwLock::new(metadata)) as Arc<RwLock<dyn TrackMetadata>>;
|
||||
let metadata_clone = metadata_arc.clone();
|
||||
|
||||
// Clone des données pour la task async
|
||||
let title = song.title.clone();
|
||||
let artist = song.artist.clone();
|
||||
let album = song.album.clone();
|
||||
let year = song.year;
|
||||
let cover_url = song.cover.as_ref().and_then(|cover| block.cover_url(cover));
|
||||
|
||||
// Configurer les métadonnées de manière asynchrone
|
||||
tokio::spawn(async move {
|
||||
let mut meta = metadata_clone.write().await;
|
||||
|
||||
// Ces méthodes peuvent échouer (retournent Result), donc on propage avec ?
|
||||
if let Err(e) = meta.set_title(Some(title)).await {
|
||||
eprintln!("Warning: Failed to set title: {}", e);
|
||||
}
|
||||
if let Err(e) = meta.set_artist(Some(artist)).await {
|
||||
eprintln!("Warning: Failed to set artist: {}", e);
|
||||
}
|
||||
if let Some(album) = album {
|
||||
if let Err(e) = meta.set_album(Some(album)).await {
|
||||
eprintln!("Warning: Failed to set album: {}", e);
|
||||
}
|
||||
}
|
||||
if let Some(year) = year {
|
||||
if let Err(e) = meta.set_year(Some(year)).await {
|
||||
eprintln!("Warning: Failed to set year: {}", e);
|
||||
}
|
||||
}
|
||||
if let Some(cover_url) = cover_url {
|
||||
if let Err(e) = meta.set_cover_url(Some(cover_url)).await {
|
||||
eprintln!("Warning: Failed to set cover_url: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
metadata_arc
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NodeLogic for RadioParadiseStreamSourceLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
_input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut order = 0u64;
|
||||
|
||||
loop {
|
||||
// Attendre un block ID (timeout court pour une radio)
|
||||
let event_id = match tokio::time::timeout(
|
||||
Duration::from_secs(BLOCK_ID_TIMEOUT_SECS),
|
||||
async {
|
||||
while self.block_queue.is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
if stop_token.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.block_queue.pop_front()
|
||||
}
|
||||
).await {
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => break, // Cancelled
|
||||
Err(_) => {
|
||||
// Timeout - pas de nouveau bloc, on termine
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Vérifier si déjà téléchargé récemment
|
||||
if self.is_recent_block(event_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Récupérer les métadonnées du bloc
|
||||
let block = self.client
|
||||
.get_block(Some(event_id))
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("Failed to get block: {}", e)))?;
|
||||
|
||||
// Marquer comme téléchargé
|
||||
self.mark_block_downloaded(event_id);
|
||||
|
||||
// Télécharger et décoder le bloc
|
||||
self.download_and_decode_block(&block, &output, &stop_token, &mut order)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Envoyer EndOfStream
|
||||
let eos = AudioSegment::new_end_of_stream(order, 0.0);
|
||||
for tx in &output {
|
||||
tx.send(eos.clone())
|
||||
.await
|
||||
.map_err(|_| AudioError::ChildDied)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RadioParadiseStreamSource - Wrapper utilisant Node<RadioParadiseStreamSourceLogic>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
pub struct RadioParadiseStreamSource {
|
||||
inner: Node<RadioParadiseStreamSourceLogic>,
|
||||
}
|
||||
|
||||
impl RadioParadiseStreamSource {
|
||||
/// Crée une nouvelle source Radio Paradise avec durée de chunk par défaut
|
||||
pub fn new(client: RadioParadiseClient) -> Self {
|
||||
Self::with_chunk_duration(client, DEFAULT_CHUNK_DURATION_MS as u32)
|
||||
}
|
||||
|
||||
/// Crée une nouvelle source avec durée de chunk personnalisée
|
||||
pub fn with_chunk_duration(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self {
|
||||
let logic = RadioParadiseStreamSourceLogic::new(client, chunk_duration_ms);
|
||||
Self {
|
||||
inner: Node::new_source(logic),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute un block ID à la file d'attente de téléchargement
|
||||
pub fn push_block_id(&mut self, event_id: EventId) {
|
||||
self.inner.logic_mut().push_block_id(event_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AudioPipelineNode for RadioParadiseStreamSource {
|
||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||
self.inner.get_tx()
|
||||
}
|
||||
|
||||
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||
self.inner.register(child);
|
||||
}
|
||||
|
||||
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||
Box::new(self.inner).run(stop_token).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedAudioNode for RadioParadiseStreamSource {
|
||||
fn input_type(&self) -> Option<TypeRequirement> {
|
||||
None // Source node
|
||||
}
|
||||
|
||||
fn output_type(&self) -> Option<TypeRequirement> {
|
||||
// Radio Paradise FLAC peut être 16-bit, 24-bit, ou 32-bit
|
||||
// La profondeur est détectée automatiquement depuis le header FLAC
|
||||
Some(TypeRequirement::any_integer())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_client() -> RadioParadiseClient {
|
||||
RadioParadiseClient::with_client(reqwest::Client::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_fifo_basic() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Ajouter 5 blocs
|
||||
for i in 1..=5 {
|
||||
logic.mark_block_downloaded(i);
|
||||
}
|
||||
|
||||
// Vérifier que tous sont dans le cache
|
||||
for i in 1..=5 {
|
||||
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||
}
|
||||
assert_eq!(logic.recent_blocks.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_fifo_exactly_10_elements() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Ajouter exactement 10 blocs
|
||||
for i in 1..=10 {
|
||||
logic.mark_block_downloaded(i);
|
||||
}
|
||||
|
||||
// Vérifier qu'on a exactement 10 éléments
|
||||
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have exactly 10 elements");
|
||||
|
||||
// Tous devraient être dans le cache
|
||||
for i in 1..=10 {
|
||||
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_fifo_eviction_oldest() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Remplir le cache avec 10 éléments (1..=10)
|
||||
for i in 1..=10 {
|
||||
logic.mark_block_downloaded(i);
|
||||
}
|
||||
|
||||
// Ajouter un 11ème élément
|
||||
logic.mark_block_downloaded(11);
|
||||
|
||||
// Le cache doit toujours avoir 10 éléments
|
||||
assert_eq!(logic.recent_blocks.len(), 10, "Cache should still have 10 elements");
|
||||
|
||||
// Le premier (plus ancien) doit avoir été évincé
|
||||
assert!(!logic.is_recent_block(1), "Oldest block (1) should be evicted");
|
||||
|
||||
// Les éléments 2..=11 doivent être présents
|
||||
for i in 2..=11 {
|
||||
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_fifo_multiple_evictions() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Remplir avec 10 éléments
|
||||
for i in 1..=10 {
|
||||
logic.mark_block_downloaded(i);
|
||||
}
|
||||
|
||||
// Ajouter 5 éléments supplémentaires
|
||||
for i in 11..=15 {
|
||||
logic.mark_block_downloaded(i);
|
||||
}
|
||||
|
||||
// Toujours 10 éléments
|
||||
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have 10 elements");
|
||||
|
||||
// Les 5 premiers doivent avoir été évincés
|
||||
for i in 1..=5 {
|
||||
assert!(!logic.is_recent_block(i), "Block {} should be evicted", i);
|
||||
}
|
||||
|
||||
// Les éléments 6..=15 doivent être présents
|
||||
for i in 6..=15 {
|
||||
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_never_exceeds_capacity() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Vérifier la capacité pré-allouée
|
||||
assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE);
|
||||
|
||||
// Ajouter beaucoup d'éléments
|
||||
for i in 1..=100 {
|
||||
logic.mark_block_downloaded(i);
|
||||
|
||||
// À chaque itération, vérifier qu'on ne dépasse jamais 10
|
||||
assert!(
|
||||
logic.recent_blocks.len() <= RECENT_BLOCKS_CACHE_SIZE,
|
||||
"Cache size {} exceeded max {}",
|
||||
logic.recent_blocks.len(),
|
||||
RECENT_BLOCKS_CACHE_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
// Finalement, on doit avoir exactement 10 éléments
|
||||
assert_eq!(logic.recent_blocks.len(), 10);
|
||||
|
||||
// Ce doivent être les 10 derniers (91..=100)
|
||||
for i in 91..=100 {
|
||||
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_fifo_order_preserved() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Ajouter 10 éléments
|
||||
for i in 1..=10 {
|
||||
logic.mark_block_downloaded(i);
|
||||
}
|
||||
|
||||
// Vérifier l'ordre dans la VecDeque (le front devrait être le plus ancien)
|
||||
let front = logic.recent_blocks.front().copied();
|
||||
assert_eq!(front, Some(1), "Front should be the oldest element");
|
||||
|
||||
let back = logic.recent_blocks.back().copied();
|
||||
assert_eq!(back, Some(10), "Back should be the newest element");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_queue_push() {
|
||||
let client = create_test_client();
|
||||
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||
|
||||
// Tester push_block_id
|
||||
logic.push_block_id(100);
|
||||
logic.push_block_id(200);
|
||||
logic.push_block_id(300);
|
||||
|
||||
assert_eq!(logic.block_queue.len(), 3);
|
||||
assert_eq!(logic.block_queue.front(), Some(&100));
|
||||
assert_eq!(logic.block_queue.back(), Some(&300));
|
||||
}
|
||||
}
|
||||
@@ -1,395 +1,114 @@
|
||||
//! Music source implementation for Radio Paradise built on the new
|
||||
//! `paradise` orchestration layer.
|
||||
//! DEPRECATED: Stub implementation of RadioParadiseSource
|
||||
//!
|
||||
//! The source exposes a DIDL-Lite hierarchy compatible with UPnP
|
||||
//! ContentDirectory while delegating block ingestion, caching and
|
||||
//! multi-client streaming to [`ParadiseChannel`].
|
||||
//! **⚠️ This module is deprecated and will be removed in a future version.**
|
||||
//!
|
||||
//! The orchestration-based RadioParadiseSource has been replaced by
|
||||
//! `RadioParadiseStreamSource`, which integrates directly with the pmoaudio
|
||||
//! pipeline for streaming and decoding.
|
||||
//!
|
||||
//! ## Migration Guide
|
||||
//!
|
||||
//! **Old approach** (deprecated):
|
||||
//! ```rust,ignore
|
||||
//! use pmoparadise::RadioParadiseSource;
|
||||
//! let source = RadioParadiseSource::from_registry(client)?;
|
||||
//! ```
|
||||
//!
|
||||
//! **New approach** (recommended):
|
||||
//! ```rust,ignore
|
||||
//! use pmoparadise::RadioParadiseStreamSource;
|
||||
//! use pmoaudio::pipeline::Node;
|
||||
//!
|
||||
//! let stream_source = RadioParadiseStreamSource::new(client, None).await?;
|
||||
//! let node = Node::from_logic(stream_source);
|
||||
//! // Use node in pmoaudio pipeline
|
||||
//! ```
|
||||
//!
|
||||
//! This stub implementation is provided only for backward compatibility with
|
||||
//! existing code (e.g., pmomediaserver) until it can be updated to use
|
||||
//! RadioParadiseStreamSource.
|
||||
|
||||
use crate::client::RadioParadiseClient;
|
||||
use crate::paradise::{
|
||||
create_history_backend, ChannelDescriptor, ParadiseChannel, PlaylistEntry, ALL_CHANNELS,
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "pmoconfig"))]
|
||||
use crate::paradise::HISTORY_DEFAULT_MAX_TRACKS;
|
||||
use anyhow::Result as AnyhowResult;
|
||||
use pmoaudiocache::Cache as AudioCache;
|
||||
use pmocovers::Cache as CoverCache;
|
||||
use pmodidl::{Container, Item, Resource};
|
||||
use pmosource::pmodidl;
|
||||
use pmosource::{
|
||||
async_trait, BrowseResult, CacheStatus, MusicSource, MusicSourceError, Result,
|
||||
SourceCacheManager, SourceStatistics,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use pmosource::pmodidl::{Container, Item};
|
||||
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
||||
use std::time::SystemTime;
|
||||
use tracing::warn;
|
||||
|
||||
/// Default image for Radio Paradise (300x300 WebP, embedded in binary)
|
||||
/// Default Radio Paradise image (embedded in binary)
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||
|
||||
fn channel_collection_id(channel_id: u8) -> String {
|
||||
format!("radio-paradise:{}", channel_id)
|
||||
}
|
||||
|
||||
fn channel_container_id(channel_id: u8) -> String {
|
||||
format!("radio-paradise:channel:{}", channel_id)
|
||||
}
|
||||
|
||||
fn parse_channel_container_id(object_id: &str) -> Option<u8> {
|
||||
let mut parts = object_id.split(':');
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some("radio-paradise"), Some("channel"), Some(id_str), None) => id_str.parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_track_channel(track_id: &str) -> Option<u8> {
|
||||
let mut parts = track_id.split(':');
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some("rp"), Some(channel_str), Some(_rest), None) => channel_str.parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_duration(duration_seconds: u64) -> String {
|
||||
let hours = duration_seconds / 3600;
|
||||
let minutes = (duration_seconds % 3600) / 60;
|
||||
let seconds = duration_seconds % 60;
|
||||
format!("{hours}:{minutes:02}:{seconds:02}")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// DEPRECATED: Stub implementation of RadioParadiseSource
|
||||
///
|
||||
/// This is a minimal stub that implements the MusicSource trait with no-op
|
||||
/// implementations. It exists only to maintain API compatibility during the
|
||||
/// migration to RadioParadiseStreamSource.
|
||||
///
|
||||
/// **Do not use this in new code.** Use `RadioParadiseStreamSource` instead.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RadioParadiseSource {
|
||||
inner: Arc<RadioParadiseSourceInner>,
|
||||
}
|
||||
|
||||
struct RadioParadiseSourceInner {
|
||||
channels: HashMap<u8, Arc<ParadiseChannel>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RadioParadiseSource {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RadioParadiseSource").finish()
|
||||
}
|
||||
_client: RadioParadiseClient,
|
||||
}
|
||||
|
||||
impl RadioParadiseSource {
|
||||
/// DEPRECATED: Create a new RadioParadiseSource from registry
|
||||
///
|
||||
/// This method is deprecated and will always return an error indicating
|
||||
/// that the orchestration-based source is no longer supported.
|
||||
///
|
||||
/// Use `RadioParadiseStreamSource` instead for audio streaming.
|
||||
#[cfg(feature = "server")]
|
||||
pub fn from_registry(client: RadioParadiseClient) -> Result<Self> {
|
||||
// Load history configuration from pmoconfig using the config extension trait
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
let (database_path, history_max_tracks) = {
|
||||
use crate::config_ext::RadioParadiseConfigExt;
|
||||
let cfg = pmoconfig::get_config();
|
||||
let database_path = cfg.get_paradise_history_database().map_err(|e| {
|
||||
MusicSourceError::SourceUnavailable(format!(
|
||||
"Failed to get history database path: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let max_tracks = cfg.get_paradise_history_size().map_err(|e| {
|
||||
MusicSourceError::SourceUnavailable(format!("Failed to get history size: {}", e))
|
||||
})?;
|
||||
(database_path, max_tracks)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "pmoconfig"))]
|
||||
let (database_path, history_max_tracks) = {
|
||||
use std::path::PathBuf;
|
||||
let mut path = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()));
|
||||
path.push(".config");
|
||||
path.push("pmo");
|
||||
path.push("paradise");
|
||||
std::fs::create_dir_all(&path).ok();
|
||||
path.push("history.db");
|
||||
(
|
||||
path.to_string_lossy().to_string(),
|
||||
HISTORY_DEFAULT_MAX_TRACKS,
|
||||
)
|
||||
};
|
||||
|
||||
let history_backend = create_history_backend(&database_path).map_err(|e| {
|
||||
MusicSourceError::SourceUnavailable(format!(
|
||||
"Failed to initialize history backend: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let mut channels = HashMap::new();
|
||||
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
let cache_manager = Arc::new(SourceCacheManager::from_registry(
|
||||
channel_collection_id(descriptor.id),
|
||||
)?);
|
||||
let channel = Arc::new(
|
||||
ParadiseChannel::new(
|
||||
*descriptor,
|
||||
client.clone(),
|
||||
history_max_tracks,
|
||||
history_backend.clone(),
|
||||
cache_manager,
|
||||
)
|
||||
.map_err(|e| {
|
||||
MusicSourceError::SourceUnavailable(format!(
|
||||
"Failed to initialize channel {}: {e}",
|
||||
descriptor.slug
|
||||
))
|
||||
})?,
|
||||
);
|
||||
channels.insert(descriptor.id, channel);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(RadioParadiseSourceInner { channels }),
|
||||
})
|
||||
pub fn from_registry(_client: RadioParadiseClient) -> Result<Self> {
|
||||
Err(MusicSourceError::SourceUnavailable(
|
||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// DEPRECATED: Create a new RadioParadiseSource from registry with defaults
|
||||
///
|
||||
/// This method creates a stub instance that will log deprecation warnings
|
||||
/// but allows existing code to compile.
|
||||
///
|
||||
/// Use `RadioParadiseStreamSource` instead for audio streaming.
|
||||
#[cfg(feature = "server")]
|
||||
pub fn from_registry_default(client: RadioParadiseClient) -> Result<Self> {
|
||||
Self::from_registry(client)
|
||||
pub fn from_registry_default(client: RadioParadiseClient) -> Self {
|
||||
tracing::warn!(
|
||||
"RadioParadiseSource::from_registry_default is deprecated. \
|
||||
Use RadioParadiseStreamSource for audio streaming."
|
||||
);
|
||||
Self { _client: client }
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
client: RadioParadiseClient,
|
||||
cover_cache: Arc<CoverCache>,
|
||||
audio_cache: Arc<AudioCache>,
|
||||
) -> Self {
|
||||
// Load history configuration from pmoconfig using the config extension trait
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
let (database_path, history_max_tracks) = {
|
||||
use crate::config_ext::RadioParadiseConfigExt;
|
||||
let cfg = pmoconfig::get_config();
|
||||
let database_path = cfg.get_paradise_history_database().unwrap_or_else(|e| {
|
||||
panic!("Failed to get history database path: {e}");
|
||||
});
|
||||
let max_tracks = cfg.get_paradise_history_size().unwrap_or_else(|e| {
|
||||
panic!("Failed to get history size: {e}");
|
||||
});
|
||||
(database_path, max_tracks)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "pmoconfig"))]
|
||||
let (database_path, history_max_tracks) = {
|
||||
use std::path::PathBuf;
|
||||
let mut path = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()));
|
||||
path.push(".config");
|
||||
path.push("pmo");
|
||||
path.push("paradise");
|
||||
std::fs::create_dir_all(&path).ok();
|
||||
path.push("history.db");
|
||||
(
|
||||
path.to_string_lossy().to_string(),
|
||||
HISTORY_DEFAULT_MAX_TRACKS,
|
||||
)
|
||||
};
|
||||
|
||||
let history_backend: Arc<dyn crate::paradise::HistoryBackend> =
|
||||
create_history_backend(&database_path).unwrap_or_else(|err| {
|
||||
panic!("Failed to initialize history backend: {err}");
|
||||
});
|
||||
let mut channels = HashMap::new();
|
||||
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
let cache_manager = Arc::new(SourceCacheManager::new(
|
||||
channel_collection_id(descriptor.id),
|
||||
Arc::clone(&cover_cache),
|
||||
Arc::clone(&audio_cache),
|
||||
));
|
||||
match ParadiseChannel::new(
|
||||
*descriptor,
|
||||
client.clone(),
|
||||
history_max_tracks,
|
||||
history_backend.clone(),
|
||||
cache_manager,
|
||||
) {
|
||||
Ok(channel) => {
|
||||
channels.insert(descriptor.id, Arc::new(channel));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
channel = descriptor.slug,
|
||||
"Failed to initialize channel: {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
inner: Arc::new(RadioParadiseSourceInner { channels }),
|
||||
}
|
||||
/// DEPRECATED: Create a new RadioParadiseSource with default settings
|
||||
///
|
||||
/// This method is deprecated and only exists for API compatibility.
|
||||
pub fn new_default(client: RadioParadiseClient) -> Self {
|
||||
tracing::warn!(
|
||||
"RadioParadiseSource::new_default is deprecated. \
|
||||
Use RadioParadiseStreamSource for audio streaming."
|
||||
);
|
||||
Self { _client: client }
|
||||
}
|
||||
|
||||
pub fn new_default(
|
||||
client: RadioParadiseClient,
|
||||
cover_cache: Arc<CoverCache>,
|
||||
audio_cache: Arc<AudioCache>,
|
||||
) -> Self {
|
||||
Self::new(client, cover_cache, audio_cache)
|
||||
}
|
||||
|
||||
pub fn client_for_channel(&self, channel: u8) -> Option<RadioParadiseClient> {
|
||||
self.inner
|
||||
.channels
|
||||
.get(&channel)
|
||||
.map(|ch| ch.client().clone())
|
||||
}
|
||||
|
||||
pub fn channel(&self, id: u8) -> Option<Arc<ParadiseChannel>> {
|
||||
self.inner.channels.get(&id).cloned()
|
||||
}
|
||||
|
||||
fn build_root_container(&self) -> Container {
|
||||
Container {
|
||||
id: "radio-paradise".to_string(),
|
||||
parent_id: "0".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(ALL_CHANNELS.len().to_string()),
|
||||
searchable: Some("1".to_string()),
|
||||
title: "Radio Paradise".to_string(),
|
||||
class: "object.container".to_string(),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_channel_containers(&self) -> Vec<Container> {
|
||||
let mut containers = Vec::new();
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
if let Some(channel) = self.channel(descriptor.id) {
|
||||
let len = channel.playlist().active_len().await;
|
||||
containers.push(Container {
|
||||
id: channel_container_id(descriptor.id),
|
||||
parent_id: "radio-paradise".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(len.to_string()),
|
||||
searchable: Some("1".to_string()),
|
||||
title: descriptor.display_name.to_string(),
|
||||
class: "object.container.playlistContainer".to_string(),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
containers
|
||||
}
|
||||
|
||||
async fn channel_items(
|
||||
&self,
|
||||
descriptor: ChannelDescriptor,
|
||||
offset: usize,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<Item>> {
|
||||
let channel = self
|
||||
.channel(descriptor.id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(descriptor.slug.to_string()))?;
|
||||
|
||||
channel
|
||||
.ensure_started()
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::SourceUnavailable(e.to_string()))?;
|
||||
|
||||
let entries = channel.playlist().active_snapshot().await;
|
||||
if entries.is_empty() || offset >= entries.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let end = limit
|
||||
.map(|count| offset + count)
|
||||
.unwrap_or(entries.len())
|
||||
.min(entries.len());
|
||||
|
||||
let parent_id = channel_container_id(descriptor.id);
|
||||
|
||||
let mut items = Vec::with_capacity(end - offset);
|
||||
for entry in entries.into_iter().skip(offset).take(end - offset) {
|
||||
match self.entry_to_item(channel.clone(), &parent_id, entry).await {
|
||||
Ok(item) => items.push(item),
|
||||
Err(err) => warn!(
|
||||
channel = descriptor.slug,
|
||||
"Failed to build DIDL item: {err:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn entry_to_item(
|
||||
&self,
|
||||
channel: Arc<ParadiseChannel>,
|
||||
parent_id: &str,
|
||||
entry: Arc<PlaylistEntry>,
|
||||
) -> AnyhowResult<Item> {
|
||||
let cache_manager = channel.cache_manager();
|
||||
let metadata = cache_manager.get_metadata(&entry.track_id).await;
|
||||
|
||||
let resource_url = cache_manager
|
||||
.resolve_uri(&entry.track_id)
|
||||
.await
|
||||
.or_else(|_| {
|
||||
metadata
|
||||
.as_ref()
|
||||
.map(|meta| meta.original_uri.clone())
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(entry.track_id.clone()))
|
||||
})?;
|
||||
|
||||
let mut album_art = metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.cached_cover_pk.as_ref())
|
||||
.and_then(|pk| cache_manager.cover_url(pk, None).ok());
|
||||
|
||||
if album_art.is_none() {
|
||||
album_art = entry.song.cover.clone();
|
||||
}
|
||||
|
||||
let duration_seconds = entry.duration_ms / 1000;
|
||||
let duration_str = if duration_seconds > 0 {
|
||||
Some(format_duration(duration_seconds as u64))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let resource = Resource {
|
||||
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
||||
bits_per_sample: None,
|
||||
sample_frequency: None,
|
||||
nr_audio_channels: None,
|
||||
duration: duration_str.clone(),
|
||||
url: resource_url,
|
||||
};
|
||||
|
||||
Ok(Item {
|
||||
id: entry.track_id.clone(),
|
||||
parent_id: parent_id.to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
title: entry.song.title.clone(),
|
||||
creator: Some(entry.song.artist.clone()),
|
||||
class: "object.item.audioItem.musicTrack".to_string(),
|
||||
artist: Some(entry.song.artist.clone()),
|
||||
album: entry.song.album.clone(),
|
||||
genre: None,
|
||||
album_art,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: None,
|
||||
resources: vec![resource],
|
||||
descriptions: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
fn channels_iter(&self) -> impl Iterator<Item = (&u8, &Arc<ParadiseChannel>)> {
|
||||
self.inner.channels.iter()
|
||||
/// DEPRECATED: Create a new RadioParadiseSource with cache
|
||||
///
|
||||
/// This method is deprecated and only exists for API compatibility.
|
||||
pub fn new_with_cache(client: RadioParadiseClient, _cache_size: usize) -> Self {
|
||||
tracing::warn!(
|
||||
"RadioParadiseSource::new_with_cache is deprecated. \
|
||||
Use RadioParadiseStreamSource for audio streaming."
|
||||
);
|
||||
Self { _client: client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for RadioParadiseSource {
|
||||
fn name(&self) -> &str {
|
||||
"Radio Paradise"
|
||||
"Radio Paradise (DEPRECATED)"
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
"radio-paradise"
|
||||
"radio-paradise-deprecated"
|
||||
}
|
||||
|
||||
fn default_image(&self) -> &[u8] {
|
||||
@@ -397,43 +116,32 @@ impl MusicSource for RadioParadiseSource {
|
||||
}
|
||||
|
||||
async fn root_container(&self) -> Result<Container> {
|
||||
Ok(self.build_root_container())
|
||||
Ok(Container {
|
||||
id: "radio-paradise-deprecated".to_string(),
|
||||
parent_id: "0".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some("0".to_string()),
|
||||
searchable: Some("0".to_string()),
|
||||
title: "Radio Paradise (DEPRECATED)".to_string(),
|
||||
class: "object.container".to_string(),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
|
||||
match object_id {
|
||||
"0" => Ok(BrowseResult::Containers(vec![self.build_root_container()])),
|
||||
"radio-paradise" => {
|
||||
let containers = self.build_channel_containers().await;
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
}
|
||||
_ => {
|
||||
if let Some(channel_id) = parse_channel_container_id(object_id) {
|
||||
let descriptor = ALL_CHANNELS
|
||||
.iter()
|
||||
.find(|desc| desc.id == channel_id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||
|
||||
let items = self.channel_items(*descriptor, 0, None).await?;
|
||||
Ok(BrowseResult::Items(items))
|
||||
} else {
|
||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn browse(&self, _object_id: &str) -> Result<BrowseResult> {
|
||||
tracing::warn!("RadioParadiseSource::browse called but source is deprecated");
|
||||
Ok(BrowseResult::Mixed {
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
||||
let channel_id = parse_track_channel(object_id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||
let channel = self
|
||||
.channel(channel_id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||
channel
|
||||
.cache_manager()
|
||||
.resolve_uri(object_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
||||
async fn resolve_uri(&self, _object_id: &str) -> Result<String> {
|
||||
Err(MusicSourceError::SourceUnavailable(
|
||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn supports_fifo(&self) -> bool {
|
||||
@@ -441,171 +149,25 @@ impl MusicSource for RadioParadiseSource {
|
||||
}
|
||||
|
||||
async fn append_track(&self, _track: Item) -> Result<()> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
Err(MusicSourceError::SourceUnavailable(
|
||||
"RadioParadiseSource is deprecated and does not support FIFO operations."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn update_id(&self) -> u32 {
|
||||
self.channels_iter()
|
||||
.map(|(_, channel)| channel.playlist().update_id())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
0
|
||||
}
|
||||
|
||||
async fn last_change(&self) -> Option<SystemTime> {
|
||||
let mut latest: Option<SystemTime> = None;
|
||||
for (_, channel) in self.channels_iter() {
|
||||
if let Some(change) = channel.playlist().last_change().await {
|
||||
latest = Some(match latest {
|
||||
Some(current) if change <= current => current,
|
||||
_ => change,
|
||||
});
|
||||
}
|
||||
}
|
||||
latest
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
|
||||
let mut all = Vec::new();
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
let mut items = self.channel_items(*descriptor, 0, None).await?;
|
||||
all.append(&mut items);
|
||||
}
|
||||
|
||||
if offset >= all.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let end = if count == 0 {
|
||||
all.len()
|
||||
} else {
|
||||
(offset + count).min(all.len())
|
||||
};
|
||||
|
||||
Ok(all.into_iter().skip(offset).take(end - offset).collect())
|
||||
}
|
||||
|
||||
async fn get_available_formats(&self, _object_id: &str) -> Result<Vec<pmosource::AudioFormat>> {
|
||||
Ok(vec![pmosource::AudioFormat {
|
||||
format_id: "flac".to_string(),
|
||||
mime_type: "audio/flac".to_string(),
|
||||
sample_rate: Some(44100),
|
||||
bit_depth: Some(16),
|
||||
bitrate: None,
|
||||
channels: Some(2),
|
||||
}])
|
||||
}
|
||||
|
||||
async fn get_cache_status(&self, object_id: &str) -> Result<CacheStatus> {
|
||||
let channel_id = parse_track_channel(object_id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||
let channel = self
|
||||
.channel(channel_id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||
channel
|
||||
.cache_manager()
|
||||
.get_cache_status(object_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn cache_item(&self, object_id: &str) -> Result<CacheStatus> {
|
||||
let channel_id = parse_track_channel(object_id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||
let channel = self
|
||||
.channel(channel_id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||
channel
|
||||
.cache_manager()
|
||||
.get_cache_status(object_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn browse_paginated(
|
||||
&self,
|
||||
object_id: &str,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<BrowseResult> {
|
||||
match object_id {
|
||||
"0" => {
|
||||
if offset == 0 {
|
||||
Ok(BrowseResult::Containers(vec![self.build_root_container()]))
|
||||
} else {
|
||||
Ok(BrowseResult::Containers(Vec::new()))
|
||||
}
|
||||
}
|
||||
"radio-paradise" => {
|
||||
let containers = self.build_channel_containers().await;
|
||||
let total = containers.len();
|
||||
if offset >= total {
|
||||
return Ok(BrowseResult::Containers(Vec::new()));
|
||||
}
|
||||
let end = if limit == 0 {
|
||||
total
|
||||
} else {
|
||||
(offset + limit).min(total)
|
||||
};
|
||||
Ok(BrowseResult::Containers(
|
||||
containers
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(end - offset)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
if let Some(channel_id) = parse_channel_container_id(object_id) {
|
||||
let descriptor = ALL_CHANNELS
|
||||
.iter()
|
||||
.find(|desc| desc.id == channel_id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||
|
||||
let items = self.channel_items(*descriptor, offset, Some(limit)).await?;
|
||||
Ok(BrowseResult::Items(items))
|
||||
} else {
|
||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_item_count(&self, object_id: &str) -> Result<usize> {
|
||||
match object_id {
|
||||
"0" => Ok(1),
|
||||
"radio-paradise" => Ok(ALL_CHANNELS.len()),
|
||||
_ => {
|
||||
if let Some(channel_id) = parse_channel_container_id(object_id) {
|
||||
let channel = self
|
||||
.channel(channel_id)
|
||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||
Ok(channel.playlist().active_len().await)
|
||||
} else {
|
||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn statistics(&self) -> Result<SourceStatistics> {
|
||||
let mut total_tracks = 0usize;
|
||||
let mut cached_tracks = 0usize;
|
||||
|
||||
for (_, channel) in self.channels_iter() {
|
||||
total_tracks += channel.playlist().active_len().await;
|
||||
let stats = channel.cache_manager().statistics().await;
|
||||
cached_tracks += stats.cached_tracks;
|
||||
}
|
||||
|
||||
Ok(SourceStatistics {
|
||||
total_items: Some(total_tracks),
|
||||
total_containers: Some(ALL_CHANNELS.len() + 1),
|
||||
cached_items: Some(cached_tracks),
|
||||
cache_size_bytes: None,
|
||||
})
|
||||
async fn get_items(&self, _offset: usize, _count: usize) -> Result<Vec<Item>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
//! Block streaming functionality
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::models::Block;
|
||||
use crate::RadioParadiseClient;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use url::Url;
|
||||
|
||||
/// A stream of audio data from a Radio Paradise block
|
||||
///
|
||||
/// This wraps the HTTP response body and provides a `Stream<Item = Result<Bytes>>`
|
||||
/// that can be consumed by audio players or written to a file.
|
||||
pub struct BlockStream {
|
||||
inner: Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>,
|
||||
}
|
||||
|
||||
impl BlockStream {
|
||||
/// Create a new block stream from a reqwest response
|
||||
pub(crate) fn new(stream: impl Stream<Item = Result<Bytes>> + Send + 'static) -> Self {
|
||||
Self {
|
||||
inner: Box::pin(stream),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the inner stream
|
||||
///
|
||||
/// Consumes the BlockStream and returns the underlying pinned stream.
|
||||
/// Useful for advanced streaming scenarios like progressive decoding.
|
||||
pub fn into_inner(self) -> Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>> {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for BlockStream {
|
||||
type Item = Result<Bytes>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
self.inner.as_mut().poll_next(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl RadioParadiseClient {
|
||||
/// Stream a block from its URL
|
||||
///
|
||||
/// Returns a `Stream` of audio bytes that can be consumed by an audio player.
|
||||
/// The stream will continue until the entire block is downloaded or an error occurs.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `block_url` - The URL of the block to stream
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoparadise::RadioParadiseClient;
|
||||
/// use futures::StreamExt;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = RadioParadiseClient::new().await?;
|
||||
/// let block = client.get_block(None).await?;
|
||||
///
|
||||
/// let mut stream = client.stream_block(&block.url.parse()?).await?;
|
||||
///
|
||||
/// while let Some(chunk) = stream.next().await {
|
||||
/// let bytes = chunk?;
|
||||
/// // Write bytes to audio player or file
|
||||
/// println!("Received {} bytes", bytes.len());
|
||||
/// }
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn stream_block(&self, block_url: &Url) -> Result<BlockStream> {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Starting block stream: {}", block_url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(block_url.clone())
|
||||
.timeout(self.block_timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::other(format!(
|
||||
"Failed to stream block: HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
// Convert reqwest's byte stream to our Result type
|
||||
let stream = response.bytes_stream();
|
||||
let mapped = futures::stream::StreamExt::map(stream, |result| result.map_err(Error::from));
|
||||
|
||||
Ok(BlockStream::new(mapped))
|
||||
}
|
||||
|
||||
/// Stream a block directly from a Block struct
|
||||
///
|
||||
/// Convenience method that parses the URL from the block.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoparadise::RadioParadiseClient;
|
||||
/// use futures::StreamExt;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = RadioParadiseClient::new().await?;
|
||||
/// let block = client.get_block(None).await?;
|
||||
///
|
||||
/// let mut stream = client.stream_block_from_metadata(&block).await?;
|
||||
///
|
||||
/// while let Some(chunk) = stream.next().await {
|
||||
/// let bytes = chunk?;
|
||||
/// // Process bytes...
|
||||
/// }
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn stream_block_from_metadata(&self, block: &Block) -> Result<BlockStream> {
|
||||
let url = Url::parse(&block.url)?;
|
||||
self.stream_block(&url).await
|
||||
}
|
||||
|
||||
/// Download an entire block as Bytes
|
||||
///
|
||||
/// This downloads the complete block file into memory. For streaming playback,
|
||||
/// use `stream_block()` instead which is more memory efficient.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `block_url` - The URL of the block to download
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoparadise::RadioParadiseClient;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = RadioParadiseClient::new().await?;
|
||||
/// let block = client.get_block(None).await?;
|
||||
/// let url = block.url.parse()?;
|
||||
/// let bytes = client.download_block(&url).await?;
|
||||
/// println!("Downloaded {} bytes", bytes.len());
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn download_block(&self, block_url: &Url) -> Result<Bytes> {
|
||||
let mut stream = self.stream_block(block_url).await?;
|
||||
let mut data = Vec::new();
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result?;
|
||||
data.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
Ok(Bytes::from(data))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_block_stream_creation() {
|
||||
let stream = futures::stream::once(async { Ok(Bytes::from("test")) });
|
||||
let _block_stream = BlockStream::new(stream);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::Stream;
|
||||
use std::io::{self, Read};
|
||||
use std::pin::Pin;
|
||||
use std::sync::mpsc::{sync_channel, Receiver, RecvError, SyncSender};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const CHANNEL_BUFFER_SIZE: usize = 64; // Augmenté de 16 à 64 pour réduire les warnings "buffer plein"
|
||||
pub const CHUNK_SIZE_FRAMES: usize = 4096;
|
||||
|
||||
pub struct ChannelReader {
|
||||
receiver: Receiver<Result<Bytes, String>>,
|
||||
current_chunk: Option<Bytes>,
|
||||
position: usize,
|
||||
}
|
||||
|
||||
impl ChannelReader {
|
||||
pub fn new(
|
||||
stream: Pin<Box<dyn Stream<Item = Result<Bytes, crate::error::Error>> + Send>>,
|
||||
) -> Self {
|
||||
let (tx, rx) = sync_channel(CHANNEL_BUFFER_SIZE);
|
||||
tokio::spawn(Self::stream_feeder(stream, tx));
|
||||
Self {
|
||||
receiver: rx,
|
||||
current_chunk: None,
|
||||
position: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_feeder(
|
||||
mut stream: Pin<Box<dyn Stream<Item = Result<Bytes, crate::error::Error>> + Send>>,
|
||||
tx: SyncSender<Result<Bytes, String>>,
|
||||
) {
|
||||
use futures::StreamExt;
|
||||
while let Some(result) = stream.next().await {
|
||||
let start = Instant::now();
|
||||
|
||||
let to_send = result.map_err(|e| e.to_string());
|
||||
match tx.try_send(to_send) {
|
||||
Ok(_) => { /* message envoyé sans attente */ }
|
||||
Err(std::sync::mpsc::TrySendError::Full(value)) => {
|
||||
tracing::warn!("stream_feeder: buffer plein");
|
||||
// Revenir à l’envoi bloquant pour ne pas perdre le message
|
||||
if tx.send(value).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => break,
|
||||
}
|
||||
let waited = start.elapsed();
|
||||
tracing::trace!("stream_feeder send {:?}", waited);
|
||||
if waited > Duration::from_millis(200) {
|
||||
tracing::warn!("stream_feeder wait {:?}", waited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for ChannelReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(chunk) = &self.current_chunk {
|
||||
if self.position < chunk.len() {
|
||||
let available = chunk.len() - self.position;
|
||||
let to_copy = available.min(buf.len());
|
||||
buf[..to_copy].copy_from_slice(&chunk[self.position..self.position + to_copy]);
|
||||
self.position += to_copy;
|
||||
tracing::trace!(
|
||||
"ChannelReader copied {} bytes (elapsed {:?})",
|
||||
to_copy,
|
||||
start.elapsed()
|
||||
);
|
||||
return Ok(to_copy);
|
||||
}
|
||||
}
|
||||
|
||||
match self.receiver.recv() {
|
||||
Ok(Ok(bytes)) => {
|
||||
tracing::trace!(
|
||||
"ChannelReader received chunk of {} bytes after {:?}",
|
||||
bytes.len(),
|
||||
start.elapsed()
|
||||
);
|
||||
self.current_chunk = Some(bytes);
|
||||
self.position = 0;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("ChannelReader received error chunk: {}", e);
|
||||
return Err(io::Error::new(io::ErrorKind::Other, e));
|
||||
}
|
||||
Err(RecvError) => {
|
||||
tracing::trace!("ChannelReader stream closed after {:?}", start.elapsed());
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PCMChunk {
|
||||
pub samples: Vec<i32>,
|
||||
pub position_ms: u64,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
}
|
||||
|
||||
pub struct StreamingPCMDecoder<R: Read> {
|
||||
reader: claxon::FlacReader<std::io::BufReader<R>>,
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
bits_per_sample: u32,
|
||||
total_samples_decoded: u64,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl StreamingPCMDecoder<ChannelReader> {
|
||||
/// Create a new decoder from an HTTP stream with default chunk size
|
||||
pub fn new(http_stream: crate::stream::BlockStream) -> anyhow::Result<Self> {
|
||||
Self::with_chunk_size(http_stream, CHUNK_SIZE_FRAMES)
|
||||
}
|
||||
|
||||
pub fn with_chunk_size(
|
||||
http_stream: crate::stream::BlockStream,
|
||||
_chunk_size: usize,
|
||||
) -> anyhow::Result<Self> {
|
||||
let channel_reader = ChannelReader::new(http_stream.into_inner());
|
||||
let buffered = std::io::BufReader::new(channel_reader);
|
||||
let reader = claxon::FlacReader::new(buffered)
|
||||
.map_err(|e| anyhow::anyhow!("FLAC reader error: {}", e))?;
|
||||
let info = reader.streaminfo();
|
||||
|
||||
Ok(Self {
|
||||
reader,
|
||||
sample_rate: info.sample_rate,
|
||||
channels: info.channels,
|
||||
bits_per_sample: info.bits_per_sample,
|
||||
total_samples_decoded: 0,
|
||||
done: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the sample rate (e.g., 44100 Hz)
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
/// Get the number of channels (e.g., 2 for stereo)
|
||||
pub fn channels(&self) -> u32 {
|
||||
self.channels
|
||||
}
|
||||
|
||||
/// Get bits per sample (e.g., 16)
|
||||
pub fn bits_per_sample(&self) -> u32 {
|
||||
self.bits_per_sample
|
||||
}
|
||||
|
||||
pub fn decode_chunk(&mut self) -> anyhow::Result<Option<PCMChunk>> {
|
||||
if self.done {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Crée le FrameReader à la volée (emprunt de self.reader)
|
||||
let mut frames = self.reader.blocks();
|
||||
|
||||
// API claxon 0.6.x : il FAUT fournir un Vec<i32> par valeur
|
||||
let buf: Vec<i32> = Vec::new();
|
||||
let frame = match frames.read_next_or_eof(buf) {
|
||||
Ok(None) => {
|
||||
self.done = true;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(f)) => f,
|
||||
Err(e) => return Err(anyhow::anyhow!("FLAC decode error: {}", e)),
|
||||
};
|
||||
|
||||
let planar_samples: Vec<i32> = frame.into_buffer();
|
||||
if planar_samples.is_empty() {
|
||||
self.done = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// IMPORTANT: Claxon retourne les samples en format PLANAR (tous les L, puis tous les R)
|
||||
// Mais nous avons besoin du format INTERLEAVED (L, R, L, R, ...) pour l'encodage
|
||||
let block_size = planar_samples.len() / self.channels as usize;
|
||||
let mut samples = Vec::with_capacity(planar_samples.len());
|
||||
|
||||
for i in 0..block_size {
|
||||
for ch in 0..self.channels as usize {
|
||||
samples.push(planar_samples[ch * block_size + i]);
|
||||
}
|
||||
}
|
||||
|
||||
let position_ms = {
|
||||
let frames = self.total_samples_decoded / self.channels as u64;
|
||||
(frames * 1000) / self.sample_rate as u64
|
||||
};
|
||||
self.total_samples_decoded += samples.len() as u64;
|
||||
|
||||
Ok(Some(PCMChunk {
|
||||
samples,
|
||||
position_ms,
|
||||
sample_rate: self.sample_rate,
|
||||
channels: self.channels,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ms_to_frames(ms: u64, sample_rate: u32) -> usize {
|
||||
((ms as u128 * sample_rate as u128) / 1000) as usize
|
||||
}
|
||||
|
||||
pub fn frames_to_ms(frames: usize, sample_rate: u32) -> u64 {
|
||||
((frames as u128 * 1000) / sample_rate as u128) as u64
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
//! Per-track extraction from FLAC blocks (optional feature)
|
||||
//!
|
||||
//! **Important Notes:**
|
||||
//!
|
||||
//! Radio Paradise publishes *blocks* containing multiple songs, not individual
|
||||
//! per-track files. This module provides experimental functionality to extract
|
||||
//! individual tracks from FLAC blocks, but comes with significant tradeoffs:
|
||||
//!
|
||||
//! - **Storage**: Requires downloading the entire block (50-100MB) to disk
|
||||
//! - **Latency**: Must download and decode before playback can start
|
||||
//! - **CPU**: FLAC decoding is CPU-intensive
|
||||
//! - **Complexity**: Seeking in FLAC requires decoding from the beginning
|
||||
//!
|
||||
//! ## Recommended Alternative
|
||||
//!
|
||||
//! For most use cases, it's better to:
|
||||
//! 1. Stream the entire block to your audio player
|
||||
//! 2. Use the `song[i].elapsed` metadata to seek within the player
|
||||
//! 3. Let the player handle gapless transitions between tracks
|
||||
//!
|
||||
//! Modern players (mpv, VLC, ffmpeg) can seek in FLAC streams efficiently.
|
||||
//!
|
||||
//! ## When to Use This Module
|
||||
//!
|
||||
//! Only use per-track extraction when you need:
|
||||
//! - Individual WAV files for further processing
|
||||
//! - PCM data for custom audio analysis
|
||||
//! - Separate files for non-streaming scenarios
|
||||
//!
|
||||
//! ## Block URL Pattern
|
||||
//!
|
||||
//! Blocks follow this URL pattern:
|
||||
//! ```text
|
||||
//! https://apps.radioparadise.com/blocks/chan/0/4/<start_event>-<end_event>.flac
|
||||
//! ```
|
||||
//!
|
||||
//! The `song[i].elapsed` field (in milliseconds) indicates when each track
|
||||
//! starts within the block.
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
use crate::error::{Error, Result};
|
||||
#[cfg(feature = "per-track")]
|
||||
use crate::models::Block;
|
||||
#[cfg(feature = "per-track")]
|
||||
use crate::RadioParadiseClient;
|
||||
#[cfg(feature = "per-track")]
|
||||
use std::io::Write;
|
||||
#[cfg(feature = "per-track")]
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Metadata for a decoded track stream
|
||||
#[cfg(feature = "per-track")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackMetadata {
|
||||
/// Sample rate in Hz (e.g., 44100)
|
||||
pub sample_rate: u32,
|
||||
/// Number of audio channels (1 = mono, 2 = stereo)
|
||||
pub channels: u16,
|
||||
/// Bits per sample (typically 16 or 24)
|
||||
pub bits_per_sample: u16,
|
||||
/// Total number of samples in this track
|
||||
pub total_samples: u64,
|
||||
}
|
||||
|
||||
/// A stream of decoded PCM audio for a single track
|
||||
///
|
||||
/// Provides access to decoded FLAC audio data for one track within a block.
|
||||
/// The audio is decoded to 16-bit PCM format.
|
||||
#[cfg(feature = "per-track")]
|
||||
pub struct TrackStream {
|
||||
/// Audio format metadata
|
||||
pub metadata: TrackMetadata,
|
||||
/// Path to the temporary FLAC file
|
||||
temp_path: PathBuf,
|
||||
/// FLAC reader
|
||||
reader: Option<claxon::FlacReader<std::io::BufReader<std::fs::File>>>,
|
||||
/// Current sample position
|
||||
current_sample: u64,
|
||||
/// End sample position (where this track ends)
|
||||
end_sample: u64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
impl TrackStream {
|
||||
/// Create a new track stream from a block
|
||||
///
|
||||
/// This will:
|
||||
/// 1. Download the entire block to a temporary file
|
||||
/// 2. Open it with a FLAC decoder
|
||||
/// 3. Seek to the track's start position
|
||||
/// 4. Prepare to decode samples
|
||||
///
|
||||
/// **Warning**: This is an expensive operation. Consider caching blocks.
|
||||
async fn from_block_internal(
|
||||
client: &RadioParadiseClient,
|
||||
block: &Block,
|
||||
track_index: usize,
|
||||
) -> Result<Self> {
|
||||
// Validate track index
|
||||
let song = block
|
||||
.get_song(track_index)
|
||||
.ok_or(Error::InvalidIndex(track_index, block.song_count()))?;
|
||||
|
||||
// Download block to temporary file
|
||||
let url = block
|
||||
.url
|
||||
.parse()
|
||||
.map_err(|e| Error::other(format!("Invalid block URL: {}", e)))?;
|
||||
|
||||
let block_data = client.download_block(&url).await?;
|
||||
|
||||
// Write to temp file
|
||||
let mut temp_file = tempfile::NamedTempFile::new()?;
|
||||
temp_file.write_all(&block_data)?;
|
||||
temp_file.flush()?;
|
||||
|
||||
let temp_path = temp_file.into_temp_path();
|
||||
let path_buf = temp_path.to_path_buf();
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Wrote block to temp file: {:?}", path_buf);
|
||||
|
||||
// Open FLAC reader
|
||||
let file = std::fs::File::open(&path_buf)?;
|
||||
let buffered = std::io::BufReader::new(file);
|
||||
let mut reader = claxon::FlacReader::new(buffered)?;
|
||||
|
||||
let streaminfo = reader.streaminfo();
|
||||
let sample_rate = streaminfo.sample_rate;
|
||||
let channels = streaminfo.channels as u16;
|
||||
let bits_per_sample = streaminfo.bits_per_sample as u16;
|
||||
|
||||
// Calculate start and end sample positions
|
||||
let start_sample = Self::ms_to_samples(song.elapsed, sample_rate);
|
||||
let duration_samples = Self::ms_to_samples(song.duration, sample_rate);
|
||||
let end_sample = start_sample + duration_samples;
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!(
|
||||
"Track {} spans samples {} to {} ({} ms to {} ms)",
|
||||
track_index,
|
||||
start_sample,
|
||||
end_sample,
|
||||
song.elapsed,
|
||||
song.elapsed + song.duration
|
||||
);
|
||||
|
||||
// Seek to start position by reading and discarding samples
|
||||
// Note: FLAC doesn't support random access, so we must decode from beginning
|
||||
if start_sample > 0 {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Seeking to sample {}", start_sample);
|
||||
|
||||
Self::skip_samples(&mut reader, start_sample)?;
|
||||
}
|
||||
|
||||
let metadata = TrackMetadata {
|
||||
sample_rate,
|
||||
channels,
|
||||
bits_per_sample,
|
||||
total_samples: duration_samples,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
metadata,
|
||||
temp_path: path_buf,
|
||||
reader: Some(reader),
|
||||
current_sample: start_sample,
|
||||
end_sample,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert milliseconds to sample count
|
||||
fn ms_to_samples(ms: u64, sample_rate: u32) -> u64 {
|
||||
(ms * sample_rate as u64) / 1000
|
||||
}
|
||||
|
||||
/// Skip samples by reading and discarding
|
||||
fn skip_samples(
|
||||
reader: &mut claxon::FlacReader<std::io::BufReader<std::fs::File>>,
|
||||
count: u64,
|
||||
) -> Result<()> {
|
||||
let mut samples = reader.samples();
|
||||
for _ in 0..count {
|
||||
if samples.next().is_none() {
|
||||
return Err(Error::other("Unexpected end of FLAC stream while seeking"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read decoded PCM samples
|
||||
///
|
||||
/// Returns samples as 16-bit signed integers (i16), interleaved by channel.
|
||||
/// For stereo: [L, R, L, R, ...]. Returns None when track ends.
|
||||
pub fn read_samples(&mut self, buffer: &mut [i16]) -> Result<Option<usize>> {
|
||||
let reader = self
|
||||
.reader
|
||||
.as_mut()
|
||||
.ok_or(Error::other("TrackStream already consumed"))?;
|
||||
|
||||
let mut samples_iter = reader.samples();
|
||||
let mut count = 0;
|
||||
|
||||
for chunk in buffer.chunks_mut(self.metadata.channels as usize) {
|
||||
if self.current_sample >= self.end_sample {
|
||||
break;
|
||||
}
|
||||
|
||||
// Read one sample per channel
|
||||
for sample_slot in chunk.iter_mut() {
|
||||
match samples_iter.next() {
|
||||
Some(Ok(sample)) => {
|
||||
// Claxon returns i32, convert to i16
|
||||
*sample_slot = (sample >> (self.metadata.bits_per_sample - 16)) as i16;
|
||||
count += 1;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
return Err(Error::FlacDecode(e.to_string()));
|
||||
}
|
||||
None => {
|
||||
return Ok(if count > 0 { Some(count) } else { None });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.current_sample += 1;
|
||||
}
|
||||
|
||||
Ok(if count > 0 { Some(count) } else { None })
|
||||
}
|
||||
|
||||
/// Export track to a WAV file
|
||||
///
|
||||
/// Decodes the entire track and writes it as a WAV file.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # #[cfg(feature = "per-track")]
|
||||
/// # {
|
||||
/// use pmoparadise::RadioParadiseClient;
|
||||
/// use std::path::Path;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = RadioParadiseClient::new().await?;
|
||||
/// let block = client.get_block(None).await?;
|
||||
///
|
||||
/// let mut track_stream = client.open_track_stream(&block, 0).await?;
|
||||
/// track_stream.export_wav(Path::new("track.wav"))?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn export_wav(&mut self, output_path: &std::path::Path) -> Result<()> {
|
||||
let spec = hound::WavSpec {
|
||||
channels: self.metadata.channels,
|
||||
sample_rate: self.metadata.sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
let mut writer = hound::WavWriter::create(output_path, spec)?;
|
||||
let mut buffer = vec![0i16; 8192 * self.metadata.channels as usize];
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!("Exporting track to WAV: {:?}", output_path);
|
||||
|
||||
loop {
|
||||
match self.read_samples(&mut buffer)? {
|
||||
Some(count) => {
|
||||
for &sample in &buffer[..count] {
|
||||
writer.write_sample(sample)?;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
writer.finalize()?;
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!("Successfully exported WAV file");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
impl Drop for TrackStream {
|
||||
fn drop(&mut self) {
|
||||
// Close reader before removing temp file
|
||||
self.reader.take();
|
||||
|
||||
// Clean up temporary file
|
||||
if let Err(_e) = std::fs::remove_file(&self.temp_path) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Failed to remove temp file {:?}: {}", self.temp_path, _e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "per-track")]
|
||||
impl RadioParadiseClient {
|
||||
/// Open a stream for a specific track within a block
|
||||
///
|
||||
/// **Warning**: This downloads the entire block to a temporary file
|
||||
/// and performs FLAC decoding. See module documentation for alternatives.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `block` - The block containing the track
|
||||
/// * `track_index` - Index of the track (0-based)
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # #[cfg(feature = "per-track")]
|
||||
/// # {
|
||||
/// use pmoparadise::RadioParadiseClient;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = RadioParadiseClient::new().await?;
|
||||
/// let block = client.get_block(None).await?;
|
||||
///
|
||||
/// // Extract first track
|
||||
/// let mut track = client.open_track_stream(&block, 0).await?;
|
||||
/// println!("Track: {} Hz, {} channels",
|
||||
/// track.metadata.sample_rate,
|
||||
/// track.metadata.channels);
|
||||
///
|
||||
/// // Read some samples
|
||||
/// let mut buffer = vec![0i16; 4096];
|
||||
/// if let Some(count) = track.read_samples(&mut buffer)? {
|
||||
/// println!("Read {} samples", count);
|
||||
/// }
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn open_track_stream(
|
||||
&self,
|
||||
block: &Block,
|
||||
track_index: usize,
|
||||
) -> Result<TrackStream> {
|
||||
TrackStream::from_block_internal(self, block, track_index).await
|
||||
}
|
||||
|
||||
/// Helper: Get track position in seconds for player-based seeking
|
||||
///
|
||||
/// Instead of downloading and decoding, you can pass this information
|
||||
/// to your audio player for efficient seeking.
|
||||
///
|
||||
/// Returns (start_seconds, duration_seconds)
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use pmoparadise::RadioParadiseClient;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = RadioParadiseClient::new().await?;
|
||||
/// let block = client.get_block(None).await?;
|
||||
///
|
||||
/// let (start, duration) = client.track_position_seconds(&block, 1)?;
|
||||
/// println!("Track 1 starts at {}s, duration {}s", start, duration);
|
||||
/// println!("Play with: mpv --start={} --length={} {}", start, duration, block.url);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn track_position_seconds(&self, block: &Block, track_index: usize) -> Result<(f64, f64)> {
|
||||
let song = block
|
||||
.get_song(track_index)
|
||||
.ok_or(Error::InvalidIndex(track_index, block.song_count()))?;
|
||||
|
||||
let start_secs = song.elapsed as f64 / 1000.0;
|
||||
let duration_secs = song.duration as f64 / 1000.0;
|
||||
|
||||
Ok((start_secs, duration_secs))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(feature = "per-track")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ms_to_samples() {
|
||||
assert_eq!(TrackStream::ms_to_samples(1000, 44100), 44100);
|
||||
assert_eq!(TrackStream::ms_to_samples(500, 44100), 22050);
|
||||
assert_eq!(TrackStream::ms_to_samples(0, 44100), 0);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use crate::playlist::Playlist;
|
||||
use crate::track::PlaylistTrack;
|
||||
use crate::Result;
|
||||
use pmocache::cache_trait::FileCache;
|
||||
use pmodidl::{Container, Item, Resource};
|
||||
use pmodidl::{Container, Item};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! WriteHandle : accès exclusif en écriture à une playlist
|
||||
|
||||
use crate::playlist::core::PlaylistConfig;
|
||||
use crate::playlist::record::Record;
|
||||
use crate::playlist::Playlist;
|
||||
use crate::Result;
|
||||
@@ -185,7 +184,7 @@ impl WriteHandle {
|
||||
|
||||
// Créer la nouvelle playlist persistante
|
||||
let manager = crate::manager::PlaylistManager();
|
||||
let mut new_handle = manager.create_persistent_playlist(new_id).await?;
|
||||
let new_handle = manager.create_persistent_playlist(new_id).await?;
|
||||
|
||||
// Copier le titre et la config
|
||||
new_handle.set_title(title).await?;
|
||||
|
||||
@@ -4,7 +4,6 @@ pub mod core;
|
||||
pub mod record;
|
||||
|
||||
use self::core::{PlaylistConfig, PlaylistCore};
|
||||
use self::record::Record;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -25,11 +25,10 @@ use tracing::Level;
|
||||
use tracing_subscriber::{
|
||||
Registry,
|
||||
filter::LevelFilter,
|
||||
layer::{Filter, SubscriberExt},
|
||||
layer::SubscriberExt,
|
||||
reload,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
/// Représente une entrée de log
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -440,7 +439,7 @@ fn level_to_levelfilter(level: Level) -> LevelFilter {
|
||||
|
||||
/// Crée le router pour l'API de gestion des logs
|
||||
pub fn create_logs_router(log_state: LogState) -> axum::Router {
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::get;
|
||||
axum::Router::new()
|
||||
.route("/log_setup", get(log_setup_get).post(log_setup_post))
|
||||
.with_state(log_state)
|
||||
|
||||
@@ -122,10 +122,10 @@ impl SourceCacheManager {
|
||||
let cache = self.track_cache.read().await;
|
||||
|
||||
if let Some(metadata) = cache.get(object_id) {
|
||||
if let Some(ref pk) = metadata.cached_audio_pk {
|
||||
if let Some(ref _pk) = metadata.cached_audio_pk {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let url = pmoupnp::cache_registry::build_audio_url(pk, Some("stream"))
|
||||
let url = pmoupnp::cache_registry::build_audio_url(_pk, Some("stream"))
|
||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))?;
|
||||
return Ok(url);
|
||||
}
|
||||
@@ -147,7 +147,7 @@ impl SourceCacheManager {
|
||||
let cache = self.track_cache.read().await;
|
||||
|
||||
if let Some(metadata) = cache.get(object_id) {
|
||||
if let Some(ref pk) = metadata.cached_audio_pk {
|
||||
if let Some(ref _pk) = metadata.cached_audio_pk {
|
||||
// TODO: Ajouter get_info() à AudioCache
|
||||
// Pour l'instant, on retourne juste Cached sans taille
|
||||
return Ok(CacheStatus::Cached { size_bytes: 0 });
|
||||
@@ -181,10 +181,10 @@ impl SourceCacheManager {
|
||||
/// # Returns
|
||||
///
|
||||
/// L'URL complète de l'image
|
||||
pub fn cover_url(&self, pk: &str, size: Option<usize>) -> Result<String> {
|
||||
pub fn cover_url(&self, _pk: &str, _size: Option<usize>) -> Result<String> {
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
pmoupnp::cache_registry::build_cover_url(pk, size)
|
||||
pmoupnp::cache_registry::build_cover_url(_pk, _size)
|
||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
env::var,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use bevy_reflect::Reflect;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::actions::{Action, ActionData, ActionInstance, ArgInstanceSet};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::{info, trace};
|
||||
use tracing::info;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::actions::{Action, ActionHandler, ActionInstance, Argument, ArgumentSet};
|
||||
|
||||
@@ -17,21 +17,3 @@ impl From<std::io::Error> for ActionError {
|
||||
ActionError::GeneralError(format!("IO error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ArgumentError {
|
||||
#[error("Argument error: {0}")]
|
||||
GeneralError(String),
|
||||
|
||||
#[error("Argument error: {0}")]
|
||||
ArgumentError(String),
|
||||
|
||||
#[error("Set operation error: {0}")]
|
||||
SetError(String),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ArgumentError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
ArgumentError::GeneralError(format!("IO error: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,21 +47,21 @@ impl UpnpObject for Service {
|
||||
elem.children.push(XMLNode::Element(service_id));
|
||||
|
||||
// SCPDURL
|
||||
let mut SCPDURL = Element::new("SCPDURL");
|
||||
SCPDURL.children.push(XMLNode::Text(self.scpd_route()));
|
||||
elem.children.push(XMLNode::Element(SCPDURL));
|
||||
let mut scpdurl = Element::new("SCPDURL");
|
||||
scpdurl.children.push(XMLNode::Text(self.scpd_route()));
|
||||
elem.children.push(XMLNode::Element(scpdurl));
|
||||
|
||||
// controlURL
|
||||
let mut controlURL = Element::new("controlURL");
|
||||
controlURL
|
||||
let mut control_url = Element::new("controlURL");
|
||||
control_url
|
||||
.children
|
||||
.push(XMLNode::Text(self.control_route()));
|
||||
elem.children.push(XMLNode::Element(controlURL));
|
||||
elem.children.push(XMLNode::Element(control_url));
|
||||
|
||||
// eventSubURL
|
||||
let mut eventSubURL = Element::new("eventSubURL");
|
||||
eventSubURL.children.push(XMLNode::Text(self.event_route()));
|
||||
elem.children.push(XMLNode::Element(eventSubURL));
|
||||
let mut event_sub_url = Element::new("eventSubURL");
|
||||
event_sub_url.children.push(XMLNode::Text(self.event_route()));
|
||||
elem.children.push(XMLNode::Element(event_sub_url));
|
||||
|
||||
elem
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@ edition = "2024"
|
||||
[dependencies]
|
||||
get_if_addrs = "0.5.3"
|
||||
os_info = "3.8"
|
||||
netstat2 = "0.9"
|
||||
netstat2 = "0.11"
|
||||
sysinfo = "0.30"
|
||||
users = "0.11"
|
||||
|
||||
Reference in New Issue
Block a user