push-rxmurnsnnyys #78
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,13 +16,14 @@
|
||||
**/*.mp4
|
||||
**/*.mp3
|
||||
**/*.ogg
|
||||
|
||||
xxx
|
||||
/dcai/
|
||||
**/.pmomusic.yml
|
||||
**/.pmomusic_covers/**
|
||||
**/.pmomusic_audio/**
|
||||
/.pmomusic
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
/target
|
||||
**/target/
|
||||
/.pmomusic_covers
|
||||
|
||||
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -4047,7 +4047,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy_reflect",
|
||||
"bevy_reflect_derive",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"utoipa",
|
||||
@@ -4148,6 +4147,7 @@ dependencies = [
|
||||
"pmoaudio",
|
||||
"pmoaudio-ext",
|
||||
"pmoaudiocache",
|
||||
"pmocache",
|
||||
"pmoconfig",
|
||||
"pmocovers",
|
||||
"pmoflac",
|
||||
@@ -4362,15 +4362,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pmoutils"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
source = "sparse+https://gargoton.petite-maison-orange.fr/api/packages/pmo/cargo/"
|
||||
checksum = "9ed68fb06ffdd1a30fc7792d883dcd6d61ad7705c3a744f54a0a5bcc341fc65e"
|
||||
dependencies = [
|
||||
"get_if_addrs",
|
||||
"netstat2",
|
||||
"os_info",
|
||||
"quick-xml",
|
||||
"sysinfo",
|
||||
"users",
|
||||
"xmltree 0.10.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -7,7 +7,6 @@ members = [
|
||||
"pmowebrenderer",
|
||||
"pmomediaserver",
|
||||
"pmoconfig",
|
||||
"pmoutils",
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoapp",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.23"
|
||||
version = "0.3.24"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -74,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let renderer_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_RENDERER.clone())
|
||||
.register_device(MEDIA_RENDERER.clone(), true)
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer");
|
||||
|
||||
@@ -85,7 +85,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_SERVER.clone())
|
||||
.register_device(MEDIA_SERVER.clone(), true)
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
|
||||
|
||||
496
pmoapp/webapp/package-lock.json
generated
496
pmoapp/webapp/package-lock.json
generated
@@ -74,9 +74,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
|
||||
"integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -91,9 +91,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
|
||||
"integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -108,9 +108,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -125,9 +125,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -142,9 +142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -159,9 +159,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -176,9 +176,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -193,9 +193,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -210,9 +210,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
|
||||
"integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -227,9 +227,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -244,9 +244,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
|
||||
"integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -261,9 +261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
|
||||
"integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -278,9 +278,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
|
||||
"integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -295,9 +295,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
|
||||
"integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -312,9 +312,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
|
||||
"integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -329,9 +329,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
|
||||
"integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -346,9 +346,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -363,9 +363,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -380,9 +380,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -397,9 +397,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -414,9 +414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -431,9 +431,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -448,9 +448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -465,9 +465,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -482,9 +482,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
|
||||
"integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -499,9 +499,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -529,9 +529,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz",
|
||||
"integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
|
||||
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -543,9 +543,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz",
|
||||
"integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -557,9 +557,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz",
|
||||
"integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -571,9 +571,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz",
|
||||
"integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -585,9 +585,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz",
|
||||
"integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -599,9 +599,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz",
|
||||
"integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -613,13 +613,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz",
|
||||
"integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -627,13 +630,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz",
|
||||
"integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -641,13 +647,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -655,13 +664,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz",
|
||||
"integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -669,13 +681,33 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -683,13 +715,33 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -697,13 +749,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -711,13 +766,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz",
|
||||
"integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -725,13 +783,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -739,13 +800,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -753,9 +817,26 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz",
|
||||
"integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -763,13 +844,13 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz",
|
||||
"integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -781,9 +862,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz",
|
||||
"integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -795,9 +876,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz",
|
||||
"integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -809,9 +890,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -823,9 +904,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz",
|
||||
"integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1177,9 +1258,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -1198,9 +1279,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||
"integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1211,32 +1292,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.10",
|
||||
"@esbuild/android-arm": "0.25.10",
|
||||
"@esbuild/android-arm64": "0.25.10",
|
||||
"@esbuild/android-x64": "0.25.10",
|
||||
"@esbuild/darwin-arm64": "0.25.10",
|
||||
"@esbuild/darwin-x64": "0.25.10",
|
||||
"@esbuild/freebsd-arm64": "0.25.10",
|
||||
"@esbuild/freebsd-x64": "0.25.10",
|
||||
"@esbuild/linux-arm": "0.25.10",
|
||||
"@esbuild/linux-arm64": "0.25.10",
|
||||
"@esbuild/linux-ia32": "0.25.10",
|
||||
"@esbuild/linux-loong64": "0.25.10",
|
||||
"@esbuild/linux-mips64el": "0.25.10",
|
||||
"@esbuild/linux-ppc64": "0.25.10",
|
||||
"@esbuild/linux-riscv64": "0.25.10",
|
||||
"@esbuild/linux-s390x": "0.25.10",
|
||||
"@esbuild/linux-x64": "0.25.10",
|
||||
"@esbuild/netbsd-arm64": "0.25.10",
|
||||
"@esbuild/netbsd-x64": "0.25.10",
|
||||
"@esbuild/openbsd-arm64": "0.25.10",
|
||||
"@esbuild/openbsd-x64": "0.25.10",
|
||||
"@esbuild/openharmony-arm64": "0.25.10",
|
||||
"@esbuild/sunos-x64": "0.25.10",
|
||||
"@esbuild/win32-arm64": "0.25.10",
|
||||
"@esbuild/win32-ia32": "0.25.10",
|
||||
"@esbuild/win32-x64": "0.25.10"
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
@@ -1454,9 +1535,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
||||
"integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1470,28 +1551,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.52.3",
|
||||
"@rollup/rollup-android-arm64": "4.52.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.52.3",
|
||||
"@rollup/rollup-darwin-x64": "4.52.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.52.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.52.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.52.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.52.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.52.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.52.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.52.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.52.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.52.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.52.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.52.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.52.3",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.0",
|
||||
"@rollup/rollup-android-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-x64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -1564,13 +1648,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
@@ -16,8 +16,11 @@ const props = defineProps<{
|
||||
const {
|
||||
getBrowseCached,
|
||||
browseContainer,
|
||||
loadMoreBrowse,
|
||||
hasMore,
|
||||
currentPath: breadcrumbPath,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
} = useMediaServers();
|
||||
|
||||
@@ -25,8 +28,9 @@ const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
||||
useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Flag pour gérer le rechargement automatique
|
||||
const isRefreshing = ref(false);
|
||||
const sentinelRef = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const browseData = computed(() =>
|
||||
getBrowseCached(props.serverId, props.containerId),
|
||||
@@ -40,6 +44,28 @@ const items = computed(
|
||||
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
|
||||
);
|
||||
|
||||
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
|
||||
loadMoreBrowse(props.serverId, props.containerId);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
if (sentinelRef.value) observer.observe(sentinelRef.value);
|
||||
}
|
||||
|
||||
onMounted(() => setupObserver());
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
|
||||
watch(sentinelRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
// Charger le container au montage et quand containerId change
|
||||
watch(
|
||||
() => props.containerId,
|
||||
@@ -51,15 +77,10 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
|
||||
// Recharger si le cache est invalidé (ContainersUpdated SSE)
|
||||
watch(
|
||||
() => browseData.value,
|
||||
async (data) => {
|
||||
// Si browseData devient undefined alors que containerId est présent,
|
||||
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
|
||||
if (
|
||||
!data &&
|
||||
props.containerId &&
|
||||
@@ -201,6 +222,14 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
>
|
||||
<p>Ce dossier est vide</p>
|
||||
</div>
|
||||
|
||||
<!-- Sentinel infinite scroll -->
|
||||
<div ref="sentinelRef" class="scroll-sentinel" />
|
||||
|
||||
<!-- Spinner load more -->
|
||||
<div v-if="loadingMore" class="load-more-spinner">
|
||||
<Loader2 :size="20" class="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -295,6 +324,17 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.scroll-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.browser-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -116,6 +116,15 @@ const offlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
);
|
||||
|
||||
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
|
||||
const duplicateNames = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const r of allRenderers.value) {
|
||||
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
|
||||
}
|
||||
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
@@ -296,7 +305,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
getProtocolLabel(
|
||||
renderer.protocol,
|
||||
)
|
||||
}}
|
||||
}}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="renderer-details">
|
||||
@@ -383,7 +392,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
</div>
|
||||
<div class="renderer-info">
|
||||
<p class="renderer-name">
|
||||
{{ renderer.friendly_name }}
|
||||
{{ renderer.friendly_name }}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="renderer.model_name"
|
||||
|
||||
@@ -18,9 +18,9 @@ import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse,
|
||||
ContainerEntry,
|
||||
} from "@/services/pmocontrol/types";
|
||||
import type { BrowseState } from "@/composables/useMediaServers";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean; // v-model pour contrôler l'ouverture
|
||||
@@ -47,7 +47,7 @@ const router = useRouter();
|
||||
|
||||
// État de navigation
|
||||
const currentServer = ref<MediaServerSummary | null>(null);
|
||||
const browseData = ref<BrowseResponse | null>(null);
|
||||
const browseData = ref<BrowseState | null>(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
||||
@@ -502,7 +502,6 @@ function handleSettingsClick() {
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@load="handleImageLoad(item.id)"
|
||||
@error="handleImageError(item.id)"
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useCoverImage(
|
||||
imageUrl: Ref<string | null | undefined>,
|
||||
options: CoverImageOptions = {},
|
||||
) {
|
||||
const { maxRetries = 3, retryDelay = 1000, forceReload = true } = options;
|
||||
const { maxRetries = 5, retryDelay = 500, forceReload = true } = options;
|
||||
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
@@ -66,6 +66,9 @@ export function useCoverImage(
|
||||
if (retryCount.value < maxRetries) {
|
||||
retryCount.value++;
|
||||
|
||||
// Backoff : 500ms, 1s, 2s, 4s, 8s — rapide au début pour les covers
|
||||
// en cours de téléchargement, plus espacé ensuite pour les erreurs réseau
|
||||
const delay = retryDelay * Math.pow(2, retryCount.value - 1);
|
||||
setTimeout(() => {
|
||||
if (!currentUrl.value) return;
|
||||
|
||||
@@ -74,7 +77,7 @@ export function useCoverImage(
|
||||
currentUrl.value,
|
||||
retryCount.value,
|
||||
);
|
||||
}, retryDelay * retryCount.value); // Exponential backoff
|
||||
}, delay);
|
||||
} else {
|
||||
console.error(
|
||||
`[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`,
|
||||
@@ -102,8 +105,15 @@ export function useCoverImage(
|
||||
|
||||
// Retry if we haven't reached max retries
|
||||
if (retryCount.value < maxRetries) {
|
||||
const delay = retryDelay * Math.pow(2, retryCount.value);
|
||||
console.log(
|
||||
`[useCoverImage] Scheduling retry ${retryCount.value + 1}/${maxRetries} in ${delay}ms for: ${currentUrl.value}`,
|
||||
);
|
||||
retryLoad();
|
||||
} else {
|
||||
console.error(
|
||||
`[useCoverImage] Giving up after ${maxRetries} retries for: ${currentUrl.value}`,
|
||||
);
|
||||
imageError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse
|
||||
ContainerEntry,
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
@@ -15,9 +15,15 @@ export interface BreadcrumbItem {
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface BrowseState {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
total_count: number
|
||||
}
|
||||
|
||||
// Cache global partagé
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
|
||||
// Timestamps
|
||||
@@ -100,6 +106,7 @@ export function useMediaServers() {
|
||||
ensureSSEConnected()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters computed
|
||||
@@ -110,7 +117,7 @@ export function useMediaServers() {
|
||||
async function fetchServers(force = false) {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
|
||||
return // Cache encore valide
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -129,11 +136,10 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Browse container (avec cache automatique)
|
||||
// Charge la première page (remplace le cache)
|
||||
async function browseContainer(serverId: string, containerId: string, useCache = true) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
|
||||
// Vérifier le cache
|
||||
if (useCache && browseCache.value.has(key)) {
|
||||
return browseCache.value.get(key)!
|
||||
}
|
||||
@@ -142,12 +148,15 @@ export function useMediaServers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.browseContainer(serverId, containerId)
|
||||
const data = await api.browseContainer(serverId, containerId, 0)
|
||||
|
||||
// Mettre en cache
|
||||
browseCache.value.set(key, data)
|
||||
browseCache.value.set(key, {
|
||||
container_id: data.container_id,
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
})
|
||||
|
||||
return data
|
||||
return browseCache.value.get(key)!
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur browse container'
|
||||
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e)
|
||||
@@ -157,6 +166,33 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Charge la page suivante et accumule (infinite scroll)
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const state = browseCache.value.get(key)
|
||||
|
||||
if (!state) return
|
||||
if (state.entries.length >= state.total_count) return
|
||||
if (loadingMore.value) return
|
||||
|
||||
try {
|
||||
loadingMore.value = true
|
||||
|
||||
const offset = state.entries.length
|
||||
const data = await api.browseContainer(serverId, containerId, offset)
|
||||
|
||||
// Accumuler les nouvelles entrées
|
||||
state.entries.push(...data.entries)
|
||||
state.total_count = data.total_count
|
||||
// Forcer la réactivité
|
||||
browseCache.value.set(key, { ...state })
|
||||
} catch (e) {
|
||||
console.error(`[useMediaServers] Erreur load more ${serverId}/${containerId}:`, e)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
function getServerById(id: string) {
|
||||
return serversCache.value.get(id)
|
||||
@@ -167,6 +203,13 @@ export function useMediaServers() {
|
||||
return browseCache.value.get(key)
|
||||
}
|
||||
|
||||
function hasMore(serverId: string, containerId: string): boolean {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const state = browseCache.value.get(key)
|
||||
if (!state) return false
|
||||
return state.entries.length < state.total_count
|
||||
}
|
||||
|
||||
// Breadcrumb path management
|
||||
function setPath(path: BreadcrumbItem[]) {
|
||||
currentPath.value = path
|
||||
@@ -179,11 +222,9 @@ export function useMediaServers() {
|
||||
// Invalidation du cache
|
||||
function invalidateCache(serverId: string, containerId?: string) {
|
||||
if (containerId) {
|
||||
// Invalider un container spécifique
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
} else {
|
||||
// Invalider tous les containers d'un serveur
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
@@ -197,6 +238,7 @@ export function useMediaServers() {
|
||||
return {
|
||||
// État
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
currentPath,
|
||||
// Getters
|
||||
@@ -204,9 +246,11 @@ export function useMediaServers() {
|
||||
onlineServers,
|
||||
getServerById,
|
||||
getBrowseCached,
|
||||
hasMore,
|
||||
// Actions
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
loadMoreBrowse,
|
||||
setPath,
|
||||
clearPath,
|
||||
invalidateCache
|
||||
|
||||
@@ -34,10 +34,10 @@ function generateUUID(): string {
|
||||
|
||||
function getOrCreateInstanceId(): string {
|
||||
try {
|
||||
let id = localStorage.getItem(INSTANCE_ID_KEY);
|
||||
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(INSTANCE_ID_KEY, id);
|
||||
sessionStorage.setItem(INSTANCE_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
@@ -71,6 +71,30 @@ export function useWebRenderer() {
|
||||
let onConnectedCallback: (() => void) | null = null;
|
||||
let sseUnsubscribe: (() => void) | null = null;
|
||||
let pendingCanPlay: (() => void) | null = null;
|
||||
let positionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Reporting de position ─────────────────────────────────────────────────
|
||||
|
||||
function startPositionReporting(): void {
|
||||
stopPositionReporting();
|
||||
positionInterval = setInterval(() => {
|
||||
if (!audioEl || !instanceId) return;
|
||||
const pos = audioEl.currentTime;
|
||||
const dur = isFinite(audioEl.duration) ? audioEl.duration : null;
|
||||
void fetch(`/api/webrenderer/${instanceId}/position`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ position_sec: pos, duration_sec: dur }),
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopPositionReporting(): void {
|
||||
if (positionInterval !== null) {
|
||||
clearInterval(positionInterval);
|
||||
positionInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connexion / déconnexion du flux audio ─────────────────────────────────
|
||||
|
||||
@@ -124,6 +148,7 @@ export function useWebRenderer() {
|
||||
el.removeEventListener("canplay", onCanPlay);
|
||||
el.play().then(() => {
|
||||
console.log("[WebRenderer] play() resolved OK");
|
||||
startPositionReporting();
|
||||
}).catch((e: unknown) => {
|
||||
console.warn("[WebRenderer] play() rejected:", e);
|
||||
});
|
||||
@@ -134,6 +159,7 @@ export function useWebRenderer() {
|
||||
|
||||
function stopStream(): void {
|
||||
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
|
||||
stopPositionReporting();
|
||||
if (!audioEl) return;
|
||||
if (pendingCanPlay) {
|
||||
audioEl.removeEventListener("canplay", pendingCanPlay);
|
||||
@@ -230,6 +256,7 @@ export function useWebRenderer() {
|
||||
onUnmounted(() => {
|
||||
sseUnsubscribe?.();
|
||||
sseUnsubscribe = null;
|
||||
stopPositionReporting();
|
||||
stopStream();
|
||||
void unregister();
|
||||
if (audioEl) {
|
||||
|
||||
@@ -418,9 +418,11 @@ class PMOControlAPI {
|
||||
async browseContainer(
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
offset = 0,
|
||||
limit = 50,
|
||||
): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}?offset=${offset}&limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface RendererSummary {
|
||||
protocol: RendererProtocolSummary;
|
||||
capabilities: RendererCapabilitiesSummary;
|
||||
online: boolean;
|
||||
server_host?: string;
|
||||
}
|
||||
|
||||
export interface RendererState {
|
||||
@@ -119,6 +120,8 @@ export interface ContainerEntry {
|
||||
export interface BrowseResponse {
|
||||
container_id: string;
|
||||
entries: ContainerEntry[];
|
||||
total_count: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -43,3 +43,6 @@ pub use sources::PlaylistSource;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use sources::UriSource;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use sources::{PlayerCommand, PlayerEvent, PlayerHandle, PlayerSource};
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
//! DirectOggFlacSink — nœud puits OGG-FLAC pour un seul client HTTP.
|
||||
//!
|
||||
//! Combine la logique de backpressure/reconnexion de `DirectFlacSink`
|
||||
//! avec l'encodage OGG-FLAC de `StreamingOggFlacSink`.
|
||||
//!
|
||||
//! # Cycle de vie
|
||||
//!
|
||||
//! - **Play** : le navigateur appelle `GET /stream`. `connect()` crée un nouveau
|
||||
//! canal PCM + pipe duplex + encodeur FLAC + wrapper OGG, installe le sender
|
||||
//! dans le sink, et notifie le sink via `client_notify`. Le flux reste ouvert :
|
||||
//! les morceaux s'enchaînent en gapless.
|
||||
//! - **Stop** : le navigateur ferme la connexion. Le pipe se rompt, l'encodeur
|
||||
//! s'arrête. Le sink voit `pcm_tx.send()` échouer, passe le sender à `None`,
|
||||
//! et **bloque** sur `client_notify` jusqu'au prochain Play.
|
||||
//! - **Play suivant** : `connect()` → nouveau pipe → `client_notify.notify_one()`
|
||||
//! → le sink se débloque et reprend la consommation des segments.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! AudioSegment I24 @ 96 kHz
|
||||
//! ↓ NodeLogic::process() [bloque si pas de client]
|
||||
//! ↓ NodeLogic::process()
|
||||
//! chunk_to_pcm_bytes() → PCM 24-bit LE
|
||||
//! ↓ Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>
|
||||
//! ByteStreamReader (AsyncRead)
|
||||
//! ↓ encode_flac_stream()
|
||||
//! ↓ broadcast_ogg_flac_stream() → wrapping OGG pages
|
||||
//! ↓ tokio::io::duplex pipe (256 KB)
|
||||
//! ↓ DirectOggFlacStream (AsyncRead) → Body HTTP
|
||||
//! ↓ pcm_tx (cap=2)
|
||||
//! ByteStreamReader → encode_flac_stream() → OGG pages (Bytes)
|
||||
//! ↓ ogg_tx mpsc::Sender<Bytes> (cap=8, backpressure naturelle)
|
||||
//! Arc<Mutex<Receiver<Bytes>>> → DirectOggFlacStream (AsyncRead) → HTTP → Safari
|
||||
//! ```
|
||||
//!
|
||||
//! # Backpressure
|
||||
//!
|
||||
//! Safari lent → ogg_rx plein → ogg_tx.send() bloque → encodeur bloque
|
||||
//! → pcm_tx.send() bloque → pipeline audio bloque.
|
||||
//!
|
||||
//! # OGG chaining (TrackBoundary)
|
||||
//!
|
||||
//! Fermer pcm_tx → encodeur termine (EOS) → lancer nouvel encodeur.
|
||||
//! Le même ogg_tx est réutilisé : le stream HTTP est continu.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -42,9 +36,10 @@ use pmoaudio::{
|
||||
};
|
||||
use pmoflac::{EncoderOptions, PcmFormat};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
use tokio::sync::{mpsc, watch, Mutex};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::sinks::byte_stream_reader::{ByteStreamReader, PcmChunk};
|
||||
use crate::sinks::chunk_to_pcm::chunk_to_pcm_bytes;
|
||||
@@ -55,95 +50,60 @@ pub const DIRECT_OGG_FLAC_SAMPLE_RATE: u32 = 96_000;
|
||||
pub const DIRECT_OGG_FLAC_CHANNELS: u8 = 2;
|
||||
pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24;
|
||||
|
||||
/// Capacité du pipe duplex (~256 KB).
|
||||
const PIPE_CAPACITY: usize = 256 * 1024;
|
||||
/// Capacité du canal OGG → HTTP.
|
||||
/// Capacité 1 : backpressure stricte, l'encodeur ne produit pas en avance.
|
||||
const OGG_CHANNEL_CAPACITY: usize = 1;
|
||||
|
||||
// ─── Shared state ─────────────────────────────────────────────────────────────
|
||||
// ─── Types partagés ───────────────────────────────────────────────────────────
|
||||
|
||||
type SharedPcmTx = Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>;
|
||||
type SharedEncoderTask = Arc<Mutex<Option<JoinHandle<()>>>>;
|
||||
type SharedOggRx = Arc<Mutex<mpsc::Receiver<Bytes>>>;
|
||||
|
||||
// ─── Handle public ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Handle vers le sink, cloneable, reconnectable à chaque Play.
|
||||
#[derive(Clone)]
|
||||
pub struct DirectOggFlacHandle {
|
||||
pcm_tx: SharedPcmTx,
|
||||
client_connect_tx: Arc<watch::Sender<u64>>,
|
||||
client_notify_internal: Arc<tokio::sync::Notify>,
|
||||
first_byte_tx: Arc<watch::Sender<bool>>,
|
||||
encoder_task: SharedEncoderTask,
|
||||
encoder_options: EncoderOptions,
|
||||
/// Position de lecture courante (mise à jour par ByteStreamReader).
|
||||
current_timestamp: Arc<tokio::sync::RwLock<f64>>,
|
||||
/// Canal OGG partagé avec le stream HTTP.
|
||||
ogg_tx: mpsc::Sender<Bytes>,
|
||||
ogg_rx: SharedOggRx,
|
||||
/// Notifié quand get_stream() est appelé (premier client).
|
||||
client_notify: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl DirectOggFlacHandle {
|
||||
/// Crée un nouveau pipe OGG-FLAC et retourne le flux côté lecture.
|
||||
/// Débloque le sink s'il attendait un client.
|
||||
pub async fn connect(&self) -> DirectOggFlacStream {
|
||||
let connect_count_before = *self.client_connect_tx.borrow();
|
||||
debug!("DirectOggFlacHandle::connect() called, connect_count={}", connect_count_before);
|
||||
|
||||
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(8);
|
||||
// Réinitialiser le timestamp à 0 pour la nouvelle connexion
|
||||
*self.current_timestamp.write().await = 0.0;
|
||||
let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64));
|
||||
// Partager current_timestamp avec ByteStreamReader : il sera mis à jour
|
||||
// avec le timestamp absolu du segment audio (position dans le fichier source).
|
||||
let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur);
|
||||
|
||||
let (pipe_writer, pipe_reader) = tokio::io::duplex(PIPE_CAPACITY);
|
||||
|
||||
let _ = self.first_byte_tx.send(false);
|
||||
debug!("DirectOggFlacHandle::connect() first_byte reset to false");
|
||||
|
||||
*self.pcm_tx.lock().await = Some(pcm_tx);
|
||||
debug!("DirectOggFlacHandle::connect() pcm_tx installed");
|
||||
|
||||
let new_count = connect_count_before.wrapping_add(1);
|
||||
let _ = self.client_connect_tx.send(new_count);
|
||||
debug!("DirectOggFlacHandle::connect() client_connect_count -> {}", new_count);
|
||||
self.client_notify_internal.notify_one();
|
||||
|
||||
let options = self.encoder_options.clone();
|
||||
let current_timestamp = self.current_timestamp.clone();
|
||||
tokio::spawn(async move {
|
||||
debug!("DirectOggFlacHandle: encoder+ogg task started");
|
||||
if let Err(e) = run_ogg_encoder(pcm_reader, pipe_writer, options, current_timestamp).await {
|
||||
debug!("DirectOggFlacStream encoder stopped: {}", e);
|
||||
}
|
||||
debug!("DirectOggFlacHandle: encoder+ogg task ended");
|
||||
});
|
||||
|
||||
debug!("DirectOggFlacHandle::connect() returning DirectOggFlacStream");
|
||||
/// Retourne le stream OGG-FLAC pour le handler HTTP.
|
||||
/// Lance le premier encodeur au premier appel.
|
||||
pub fn get_stream(&self) -> DirectOggFlacStream {
|
||||
tracing::info!(target: "pmoaudio_ext::stream_connect", "get_stream() called — new HTTP client connecting");
|
||||
self.client_notify.notify_one();
|
||||
DirectOggFlacStream {
|
||||
inner: pipe_reader,
|
||||
first_byte_tx: Some(self.first_byte_tx.clone()),
|
||||
ogg_rx: self.ogg_rx.clone(),
|
||||
buffer: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn first_byte_ready(&self) -> watch::Receiver<bool> {
|
||||
self.first_byte_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Retourne la position de lecture courante en secondes.
|
||||
pub async fn current_position_sec(&self) -> f64 {
|
||||
*self.current_timestamp.read().await
|
||||
}
|
||||
|
||||
pub async fn wait_for_client(&self) {
|
||||
let seen = *self.client_connect_tx.borrow();
|
||||
debug!("DirectOggFlacHandle::wait_for_client() called, seen connect_count={}", seen);
|
||||
let mut rx = self.client_connect_tx.subscribe();
|
||||
let _ = rx.wait_for(|v| *v > seen).await;
|
||||
debug!("DirectOggFlacHandle::wait_for_client() unblocked");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stream public ────────────────────────────────────────────────────────────
|
||||
|
||||
/// AsyncRead sur le canal OGG-FLAC.
|
||||
pub struct DirectOggFlacStream {
|
||||
inner: tokio::io::DuplexStream,
|
||||
first_byte_tx: Option<Arc<watch::Sender<bool>>>,
|
||||
ogg_rx: SharedOggRx,
|
||||
buffer: VecDeque<u8>,
|
||||
}
|
||||
|
||||
impl Drop for DirectOggFlacStream {
|
||||
fn drop(&mut self) {
|
||||
tracing::info!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream dropped — HTTP client disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for DirectOggFlacStream {
|
||||
@@ -152,18 +112,41 @@ impl AsyncRead for DirectOggFlacStream {
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let filled_before = buf.filled().len();
|
||||
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
if let Poll::Ready(Ok(())) = &result {
|
||||
let filled_after = buf.filled().len();
|
||||
if filled_after > filled_before {
|
||||
if let Some(tx) = self.first_byte_tx.take() {
|
||||
debug!("DirectOggFlacStream: first {} bytes sent to HTTP client", filled_after - filled_before);
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
}
|
||||
// Vider le buffer interne en premier
|
||||
if !self.buffer.is_empty() {
|
||||
let to_copy = self.buffer.len().min(buf.remaining());
|
||||
let chunk: Vec<u8> = self.buffer.drain(..to_copy).collect();
|
||||
buf.put_slice(&chunk);
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
// Tenter de recevoir le prochain chunk OGG
|
||||
let mut guard = match self.ogg_rx.try_lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
tracing::warn!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream: try_lock() FAILED — concurrent access detected!");
|
||||
cx.waker().wake_by_ref();
|
||||
return Poll::Pending;
|
||||
}
|
||||
};
|
||||
|
||||
match guard.poll_recv(cx) {
|
||||
Poll::Ready(Some(bytes)) => {
|
||||
drop(guard);
|
||||
tracing::info!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream: sending {} bytes to HTTP client", bytes.len());
|
||||
let to_copy = bytes.len().min(buf.remaining());
|
||||
buf.put_slice(&bytes[..to_copy]);
|
||||
if to_copy < bytes.len() {
|
||||
self.buffer.extend(&bytes[to_copy..]);
|
||||
}
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
tracing::info!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream: ogg_rx closed → EOF sent to HTTP client");
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +154,12 @@ impl AsyncRead for DirectOggFlacStream {
|
||||
|
||||
struct DirectOggFlacSinkLogic {
|
||||
pcm_tx: SharedPcmTx,
|
||||
encoder_task: SharedEncoderTask,
|
||||
encoder_options: EncoderOptions,
|
||||
current_timestamp: Arc<tokio::sync::RwLock<f64>>,
|
||||
ogg_tx: mpsc::Sender<Bytes>,
|
||||
client_notify: Arc<tokio::sync::Notify>,
|
||||
has_encoded_frames: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -186,6 +174,15 @@ impl NodeLogic for DirectOggFlacSinkLogic {
|
||||
AudioError::ProcessingError("DirectOggFlacSink requires an input".into())
|
||||
})?;
|
||||
|
||||
// Attendre le premier client Safari avant de démarrer l'encodeur
|
||||
debug!("DirectOggFlacSink: waiting for first client...");
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => return Ok(()),
|
||||
_ = self.client_notify.notified() => {}
|
||||
}
|
||||
debug!("DirectOggFlacSink: first client connected, starting encoder");
|
||||
self.start_encoder().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
@@ -201,25 +198,9 @@ impl NodeLogic for DirectOggFlacSinkLogic {
|
||||
}
|
||||
Some(seg) => match &seg.segment {
|
||||
_AudioSegment::Chunk(chunk) => {
|
||||
// Attendre un client si nécessaire (backpressure quand pas de Play)
|
||||
loop {
|
||||
let tx_opt = self.pcm_tx.lock().await.clone();
|
||||
if tx_opt.is_some() {
|
||||
break;
|
||||
}
|
||||
debug!("DirectOggFlacSink: no pcm_tx, waiting for client_notify...");
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
debug!("DirectOggFlacSink: cancelled while waiting for client");
|
||||
return Ok(());
|
||||
}
|
||||
_ = self.client_notify.notified() => {
|
||||
debug!("DirectOggFlacSink: client_notify received, rechecking pcm_tx");
|
||||
}
|
||||
}
|
||||
}
|
||||
let tx = self.pcm_tx.lock().await.clone();
|
||||
let Some(tx) = tx else { continue; };
|
||||
|
||||
let tx = self.pcm_tx.lock().await.clone().unwrap();
|
||||
let pcm_bytes = chunk_to_pcm_bytes(chunk, DIRECT_OGG_FLAC_BITS_PER_SAMPLE)?;
|
||||
let duration_sec = chunk.len() as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64;
|
||||
let pcm_chunk = PcmChunk {
|
||||
@@ -228,17 +209,26 @@ impl NodeLogic for DirectOggFlacSinkLogic {
|
||||
duration_sec,
|
||||
};
|
||||
if tx.send(pcm_chunk).await.is_err() {
|
||||
warn!(
|
||||
ts = seg.timestamp_sec,
|
||||
"DirectOggFlacSink: chunk dropped (client disconnected at {:.3}s), waiting for reconnect",
|
||||
seg.timestamp_sec,
|
||||
);
|
||||
warn!("DirectOggFlacSink: encoder gone at {:.3}s", seg.timestamp_sec);
|
||||
*self.pcm_tx.lock().await = None;
|
||||
self.has_encoded_frames = false;
|
||||
} else {
|
||||
self.has_encoded_frames = true;
|
||||
}
|
||||
}
|
||||
_AudioSegment::Sync(marker) => match marker.as_ref() {
|
||||
SyncMarker::TrackBoundary { .. } => {
|
||||
if self.has_encoded_frames {
|
||||
debug!("DirectOggFlacSink: TrackBoundary — OGG chaining");
|
||||
self.has_encoded_frames = false;
|
||||
self.do_track_boundary().await;
|
||||
} else {
|
||||
debug!("DirectOggFlacSink: TrackBoundary ignored (no frames)");
|
||||
}
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
debug!("DirectOggFlacSink: EndOfStream");
|
||||
self.stop_encoder().await;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -248,6 +238,7 @@ impl NodeLogic for DirectOggFlacSinkLogic {
|
||||
}
|
||||
}
|
||||
|
||||
self.stop_encoder().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -256,14 +247,57 @@ impl NodeLogic for DirectOggFlacSinkLogic {
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectOggFlacSinkLogic {
|
||||
async fn start_encoder(&mut self) {
|
||||
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(2);
|
||||
let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64));
|
||||
let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur);
|
||||
*self.pcm_tx.lock().await = Some(pcm_tx);
|
||||
|
||||
let ogg_tx = self.ogg_tx.clone();
|
||||
let options = self.encoder_options.clone();
|
||||
let current_timestamp = self.current_timestamp.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
debug!("DirectOggFlacSink: encoder task started");
|
||||
if let Err(e) = run_ogg_encoder(pcm_reader, ogg_tx, options, current_timestamp).await {
|
||||
debug!("DirectOggFlacSink: encoder stopped: {}", e);
|
||||
}
|
||||
debug!("DirectOggFlacSink: encoder task ended");
|
||||
});
|
||||
*self.encoder_task.lock().await = Some(handle);
|
||||
}
|
||||
|
||||
async fn stop_encoder(&mut self) {
|
||||
*self.pcm_tx.lock().await = None;
|
||||
if let Some(handle) = self.encoder_task.lock().await.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
self.has_encoded_frames = false;
|
||||
}
|
||||
|
||||
async fn do_track_boundary(&mut self) {
|
||||
// Fermer pcm_tx → encodeur écrit EOS, se termine
|
||||
*self.pcm_tx.lock().await = None;
|
||||
if let Some(handle) = self.encoder_task.lock().await.take() {
|
||||
let _ = handle.await;
|
||||
debug!("DirectOggFlacSink: previous encoder joined");
|
||||
}
|
||||
// Démarrer le nouvel encodeur sur le même ogg_tx
|
||||
self.start_encoder().await;
|
||||
debug!("DirectOggFlacSink: OGG chaining complete");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Encodeur FLAC + wrapper OGG ─────────────────────────────────────────────
|
||||
|
||||
async fn run_ogg_encoder(
|
||||
pcm_reader: ByteStreamReader,
|
||||
mut pipe_writer: tokio::io::DuplexStream,
|
||||
ogg_tx: mpsc::Sender<Bytes>,
|
||||
options: EncoderOptions,
|
||||
_current_timestamp: Arc<tokio::sync::RwLock<f64>>,
|
||||
) -> Result<(), AudioError> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let format = PcmFormat {
|
||||
sample_rate: DIRECT_OGG_FLAC_SAMPLE_RATE,
|
||||
channels: DIRECT_OGG_FLAC_CHANNELS,
|
||||
@@ -274,56 +308,54 @@ async fn run_ogg_encoder(
|
||||
.await
|
||||
.map_err(|e| AudioError::ProcessingError(format!("FLAC encoder init: {}", e)))?;
|
||||
|
||||
// Lire le header FLAC et construire les pages OGG d'en-tête
|
||||
let flac_header = read_flac_header(&mut flac_stream).await?;
|
||||
let sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?;
|
||||
let _sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?;
|
||||
|
||||
let stream_serial: u32 = rand::random();
|
||||
let mut ogg = OggPageWriter::new(stream_serial);
|
||||
|
||||
// Page BOS (identification OGG-FLAC)
|
||||
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
|
||||
let bos_page = Bytes::from(ogg.create_page(&ogg_flac_id, true, false, false));
|
||||
|
||||
// Page Vorbis Comment
|
||||
let bos_page = ogg.create_page(&ogg_flac_id, true, false, false);
|
||||
let vorbis_comment = create_empty_vorbis_comment();
|
||||
let comment_page = Bytes::from(ogg.create_page(&vorbis_comment, false, false, false));
|
||||
let comment_page = ogg.create_page(&vorbis_comment, false, false, false);
|
||||
|
||||
pipe_writer.write_all(&bos_page).await
|
||||
.map_err(|e| AudioError::IoError(format!("OGG BOS write: {}", e)))?;
|
||||
pipe_writer.write_all(&comment_page).await
|
||||
.map_err(|e| AudioError::IoError(format!("OGG comment write: {}", e)))?;
|
||||
let mut header = Vec::new();
|
||||
header.extend_from_slice(&bos_page);
|
||||
header.extend_from_slice(&comment_page);
|
||||
if ogg_tx.send(Bytes::from(header)).await.is_err() {
|
||||
debug!("DirectOggFlacSink: ogg_tx closed on headers");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size};
|
||||
|
||||
// Lire les frames FLAC et les encapsuler dans des pages OGG
|
||||
let sample_rate_f64 = sample_rate as f64;
|
||||
let mut encoded_samples = 0u64;
|
||||
let mut read_buffer = vec![0u8; 16384];
|
||||
let mut read_buffer = vec![0u8; 65536];
|
||||
let mut accumulator: Vec<u8> = Vec::with_capacity(32768);
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
loop {
|
||||
match flac_stream.read(&mut read_buffer).await {
|
||||
Ok(0) => {
|
||||
// EOF : page EOS finale
|
||||
let eos_page = Bytes::from(ogg.create_page(&accumulator, false, true, false));
|
||||
let _ = pipe_writer.write_all(&eos_page).await;
|
||||
trace!(
|
||||
"OggEncoder: EOF — accum={} B, encoded={:.3}s",
|
||||
accumulator.len(),
|
||||
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
|
||||
);
|
||||
let eos_page = ogg.create_page(&accumulator, false, true, false);
|
||||
let _ = ogg_tx.send(Bytes::from(eos_page)).await;
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
accumulator.extend_from_slice(&read_buffer[..n]);
|
||||
|
||||
loop {
|
||||
if accumulator.len() < 4 {
|
||||
break;
|
||||
}
|
||||
if accumulator.len() < 4 { break; }
|
||||
|
||||
// Trouver les positions de sync FLAC
|
||||
let mut sync_data: Vec<(usize, u32)> = Vec::new();
|
||||
for i in 0..accumulator.len() - 1 {
|
||||
let b1 = accumulator[i];
|
||||
let b2 = accumulator[i + 1];
|
||||
if b1 == 0xFF && b2 >= 0xF8 && b2 <= 0xFE {
|
||||
use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size};
|
||||
if validate_frame_header_crc(&accumulator, i) {
|
||||
if let Some(samples) = parse_flac_block_size(&accumulator, i) {
|
||||
sync_data.push((i, samples));
|
||||
@@ -332,9 +364,7 @@ async fn run_ogg_encoder(
|
||||
}
|
||||
}
|
||||
|
||||
if sync_data.len() < 2 {
|
||||
break;
|
||||
}
|
||||
if sync_data.len() < 2 { break; }
|
||||
|
||||
let first_start = sync_data[0].0;
|
||||
let first_samples = sync_data[0].1;
|
||||
@@ -346,19 +376,19 @@ async fn run_ogg_encoder(
|
||||
}
|
||||
|
||||
let frame: Vec<u8> = accumulator.drain(0..second_start).collect();
|
||||
|
||||
encoded_samples = encoded_samples.saturating_add(first_samples as u64);
|
||||
ogg.add_samples(first_samples as u64);
|
||||
|
||||
let ogg_page = Bytes::from(ogg.create_page(&frame, false, false, false));
|
||||
if pipe_writer.write_all(&ogg_page).await.is_err() {
|
||||
// Client déconnecté — le pipe HTTP s'est rompu
|
||||
warn!(
|
||||
samples = encoded_samples,
|
||||
"DirectOggFlacSink: OGG pipe broken after {} samples ({:.3}s), client disconnected",
|
||||
encoded_samples,
|
||||
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
|
||||
);
|
||||
trace!(
|
||||
"OggEncoder: frame {} B, {:.3}s total",
|
||||
frame.len(),
|
||||
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
|
||||
);
|
||||
|
||||
let ogg_page = ogg.create_page(&frame, false, false, false);
|
||||
if ogg_tx.send(Bytes::from(ogg_page)).await.is_err() {
|
||||
warn!("DirectOggFlacSink: ogg_tx closed after {:.3}s",
|
||||
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -375,7 +405,7 @@ async fn run_ogg_encoder(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── OGG helpers (copiés de streaming_ogg_flac_sink) ─────────────────────────
|
||||
// ─── OGG helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
struct OggPageWriter {
|
||||
stream_serial: u32,
|
||||
@@ -515,16 +545,21 @@ pub struct DirectOggFlacSink {
|
||||
impl DirectOggFlacSink {
|
||||
pub fn new(encoder_options: EncoderOptions) -> (Self, DirectOggFlacHandle) {
|
||||
let pcm_tx: SharedPcmTx = Arc::new(Mutex::new(None));
|
||||
let client_notify_internal = Arc::new(tokio::sync::Notify::new());
|
||||
let (client_connect_tx, _) = watch::channel(0u64);
|
||||
let client_connect_tx = Arc::new(client_connect_tx);
|
||||
let (first_byte_tx, _) = watch::channel(false);
|
||||
let first_byte_tx = Arc::new(first_byte_tx);
|
||||
let encoder_task: SharedEncoderTask = Arc::new(Mutex::new(None));
|
||||
let current_timestamp = Arc::new(tokio::sync::RwLock::new(0.0f64));
|
||||
let client_notify = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
let (ogg_tx, ogg_rx) = mpsc::channel::<Bytes>(OGG_CHANNEL_CAPACITY);
|
||||
let ogg_rx = Arc::new(Mutex::new(ogg_rx));
|
||||
|
||||
let logic = DirectOggFlacSinkLogic {
|
||||
pcm_tx: pcm_tx.clone(),
|
||||
client_notify: client_notify_internal.clone(),
|
||||
encoder_task: encoder_task.clone(),
|
||||
encoder_options: encoder_options.clone(),
|
||||
current_timestamp: current_timestamp.clone(),
|
||||
ogg_tx: ogg_tx.clone(),
|
||||
client_notify: client_notify.clone(),
|
||||
has_encoded_frames: false,
|
||||
};
|
||||
|
||||
let sink = Self {
|
||||
@@ -533,11 +568,12 @@ impl DirectOggFlacSink {
|
||||
|
||||
let handle = DirectOggFlacHandle {
|
||||
pcm_tx,
|
||||
client_connect_tx,
|
||||
client_notify_internal,
|
||||
first_byte_tx,
|
||||
encoder_task,
|
||||
encoder_options,
|
||||
current_timestamp,
|
||||
ogg_tx,
|
||||
ogg_rx,
|
||||
client_notify,
|
||||
};
|
||||
|
||||
(sink, handle)
|
||||
|
||||
@@ -88,10 +88,8 @@ impl IcyClientStream {
|
||||
|
||||
// Add cover URL if we have a cover_pk
|
||||
if let Some(pk) = &meta.cover_pk {
|
||||
// Use relative URL /covers/image/{pk}/256
|
||||
// This works when streaming from the same server that serves covers
|
||||
// VLC and other players will resolve relative URLs correctly
|
||||
metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk));
|
||||
let cover_url = pmocache::covers_absolute_url_for(pk, None);
|
||||
metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
|
||||
} else if let Some(url) = &meta.cover_url {
|
||||
// Fallback to external cover URL if no local pk
|
||||
metadata_str.push_str(&format!("StreamUrl='{}';", url));
|
||||
|
||||
@@ -490,6 +490,11 @@ impl SharedSinkContext {
|
||||
|
||||
debug!("Restarting FLAC encoder for new track");
|
||||
|
||||
// Invalider le header en cache immédiatement : tout nouveau client qui se connecte
|
||||
// pendant le restart attendra (Poll::Pending) jusqu'à ce que le nouveau header OGG
|
||||
// soit prêt, évitant qu'il reçoive l'ancien header suivi des nouvelles données.
|
||||
*self.header.write().await = None;
|
||||
|
||||
let last_timestamp = *self.current_timestamp.read().await;
|
||||
debug!("Last timestamp before restart: {:.3}s", last_timestamp);
|
||||
|
||||
|
||||
@@ -19,3 +19,9 @@ mod uri_source;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use uri_source::UriSource;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
mod player_source;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use player_source::{PlayerCommand, PlayerEvent, PlayerHandle, PlayerSource};
|
||||
|
||||
562
pmoaudio-ext/src/sources/player_source.rs
Normal file
562
pmoaudio-ext/src/sources/player_source.rs
Normal file
@@ -0,0 +1,562 @@
|
||||
//! PlayerSource — Source audio avec contrôle de transport AVTransport UPnP.
|
||||
//!
|
||||
//! Implémente un nœud `AudioPipelineNode` qui encapsule le cycle de vie complet
|
||||
//! d'un renderer UPnP : LoadUri, LoadNextUri, Play, Pause, Stop, Seek.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! PlayerHandle (clonable, envoyé aux handlers UPnP)
|
||||
//! │ PlayerCommand (mpsc)
|
||||
//! ▼
|
||||
//! PlayerSource (AudioPipelineNode — nœud source)
|
||||
//! │ AudioSegment (mpsc)
|
||||
//! ▼
|
||||
//! ResamplingNode → ToI24Node → StreamingOggFlacSink → HTTP clients
|
||||
//! ```
|
||||
//!
|
||||
//! # Gestion de la Pause
|
||||
//!
|
||||
//! La Pause ne coupe pas la source brutalement. Elle bloque l'émission de chunks.
|
||||
//! À la reprise, un `TrackBoundary` est injecté avant les données — cela déclenche
|
||||
//! EOS + nouveau BOS OGG dans `StreamingOggFlacSink`, garantissant un bitstream
|
||||
//! propre aligné sur un frame boundary.
|
||||
//!
|
||||
//! # Transitions gapless
|
||||
//!
|
||||
//! Si `LoadNextUri` a été appelé avant la fin de la piste courante, la transition
|
||||
//! se fait via un `TrackBoundary` sans interruption du flux OGG.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||
use pmoaudio::{
|
||||
AudioSegment,
|
||||
nodes::AudioError,
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic, send_to_children},
|
||||
};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::uri_source::UriSource;
|
||||
|
||||
// ─── Commandes de transport ───────────────────────────────────────────────────
|
||||
|
||||
/// Commandes de transport AVTransport UPnP
|
||||
#[derive(Debug)]
|
||||
pub enum PlayerCommand {
|
||||
/// SetAVTransportURI — charge une URI (démarre pas encore)
|
||||
LoadUri(String),
|
||||
/// SetNextAVTransportURI — pré-charge pour transition gapless
|
||||
LoadNextUri(String),
|
||||
/// Play — démarre ou reprend la lecture
|
||||
Play,
|
||||
/// Pause — suspend la lecture, position préservée
|
||||
Pause,
|
||||
/// Stop — arrête la lecture, position remise à 0
|
||||
Stop,
|
||||
/// Seek — reprend depuis la position donnée (en secondes)
|
||||
Seek(f64),
|
||||
}
|
||||
|
||||
// ─── Événements remontés ──────────────────────────────────────────────────────
|
||||
|
||||
/// Événements remontés par la PlayerSource vers les handlers UPnP
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PlayerEvent {
|
||||
/// Lecture démarrée ou reprise
|
||||
Playing {
|
||||
uri: String,
|
||||
duration_sec: Option<f64>,
|
||||
},
|
||||
/// Lecture suspendue
|
||||
Paused {
|
||||
position_sec: f64,
|
||||
},
|
||||
/// Lecture arrêtée
|
||||
Stopped,
|
||||
/// Fin de piste (pour que le ControlPoint avance la queue)
|
||||
TrackEnded,
|
||||
/// Position courante (émise ~1/s pendant la lecture)
|
||||
Position { position_sec: f64 },
|
||||
/// Erreur lors de l'ouverture ou de la lecture
|
||||
Error(String),
|
||||
}
|
||||
|
||||
// ─── Handle de contrôle ───────────────────────────────────────────────────────
|
||||
|
||||
/// Handle de contrôle clonable, envoyé aux handlers UPnP
|
||||
#[derive(Clone)]
|
||||
pub struct PlayerHandle {
|
||||
command_tx: mpsc::Sender<PlayerCommand>,
|
||||
event_tx: broadcast::Sender<PlayerEvent>,
|
||||
}
|
||||
|
||||
impl PlayerHandle {
|
||||
/// Charge une URI (sans démarrer la lecture)
|
||||
pub async fn load_uri(&self, uri: impl Into<String>) {
|
||||
let _ = self.command_tx.send(PlayerCommand::LoadUri(uri.into())).await;
|
||||
}
|
||||
|
||||
/// Pré-charge l'URI suivante pour transition gapless
|
||||
pub async fn load_next_uri(&self, uri: impl Into<String>) {
|
||||
let _ = self.command_tx.send(PlayerCommand::LoadNextUri(uri.into())).await;
|
||||
}
|
||||
|
||||
/// Démarre ou reprend la lecture
|
||||
pub async fn play(&self) {
|
||||
let _ = self.command_tx.send(PlayerCommand::Play).await;
|
||||
}
|
||||
|
||||
/// Suspend la lecture (position préservée)
|
||||
pub async fn pause(&self) {
|
||||
let _ = self.command_tx.send(PlayerCommand::Pause).await;
|
||||
}
|
||||
|
||||
/// Arrête la lecture (position remise à 0)
|
||||
pub async fn stop(&self) {
|
||||
let _ = self.command_tx.send(PlayerCommand::Stop).await;
|
||||
}
|
||||
|
||||
/// Reprend depuis la position donnée (en secondes)
|
||||
pub async fn seek(&self, pos_sec: f64) {
|
||||
let _ = self.command_tx.send(PlayerCommand::Seek(pos_sec)).await;
|
||||
}
|
||||
|
||||
/// Souscrit aux événements de transport
|
||||
pub fn subscribe_events(&self) -> broadcast::Receiver<PlayerEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── État de transport ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum TransportState {
|
||||
/// Aucune URI chargée
|
||||
Idle,
|
||||
/// URI chargée, en attente de Play
|
||||
Loaded,
|
||||
/// Lecture en cours
|
||||
Playing,
|
||||
/// Lecture suspendue
|
||||
Paused,
|
||||
}
|
||||
|
||||
// ─── Logique interne ──────────────────────────────────────────────────────────
|
||||
|
||||
struct PlayerSourceLogic {
|
||||
command_rx: mpsc::Receiver<PlayerCommand>,
|
||||
event_tx: broadcast::Sender<PlayerEvent>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NodeLogic for PlayerSourceLogic {
|
||||
async fn process(
|
||||
&mut self,
|
||||
_input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut state = TransportState::Idle;
|
||||
let mut current_uri: Option<String> = None;
|
||||
let mut next_uri: Option<String> = None;
|
||||
let mut paused_at_sec: f64 = 0.0;
|
||||
|
||||
info!("PlayerSource: started");
|
||||
|
||||
loop {
|
||||
match state {
|
||||
TransportState::Idle | TransportState::Loaded | TransportState::Paused => {
|
||||
// Attendre une commande
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
debug!("PlayerSource: stop requested");
|
||||
break;
|
||||
}
|
||||
cmd = self.command_rx.recv() => {
|
||||
match cmd {
|
||||
None => break,
|
||||
Some(cmd) => {
|
||||
self.handle_command(
|
||||
cmd, &mut state, &mut current_uri,
|
||||
&mut next_uri, &mut paused_at_sec,
|
||||
&output, &stop_token,
|
||||
).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TransportState::Playing => {
|
||||
let uri = match current_uri.clone() {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
state = TransportState::Idle;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Ouvrir la source depuis la position courante
|
||||
let source = match UriSource::open(&uri, paused_at_sec, stop_token.clone()).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("PlayerSource: failed to open {:?}: {}", uri, e);
|
||||
let _ = self.event_tx.send(PlayerEvent::Error(e.to_string()));
|
||||
state = TransportState::Idle;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let duration_sec = source.duration_sec();
|
||||
let _ = self.event_tx.send(PlayerEvent::Playing {
|
||||
uri: uri.clone(),
|
||||
duration_sec,
|
||||
});
|
||||
info!("PlayerSource: playing {:?} from {:.1}s", uri, paused_at_sec);
|
||||
|
||||
// Pompe audio — s'arrête sur EOF, Pause, Stop, ou commande
|
||||
let result = self.pump(
|
||||
source,
|
||||
&mut state,
|
||||
&mut current_uri,
|
||||
&mut next_uri,
|
||||
&mut paused_at_sec,
|
||||
&output,
|
||||
&stop_token,
|
||||
).await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {}
|
||||
Err(AudioError::ChildDied) => {
|
||||
debug!("PlayerSource: child died, stopping");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("PlayerSource: pump error: {}", e);
|
||||
let _ = self.event_tx.send(PlayerEvent::Error(e.to_string()));
|
||||
state = TransportState::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("PlayerSource: stopped");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerSourceLogic {
|
||||
/// Traite une commande de transport dans les états non-Playing.
|
||||
async fn handle_command(
|
||||
&mut self,
|
||||
cmd: PlayerCommand,
|
||||
state: &mut TransportState,
|
||||
current_uri: &mut Option<String>,
|
||||
next_uri: &mut Option<String>,
|
||||
paused_at_sec: &mut f64,
|
||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
match cmd {
|
||||
PlayerCommand::LoadUri(uri) => {
|
||||
info!("PlayerSource: LoadUri {:?}", uri);
|
||||
*current_uri = Some(uri);
|
||||
*next_uri = None;
|
||||
*paused_at_sec = 0.0;
|
||||
*state = TransportState::Loaded;
|
||||
}
|
||||
|
||||
PlayerCommand::LoadNextUri(uri) => {
|
||||
debug!("PlayerSource: LoadNextUri {:?}", uri);
|
||||
*next_uri = Some(uri);
|
||||
}
|
||||
|
||||
PlayerCommand::Play => {
|
||||
match state {
|
||||
TransportState::Paused => {
|
||||
info!("PlayerSource: Play (resume from {:.1}s)", paused_at_sec);
|
||||
// Injecter un TrackBoundary pour EOS + nouveau BOS OGG propre
|
||||
send_track_boundary(current_uri.as_deref(), output, *paused_at_sec, stop_token).await?;
|
||||
*state = TransportState::Playing;
|
||||
}
|
||||
TransportState::Loaded => {
|
||||
info!("PlayerSource: Play (start)");
|
||||
*paused_at_sec = 0.0;
|
||||
// TrackBoundary initial pour le premier BOS OGG
|
||||
send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await?;
|
||||
*state = TransportState::Playing;
|
||||
}
|
||||
TransportState::Idle => {
|
||||
debug!("PlayerSource: Play ignored (no URI loaded)");
|
||||
}
|
||||
TransportState::Playing => {
|
||||
debug!("PlayerSource: Play ignored (already playing)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlayerCommand::Pause => {
|
||||
if *state == TransportState::Playing {
|
||||
// Géré dans pump() — ne devrait pas arriver ici
|
||||
debug!("PlayerSource: Pause ignored in non-Playing state");
|
||||
} else {
|
||||
debug!("PlayerSource: Pause ignored (not playing)");
|
||||
}
|
||||
}
|
||||
|
||||
PlayerCommand::Stop => {
|
||||
info!("PlayerSource: Stop");
|
||||
*paused_at_sec = 0.0;
|
||||
*state = TransportState::Idle;
|
||||
let _ = self.event_tx.send(PlayerEvent::Stopped);
|
||||
}
|
||||
|
||||
PlayerCommand::Seek(pos) => {
|
||||
if current_uri.is_some() {
|
||||
info!("PlayerSource: Seek to {:.1}s", pos);
|
||||
*paused_at_sec = pos;
|
||||
send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await?;
|
||||
*state = TransportState::Playing;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pompe audio : émet les chunks depuis `source` vers `output`.
|
||||
///
|
||||
/// Surveille simultanément les commandes de contrôle.
|
||||
/// Se termine quand : EOF, Pause, Stop, cancel, ou erreur.
|
||||
async fn pump(
|
||||
&mut self,
|
||||
source: UriSource,
|
||||
state: &mut TransportState,
|
||||
current_uri: &mut Option<String>,
|
||||
next_uri: &mut Option<String>,
|
||||
paused_at_sec: &mut f64,
|
||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
// Canal interne pour recevoir les chunks de UriSource
|
||||
let (chunk_tx, mut chunk_rx) = mpsc::channel::<Arc<AudioSegment>>(16);
|
||||
let source_stop = stop_token.child_token();
|
||||
let source_stop_clone = source_stop.clone();
|
||||
|
||||
// Dernière seconde entière pour laquelle on a émis un Position
|
||||
let mut last_reported_sec: i64 = -1;
|
||||
|
||||
// Spawner l'émission de la source dans une tâche séparée
|
||||
let emit_task = tokio::spawn(async move {
|
||||
source.emit_to_channel(&chunk_tx, &source_stop_clone).await
|
||||
});
|
||||
|
||||
let mut result = Ok(());
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
source_stop.cancel();
|
||||
break;
|
||||
}
|
||||
|
||||
cmd = self.command_rx.recv() => {
|
||||
match cmd {
|
||||
None => {
|
||||
source_stop.cancel();
|
||||
break;
|
||||
}
|
||||
Some(PlayerCommand::Pause) => {
|
||||
info!("PlayerSource: Pause at {:.1}s", *paused_at_sec);
|
||||
source_stop.cancel();
|
||||
*state = TransportState::Paused;
|
||||
let _ = self.event_tx.send(PlayerEvent::Paused {
|
||||
position_sec: *paused_at_sec,
|
||||
});
|
||||
break;
|
||||
}
|
||||
Some(PlayerCommand::Stop) => {
|
||||
info!("PlayerSource: Stop");
|
||||
source_stop.cancel();
|
||||
*paused_at_sec = 0.0;
|
||||
*state = TransportState::Idle;
|
||||
let _ = self.event_tx.send(PlayerEvent::Stopped);
|
||||
break;
|
||||
}
|
||||
Some(PlayerCommand::LoadUri(uri)) => {
|
||||
info!("PlayerSource: LoadUri (replacing current) {:?}", uri);
|
||||
source_stop.cancel();
|
||||
*current_uri = Some(uri);
|
||||
*next_uri = None;
|
||||
*paused_at_sec = 0.0;
|
||||
// TrackBoundary pour clore le bitstream OGG proprement
|
||||
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await {
|
||||
result = Err(e);
|
||||
}
|
||||
*state = TransportState::Playing;
|
||||
break;
|
||||
}
|
||||
Some(PlayerCommand::LoadNextUri(uri)) => {
|
||||
debug!("PlayerSource: LoadNextUri {:?}", uri);
|
||||
*next_uri = Some(uri);
|
||||
}
|
||||
Some(PlayerCommand::Seek(pos)) => {
|
||||
info!("PlayerSource: Seek to {:.1}s", pos);
|
||||
source_stop.cancel();
|
||||
*paused_at_sec = pos;
|
||||
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await {
|
||||
result = Err(e);
|
||||
break;
|
||||
}
|
||||
*state = TransportState::Playing;
|
||||
break;
|
||||
}
|
||||
Some(PlayerCommand::Play) => {
|
||||
debug!("PlayerSource: Play ignored (already playing)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
segment = chunk_rx.recv() => {
|
||||
match segment {
|
||||
None => {
|
||||
// EOF de la source
|
||||
debug!("PlayerSource: EOF");
|
||||
// Signaler la fin de piste dans tous les cas (gapless ou non)
|
||||
// pour que le ControlPoint avance sa queue et mette à jour son état.
|
||||
let _ = self.event_tx.send(PlayerEvent::TrackEnded);
|
||||
if let Some(next) = next_uri.take() {
|
||||
// Transition gapless vers la piste suivante
|
||||
info!("PlayerSource: gapless transition to {:?}", next);
|
||||
*current_uri = Some(next);
|
||||
*paused_at_sec = 0.0;
|
||||
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await {
|
||||
result = Err(e);
|
||||
}
|
||||
*state = TransportState::Playing;
|
||||
} else {
|
||||
*state = TransportState::Loaded;
|
||||
*paused_at_sec = 0.0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(seg) => {
|
||||
// Mettre à jour la position courante
|
||||
if seg.is_audio_chunk() {
|
||||
*paused_at_sec = seg.timestamp_sec;
|
||||
// Émettre Position ~1/s
|
||||
let sec = paused_at_sec.floor() as i64;
|
||||
if sec != last_reported_sec {
|
||||
last_reported_sec = sec;
|
||||
let _ = self.event_tx.send(PlayerEvent::Position {
|
||||
position_sec: *paused_at_sec,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Envoyer au pipeline en aval
|
||||
if let Err(e) = send_to_children("PlayerSource", output, seg).await {
|
||||
source_stop.cancel();
|
||||
result = Err(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vider chunk_rx pour débloquer emit_task si elle est bloquée sur un send
|
||||
// (peut arriver si le pipeline en aval est saturé au moment du cancel)
|
||||
while chunk_rx.try_recv().is_ok() {}
|
||||
|
||||
// Attendre que la tâche source se termine, avec timeout de sécurité
|
||||
tokio::select! {
|
||||
_ = emit_task => {}
|
||||
_ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
|
||||
warn!("PlayerSource: emit_task did not finish in time after cancel");
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Envoie un TrackBoundary à tous les enfants.
|
||||
///
|
||||
/// Utilisé pour déclencher EOS + nouveau BOS OGG dans StreamingOggFlacSink,
|
||||
/// garantissant un bitstream propre à chaque démarrage, reprise ou seek.
|
||||
async fn send_track_boundary(
|
||||
uri: Option<&str>,
|
||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
timestamp_sec: f64,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
if output.is_empty() || stop_token.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut meta = MemoryTrackMetadata::new();
|
||||
if let Some(u) = uri {
|
||||
let _ = meta.set_title(Some(u.to_string())).await;
|
||||
}
|
||||
let meta_arc = Arc::new(tokio::sync::RwLock::new(meta));
|
||||
let boundary = AudioSegment::new_track_boundary(0, timestamp_sec, meta_arc);
|
||||
|
||||
send_to_children("PlayerSource", output, boundary).await
|
||||
}
|
||||
|
||||
// ─── Nœud public ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Source audio avec contrôle de transport AVTransport UPnP.
|
||||
///
|
||||
/// Utilisée comme nœud racine d'un pipeline audio. Le `PlayerHandle` retourné
|
||||
/// permet d'envoyer des commandes (Play, Pause, Stop, Seek, LoadUri) depuis
|
||||
/// les handlers UPnP.
|
||||
pub struct PlayerSource {
|
||||
inner: Node<PlayerSourceLogic>,
|
||||
}
|
||||
|
||||
impl PlayerSource {
|
||||
/// Crée une nouvelle PlayerSource et son handle de contrôle.
|
||||
pub fn new() -> (Self, PlayerHandle) {
|
||||
let (command_tx, command_rx) = mpsc::channel::<PlayerCommand>(32);
|
||||
let (event_tx, _) = broadcast::channel::<PlayerEvent>(16);
|
||||
|
||||
let logic = PlayerSourceLogic {
|
||||
command_rx,
|
||||
event_tx: event_tx.clone(),
|
||||
};
|
||||
|
||||
let handle = PlayerHandle {
|
||||
command_tx,
|
||||
event_tx,
|
||||
};
|
||||
|
||||
(
|
||||
Self {
|
||||
inner: Node::new_source(logic),
|
||||
},
|
||||
handle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AudioPipelineNode for PlayerSource {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,8 @@ use pmoaudio::{
|
||||
AudioSegment,
|
||||
};
|
||||
use pmoaudiocache::Cache as AudioCache;
|
||||
use pmoflac::{decode_audio_stream, StreamInfo};
|
||||
use pmoplaylist::{PlaylistRole, ReadHandle};
|
||||
use pmoflac::decode_audio_stream;
|
||||
use pmoplaylist::ReadHandle;
|
||||
|
||||
use super::pcm_decode::{bytes_to_segment, validate_stream};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{cache::is_lazy_pk, CacheConfig, DB};
|
||||
/// Trait générique pour les caches de fichiers
|
||||
///
|
||||
/// Définit l'interface commune pour tous les types de caches (images, audio, etc.)
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
fn get_cache_dir(&self) -> &Path;
|
||||
fn get_database(&self) -> Arc<DB>;
|
||||
@@ -68,14 +69,7 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
|
||||
/// Retourne la route relative pour accéder à un item du cache
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de la piste
|
||||
/// * `param` - Paramètre optionnel (ex: "orig", "128k", etc.)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Route relative (ex: "/audio/flac/abc123" ou "/audio/tracks/abc123/orig")
|
||||
/// Format: `/{cache_name}/{cache_type}/{pk}[/{param}]`
|
||||
fn route_for(&self, pk: &str, param: Option<&str>) -> String {
|
||||
if let Some(p) = param {
|
||||
format!("/{}/{}/{}/{}", C::cache_name(), C::cache_type(), pk, p)
|
||||
@@ -84,6 +78,16 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'URL absolue pour accéder à un item du cache
|
||||
///
|
||||
/// Utilise la variable d'environnement `PMO_SERVER_URL` comme base,
|
||||
/// avec `http://localhost:8080` comme valeur par défaut.
|
||||
fn absolute_url_for(&self, pk: &str, param: Option<&str>) -> String {
|
||||
let base = std::env::var("PMO_SERVER_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
format!("{}{}", base.trim_end_matches('/'), self.route_for(pk, param))
|
||||
}
|
||||
|
||||
/// Télécharge un fichier depuis une URL et l'ajoute au cache
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -144,6 +144,22 @@ pub use cache::{
|
||||
CacheSubscription,
|
||||
};
|
||||
pub use cache_trait::{pk_from_content_header, FileCache};
|
||||
|
||||
/// Retourne la route relative pour une cover: `/covers/image/{pk}[/{param}]`
|
||||
pub fn covers_route_for(pk: &str, param: Option<&str>) -> String {
|
||||
if let Some(p) = param {
|
||||
format!("/covers/image/{}/{}", pk, p)
|
||||
} else {
|
||||
format!("/covers/image/{}", pk)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'URL absolue pour une cover via `PMO_SERVER_URL`
|
||||
pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String {
|
||||
let base = std::env::var("PMO_SERVER_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
|
||||
}
|
||||
pub use db::{CacheEntry, DB};
|
||||
pub use download::{
|
||||
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,
|
||||
|
||||
@@ -92,9 +92,17 @@ async fn get_file<C: CacheConfig + 'static>(
|
||||
Path(pk): Path<String>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
// Utiliser le param par défaut
|
||||
let param = C::default_param();
|
||||
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await
|
||||
tracing::debug!(
|
||||
"[{}] GET /{}/{} param={}",
|
||||
C::cache_name(), C::cache_type(), pk, param
|
||||
);
|
||||
let response = serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await;
|
||||
tracing::debug!(
|
||||
"[{}] GET /{}/{} → {}",
|
||||
C::cache_name(), C::cache_type(), pk, response.status()
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param}
|
||||
@@ -267,6 +275,16 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
}
|
||||
|
||||
// Fichier terminé ou pas de download en cours, servir normalement avec support Range
|
||||
if !file_path.exists() {
|
||||
// Fichier absent : soit download background pas encore terminé,
|
||||
// soit transformation WebP/variant pas encore écrite sur disque
|
||||
let in_db = cache.db.get(pk, false).is_ok();
|
||||
warn!(
|
||||
"Requested file not on disk yet: pk={} in_db={} path={:?} — client should retry",
|
||||
pk, in_db, file_path
|
||||
);
|
||||
}
|
||||
|
||||
let response = serve_complete_file(file_path, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
@@ -287,7 +305,7 @@ async fn stream_file_progressive(
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
use axum::http::header;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio::io::AsyncSeekExt;
|
||||
|
||||
// Attendre qu'au moins 64 KB soient disponibles avant de commencer
|
||||
const MIN_SIZE_TO_START: u64 = 64 * 1024;
|
||||
@@ -602,6 +620,7 @@ pub fn create_api_router<C: CacheConfig + 'static>(cache: Arc<Cache<C>>) -> Rout
|
||||
///
|
||||
/// Permet d'initialiser un cache générique avec routes HTTP complètes
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait GenericCacheExt {
|
||||
/// Initialise un cache générique avec routes complètes
|
||||
///
|
||||
|
||||
@@ -5,7 +5,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pmoutils ={ path = "../pmoutils" }
|
||||
pmoutils = { version = "0.1.2", registry = "pmo" }
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
|
||||
@@ -6,7 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
pmoupnp = { path = "../pmoupnp" }
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
pmoutils = { path = "../pmoutils" }
|
||||
pmoutils = { version = "0.1.2", registry = "pmo" }
|
||||
quick-xml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ureq = "3.1.4"
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::openapi::{
|
||||
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
||||
RendererStateView,
|
||||
};
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
||||
use crate::registry::DeviceRegistry;
|
||||
|
||||
/// Control point minimal :
|
||||
|
||||
@@ -91,6 +91,16 @@ impl UpnpMediaServer {
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
Ok(self.browse_with_flag_paged(object_id, browse_flag, start, count)?.entries)
|
||||
}
|
||||
|
||||
fn browse_with_flag_paged(
|
||||
&self,
|
||||
object_id: &str,
|
||||
browse_flag: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
let start_str = start.to_string();
|
||||
let count_str = count.to_string();
|
||||
let args = vec![
|
||||
@@ -104,10 +114,12 @@ impl UpnpMediaServer {
|
||||
|
||||
let response = self.invoke_content_directory("Browse", None, args)?;
|
||||
let envelope = response.envelope.ok_or_else(|| {
|
||||
ControlPointError::MediaServerError(format!("Missing SOAP envelope in Browse response"))
|
||||
ControlPointError::MediaServerError("Missing SOAP envelope in Browse response".to_string())
|
||||
})?;
|
||||
let total_count = extract_total_matches(&envelope, "BrowseResponse");
|
||||
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
||||
map_didl_entries(&didl_xml)
|
||||
let entries = map_didl_entries(&didl_xml)?;
|
||||
Ok(BrowsePage { entries, total_count })
|
||||
}
|
||||
|
||||
fn has_content_directory(&self) -> bool {
|
||||
@@ -362,6 +374,13 @@ pub struct MediaEntry {
|
||||
pub creator: Option<String>,
|
||||
}
|
||||
|
||||
/// Résultat paginé d'un browse avec total connu.
|
||||
#[derive(Debug)]
|
||||
pub struct BrowsePage {
|
||||
pub entries: Vec<MediaEntry>,
|
||||
pub total_count: u32,
|
||||
}
|
||||
|
||||
/// Backend-agnostic media browsing contract.
|
||||
pub trait MediaBrowser {
|
||||
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||
@@ -371,6 +390,16 @@ pub trait MediaBrowser {
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||
fn browse_children_paged(
|
||||
&self,
|
||||
object_id: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
let entries = self.browse_children(object_id, start, count)?;
|
||||
let total_count = entries.len() as u32 + start;
|
||||
Ok(BrowsePage { entries, total_count })
|
||||
}
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
|
||||
fn search(
|
||||
&self,
|
||||
@@ -488,6 +517,17 @@ impl MediaBrowser for MusicServer {
|
||||
}
|
||||
}
|
||||
|
||||
fn browse_children_paged(
|
||||
&self,
|
||||
object_id: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
match self {
|
||||
MusicServer::Upnp(upnp) => upnp.browse_children_paged(object_id, start, count),
|
||||
}
|
||||
}
|
||||
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
|
||||
match self {
|
||||
MusicServer::Upnp(upnp) => upnp.browse_object(object_id),
|
||||
@@ -603,6 +643,15 @@ impl MediaBrowser for UpnpMediaServer {
|
||||
self.browse_with_flag(object_id, "BrowseDirectChildren", start, count)
|
||||
}
|
||||
|
||||
fn browse_children_paged(
|
||||
&self,
|
||||
object_id: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
self.browse_with_flag_paged(object_id, "BrowseDirectChildren", start, count)
|
||||
}
|
||||
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
|
||||
let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?;
|
||||
entries.into_iter().next().ok_or_else(|| {
|
||||
@@ -721,6 +770,17 @@ fn extract_result_payload(
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn extract_total_matches(envelope: &SoapEnvelope, response_suffix: &str) -> u32 {
|
||||
let response = match find_child_with_suffix(&envelope.body.content, response_suffix) {
|
||||
Some(r) => r,
|
||||
None => return 0,
|
||||
};
|
||||
find_child_with_suffix(response, "TotalMatches")
|
||||
.and_then(|e| e.get_text())
|
||||
.and_then(|t| t.trim().parse::<u32>().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
|
||||
parent.children.iter().find_map(|node| match node {
|
||||
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
|
||||
|
||||
@@ -35,7 +35,7 @@ use crate::music_renderer::watcher::{
|
||||
};
|
||||
use crate::online::DeviceConnectionState;
|
||||
use crate::queue::{
|
||||
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot,
|
||||
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot,
|
||||
};
|
||||
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||
|
||||
@@ -639,6 +639,14 @@ impl MusicRenderer {
|
||||
);
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
self.clear_has_played_flag();
|
||||
} else if self.is_playing_a_stream() {
|
||||
// Continuous stream (radio) stopped naturally — never auto-advance
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"Renderer stopped during continuous stream; not auto-advancing"
|
||||
);
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
self.clear_has_played_flag();
|
||||
} else if self.is_playing_from_queue() {
|
||||
// Only auto-advance if we have actually seen a PLAYING state
|
||||
// since the track was started. This prevents auto-advance on
|
||||
@@ -1554,7 +1562,7 @@ pub(crate) fn build_didl_lite_metadata(
|
||||
protocol_info: &str,
|
||||
) -> String {
|
||||
use pmodidl::{DIDLLite, Item, Resource};
|
||||
use pmoutils::ToXmlElement;
|
||||
use pmodidl::ToXmlElement;
|
||||
|
||||
// Construire l'Item DIDL avec toutes les métadonnées
|
||||
let item = Item {
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct RendererSummary {
|
||||
pub capabilities: RendererCapabilitiesSummary,
|
||||
/// Renderer en ligne
|
||||
pub online: bool,
|
||||
/// Adresse IP du serveur hébergeant ce renderer (extraite de la location UPnP)
|
||||
pub server_host: Option<String>,
|
||||
}
|
||||
|
||||
/// Protocole exposé par le renderer
|
||||
@@ -195,8 +197,12 @@ pub struct ContainerEntry {
|
||||
pub struct BrowseResponse {
|
||||
/// ID du container browsé
|
||||
pub container_id: String,
|
||||
/// Entrées du container
|
||||
/// Entrées du container (page courante)
|
||||
pub entries: Vec<ContainerEntry>,
|
||||
/// Nombre total d'entrées dans le container
|
||||
pub total_count: u32,
|
||||
/// Offset de la page courante
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -27,7 +27,7 @@ use async_trait::async_trait;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
};
|
||||
@@ -43,7 +43,7 @@ use tracing::{debug, warn};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
const BROWSE_PAGE_SIZE: u32 = 100;
|
||||
const BROWSE_DEFAULT_LIMIT: u32 = 50;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
@@ -102,6 +102,13 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let info = r.info();
|
||||
// Extraire le host depuis la location URL (ex: "http://192.168.1.10:8080/...")
|
||||
let server_host = info.location()
|
||||
.split("://").nth(1)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| s.split(':').next())
|
||||
.map(|h| h.to_string())
|
||||
.filter(|h| !h.is_empty());
|
||||
RendererSummary {
|
||||
id: r.id().0.clone(),
|
||||
friendly_name: r.friendly_name().to_string(),
|
||||
@@ -109,6 +116,7 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
|
||||
protocol: protocol_summary(&info.protocol()),
|
||||
capabilities: capability_summary(&info.capabilities()),
|
||||
online: r.is_online(),
|
||||
server_host,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -2042,6 +2050,15 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
|
||||
Json(summaries)
|
||||
}
|
||||
|
||||
/// Paramètres de pagination pour le browse
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct BrowseParams {
|
||||
#[serde(default)]
|
||||
offset: u32,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// GET /control/servers/{server_id}/containers/{container_id} - Browse un container
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
@@ -2049,7 +2066,9 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
|
||||
path = "/servers/{server_id}/containers/{container_id}",
|
||||
params(
|
||||
("server_id" = String, Path, description = "ID unique du serveur"),
|
||||
("container_id" = String, Path, description = "ID du container (use '0' for root)")
|
||||
("container_id" = String, Path, description = "ID du container (use '0' for root)"),
|
||||
("offset" = Option<u32>, Query, description = "Index de départ (défaut: 0)"),
|
||||
("limit" = Option<u32>, Query, description = "Nombre max d'items (défaut: 50)"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Contenu du container", body = BrowseResponse),
|
||||
@@ -2061,6 +2080,7 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
|
||||
async fn browse_container(
|
||||
State(state): State<ControlPointState>,
|
||||
Path((server_id, container_id)): Path<(String, String)>,
|
||||
Query(params): Query<BrowseParams>,
|
||||
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let sid = DeviceId(server_id.clone());
|
||||
|
||||
@@ -2091,14 +2111,17 @@ async fn browse_container(
|
||||
));
|
||||
}
|
||||
|
||||
let offset = params.offset;
|
||||
let limit = params.limit.unwrap_or(BROWSE_DEFAULT_LIMIT);
|
||||
|
||||
// Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls
|
||||
let container_id_clone = container_id.clone();
|
||||
let server_clone = server.clone();
|
||||
let browse_task = tokio::task::spawn_blocking(move || {
|
||||
server_clone.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
|
||||
server_clone.browse_children_paged(&container_id_clone, offset, limit)
|
||||
});
|
||||
|
||||
let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
|
||||
let page = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
@@ -2137,14 +2160,14 @@ async fn browse_container(
|
||||
)
|
||||
})?;
|
||||
|
||||
let container_entries: Vec<ContainerEntry> = entries
|
||||
let container_entries: Vec<ContainerEntry> = page.entries
|
||||
.into_iter()
|
||||
.map(|e| ContainerEntry {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
class: e.class,
|
||||
is_container: e.is_container,
|
||||
child_count: None, // Could be extracted from DIDL-Lite if needed
|
||||
child_count: None,
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
album_art_uri: e.album_art_uri,
|
||||
@@ -2154,6 +2177,8 @@ async fn browse_container(
|
||||
Ok(Json(BrowseResponse {
|
||||
container_id,
|
||||
entries: container_entries,
|
||||
total_count: page.total_count,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2210,7 +2235,7 @@ fn fetch_playback_items(
|
||||
|
||||
let entries = if object_metadata.is_container {
|
||||
// For containers, browse children to get all items
|
||||
server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?
|
||||
server.browse_children(object_id, 0, BROWSE_DEFAULT_LIMIT)?
|
||||
} else {
|
||||
// For items, use the object itself
|
||||
vec![object_metadata]
|
||||
|
||||
@@ -10,5 +10,4 @@ utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
||||
quick-xml = { workspace = true }
|
||||
bevy_reflect = "0.17.1"
|
||||
bevy_reflect_derive = "0.17.1"
|
||||
pmoutils = { path = "../pmoutils" }
|
||||
xmltree = "0.10"
|
||||
|
||||
@@ -3,14 +3,31 @@
|
||||
//! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA.
|
||||
|
||||
use bevy_reflect::Reflect;
|
||||
use pmoutils::ToXmlElement;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
use std::io::Cursor;
|
||||
use std::time::SystemTime;
|
||||
use xmltree::{Element, XMLNode};
|
||||
use xmltree::{Element, EmitterConfig, XMLNode};
|
||||
|
||||
/// Trait générique pour obtenir un élément XML (xmltree::Element).
|
||||
pub trait ToXmlElement {
|
||||
/// Convertit l'objet en élément XML.
|
||||
fn to_xml_element(&self) -> Element;
|
||||
|
||||
/// Sérialise en chaîne XML formatée.
|
||||
fn to_xml(&self) -> String {
|
||||
let elem = self.to_xml_element();
|
||||
let config = EmitterConfig::new()
|
||||
.perform_indent(true)
|
||||
.indent_string(" ");
|
||||
let mut buf = Vec::new();
|
||||
elem.write_with_config(&mut buf, config)
|
||||
.expect("Failed to write XML");
|
||||
String::from_utf8(buf).expect("Invalid UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Couche d'abstraction générique =============
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ quick-xml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
pmoutils = { path = "../pmoutils" }
|
||||
pmoutils = { version = "0.1.2", registry = "pmo" }
|
||||
|
||||
# Optional dependencies
|
||||
axum = { version = "0.8", optional = true }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
use pmodidl::{Container, DIDLLite};
|
||||
use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
|
||||
use pmosource::{BrowseResult, MusicSource, MusicSourceError};
|
||||
use pmoutils::ToXmlElement;
|
||||
use pmodidl::ToXmlElement;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ pmoplaylist = { path = "../pmoplaylist" }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
|
||||
# Cache support (OBLIGATOIRE - architecture refactorisée)
|
||||
pmocache = { path = "../pmocache" }
|
||||
pmocovers = { path = "../pmocovers" }
|
||||
pmoaudiocache = { path = "../pmoaudiocache" }
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ impl RadioParadiseSource {
|
||||
let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string());
|
||||
let cover_url = cover_pk
|
||||
.as_ref()
|
||||
.map(|pk| format!("{}/covers/jpeg/{}", self.base_url, pk))
|
||||
.map(|pk| pmocache::covers_absolute_url_for(pk, None))
|
||||
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string()))
|
||||
.or_else(|| Some(self.default_cover_url()));
|
||||
|
||||
|
||||
@@ -517,7 +517,7 @@ fn playlist_track_to_response(
|
||||
}
|
||||
|
||||
fn cover_url_from_pk(pk: &str) -> String {
|
||||
format!("/covers/image/{}/256", pk)
|
||||
pmocache::covers_route_for(pk, None)
|
||||
}
|
||||
|
||||
fn normalize_cover_pk(input: Option<String>) -> Option<String> {
|
||||
|
||||
@@ -182,7 +182,7 @@ impl ReadHandle {
|
||||
let _remaining = self.remaining().await?;
|
||||
|
||||
// Convertir cover_pk en URL si présent
|
||||
let album_art = cover_pk.map(|pk| format!("/cover/{}", pk));
|
||||
let album_art = cover_pk.map(|pk| pmocache::covers_route_for(&pk, None));
|
||||
|
||||
Ok(Container {
|
||||
id: self.playlist.id.clone(),
|
||||
@@ -253,7 +253,7 @@ impl ReadHandle {
|
||||
let track_number = meta.get_track_number().await.ok().flatten();
|
||||
let cover_pk = meta.get_cover_pk().await.ok().flatten();
|
||||
let cover_url = if let Some(pk) = cover_pk.as_ref() {
|
||||
Some(format!("/covers/jpeg/{}/256", pk))
|
||||
Some(pmocache::covers_route_for(pk, None))
|
||||
} else {
|
||||
meta.get_cover_url().await.ok().flatten()
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::debug;
|
||||
|
||||
pub use spoofer::Spoofer;
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ async fn cache_album_image(mut album: Album, cover_cache: &Arc<pmocovers::Cache>
|
||||
if let Some(ref image_url) = album.image {
|
||||
match cover_cache.add_from_url(image_url, None).await {
|
||||
Ok(pk) => {
|
||||
album.image_cached = Some(format!("/covers/images/{}", pk));
|
||||
album.image_cached = Some(pmocache::covers_route_for(&pk, None));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to cache album image: {}", e);
|
||||
|
||||
@@ -19,6 +19,39 @@ use std::time::{Duration, SystemTime};
|
||||
/// TTL pour les playlists d'albums (7 jours)
|
||||
const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600);
|
||||
|
||||
/// Trait pour les types dont on peut cacher la cover image.
|
||||
trait CoverCacheable {
|
||||
fn image_url(&self) -> Option<&str>;
|
||||
fn set_image_cached(&mut self, url: String);
|
||||
}
|
||||
|
||||
impl CoverCacheable for crate::models::Album {
|
||||
fn image_url(&self) -> Option<&str> { self.image.as_deref() }
|
||||
fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
|
||||
}
|
||||
|
||||
impl CoverCacheable for crate::models::Playlist {
|
||||
fn image_url(&self) -> Option<&str> { self.image.as_deref() }
|
||||
fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
|
||||
}
|
||||
|
||||
impl CoverCacheable for crate::models::Artist {
|
||||
fn image_url(&self) -> Option<&str> { self.image.as_deref() }
|
||||
fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
|
||||
}
|
||||
|
||||
/// Pour Track, la cover est celle de l'album.
|
||||
impl CoverCacheable for crate::models::Track {
|
||||
fn image_url(&self) -> Option<&str> {
|
||||
self.album.as_ref()?.image.as_deref()
|
||||
}
|
||||
fn set_image_cached(&mut self, url: String) {
|
||||
if let Some(ref mut album) = self.album {
|
||||
album.image_cached = Some(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default image for Qobuz (300x300 WebP, embedded in binary)
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||
|
||||
@@ -853,6 +886,77 @@ impl QobuzSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache les covers d'une liste d'items en parallèle (générique via `CoverCacheable`).
|
||||
async fn cache_covers<T>(&self, items: Vec<T>) -> Vec<T>
|
||||
where
|
||||
T: CoverCacheable + Send + 'static,
|
||||
{
|
||||
let futs: Vec<_> = items.into_iter().map(|mut item| {
|
||||
let source = self.clone();
|
||||
async move {
|
||||
if let Some(image_url) = item.image_url().map(str::to_string) {
|
||||
if let Ok(pk) = source.inner.cache_manager.cache_cover(&image_url).await {
|
||||
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
|
||||
item.set_image_cached(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
item
|
||||
}
|
||||
}).collect();
|
||||
tokio::task::JoinSet::from_iter(futs).join_all().await
|
||||
}
|
||||
|
||||
async fn cache_album_covers(&self, albums: Vec<crate::models::Album>) -> Vec<crate::models::Album> {
|
||||
self.cache_covers(albums).await
|
||||
}
|
||||
|
||||
async fn cache_playlist_covers(&self, playlists: Vec<crate::models::Playlist>) -> Vec<crate::models::Playlist> {
|
||||
self.cache_covers(playlists).await
|
||||
}
|
||||
|
||||
/// Cache les covers d'une liste de tracks en parallèle (via l'image de l'album).
|
||||
async fn cache_track_covers(&self, tracks: Vec<crate::models::Track>) -> Vec<crate::models::Track> {
|
||||
let futs: Vec<_> = tracks.into_iter().map(|mut track| {
|
||||
let source = self.clone();
|
||||
async move {
|
||||
if let Some(ref mut album) = track.album {
|
||||
if let Some(ref image_url) = album.image.clone() {
|
||||
if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await {
|
||||
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
|
||||
album.image_cached = Some(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
track
|
||||
}
|
||||
}).collect();
|
||||
tokio::task::JoinSet::from_iter(futs)
|
||||
.join_all()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Cache les covers d'une liste d'artistes en parallèle.
|
||||
async fn cache_artist_covers(&self, artists: Vec<crate::models::Artist>) -> Vec<crate::models::Artist> {
|
||||
let futs: Vec<_> = artists.into_iter().map(|mut artist| {
|
||||
let source = self.clone();
|
||||
async move {
|
||||
if let Some(ref image_url) = artist.image.clone() {
|
||||
if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await {
|
||||
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
|
||||
artist.image_cached = Some(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
artist
|
||||
}
|
||||
}).collect();
|
||||
tokio::task::JoinSet::from_iter(futs)
|
||||
.join_all()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Browse Favourites - retourne 4 sous-containers
|
||||
async fn browse_favourites(&self) -> Result<BrowseResult> {
|
||||
let containers = vec![
|
||||
@@ -873,6 +977,7 @@ impl QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let albums = self.cache_album_covers(albums).await;
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok())
|
||||
@@ -890,6 +995,7 @@ impl QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let tracks = self.cache_covers(tracks).await;
|
||||
let items: Vec<Item> = tracks
|
||||
.into_iter()
|
||||
.filter_map(|track| track.to_didl_item("qobuz:favorites:tracks").ok())
|
||||
@@ -907,23 +1013,21 @@ impl QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let artists = self.cache_covers(artists).await;
|
||||
let containers: Vec<Container> = artists
|
||||
.into_iter()
|
||||
.filter_map(|artist| {
|
||||
// Créer un container pour chaque artiste
|
||||
Some(Container {
|
||||
id: format!("qobuz:artist:{}", artist.id),
|
||||
parent_id: "qobuz:favorites:artists".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: None,
|
||||
searchable: Some("1".to_string()),
|
||||
title: artist.name.clone(),
|
||||
class: "object.container".to_string(),
|
||||
artist: Some(artist.name.clone()),
|
||||
album_art: artist.image_cached.clone().or_else(|| artist.image.clone()),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
.map(|artist| Container {
|
||||
id: format!("qobuz:artist:{}", artist.id),
|
||||
parent_id: "qobuz:favorites:artists".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: None,
|
||||
searchable: Some("1".to_string()),
|
||||
title: artist.name.clone(),
|
||||
class: "object.container".to_string(),
|
||||
artist: Some(artist.name.clone()),
|
||||
album_art: artist.image_cached.clone(),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -939,6 +1043,7 @@ impl QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let playlists = self.cache_playlist_covers(playlists).await;
|
||||
let containers: Vec<Container> = playlists
|
||||
.into_iter()
|
||||
.filter_map(|playlist| playlist.to_didl_container("qobuz:favorites:playlists").ok())
|
||||
@@ -1061,76 +1166,56 @@ impl QobuzSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse Discover Playlists (all featured playlists)
|
||||
async fn browse_discover_playlists(&self) -> Result<BrowseResult> {
|
||||
/// Helper : récupère des albums featured, cache les covers, retourne des containers DIDL
|
||||
async fn browse_featured_albums(&self, genre_id: Option<&str>, filter: &str, parent_id: &str) -> Result<BrowseResult> {
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_albums(genre_id, filter)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let albums = self.cache_album_covers(albums).await;
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container(parent_id).ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
}
|
||||
|
||||
/// Helper : récupère des playlists featured, cache les covers, retourne des containers DIDL
|
||||
async fn browse_featured_playlists(&self, genre_id: Option<&str>, parent_id: &str) -> Result<BrowseResult> {
|
||||
let playlists = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_playlists(None, None)
|
||||
.get_featured_playlists(genre_id, None)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let playlists = self.cache_playlist_covers(playlists).await;
|
||||
let containers: Vec<Container> = playlists
|
||||
.into_iter()
|
||||
.filter_map(|playlist| playlist.to_didl_container("qobuz:discover:playlists").ok())
|
||||
.filter_map(|playlist| playlist.to_didl_container(parent_id).ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
}
|
||||
|
||||
/// Browse Discover Albums (Ideal Discography)
|
||||
async fn browse_discover_playlists(&self) -> Result<BrowseResult> {
|
||||
self.browse_featured_playlists(None, "qobuz:discover:playlists").await
|
||||
}
|
||||
|
||||
async fn browse_discover_albums_ideal(&self) -> Result<BrowseResult> {
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_albums(None, "ideal-discography")
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container("qobuz:discover:albums:ideal").ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
self.browse_featured_albums(None, "ideal-discography", "qobuz:discover:albums:ideal").await
|
||||
}
|
||||
|
||||
/// Browse Discover Albums (Qobuzissime)
|
||||
async fn browse_discover_albums_qobuzissime(&self) -> Result<BrowseResult> {
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_albums(None, "qobuzissims")
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| {
|
||||
album
|
||||
.to_didl_container("qobuz:discover:albums:qobuzissime")
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
self.browse_featured_albums(None, "qobuzissims", "qobuz:discover:albums:qobuzissime").await
|
||||
}
|
||||
|
||||
/// Browse Discover Albums (New Releases)
|
||||
async fn browse_discover_albums_new(&self) -> Result<BrowseResult> {
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_albums(None, "new-releases")
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container("qobuz:discover:albums:new").ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
self.browse_featured_albums(None, "new-releases", "qobuz:discover:albums:new").await
|
||||
}
|
||||
|
||||
/// Browse Discover Artists (Featured Artists)
|
||||
@@ -1142,6 +1227,7 @@ impl QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let artists = self.cache_covers(artists).await;
|
||||
let containers: Vec<Container> = artists
|
||||
.into_iter()
|
||||
.map(|artist| Container {
|
||||
@@ -1153,7 +1239,7 @@ impl QobuzSource {
|
||||
title: artist.name.clone(),
|
||||
class: "object.container".to_string(),
|
||||
artist: Some(artist.name.clone()),
|
||||
album_art: artist.image_cached.clone().or_else(|| artist.image.clone()),
|
||||
album_art: artist.image_cached.clone(),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
@@ -1171,6 +1257,7 @@ impl QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let playlists = self.cache_covers(playlists).await;
|
||||
let parent_id = format!("qobuz:discover:playlists:{}", tag);
|
||||
let containers: Vec<Container> = playlists
|
||||
.into_iter()
|
||||
@@ -1324,112 +1411,28 @@ impl QobuzSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse Genre New Releases
|
||||
async fn browse_genre_new_releases(&self, genre_id: &str) -> Result<BrowseResult> {
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_albums(Some(genre_id), "new-releases")
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let parent_id = format!("qobuz:genre:{}:new-releases", genre_id);
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container(&parent_id).ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
self.browse_featured_albums(Some(genre_id), "new-releases", &format!("qobuz:genre:{}:new-releases", genre_id)).await
|
||||
}
|
||||
|
||||
/// Browse Genre Ideal Discography
|
||||
async fn browse_genre_ideal_discography(&self, genre_id: &str) -> Result<BrowseResult> {
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_albums(Some(genre_id), "ideal-discography")
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let parent_id = format!("qobuz:genre:{}:ideal", genre_id);
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container(&parent_id).ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
self.browse_featured_albums(Some(genre_id), "ideal-discography", &format!("qobuz:genre:{}:ideal", genre_id)).await
|
||||
}
|
||||
|
||||
/// Browse Genre Qobuzissime
|
||||
async fn browse_genre_qobuzissime(&self, genre_id: &str) -> Result<BrowseResult> {
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_albums(Some(genre_id), "qobuzissims")
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let parent_id = format!("qobuz:genre:{}:qobuzissime", genre_id);
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container(&parent_id).ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
self.browse_featured_albums(Some(genre_id), "qobuzissims", &format!("qobuz:genre:{}:qobuzissime", genre_id)).await
|
||||
}
|
||||
|
||||
/// Browse Genre Editor Picks
|
||||
async fn browse_genre_editor_picks(&self, genre_id: &str) -> Result<BrowseResult> {
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_albums(Some(genre_id), "editor-picks")
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let parent_id = format!("qobuz:genre:{}:editor-picks", genre_id);
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container(&parent_id).ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
self.browse_featured_albums(Some(genre_id), "editor-picks", &format!("qobuz:genre:{}:editor-picks", genre_id)).await
|
||||
}
|
||||
|
||||
/// Browse Genre Press Awards
|
||||
async fn browse_genre_press_awards(&self, genre_id: &str) -> Result<BrowseResult> {
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_albums(Some(genre_id), "press-awards")
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let parent_id = format!("qobuz:genre:{}:press-awards", genre_id);
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container(&parent_id).ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
self.browse_featured_albums(Some(genre_id), "press-awards", &format!("qobuz:genre:{}:press-awards", genre_id)).await
|
||||
}
|
||||
|
||||
/// Browse Genre Playlists
|
||||
async fn browse_genre_playlists(&self, genre_id: &str) -> Result<BrowseResult> {
|
||||
let playlists = self
|
||||
.inner
|
||||
.client
|
||||
.get_featured_playlists(Some(genre_id), None)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let parent_id = format!("qobuz:genre:{}:playlists", genre_id);
|
||||
let containers: Vec<Container> = playlists
|
||||
.into_iter()
|
||||
.filter_map(|playlist| playlist.to_didl_container(&parent_id).ok())
|
||||
.collect();
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
self.browse_featured_playlists(Some(genre_id), &format!("qobuz:genre:{}:playlists", genre_id)).await
|
||||
}
|
||||
|
||||
/// Parse object_id to determine what to browse
|
||||
@@ -1620,7 +1623,6 @@ impl MusicSource for QobuzSource {
|
||||
}
|
||||
|
||||
ObjectIdType::Playlist(playlist_id) => {
|
||||
// Get tracks in playlist
|
||||
let tracks = self
|
||||
.inner
|
||||
.client
|
||||
@@ -1628,6 +1630,7 @@ impl MusicSource for QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let tracks = self.cache_covers(tracks).await;
|
||||
let items: Vec<Item> = tracks
|
||||
.into_iter()
|
||||
.filter_map(|track| {
|
||||
@@ -1641,7 +1644,6 @@ impl MusicSource for QobuzSource {
|
||||
}
|
||||
|
||||
ObjectIdType::Artist(artist_id) => {
|
||||
// Get albums by artist
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
@@ -1649,6 +1651,7 @@ impl MusicSource for QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let albums = self.cache_covers(albums).await;
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| {
|
||||
@@ -1722,12 +1725,9 @@ impl MusicSource for QobuzSource {
|
||||
|
||||
// Get cover URL from cache if available
|
||||
let cover_url = if let Some(ref album) = track.album {
|
||||
if let Some(ref cached) = album.image_cached {
|
||||
Some(format!("{}{}", self.inner.base_url, cached))
|
||||
} else if let Some(ref image) = album.image {
|
||||
// Try to cache it
|
||||
if let Some(ref image) = album.image {
|
||||
if let Ok(pk) = self.inner.cache_manager.cache_cover(image).await {
|
||||
Some(format!("{}/covers/jpeg/{}", self.inner.base_url, pk))
|
||||
self.inner.cache_manager.cover_url(&pk, None).ok()
|
||||
} else {
|
||||
Some(image.clone())
|
||||
}
|
||||
@@ -1802,6 +1802,7 @@ impl MusicSource for QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let all_tracks = self.cache_covers(all_tracks).await;
|
||||
let items: Vec<Item> = all_tracks
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
@@ -1821,15 +1822,16 @@ impl MusicSource for QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
// Convert albums to containers and tracks to items
|
||||
let containers: Vec<Container> = results
|
||||
.albums
|
||||
let (albums, tracks) = tokio::join!(
|
||||
self.cache_covers(results.albums),
|
||||
self.cache_covers(results.tracks),
|
||||
);
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container("qobuz").ok())
|
||||
.collect();
|
||||
|
||||
let items: Vec<Item> = results
|
||||
.tracks
|
||||
let items: Vec<Item> = tracks
|
||||
.into_iter()
|
||||
.filter_map(|track| track.to_didl_item("qobuz").ok())
|
||||
.collect();
|
||||
@@ -2043,6 +2045,7 @@ impl MusicSource for QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||
|
||||
let playlists = self.cache_covers(playlists).await;
|
||||
let containers: Vec<Container> = playlists
|
||||
.into_iter()
|
||||
.filter_map(|playlist| playlist.to_didl_container("qobuz").ok())
|
||||
@@ -2125,6 +2128,7 @@ impl MusicSource for QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let albums = self.cache_covers(albums).await;
|
||||
let containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
|
||||
@@ -34,12 +34,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
if let Some(intro) = &metadata.now.intro {
|
||||
let short_intro = if intro.len() > 100 {
|
||||
format!("{}...", &intro[..100])
|
||||
} else {
|
||||
intro.clone()
|
||||
};
|
||||
println!(" Description: {}", short_intro);
|
||||
let short_intro: String = intro.chars().take(100).collect();
|
||||
let suffix = if intro.chars().count() > 100 { "..." } else { "" };
|
||||
println!(" Description: {}{}", short_intro, suffix);
|
||||
}
|
||||
|
||||
// Song info (for music stations)
|
||||
|
||||
@@ -28,17 +28,26 @@
|
||||
//! ```
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::models::{ImageSize, LiveResponse, ShowMetadata, Station, StreamSource};
|
||||
use crate::models::{
|
||||
EmbedImage, ImageSize, Line, LiveResponse, Media, PullResponse, ShowMetadata, Song, Station,
|
||||
StreamSource,
|
||||
};
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use scraper::{Html, Selector};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
use crate::config_ext::StationInfo;
|
||||
|
||||
/// Default Radio France base URL
|
||||
pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr";
|
||||
|
||||
/// Livemeta API base URL (new API since 2026)
|
||||
pub const LIVEMETA_API_URL: &str = "https://api.radiofrance.fr/livemeta/pull";
|
||||
|
||||
/// Default timeout for HTTP requests (30 seconds)
|
||||
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
@@ -56,6 +65,103 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
|
||||
("francebleu", "France Bleu"),
|
||||
];
|
||||
|
||||
/// Mapping slug → numeric station ID for the livemeta/pull API
|
||||
/// IDs extracted from www.radiofrance.fr SvelteKit page data (2026)
|
||||
pub const STATION_IDS: &[(&str, u32)] = &[
|
||||
("franceinter", 1),
|
||||
("franceinfo", 2),
|
||||
("francemusique", 4),
|
||||
("franceculture", 5),
|
||||
("mouv", 6),
|
||||
("fip", 7),
|
||||
("francebleu", 56),
|
||||
// FIP webradios
|
||||
("fip_rock", 64),
|
||||
("fip_jazz", 65),
|
||||
("fip_groove", 66),
|
||||
("fip_reggae", 71),
|
||||
("fip_electro", 74),
|
||||
("fip_metal", 77),
|
||||
("fip_pop", 78),
|
||||
("fip_world", 69),
|
||||
("fip_nouveautes", 70),
|
||||
("fip_hiphop", 95),
|
||||
("fip_sacre_francais", 96),
|
||||
("fip_cultes", 709),
|
||||
];
|
||||
|
||||
/// Icecast HiFi AAC stream URLs (stable, not from API)
|
||||
/// Format: https://icecast.radiofrance.fr/{name}-hifi.aac
|
||||
pub const STATION_STREAMS: &[(&str, &str)] = &[
|
||||
(
|
||||
"franceinter",
|
||||
"https://icecast.radiofrance.fr/franceinter-hifi.aac",
|
||||
),
|
||||
(
|
||||
"franceinfo",
|
||||
"https://icecast.radiofrance.fr/franceinfo-hifi.aac",
|
||||
),
|
||||
(
|
||||
"franceculture",
|
||||
"https://icecast.radiofrance.fr/franceculture-hifi.aac",
|
||||
),
|
||||
(
|
||||
"francemusique",
|
||||
"https://icecast.radiofrance.fr/francemusique-hifi.aac",
|
||||
),
|
||||
("fip", "https://icecast.radiofrance.fr/fip-hifi.aac"),
|
||||
("mouv", "https://icecast.radiofrance.fr/mouv-hifi.aac"),
|
||||
// FIP webradios
|
||||
(
|
||||
"fip_rock",
|
||||
"https://icecast.radiofrance.fr/fiprock-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_jazz",
|
||||
"https://icecast.radiofrance.fr/fipjazz-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_groove",
|
||||
"https://icecast.radiofrance.fr/fipgroove-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_reggae",
|
||||
"https://icecast.radiofrance.fr/fipreggae-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_electro",
|
||||
"https://icecast.radiofrance.fr/fipelectro-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_metal",
|
||||
"https://icecast.radiofrance.fr/fipmetal-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_pop",
|
||||
"https://icecast.radiofrance.fr/fippop-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_world",
|
||||
"https://icecast.radiofrance.fr/fipworld-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_nouveautes",
|
||||
"https://icecast.radiofrance.fr/fipnouveautes-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_hiphop",
|
||||
"https://icecast.radiofrance.fr/fiphiphop-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_sacre_francais",
|
||||
"https://icecast.radiofrance.fr/fipsacrefrancais-hifi.aac",
|
||||
),
|
||||
(
|
||||
"fip_cultes",
|
||||
"https://icecast.radiofrance.fr/fipcultes-hifi.aac",
|
||||
),
|
||||
];
|
||||
|
||||
/// Radio France HTTP client
|
||||
///
|
||||
/// This client provides access to Radio France's public APIs for:
|
||||
@@ -63,13 +169,23 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
|
||||
/// - Live metadata (current show, next show, stream URLs)
|
||||
/// - Image URL construction (Pikapi)
|
||||
///
|
||||
/// The client is stateless and does not cache responses internally.
|
||||
/// Caching should be handled by higher layers (e.g., config extension).
|
||||
/// The client holds an in-memory station mapping (slug → numeric ID + stream URL)
|
||||
/// seeded from hardcoded constants. Use `with_station_mapping()` to inject a
|
||||
/// mapping loaded from persistent storage (pmoconfig).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RadioFranceClient {
|
||||
pub(crate) client: Client,
|
||||
base_url: String,
|
||||
timeout: Duration,
|
||||
/// Mapping slug → { station_id, stream_url } — thread-safe, updatable at runtime
|
||||
station_mapping: Arc<RwLock<HashMap<String, StationMappingEntry>>>,
|
||||
}
|
||||
|
||||
/// Entry in the station mapping
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StationMappingEntry {
|
||||
pub station_id: u32,
|
||||
pub stream_url: String,
|
||||
}
|
||||
|
||||
impl RadioFranceClient {
|
||||
@@ -91,6 +207,7 @@ impl RadioFranceClient {
|
||||
client,
|
||||
base_url: DEFAULT_BASE_URL.to_string(),
|
||||
timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
||||
station_mapping: Arc::new(RwLock::new(Self::default_station_mapping())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +221,89 @@ impl RadioFranceClient {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Build the default station mapping from hardcoded constants
|
||||
fn default_station_mapping() -> HashMap<String, StationMappingEntry> {
|
||||
// Build a lookup for stream URLs
|
||||
let stream_map: HashMap<&str, &str> = STATION_STREAMS.iter().copied().collect();
|
||||
|
||||
STATION_IDS
|
||||
.iter()
|
||||
.map(|(slug, id)| {
|
||||
let stream_url = stream_map
|
||||
.get(slug)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
(
|
||||
slug.to_string(),
|
||||
StationMappingEntry {
|
||||
station_id: *id,
|
||||
stream_url,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remplace le mapping en mémoire par un mapping chargé depuis le cache persistant
|
||||
///
|
||||
/// Appelé au démarrage par MetadataCache pour charger le mapping pmoconfig.
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub fn set_station_mapping(&self, mapping: HashMap<String, StationInfo>) {
|
||||
let mut m = self.station_mapping.write().unwrap();
|
||||
m.clear();
|
||||
for (slug, info) in mapping {
|
||||
m.insert(
|
||||
slug,
|
||||
StationMappingEntry {
|
||||
station_id: info.station_id,
|
||||
stream_url: info.stream_url,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'ensemble du mapping courant (pour persistance dans pmoconfig)
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub fn get_station_mapping(&self) -> HashMap<String, StationInfo> {
|
||||
let m = self.station_mapping.read().unwrap();
|
||||
m.iter()
|
||||
.map(|(slug, entry)| {
|
||||
(
|
||||
slug.clone(),
|
||||
StationInfo {
|
||||
station_id: entry.station_id,
|
||||
stream_url: entry.stream_url.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Met à jour une entrée dans le mapping (utilisé après re-découverte unitaire)
|
||||
pub fn update_station_entry(&self, slug: &str, station_id: u32, stream_url: String) {
|
||||
let mut m = self.station_mapping.write().unwrap();
|
||||
m.insert(
|
||||
slug.to_string(),
|
||||
StationMappingEntry {
|
||||
station_id,
|
||||
stream_url,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne l'ID numérique pour un slug, ou None si inconnu
|
||||
pub fn station_id_for_slug(&self, slug: &str) -> Option<u32> {
|
||||
let m = self.station_mapping.read().unwrap();
|
||||
m.get(slug).map(|e| e.station_id)
|
||||
}
|
||||
|
||||
/// Retourne l'URL du stream HiFi pour un slug, ou None si inconnu
|
||||
pub fn stream_url_for_slug(&self, slug: &str) -> Option<String> {
|
||||
let m = self.station_mapping.read().unwrap();
|
||||
m.get(slug).map(|e| e.stream_url.clone())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Station Discovery
|
||||
// ========================================================================
|
||||
@@ -254,20 +454,71 @@ impl RadioFranceClient {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Discover local France Bleu radios via API
|
||||
/// Discover local France Bleu radios via SvelteKit page data
|
||||
///
|
||||
/// Uses the France Bleu /api/live? endpoint which includes
|
||||
/// a `localRadios` array in the `now` field.
|
||||
/// Scrapes francebleu page for local station slugs and discovers their IDs.
|
||||
/// Met à jour le station_mapping avec les IDs trouvés.
|
||||
pub async fn discover_local_radios(&self) -> Result<Vec<Station>> {
|
||||
let response = self.live_metadata("francebleu").await?;
|
||||
let url = format!("{}/francebleu/__data.json", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
Ok(response
|
||||
.local_radios()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
// Extraire les slugs "francebleu_xxx" et les paires (slug, id) de la page
|
||||
let re_slug_id = Regex::new(r#""(francebleu_[a-z_]+)","(\d+)""#)?;
|
||||
let mut stations = Vec::new();
|
||||
let mut found_any = false;
|
||||
|
||||
for cap in re_slug_id.captures_iter(&resp) {
|
||||
let slug = cap[1].to_string();
|
||||
if let Ok(id) = cap[2].parse::<u32>() {
|
||||
let stream_url = Self::derive_stream_url(&slug);
|
||||
self.update_station_entry(&slug, id, stream_url);
|
||||
let name = Self::slug_to_display_name(&slug);
|
||||
stations.push(Station::new(slug, name));
|
||||
found_any = true;
|
||||
}
|
||||
}
|
||||
|
||||
if found_any {
|
||||
return Ok(stations);
|
||||
}
|
||||
|
||||
// Fallback : scraper la page HTML pour les noms "ICI ..."
|
||||
let html_url = format!("{}/francebleu", self.base_url);
|
||||
let html = self
|
||||
.client
|
||||
.get(&html_url)
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
let re_ici = Regex::new(r#"ICI ([A-Za-z\u00C0-\u017E][A-Za-z\u00C0-\u017E\s-]+)"#)?;
|
||||
let re_slug = Regex::new(r#""(francebleu_[a-z_]+)""#)?;
|
||||
|
||||
let mut slugs: HashSet<String> = HashSet::new();
|
||||
for cap in re_slug.captures_iter(&html) {
|
||||
slugs.insert(cap[1].to_string());
|
||||
}
|
||||
|
||||
let _names: Vec<String> = re_ici
|
||||
.captures_iter(&html)
|
||||
.map(|c| c[1].trim().to_string())
|
||||
.collect();
|
||||
|
||||
Ok(slugs
|
||||
.into_iter()
|
||||
.filter(|local| local.is_on_air)
|
||||
.map(|local| Station::new(local.name, local.title))
|
||||
.map(|slug| {
|
||||
let name = Self::slug_to_display_name(&slug);
|
||||
Station::new(slug, name)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -370,28 +621,42 @@ impl RadioFranceClient {
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn live_metadata(&self, station: &str) -> Result<LiveResponse> {
|
||||
let (base_station, webradio) = Self::parse_station_slug(station);
|
||||
let station_id = self
|
||||
.station_id_for_slug(station)
|
||||
.ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", station)))?;
|
||||
|
||||
let mut url = Url::parse(&format!("{}/{}/api/live", self.base_url, base_station))?;
|
||||
|
||||
// Add webradio parameter if needed
|
||||
if let Some(wr) = webradio {
|
||||
url.query_pairs_mut().append_pair("webradio", wr);
|
||||
}
|
||||
let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Fetching live metadata: {}", url);
|
||||
|
||||
let response = self.client.get(url).timeout(self.timeout).send().await?;
|
||||
let response = self.client.get(&url).timeout(self.timeout).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::ApiError(format!(
|
||||
"API returned status: {}",
|
||||
response.status()
|
||||
"livemeta API returned status {} for station {} (id={})",
|
||||
response.status(),
|
||||
station,
|
||||
station_id
|
||||
)));
|
||||
}
|
||||
|
||||
let live: LiveResponse = response.json().await?;
|
||||
let pull: PullResponse = response.json().await?;
|
||||
|
||||
// Fix 1: Vérifier que l'ID retourné correspond bien à la station demandée.
|
||||
// Si Radio France réassigne les IDs, on déclenche une redécouverte.
|
||||
if pull.station_id != station_id {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!(
|
||||
"Station ID mismatch for {}: expected {}, API returned {} — triggering rediscovery",
|
||||
station, station_id, pull.station_id
|
||||
);
|
||||
// Mettre à jour le mapping avec le nouvel ID retourné par l'API
|
||||
let stream_url = self.stream_url_for_slug(station).unwrap_or_default();
|
||||
self.update_station_entry(station, pull.station_id, stream_url);
|
||||
}
|
||||
|
||||
let live = self.pull_to_live_response(station, &pull);
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!(
|
||||
@@ -404,6 +669,268 @@ impl RadioFranceClient {
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Convertit une PullResponse (nouvelle API) en LiveResponse (format interne)
|
||||
fn pull_to_live_response(&self, station_slug: &str, pull: &PullResponse) -> LiveResponse {
|
||||
let now = if let Some(step) = pull.current_step() {
|
||||
self.step_to_show_metadata(station_slug, step)
|
||||
} else {
|
||||
ShowMetadata::default()
|
||||
};
|
||||
|
||||
// delayToRefresh : on utilise end_time - now comme indicateur (min 30s)
|
||||
let delay_to_refresh = if let Some(end) = now.end_time {
|
||||
let current = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
if end > current {
|
||||
((end - current) * 1000).min(60_000) // max 60s, en ms
|
||||
} else {
|
||||
30_000
|
||||
}
|
||||
} else {
|
||||
30_000
|
||||
};
|
||||
|
||||
LiveResponse {
|
||||
station_name: station_slug.to_string(),
|
||||
delay_to_refresh,
|
||||
migrated: true,
|
||||
now,
|
||||
next: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un PullStep en ShowMetadata
|
||||
fn step_to_show_metadata(&self, station_slug: &str, step: &crate::models::PullStep) -> ShowMetadata {
|
||||
// Stream : injecté depuis le mapping en mémoire
|
||||
let stream_url = self.stream_url_for_slug(station_slug).unwrap_or_default();
|
||||
let sources = if !stream_url.is_empty() {
|
||||
vec![StreamSource {
|
||||
url: stream_url,
|
||||
broadcast_type: crate::models::BroadcastType::Live,
|
||||
format: crate::models::StreamFormat::Aac,
|
||||
bitrate: 192,
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Visual : la nouvelle API donne directement l'UUID (pas une URL complète)
|
||||
let visual_background = step.visual.as_deref().map(|uuid| EmbedImage {
|
||||
model: "EmbedImage".to_string(),
|
||||
src: ImageSize::Large.build_url(uuid),
|
||||
width: None,
|
||||
height: None,
|
||||
dominant: None,
|
||||
copyright: None,
|
||||
});
|
||||
|
||||
if step.is_song() {
|
||||
// === Radio musicale ===
|
||||
let artists = step.artists_display();
|
||||
let song = Some(Song {
|
||||
id: step.song_id.clone().unwrap_or_default(),
|
||||
year: step.annee_edition_musique,
|
||||
interpreters: if artists.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![artists.clone()]
|
||||
},
|
||||
release: crate::models::Release {
|
||||
label: step.label.clone(),
|
||||
title: step.titre_album.clone(),
|
||||
reference: None,
|
||||
},
|
||||
});
|
||||
|
||||
ShowMetadata {
|
||||
start_time: step.start,
|
||||
end_time: step.end,
|
||||
producer: step.disc_jockey.clone(),
|
||||
first_line: Line {
|
||||
title: Some(step.title.clone()),
|
||||
id: None,
|
||||
path: step.path.clone(),
|
||||
},
|
||||
second_line: Line {
|
||||
title: if artists.is_empty() { None } else { Some(artists) },
|
||||
id: None,
|
||||
path: None,
|
||||
},
|
||||
song,
|
||||
media: Media { sources },
|
||||
visual_background,
|
||||
..ShowMetadata::default()
|
||||
}
|
||||
} else {
|
||||
// === Radio parlée / émission ===
|
||||
let show_title = step.title_concept.as_deref()
|
||||
.unwrap_or(step.title.as_str())
|
||||
.to_string();
|
||||
let episode_title = if step.title_concept.is_some() {
|
||||
step.title.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let producer = step.disc_jockey.clone()
|
||||
.or_else(|| step.producers.first().map(|p| p.name.clone()));
|
||||
|
||||
ShowMetadata {
|
||||
start_time: step.start,
|
||||
end_time: step.end,
|
||||
producer,
|
||||
first_line: Line {
|
||||
title: Some(show_title),
|
||||
id: None,
|
||||
path: step.path.clone(),
|
||||
},
|
||||
second_line: Line {
|
||||
title: if episode_title.is_empty() { None } else { Some(episode_title) },
|
||||
id: None,
|
||||
path: None,
|
||||
},
|
||||
intro: step.expression_description.clone()
|
||||
.or_else(|| step.description.clone()),
|
||||
media: Media { sources },
|
||||
visual_background,
|
||||
..ShowMetadata::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tente de re-découvrir l'ID et l'URL stream d'une station depuis le web
|
||||
///
|
||||
/// Utilisé quand un stream échoue ou qu'un slug est inconnu.
|
||||
/// Met à jour le mapping en mémoire si la découverte réussit.
|
||||
pub async fn rediscover_station(&self, slug: &str) -> Result<(u32, String)> {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Rediscovering station mapping for: {}", slug);
|
||||
|
||||
// Construire l'URL de la page de la station principale
|
||||
let base_slug = slug.split('_').next().unwrap_or(slug);
|
||||
let page_url = format!("{}/{}/__data.json", self.base_url, base_slug);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&page_url)
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(Error::ApiError(format!(
|
||||
"Cannot rediscover station {}: page returned {}",
|
||||
slug,
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let text = resp.text().await?;
|
||||
|
||||
// Fix 2: chercher l'ID spécifique à la station via son brandEnum.
|
||||
// Ex: "fip_rock" → "FIP_ROCK" → cherche `"FIP_ROCK","64"` dans le JSON SvelteKit.
|
||||
// Pour les stations principales, on cherche aussi le pattern `"Brand","<id>"`.
|
||||
let brand_enum = slug.to_uppercase(); // fip_rock → FIP_ROCK
|
||||
let re_enum = Regex::new(&format!(r#""{}","(\d+)""#, regex::escape(&brand_enum)))?;
|
||||
let brand_id: Option<u32> = re_enum
|
||||
.captures_iter(&text)
|
||||
.filter_map(|c| c[1].parse::<u32>().ok())
|
||||
.next();
|
||||
|
||||
// Fallback : premier "Brand","<id>" dans la page (pour stations principales)
|
||||
let brand_id = if brand_id.is_some() {
|
||||
brand_id
|
||||
} else {
|
||||
let re_brand = Regex::new(r#""Brand","(\d+)""#)?;
|
||||
let ids: Vec<u32> = re_brand
|
||||
.captures_iter(&text)
|
||||
.filter_map(|c| c[1].parse::<u32>().ok())
|
||||
.collect();
|
||||
ids.into_iter().next()
|
||||
};
|
||||
|
||||
let station_id = brand_id.ok_or_else(|| {
|
||||
Error::ApiError(format!("Could not find station_id for {} in page data", slug))
|
||||
})?;
|
||||
|
||||
// Dériver l'URL stream depuis le slug (format icecast connu)
|
||||
let stream_url = Self::derive_stream_url(slug);
|
||||
|
||||
self.update_station_entry(slug, station_id, stream_url.clone());
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!(
|
||||
"Rediscovered station {}: id={}, stream={}",
|
||||
slug,
|
||||
station_id,
|
||||
stream_url
|
||||
);
|
||||
|
||||
Ok((station_id, stream_url))
|
||||
}
|
||||
|
||||
/// Valide qu'une station existe toujours et que son stream est accessible
|
||||
///
|
||||
/// Vérifie deux choses :
|
||||
/// 1. L'API livemeta répond avec un stationId valide (station connue de Radio France)
|
||||
/// 2. L'URL icecast répond HTTP 200 (stream physiquement disponible)
|
||||
///
|
||||
/// Retourne Ok(()) si tout est OK, Err si la station est invalide ou le stream mort.
|
||||
pub async fn validate_station(&self, slug: &str) -> Result<()> {
|
||||
// 1. Vérifier que l'API livemeta répond pour cette station
|
||||
let station_id = self
|
||||
.station_id_for_slug(slug)
|
||||
.ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", slug)))?;
|
||||
|
||||
let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
|
||||
let response = self.client.get(&url).timeout(self.timeout).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::ApiError(format!(
|
||||
"Station {} (id={}) no longer exists: livemeta returned {}",
|
||||
slug, station_id, response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let pull: PullResponse = response.json().await?;
|
||||
|
||||
// Vérifier cohérence de l'ID — si mismatch, la station a peut-être été réassignée
|
||||
if pull.station_id != station_id {
|
||||
return Err(Error::ApiError(format!(
|
||||
"Station {} ID mismatch: stored={}, API returned={} — mapping is stale",
|
||||
slug, station_id, pull.station_id
|
||||
)));
|
||||
}
|
||||
|
||||
// 2. Vérifier que l'URL icecast répond
|
||||
if let Some(stream_url) = self.stream_url_for_slug(slug) {
|
||||
if !stream_url.is_empty() {
|
||||
let stream_resp = self
|
||||
.client
|
||||
.head(&stream_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await?;
|
||||
if !stream_resp.status().is_success() {
|
||||
return Err(Error::ApiError(format!(
|
||||
"Stream for {} is not accessible: {} returned {}",
|
||||
slug, stream_url, stream_resp.status()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dérive l'URL icecast depuis le slug selon le pattern Radio France
|
||||
pub fn derive_stream_url(slug: &str) -> String {
|
||||
// fip_rock → fiprock, fip_sacre_francais → fipsacrefrancais
|
||||
let name = slug.replace('_', "");
|
||||
format!("https://icecast.radiofrance.fr/{}-hifi.aac", name)
|
||||
}
|
||||
|
||||
/// Get only the current show metadata
|
||||
pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> {
|
||||
let response = self.live_metadata(station).await?;
|
||||
@@ -464,13 +991,7 @@ impl RadioFranceClient {
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn get_hifi_stream_url(&self, station: &str) -> Result<String> {
|
||||
let metadata = self.live_metadata(station).await?;
|
||||
|
||||
metadata
|
||||
.now
|
||||
.media
|
||||
.best_hifi_stream()
|
||||
.map(|s| s.url.clone())
|
||||
self.stream_url_for_slug(station)
|
||||
.ok_or_else(|| Error::NoHifiStream(station.to_string()))
|
||||
}
|
||||
|
||||
@@ -619,6 +1140,7 @@ impl ClientBuilder {
|
||||
client,
|
||||
base_url: self.base_url,
|
||||
timeout: self.timeout,
|
||||
station_mapping: Arc::new(RwLock::new(RadioFranceClient::default_station_mapping())),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -644,7 +1166,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
RadioFranceClient::parse_station_slug("francebleu_alsace"),
|
||||
("francebleu_alsace", None)
|
||||
("francebleu", Some("francebleu_alsace"))
|
||||
);
|
||||
assert_eq!(
|
||||
RadioFranceClient::parse_station_slug("franceculture"),
|
||||
|
||||
@@ -37,11 +37,24 @@ use anyhow::Result;
|
||||
use pmoconfig::Config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Default TTL for station list cache (7 days in seconds)
|
||||
pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
|
||||
|
||||
/// Default TTL for station mapping cache (30 days — IDs and stream URLs are very stable)
|
||||
pub const DEFAULT_STATION_MAPPING_TTL_SECS: u64 = 30 * 24 * 3600;
|
||||
|
||||
/// Informations d'une station : ID numérique et URL du stream HiFi
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StationInfo {
|
||||
/// ID numérique pour l'API livemeta/pull
|
||||
pub station_id: u32,
|
||||
/// URL du stream HiFi (icecast AAC ou MP3)
|
||||
pub stream_url: String,
|
||||
}
|
||||
|
||||
/// Cached station list (simplifié)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CachedStations {
|
||||
@@ -49,6 +62,14 @@ struct CachedStations {
|
||||
last_updated: u64, // Unix timestamp
|
||||
}
|
||||
|
||||
/// Mapping slug → StationInfo avec timestamp de mise à jour
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CachedStationMapping {
|
||||
/// slug → { station_id, stream_url }
|
||||
mapping: HashMap<String, StationInfo>,
|
||||
last_updated: u64, // Unix timestamp
|
||||
}
|
||||
|
||||
/// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
|
||||
///
|
||||
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
|
||||
@@ -113,6 +134,36 @@ pub trait RadioFranceConfigExt {
|
||||
|
||||
/// Efface le cache des stations (force re-découverte)
|
||||
fn clear_radiofrance_station_cache(&self) -> Result<()>;
|
||||
|
||||
// ========================================================================
|
||||
// Station Mapping Cache (slug → station_id + stream_url)
|
||||
// ========================================================================
|
||||
|
||||
/// Récupère le mapping slug → StationInfo depuis le cache (si valide)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Some(HashMap)` si le cache existe et n'est pas expiré
|
||||
/// - `None` si absent ou TTL dépassé → il faut re-découvrir
|
||||
fn get_radiofrance_station_mapping(&self) -> Result<Option<HashMap<String, StationInfo>>>;
|
||||
|
||||
/// Enregistre le mapping slug → StationInfo en cache
|
||||
fn set_radiofrance_station_mapping(&self, mapping: &HashMap<String, StationInfo>)
|
||||
-> Result<()>;
|
||||
|
||||
/// Récupère le TTL du mapping (en secondes, défaut 30 jours)
|
||||
fn get_radiofrance_mapping_ttl(&self) -> Result<u64>;
|
||||
|
||||
/// Définit le TTL du mapping (en secondes)
|
||||
fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()>;
|
||||
|
||||
/// Efface le cache du mapping (force re-découverte au prochain accès)
|
||||
fn clear_radiofrance_station_mapping(&self) -> Result<()>;
|
||||
|
||||
/// Met à jour une seule entrée dans le mapping (sans invalider tout le cache)
|
||||
///
|
||||
/// Utilisé pour la mise à jour incrémentale lors d'une re-découverte unitaire.
|
||||
fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()>;
|
||||
}
|
||||
|
||||
impl RadioFranceConfigExt for Config {
|
||||
@@ -201,6 +252,90 @@ impl RadioFranceConfigExt for Config {
|
||||
// Set to null to clear
|
||||
self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null)
|
||||
}
|
||||
|
||||
fn get_radiofrance_station_mapping(&self) -> Result<Option<HashMap<String, StationInfo>>> {
|
||||
let ttl = self.get_radiofrance_mapping_ttl()?;
|
||||
|
||||
match self.get_value(&["sources", "radiofrance", "station_mapping"]) {
|
||||
Ok(value) => {
|
||||
let cached: CachedStationMapping = serde_yaml::from_value(value)?;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
if now - cached.last_updated < ttl {
|
||||
Ok(Some(cached.mapping))
|
||||
} else {
|
||||
Ok(None) // Expiré
|
||||
}
|
||||
}
|
||||
Err(_) => Ok(None), // Absent
|
||||
}
|
||||
}
|
||||
|
||||
fn set_radiofrance_station_mapping(
|
||||
&self,
|
||||
mapping: &HashMap<String, StationInfo>,
|
||||
) -> Result<()> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let cached = CachedStationMapping {
|
||||
mapping: mapping.clone(),
|
||||
last_updated: now,
|
||||
};
|
||||
|
||||
let value = serde_yaml::to_value(&cached)?;
|
||||
self.set_value(&["sources", "radiofrance", "station_mapping"], value)
|
||||
}
|
||||
|
||||
fn get_radiofrance_mapping_ttl(&self) -> Result<u64> {
|
||||
match self.get_value(&["sources", "radiofrance", "station_mapping_ttl_secs"]) {
|
||||
Ok(Value::Number(n)) => {
|
||||
if let Some(ttl) = n.as_u64() {
|
||||
Ok(ttl)
|
||||
} else {
|
||||
self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?;
|
||||
Ok(DEFAULT_STATION_MAPPING_TTL_SECS)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?;
|
||||
Ok(DEFAULT_STATION_MAPPING_TTL_SECS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()> {
|
||||
self.set_value(
|
||||
&["sources", "radiofrance", "station_mapping_ttl_secs"],
|
||||
Value::Number(serde_yaml::Number::from(ttl_secs)),
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_radiofrance_station_mapping(&self) -> Result<()> {
|
||||
self.set_value(
|
||||
&["sources", "radiofrance", "station_mapping"],
|
||||
Value::Null,
|
||||
)
|
||||
}
|
||||
|
||||
fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()> {
|
||||
// Charger le mapping existant (même expiré) pour mise à jour incrémentale
|
||||
let mut mapping = match self.get_value(&["sources", "radiofrance", "station_mapping"]) {
|
||||
Ok(value) => serde_yaml::from_value::<CachedStationMapping>(value)
|
||||
.map(|c| c.mapping)
|
||||
.unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
};
|
||||
|
||||
mapping.insert(slug.to_string(), info);
|
||||
|
||||
self.set_radiofrance_station_mapping(&mapping)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -122,7 +122,7 @@ pub use models::{
|
||||
};
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub use config_ext::RadioFranceConfigExt;
|
||||
pub use config_ext::{RadioFranceConfigExt, StationInfo};
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback};
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
//! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements
|
||||
|
||||
use crate::client::RadioFranceClient;
|
||||
use crate::config_ext::{RadioFranceConfigExt, StationInfo};
|
||||
use crate::error::Result;
|
||||
use crate::models::{ImageSize, LiveResponse, Station};
|
||||
use pmoconfig::Config;
|
||||
#[cfg(feature = "playlist")]
|
||||
use pmodidl::{Container, Item, Resource};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -59,6 +61,9 @@ pub struct CachedMetadata {
|
||||
|
||||
// TTL = end_time de l'API Radio France
|
||||
pub end_time: Option<u64>, // Unix timestamp
|
||||
|
||||
/// Timestamp de la dernière récupération (pour TTL minimum)
|
||||
pub fetched_at: u64,
|
||||
}
|
||||
|
||||
impl CachedMetadata {
|
||||
@@ -89,6 +94,10 @@ impl CachedMetadata {
|
||||
|
||||
// 4. TTL = end_time
|
||||
let end_time = live.now.end_time;
|
||||
let fetched_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
Ok(Self {
|
||||
slug: station.slug.clone(),
|
||||
@@ -106,6 +115,7 @@ impl CachedMetadata {
|
||||
nr_audio_channels,
|
||||
duration,
|
||||
end_time,
|
||||
fetched_at,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,15 +248,16 @@ impl CachedMetadata {
|
||||
match cache.add_from_url(&cover_url, Some("radiofrance")).await {
|
||||
Ok(pk) => {
|
||||
// Construire l'URL publique
|
||||
let route = cache.route_for(&pk, None);
|
||||
let public_url = format!("{}{}", server_base_url.trim_end_matches('/'), route);
|
||||
// Note: add_from_url() lance le téléchargement complet en arrière-plan.
|
||||
// L'URL est valide immédiatement — si le fichier n'est pas encore prêt,
|
||||
// le client web doit réessayer (retry avec backoff).
|
||||
let public_url = pmocache::covers_absolute_url_for(&pk, None);
|
||||
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!(
|
||||
"Cached cover - UUID: {}, PK: {}, route: {}, public_url: {}",
|
||||
"Cached cover - UUID: {}, PK: {}, public_url: {}",
|
||||
uuid,
|
||||
pk,
|
||||
route,
|
||||
public_url
|
||||
);
|
||||
|
||||
@@ -349,6 +360,7 @@ impl CachedMetadata {
|
||||
/// Construit un Container DIDL de playlist à un item
|
||||
///
|
||||
/// La playlist et l'item ont EXACTEMENT les mêmes métadonnées
|
||||
#[cfg(feature = "playlist")]
|
||||
pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container {
|
||||
// Calculer la duration dynamiquement (temps restant jusqu'à end_time)
|
||||
let duration = if let Some(end) = self.end_time {
|
||||
@@ -432,17 +444,23 @@ impl CachedMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// TTL minimum quand l'API ne fournit pas de end_time (3 minutes)
|
||||
pub const FALLBACK_TTL_SECS: u64 = 3 * 60;
|
||||
|
||||
/// Vérifie si le TTL est dépassé
|
||||
///
|
||||
/// - Si `end_time` est fourni par l'API : expire exactement quand l'émission se termine
|
||||
/// - Sinon : expire après `FALLBACK_TTL_SECS` depuis le dernier fetch
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
if let Some(end_time) = self.end_time {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
now >= end_time
|
||||
} else {
|
||||
// Pas de end_time = toujours expiré (refresh systématique)
|
||||
true
|
||||
now >= self.fetched_at + Self::FALLBACK_TTL_SECS
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,6 +496,9 @@ pub struct MetadataCache {
|
||||
|
||||
impl MetadataCache {
|
||||
/// Constructeur avec tous les paramètres obligatoires
|
||||
///
|
||||
/// Charge automatiquement le mapping station depuis pmoconfig (si valide).
|
||||
/// Si le cache est absent ou expiré, utilise les valeurs hardcodées du client.
|
||||
#[cfg(feature = "cache")]
|
||||
pub fn new(
|
||||
client: RadioFranceClient,
|
||||
@@ -485,6 +506,25 @@ impl MetadataCache {
|
||||
server_base_url: String,
|
||||
config: Arc<Config>,
|
||||
) -> Self {
|
||||
// Charger le mapping depuis pmoconfig et l'injecter dans le client
|
||||
if let Ok(Some(mapping)) = config.get_radiofrance_station_mapping() {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!(
|
||||
"Loaded station mapping from config ({} entries)",
|
||||
mapping.len()
|
||||
);
|
||||
client.set_station_mapping(mapping);
|
||||
} else {
|
||||
// Pas de cache persistant : persister le mapping par défaut
|
||||
let mapping = client.get_station_mapping();
|
||||
let _ = config.set_radiofrance_station_mapping(&mapping);
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!(
|
||||
"Initialized station mapping from defaults ({} entries)",
|
||||
mapping.len()
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
client,
|
||||
@@ -525,18 +565,46 @@ impl MetadataCache {
|
||||
let live_response = match self.client.live_metadata(slug).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
// Graceful degradation: retourner les données expirées si API down
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(metadata) = cache.get(slug) {
|
||||
// Si station inconnue → tenter une re-découverte du mapping
|
||||
let is_unknown = e.to_string().contains("Unknown station slug");
|
||||
if is_unknown {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!(
|
||||
"API Radio France down for {}, using expired cache: {}",
|
||||
slug,
|
||||
e
|
||||
);
|
||||
return Ok(metadata.clone());
|
||||
tracing::warn!("Unknown station '{}', attempting rediscovery", slug);
|
||||
|
||||
match self.client.rediscover_station(slug).await {
|
||||
Ok(_) => {
|
||||
// Persister le mapping mis à jour
|
||||
self.persist_station_mapping();
|
||||
// Réessayer la requête
|
||||
match self.client.live_metadata(slug).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e2) => return Err(e2),
|
||||
}
|
||||
}
|
||||
Err(discover_err) => {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::error!(
|
||||
"Rediscovery failed for '{}': {}",
|
||||
slug,
|
||||
discover_err
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Graceful degradation: retourner les données expirées si API down
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(metadata) = cache.get(slug) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!(
|
||||
"API Radio France down for {}, using expired cache: {}",
|
||||
slug,
|
||||
e
|
||||
);
|
||||
return Ok(metadata.clone());
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -575,17 +643,34 @@ impl MetadataCache {
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Récupère la liste des stations (cache persistant via pmoconfig)
|
||||
/// Récupère la liste des stations (cache persistant via pmoconfig, TTL 7 jours)
|
||||
///
|
||||
/// # Logique
|
||||
///
|
||||
/// 1. Essaie de lire depuis pmoconfig
|
||||
/// 2. Si cache valide (TTL 1 semaine), retourne
|
||||
/// 3. Sinon, découvre via API et met à jour pmoconfig
|
||||
/// 1. Essaie de lire depuis pmoconfig (cache TTL 7 jours)
|
||||
/// 2. Si cache valide, retourne sans appel réseau
|
||||
/// 3. Sinon, découvre via scraping et met à jour pmoconfig
|
||||
pub async fn get_stations(&self) -> Result<Vec<Station>> {
|
||||
// TODO: Implémenter avec config_ext
|
||||
// Pour l'instant, découvre directement
|
||||
self.client.discover_all_stations().await
|
||||
// 1. Essayer le cache persistant
|
||||
if let Ok(Some(stations)) = self.config.get_radiofrance_stations_cached() {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::debug!("Using cached station list ({} stations)", stations.len());
|
||||
return Ok(stations);
|
||||
}
|
||||
|
||||
// 2. Cache miss : découverte via scraping
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!("Station cache miss, discovering via web scraping...");
|
||||
|
||||
let stations = self.client.discover_all_stations().await?;
|
||||
|
||||
// 3. Persister dans pmoconfig
|
||||
if let Err(e) = self.config.set_radiofrance_cached_stations(&stations) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Failed to cache station list: {}", e);
|
||||
}
|
||||
|
||||
Ok(stations)
|
||||
}
|
||||
|
||||
/// Récupère les métadonnées live brutes de l'API (sans cache)
|
||||
@@ -601,6 +686,60 @@ impl MetadataCache {
|
||||
self.client.get_hifi_stream_url(slug).await
|
||||
}
|
||||
|
||||
/// Force une re-découverte du mapping pour un slug donné
|
||||
///
|
||||
/// Utilisé quand un stream retourne une erreur HTTP (404, connexion refusée, etc.)
|
||||
/// Persiste le mapping mis à jour dans pmoconfig.
|
||||
pub async fn handle_stream_failure(&self, slug: &str) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Stream failure for '{}', triggering rediscovery", slug);
|
||||
|
||||
match self.client.rediscover_station(slug).await {
|
||||
Ok((id, url)) => {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::info!(
|
||||
"Re-discovered station '{}': id={}, url={}",
|
||||
slug,
|
||||
id,
|
||||
url
|
||||
);
|
||||
self.persist_station_mapping();
|
||||
// Invalider le cache mémoire pour forcer un refresh des métadonnées
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.remove(slug);
|
||||
}
|
||||
Err(e) => {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::error!("Rediscovery failed for '{}': {}", slug, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persiste le mapping courant dans pmoconfig
|
||||
fn persist_station_mapping(&self) {
|
||||
let mapping = self.client.get_station_mapping();
|
||||
if let Err(e) = self.config.set_radiofrance_station_mapping(&mapping) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Failed to persist station mapping: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour manuellement le mapping d'une station et le persiste
|
||||
pub fn update_station_mapping(&self, slug: &str, station_id: u32, stream_url: String) {
|
||||
self.client
|
||||
.update_station_entry(slug, station_id, stream_url.clone());
|
||||
|
||||
// Persister dans pmoconfig
|
||||
let info = StationInfo {
|
||||
station_id,
|
||||
stream_url,
|
||||
};
|
||||
if let Err(e) = self.config.upsert_radiofrance_station_info(slug, info) {
|
||||
#[cfg(feature = "logging")]
|
||||
tracing::warn!("Failed to upsert station info for '{}': {}", slug, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// S'abonner aux changements de métadonnées
|
||||
///
|
||||
/// Le callback sera appelé avec le slug chaque fois que
|
||||
@@ -634,7 +773,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cached_metadata_is_expired() {
|
||||
let metadata = CachedMetadata {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let base = CachedMetadata {
|
||||
slug: "test".to_string(),
|
||||
title: "Test".to_string(),
|
||||
creator: None,
|
||||
@@ -650,15 +794,26 @@ mod tests {
|
||||
nr_audio_channels: None,
|
||||
duration: None,
|
||||
end_time: Some(0), // Dans le passé
|
||||
fetched_at: now,
|
||||
};
|
||||
|
||||
assert!(metadata.is_expired());
|
||||
// end_time dans le passé → expiré
|
||||
assert!(base.is_expired());
|
||||
|
||||
let metadata_no_end = CachedMetadata {
|
||||
// end_time dans le futur → non expiré
|
||||
let not_expired = CachedMetadata { end_time: Some(now + 3600), ..base.clone() };
|
||||
assert!(!not_expired.is_expired());
|
||||
|
||||
// Pas de end_time, fetched_at récent → non expiré (fallback TTL)
|
||||
let no_end_fresh = CachedMetadata { end_time: None, fetched_at: now, ..base.clone() };
|
||||
assert!(!no_end_fresh.is_expired());
|
||||
|
||||
// Pas de end_time, fetched_at ancien → expiré
|
||||
let no_end_old = CachedMetadata {
|
||||
end_time: None,
|
||||
..metadata
|
||||
fetched_at: now - CachedMetadata::FALLBACK_TTL_SECS - 1,
|
||||
..base
|
||||
};
|
||||
|
||||
assert!(metadata_no_end.is_expired());
|
||||
assert!(no_end_old.is_expired());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,14 +31,117 @@ impl Station {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live API Response Models
|
||||
// New livemeta/pull API Models (api.radiofrance.fr)
|
||||
// ============================================================================
|
||||
|
||||
/// Response from the /api/live? endpoint
|
||||
/// Response from https://api.radiofrance.fr/livemeta/pull/{stationId}
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullResponse {
|
||||
/// Numeric station ID
|
||||
pub station_id: u32,
|
||||
/// Map of stepId -> step metadata
|
||||
pub steps: std::collections::HashMap<String, PullStep>,
|
||||
/// Ordered levels (depth 1 = current show/song)
|
||||
pub levels: Vec<PullLevel>,
|
||||
}
|
||||
|
||||
impl PullResponse {
|
||||
/// Get the current step at depth 1 (the "now playing" item)
|
||||
pub fn current_step(&self) -> Option<&PullStep> {
|
||||
// levels[0].items[0] is the current item at depth 1 (most relevant)
|
||||
let level = self.levels.first()?;
|
||||
let step_id = level.items.first()?;
|
||||
self.steps.get(step_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A level in the livemeta response (groups steps by depth)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct PullLevel {
|
||||
/// Ordered list of step IDs at this level
|
||||
pub items: Vec<String>,
|
||||
/// Depth (1 = show/song, 2 = sub-item, 3 = deeper)
|
||||
pub position: u32,
|
||||
}
|
||||
|
||||
/// A step (show, episode, or song) from the livemeta pull API
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullStep {
|
||||
pub uuid: String,
|
||||
pub step_id: String,
|
||||
pub title: String,
|
||||
pub start: Option<u64>,
|
||||
pub end: Option<u64>,
|
||||
pub station_id: u32,
|
||||
pub embed_type: Option<String>, // "song", "expression", "concept"
|
||||
pub depth: u32,
|
||||
pub disc_jockey: Option<String>,
|
||||
/// For songs: authors
|
||||
#[serde(default)]
|
||||
pub authors: serde_json::Value, // can be String or Vec<String>
|
||||
#[serde(default)]
|
||||
pub performers: Option<String>,
|
||||
#[serde(default)]
|
||||
pub highlighted_artists: Vec<String>,
|
||||
pub song_id: Option<String>,
|
||||
pub titre_album: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub annee_edition_musique: Option<u32>,
|
||||
pub cover_uuid: Option<String>,
|
||||
/// Direct UUID for visual (new API uses UUID directly, not URL)
|
||||
pub visual: Option<String>,
|
||||
/// For shows: concept title (e.g. "La Science, CQFD")
|
||||
pub title_concept: Option<String>,
|
||||
/// For shows: producers list
|
||||
#[serde(default)]
|
||||
pub producers: Vec<PullProducer>,
|
||||
pub path: Option<String>,
|
||||
pub expression_description: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl PullStep {
|
||||
/// Returns true if this step is a music track
|
||||
pub fn is_song(&self) -> bool {
|
||||
self.embed_type.as_deref() == Some("song")
|
||||
}
|
||||
|
||||
/// Get artist display string
|
||||
pub fn artists_display(&self) -> String {
|
||||
if !self.highlighted_artists.is_empty() {
|
||||
return self.highlighted_artists.join(", ");
|
||||
}
|
||||
match &self.authors {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Array(arr) => arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A producer in a PullStep
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct PullProducer {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live API Response Models (internal representation)
|
||||
// ============================================================================
|
||||
|
||||
/// Internal live metadata representation
|
||||
/// Built from PullResponse (new API) or used directly in tests
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveResponse {
|
||||
/// Station name (slug)
|
||||
/// Station slug
|
||||
pub station_name: String,
|
||||
/// Recommended delay before next refresh (milliseconds)
|
||||
pub delay_to_refresh: u64,
|
||||
@@ -59,7 +162,7 @@ impl LiveResponse {
|
||||
}
|
||||
|
||||
/// Metadata for a show or track currently playing
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShowMetadata {
|
||||
/// Whether to display music program info
|
||||
|
||||
@@ -98,18 +98,26 @@ impl StationGroups {
|
||||
|
||||
/// Niveau 0: to_didl() retourne le container "radiofrance" avec tous les groupes
|
||||
///
|
||||
/// Appelle to_stub() sur chaque StationGroup
|
||||
/// Appelle to_stub() sur chaque StationGroup en parallèle
|
||||
pub async fn to_didl(
|
||||
&self,
|
||||
metadata_cache: &MetadataCache,
|
||||
server_base_url: &str,
|
||||
) -> Result<Container> {
|
||||
let mut containers = Vec::new();
|
||||
use futures::stream::{self, StreamExt};
|
||||
|
||||
for group in &self.groups {
|
||||
let container = group.to_stub(metadata_cache, server_base_url).await?;
|
||||
containers.push(container);
|
||||
}
|
||||
let futures: Vec<_> = self
|
||||
.groups
|
||||
.iter()
|
||||
.map(|group| group.to_stub(metadata_cache, server_base_url))
|
||||
.collect();
|
||||
|
||||
let results: Vec<Result<Container>> = stream::iter(futures)
|
||||
.buffer_unordered(8)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
let containers: Vec<Container> = results.into_iter().collect::<Result<Vec<_>>>()?;
|
||||
|
||||
Ok(Container {
|
||||
id: "radiofrance".to_string(),
|
||||
|
||||
@@ -50,6 +50,7 @@ impl RadioFranceState {
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait RadioFranceExt {
|
||||
/// Initialise l'extension Radio France et enregistre les routes HTTP
|
||||
///
|
||||
|
||||
@@ -114,14 +114,16 @@ impl Server {
|
||||
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self {
|
||||
let api_registry = Arc::new(RwLock::new(Vec::new()));
|
||||
|
||||
let base_url = base_url.into();
|
||||
|
||||
// Créer le router initial avec l'endpoint de registre
|
||||
let registry_route = Router::new()
|
||||
.route("/api/registry", get(get_api_registry))
|
||||
.with_state(api_registry.clone());
|
||||
|
||||
Self {
|
||||
let server = Self {
|
||||
name: name.into(),
|
||||
base_url: base_url.into(),
|
||||
base_url,
|
||||
http_port,
|
||||
router: Arc::new(RwLock::new(registry_route)),
|
||||
api_router: Arc::new(RwLock::new(None)),
|
||||
@@ -129,7 +131,14 @@ impl Server {
|
||||
log_state: None,
|
||||
api_registry,
|
||||
shutdown_token: CancellationToken::new(),
|
||||
}
|
||||
};
|
||||
|
||||
// Initialiser PMO_SERVER_URL avec l'URL complète (incluant le port).
|
||||
// base_url() normalise l'URL en ajoutant le port si absent.
|
||||
// SAFETY: appelé une seule fois au démarrage du serveur, avant tout thread concurrent.
|
||||
unsafe { std::env::set_var("PMO_SERVER_URL", server.base_url()) };
|
||||
|
||||
server
|
||||
}
|
||||
|
||||
pub fn new_configured() -> Self {
|
||||
|
||||
@@ -919,7 +919,7 @@ async fn stream_source_item_metadata(
|
||||
Query(params): Query<ObjectQuery>,
|
||||
) -> impl IntoResponse {
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures::stream::{self, Stream};
|
||||
use futures::stream;
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
use tokio_stream::StreamExt as _;
|
||||
|
||||
@@ -6,7 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmodidl = { path = "../pmodidl"}
|
||||
pmoutils = { path = "../pmoutils" }
|
||||
pmoutils = { version = "0.1.2", registry = "pmo" }
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] }
|
||||
|
||||
@@ -54,18 +54,10 @@ pub fn get_audio_cache() -> Option<Arc<AudioCache>> {
|
||||
/// // url = "http://localhost:8080/covers/images/abc123/300"
|
||||
/// ```
|
||||
pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String> {
|
||||
// Récupérer l'URL de base depuis la variable d'environnement ou une config
|
||||
let base_url =
|
||||
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
let cache = get_cover_cache().ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?;
|
||||
|
||||
let param = match size {
|
||||
Some(size_) => Some(size_.to_string()),
|
||||
None => None,
|
||||
};
|
||||
let route = cache.route_for(pk, param.as_deref());
|
||||
Ok(format!("{}{}", base_url, route))
|
||||
Ok(pmocache::covers_absolute_url_for(
|
||||
pk,
|
||||
size.map(|s| s.to_string()).as_deref(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Construit l'URL complète pour une piste audio
|
||||
@@ -84,12 +76,6 @@ pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String>
|
||||
/// // url = "http://localhost:8080/audio/tracks/abc123/stream"
|
||||
/// ```
|
||||
pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result<String> {
|
||||
// Récupérer l'URL de base depuis la variable d'environnement ou une config
|
||||
let base_url =
|
||||
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
let cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
|
||||
|
||||
let route = cache.route_for(pk, param);
|
||||
Ok(format!("{}{}", base_url, route))
|
||||
Ok(cache.absolute_url_for(pk, param))
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ pub trait UpnpServerExt {
|
||||
async fn register_device(
|
||||
&mut self,
|
||||
device: Arc<Device>,
|
||||
with_ssdp: bool,
|
||||
) -> Result<Arc<DeviceInstance>, DeviceError>;
|
||||
|
||||
/// Retourne le nombre de devices enregistrés.
|
||||
@@ -232,6 +233,7 @@ impl UpnpServerExt for Server {
|
||||
async fn register_device(
|
||||
&mut self,
|
||||
device: Arc<Device>,
|
||||
with_ssdp: bool,
|
||||
) -> Result<Arc<DeviceInstance>, DeviceError> {
|
||||
use tracing::info;
|
||||
|
||||
@@ -259,8 +261,8 @@ impl UpnpServerExt for Server {
|
||||
.register(di.clone())
|
||||
.map_err(|e| DeviceError::UrlRegistrationError(e))?;
|
||||
|
||||
// Annoncer via SSDP (si initialisé)
|
||||
if self.ssdp_enabled() {
|
||||
// Annoncer via SSDP (si initialisé et demandé)
|
||||
if with_ssdp && self.ssdp_enabled() {
|
||||
let ssdp_opt = SSDP_SERVER.read().unwrap();
|
||||
if let Some(ref ssdp) = *ssdp_opt {
|
||||
use crate::config_ext::UpnpConfigExt;
|
||||
@@ -428,7 +430,7 @@ impl UpnpServerExt for Server {
|
||||
let kind = e.kind();
|
||||
if kind == std::io::ErrorKind::AddrInUse {
|
||||
let port = crate::ssdp::SSDP_PORT;
|
||||
if let Some(process) = find_process_using_port(port, TransportProtocol::Udp) {
|
||||
if let Some(process) = find_process_using_port(TransportProtocol::Udp, port) {
|
||||
error!(
|
||||
"❌ SSDP initialization failed: port {} is already in use by \
|
||||
PID {} ({}) owned by {}: {}",
|
||||
@@ -507,7 +509,7 @@ mod tests {
|
||||
"Test Renderer".to_string(),
|
||||
));
|
||||
|
||||
let instance = server.register_device(device).await.unwrap();
|
||||
let instance = server.register_device(device, false).await.unwrap();
|
||||
|
||||
// Vérifier que le device est dans le registre
|
||||
assert_eq!(server.device_count(), 1);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
[package]
|
||||
name = "pmoutils"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
get_if_addrs = "0.5.3"
|
||||
os_info = "3.8"
|
||||
netstat2 = "0.11"
|
||||
sysinfo = "0.30"
|
||||
users = "0.11"
|
||||
quick-xml = { workspace = true }
|
||||
xmltree = "0.10"
|
||||
@@ -1,13 +0,0 @@
|
||||
[package]
|
||||
name = "pmoutils"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
get_if_addrs = "0.5.3"
|
||||
os_info = "3.8"
|
||||
netstat2 = "0.11"
|
||||
sysinfo = "0.30"
|
||||
users = "0.11"
|
||||
quick-xml = "0.38.3"
|
||||
xmltree = "0.10"
|
||||
@@ -1,273 +0,0 @@
|
||||
use get_if_addrs::get_if_addrs;
|
||||
use std::net::UdpSocket;
|
||||
|
||||
/// Devine l'adresse IP locale de la machine.
|
||||
///
|
||||
/// Cette fonction tente de déterminer l'adresse IP locale en créant une connexion UDP
|
||||
/// vers un serveur DNS public (8.8.8.8). Cette technique permet d'identifier l'interface
|
||||
/// réseau qui serait utilisée pour communiquer avec Internet.
|
||||
///
|
||||
/// # Fonctionnement
|
||||
///
|
||||
/// 1. Crée un socket UDP lié à `0.0.0.0:0` (n'importe quelle interface, port aléatoire)
|
||||
/// 2. Tente une connexion (non effective pour UDP) vers `8.8.8.8:80`
|
||||
/// 3. Récupère l'adresse IP locale du socket
|
||||
/// 4. En cas d'échec à n'importe quelle étape, retourne `127.0.0.1`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Retourne l'adresse IP locale sous forme de `String`, ou `"127.0.0.1"` en cas d'erreur.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoutils::guess_local_ip;
|
||||
///
|
||||
/// let ip = guess_local_ip();
|
||||
/// println!("IP locale détectée: {}", ip);
|
||||
/// // Affiche par exemple: "IP locale détectée: 192.168.1.42"
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Cette méthode ne crée pas de véritable connexion réseau (UDP est sans connexion),
|
||||
/// elle demande simplement au système d'exploitation quelle interface serait utilisée
|
||||
/// pour joindre l'adresse cible.
|
||||
pub fn guess_local_ip() -> String {
|
||||
match UdpSocket::bind("0.0.0.0:0") {
|
||||
Ok(socket) => {
|
||||
if socket.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(local_addr) = socket.local_addr() {
|
||||
return local_addr.ip().to_string();
|
||||
}
|
||||
}
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
Err(_) => "127.0.0.1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Liste toutes les adresses IP non-loopback des interfaces réseau.
|
||||
///
|
||||
/// Parcourt toutes les interfaces réseau de la machine et collecte leurs adresses IPv4,
|
||||
/// en excluant les adresses de loopback (127.0.0.1).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Retourne une `HashMap` où :
|
||||
/// - **Clé** : nom de l'interface réseau (ex: `"eth0"`, `"wlan0"`, `"en0"`)
|
||||
/// - **Valeur** : vecteur des adresses IP (format String) associées à cette interface
|
||||
///
|
||||
/// En cas d'erreur lors de la récupération des interfaces, retourne une HashMap
|
||||
/// contenant une entrée `"error"` avec un message d'erreur.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoutils::ip_utils::list_all_ips;
|
||||
///
|
||||
/// let ips = list_all_ips();
|
||||
/// for (interface, addresses) in ips {
|
||||
/// println!("Interface {}: {:?}", interface, addresses);
|
||||
/// }
|
||||
/// // Affiche par exemple:
|
||||
/// // Interface eth0: ["192.168.1.42"]
|
||||
/// // Interface wlan0: ["10.0.0.15"]
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// - Seules les adresses IPv4 sont retournées
|
||||
/// - Les adresses de loopback (127.x.x.x) sont filtrées
|
||||
/// - Les adresses IPv6 sont ignorées
|
||||
pub fn list_all_ips() -> std::collections::HashMap<String, Vec<String>> {
|
||||
let mut result = std::collections::HashMap::new();
|
||||
|
||||
if let Ok(interfaces) = get_if_addrs() {
|
||||
for iface in interfaces {
|
||||
let ip = iface.ip();
|
||||
if ip.is_loopback() {
|
||||
continue;
|
||||
}
|
||||
if ip.is_ipv4() {
|
||||
result
|
||||
.entry(iface.name)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(ip.to_string());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.insert(
|
||||
"error".to_string(),
|
||||
vec!["Failed to get interfaces".to_string()],
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::IpAddr;
|
||||
|
||||
#[test]
|
||||
fn test_guess_local_ip_returns_valid_ip() {
|
||||
let ip = guess_local_ip();
|
||||
|
||||
// Vérifie que le résultat est parsable comme une IP
|
||||
assert!(
|
||||
ip.parse::<IpAddr>().is_ok(),
|
||||
"Should return a valid IP address"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guess_local_ip_not_empty() {
|
||||
let ip = guess_local_ip();
|
||||
|
||||
assert!(!ip.is_empty(), "IP should not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guess_local_ip_is_ipv4() {
|
||||
let ip = guess_local_ip();
|
||||
|
||||
if let Ok(parsed_ip) = ip.parse::<IpAddr>() {
|
||||
assert!(parsed_ip.is_ipv4(), "Should return an IPv4 address");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guess_local_ip_fallback_is_localhost() {
|
||||
// Ce test vérifie que si aucune IP n'est trouvée, on retourne 127.0.0.1
|
||||
// (difficile à tester sans mocker, mais on vérifie la cohérence)
|
||||
let ip = guess_local_ip();
|
||||
let parsed = ip.parse::<IpAddr>().unwrap();
|
||||
|
||||
// L'IP doit être soit locale (127.0.0.1) soit une IP privée valide
|
||||
assert!(
|
||||
parsed.is_loopback() || is_private_ip(&ip),
|
||||
"IP should be either loopback or private"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_all_ips_no_loopback() {
|
||||
let ips = list_all_ips();
|
||||
|
||||
// Vérifie qu'aucune adresse de loopback n'est présente
|
||||
for (_, addresses) in ips.iter() {
|
||||
for addr in addresses {
|
||||
if let Ok(parsed_ip) = addr.parse::<IpAddr>() {
|
||||
assert!(
|
||||
!parsed_ip.is_loopback(),
|
||||
"Loopback addresses should be filtered out"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_all_ips_only_ipv4() {
|
||||
let ips = list_all_ips();
|
||||
|
||||
// Vérifie que seules des adresses IPv4 sont retournées
|
||||
for (iface_name, addresses) in ips.iter() {
|
||||
if iface_name == "error" {
|
||||
continue; // Skip error entries
|
||||
}
|
||||
|
||||
for addr in addresses {
|
||||
if let Ok(parsed_ip) = addr.parse::<IpAddr>() {
|
||||
assert!(
|
||||
parsed_ip.is_ipv4(),
|
||||
"Only IPv4 addresses should be returned"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_all_ips_valid_format() {
|
||||
let ips = list_all_ips();
|
||||
|
||||
// Vérifie que toutes les IPs sont dans un format valide
|
||||
for (iface_name, addresses) in ips.iter() {
|
||||
if iface_name == "error" {
|
||||
continue;
|
||||
}
|
||||
|
||||
for addr in addresses {
|
||||
assert!(
|
||||
addr.parse::<IpAddr>().is_ok(),
|
||||
"Each IP should be in valid format: {}",
|
||||
addr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_all_ips_interface_names_not_empty() {
|
||||
let ips = list_all_ips();
|
||||
|
||||
// Vérifie que les noms d'interface ne sont pas vides
|
||||
for (iface_name, _) in ips.iter() {
|
||||
assert!(
|
||||
!iface_name.is_empty(),
|
||||
"Interface names should not be empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_all_ips_no_duplicate_ips_per_interface() {
|
||||
let ips = list_all_ips();
|
||||
|
||||
// Vérifie qu'il n'y a pas de doublons par interface
|
||||
for (iface_name, addresses) in ips.iter() {
|
||||
if iface_name == "error" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let unique_addresses: std::collections::HashSet<_> = addresses.iter().collect();
|
||||
assert_eq!(
|
||||
addresses.len(),
|
||||
unique_addresses.len(),
|
||||
"No duplicate IPs should exist for interface {}",
|
||||
iface_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction helper pour les tests
|
||||
fn is_private_ip(ip_str: &str) -> bool {
|
||||
if let Ok(ip) = ip_str.parse::<IpAddr>() {
|
||||
match ip {
|
||||
IpAddr::V4(ipv4) => {
|
||||
let octets = ipv4.octets();
|
||||
// Plages privées: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
||||
octets[0] == 10
|
||||
|| (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31)
|
||||
|| (octets[0] == 192 && octets[1] == 168)
|
||||
}
|
||||
IpAddr::V6(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helper_is_private_ip() {
|
||||
// Tests pour la fonction helper
|
||||
assert!(is_private_ip("10.0.0.1"));
|
||||
assert!(is_private_ip("172.16.0.1"));
|
||||
assert!(is_private_ip("192.168.1.1"));
|
||||
assert!(!is_private_ip("8.8.8.8"));
|
||||
assert!(!is_private_ip("127.0.0.1")); // loopback n'est pas "privé" au sens réseau local
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/// Utilitaires pour la gestion des adresses IP réseau.
|
||||
///
|
||||
/// Ce module fournit des fonctions pour détecter et lister les adresses IP
|
||||
/// des interfaces réseau locales de la machine.
|
||||
///
|
||||
/// # Fonctions principales
|
||||
///
|
||||
/// - [`guess_local_ip`] : Devine l'adresse IP locale utilisée pour les connexions sortantes
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoutils::guess_local_ip;
|
||||
///
|
||||
/// let ip = guess_local_ip();
|
||||
/// println!("Adresse IP locale: {}", ip);
|
||||
/// ```
|
||||
pub mod ip_utils;
|
||||
|
||||
pub use ip_utils::guess_local_ip;
|
||||
pub mod process;
|
||||
pub use process::{ProcessPortInfo, TransportProtocol, find_process_using_port};
|
||||
use xmltree::{Element, EmitterConfig};
|
||||
|
||||
/// Retourne une chaîne décrivant le système d'exploitation et sa version.
|
||||
///
|
||||
/// Utilise la crate `os_info` pour obtenir de manière portable et fiable
|
||||
/// les informations sur le système d'exploitation courant.
|
||||
///
|
||||
/// # Format
|
||||
/// - macOS: "macOS/15.1" ou "Mac OS/10.15.7"
|
||||
/// - Linux: "Linux/6.5.0" ou "Ubuntu/22.04"
|
||||
/// - Windows: "Windows/10.0.19045"
|
||||
/// - Autre: "{OS}/Unknown"
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoutils::get_os_string;
|
||||
///
|
||||
/// let os = get_os_string();
|
||||
/// println!("OS: {}", os); // Ex: "Linux/6.5.0"
|
||||
/// ```
|
||||
pub fn get_os_string() -> String {
|
||||
let info = os_info::get();
|
||||
let os_type = format!("{:?}", info.os_type());
|
||||
|
||||
// Obtenir la version si disponible
|
||||
let version = info.version();
|
||||
if version != &os_info::Version::Unknown {
|
||||
format!("{}/{}", os_type, version)
|
||||
} else {
|
||||
format!("{}/Unknown", os_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait générique pour obtenir un élément XML (xmltree::Element).
|
||||
///
|
||||
/// Aligné sur la signature utilisée dans pmoupnp (UpnpObject::to_xml_element),
|
||||
/// afin de pouvoir factoriser la sérialisation XML entre crates.
|
||||
pub trait ToXmlElement {
|
||||
/// Convertit l'objet en élément XML.
|
||||
fn to_xml_element(&self) -> Element;
|
||||
|
||||
/// Sérialise en chaîne XML formatée.
|
||||
fn to_xml(&self) -> String {
|
||||
let elem = self.to_xml_element();
|
||||
let config = EmitterConfig::new()
|
||||
.perform_indent(true)
|
||||
.indent_string(" ");
|
||||
let mut buf = Vec::new();
|
||||
elem.write_with_config(&mut buf, config)
|
||||
.expect("Failed to write XML");
|
||||
String::from_utf8(buf).expect("Invalid UTF-8")
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, get_sockets_info};
|
||||
use sysinfo::{Pid, System};
|
||||
|
||||
/// Informations sur un processus utilisant un port réseau.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessPortInfo {
|
||||
pub pid: u32,
|
||||
pub process_name: String,
|
||||
pub owner: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// Protocole de transport utilisé pour la recherche.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum TransportProtocol {
|
||||
Tcp,
|
||||
Udp,
|
||||
}
|
||||
|
||||
/// Tente de trouver le processus qui écoute sur `port` pour le protocole donné.
|
||||
///
|
||||
/// Retourne `Some(ProcessPortInfo)` si un processus a pu être identifié, sinon `None`.
|
||||
pub fn find_process_using_port(port: u16, protocol: TransportProtocol) -> Option<ProcessPortInfo> {
|
||||
let proto_flag = match protocol {
|
||||
TransportProtocol::Tcp => ProtocolFlags::TCP,
|
||||
TransportProtocol::Udp => ProtocolFlags::UDP,
|
||||
};
|
||||
|
||||
let sockets = get_sockets_info(
|
||||
AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6,
|
||||
proto_flag,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
// Préparer l'inspection des processus.
|
||||
let mut system = System::new_all();
|
||||
system.refresh_all();
|
||||
|
||||
for socket in sockets {
|
||||
match socket.protocol_socket_info {
|
||||
ProtocolSocketInfo::Tcp(ref tcp_info)
|
||||
if matches!(protocol, TransportProtocol::Tcp) && tcp_info.local_port == port =>
|
||||
{
|
||||
if let Some(info) =
|
||||
build_process_info(&mut system, port, socket.associated_pids.first())
|
||||
{
|
||||
return Some(info);
|
||||
}
|
||||
}
|
||||
ProtocolSocketInfo::Udp(ref udp_info)
|
||||
if matches!(protocol, TransportProtocol::Udp) && udp_info.local_port == port =>
|
||||
{
|
||||
if let Some(info) =
|
||||
build_process_info(&mut system, port, socket.associated_pids.first())
|
||||
{
|
||||
return Some(info);
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn build_process_info(
|
||||
system: &mut System,
|
||||
port: u16,
|
||||
pid_opt: Option<&u32>,
|
||||
) -> Option<ProcessPortInfo> {
|
||||
let pid = *pid_opt?;
|
||||
let process = system.process(Pid::from_u32(pid))?;
|
||||
let process_name = process.name().to_string();
|
||||
|
||||
let owner = process
|
||||
.user_id()
|
||||
.and_then(|uid| {
|
||||
users::get_user_by_uid(**uid).map(|user| user.name().to_string_lossy().into_owned())
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
Some(ProcessPortInfo {
|
||||
pid,
|
||||
process_name,
|
||||
owner,
|
||||
port,
|
||||
})
|
||||
}
|
||||
@@ -40,7 +40,7 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
pmoutils = { path = "../pmoutils" }
|
||||
pmoutils = { version = "0.1.2", registry = "pmo" }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -15,7 +15,7 @@ use pmocontrol::ControlPoint;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::error::WebRendererError;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::register::{register_handler, unregister_handler};
|
||||
use crate::register::{position_update_handler, register_handler, unregister_handler};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::registry::RendererRegistry;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -48,9 +48,10 @@ impl WebRendererExt for pmoserver::Server {
|
||||
)
|
||||
.await;
|
||||
|
||||
// GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id}
|
||||
// GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id} + POST /api/webrenderer/{id}/position
|
||||
let dynamic_router = Router::new()
|
||||
.route("/{id}/stream", get(stream_handler))
|
||||
.route("/{id}/position", post(position_update_handler))
|
||||
.route("/{id}", delete(unregister_handler))
|
||||
.with_state(registry.clone());
|
||||
self.add_router("/api/webrenderer", dynamic_router).await;
|
||||
@@ -58,6 +59,7 @@ impl WebRendererExt for pmoserver::Server {
|
||||
tracing::info!("WebRenderer server-side streaming endpoints registered");
|
||||
tracing::info!(" POST /api/webrenderer/register");
|
||||
tracing::info!(" GET /api/webrenderer/{{id}}/stream");
|
||||
tracing::info!(" POST /api/webrenderer/{{id}}/position");
|
||||
tracing::info!(" DELETE /api/webrenderer/{{id}}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ use std::sync::Arc;
|
||||
use pmodidl::DIDLLite;
|
||||
use pmoupnp::actions::{ActionData, ActionError, ActionHandler, get_value};
|
||||
use pmoupnp::{get, set};
|
||||
use pmoutils::ToXmlElement;
|
||||
use pmodidl::ToXmlElement;
|
||||
|
||||
use crate::messages::PlaybackState;
|
||||
use crate::pipeline::{PipelineControl, PipelineHandle, seconds_to_upnp_time, upnp_time_to_seconds};
|
||||
use crate::pipeline::{PipelineControl, PipelineHandle, upnp_time_to_seconds};
|
||||
use crate::state::SharedState;
|
||||
|
||||
type ActionFuture =
|
||||
@@ -226,13 +226,11 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
|
||||
|
||||
// ─── RenderingControl Handlers ──────────────────────────────────────────────
|
||||
|
||||
pub fn set_volume_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
pub fn set_volume_handler(_pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
let volume: u16 = get!(&data, "DesiredVolume", u16);
|
||||
pipeline.send(PipelineControl::SetVolume(volume)).await;
|
||||
state.write().volume = volume;
|
||||
Ok(data)
|
||||
})
|
||||
@@ -251,13 +249,11 @@ pub fn get_volume_handler(state: SharedState) -> ActionHandler {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_mute_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
pub fn set_mute_handler(_pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
let mute: bool = get!(&data, "DesiredMute", bool);
|
||||
pipeline.send(PipelineControl::SetMute(mute)).await;
|
||||
state.write().mute = mute;
|
||||
Ok(data)
|
||||
})
|
||||
|
||||
@@ -1,56 +1,51 @@
|
||||
//! Pipeline audio serveur par instance WebRenderer
|
||||
//!
|
||||
//! Chaque instance WebRenderer possède un pipeline indépendant :
|
||||
//! - Un `StreamingFlacSink` qui encode et diffuse le flux FLAC aux clients HTTP
|
||||
//! - Un canal de contrôle `PipelineControl` alimenté par les handlers UPnP
|
||||
//! - Une task background qui orchestre sources et sink
|
||||
//! - Une `PlayerSource` qui gère le cycle de vie AVTransport (Play/Pause/Stop/Seek/LoadUri)
|
||||
//! - Un `StreamingOggFlacSink` qui encode et diffuse le flux OGG-FLAC aux clients HTTP
|
||||
//! - Des nœuds de normalisation (resampling → 96 kHz, conversion → I24)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pmoaudio::{AudioSegment, PositionTrackerNode, ResamplingNode, ToI24Node};
|
||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||
use pmoaudio_ext::sinks::{
|
||||
OggFlacStreamHandle, StreamingOggFlacSink,
|
||||
DIRECT_OGG_FLAC_BITS_PER_SAMPLE, DIRECT_OGG_FLAC_SAMPLE_RATE,
|
||||
};
|
||||
use pmoaudio_ext::UriSource;
|
||||
use pmoaudio::{ResamplingNode, ToI24Node};
|
||||
use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource};
|
||||
use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink};
|
||||
use pmoflac::EncoderOptions;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::messages::PlaybackState;
|
||||
use crate::state::SharedState;
|
||||
|
||||
// ─── Commandes de contrôle ───────────────────────────────────────────────────
|
||||
// ─── Ré-export des commandes pour les handlers ────────────────────────────────
|
||||
|
||||
/// Commandes envoyées au pipeline audio de l'instance
|
||||
#[derive(Debug)]
|
||||
pub enum PipelineControl {
|
||||
LoadUri(String),
|
||||
LoadNextUri(String),
|
||||
Play,
|
||||
Pause,
|
||||
Stop,
|
||||
Seek(f64),
|
||||
SetVolume(u16),
|
||||
SetMute(bool),
|
||||
/// Notification interne : la source courante s'est terminée (EOF ou erreur)
|
||||
SourceEnded,
|
||||
}
|
||||
/// Commandes de transport — alias vers PlayerCommand pour compatibilité handlers
|
||||
pub use pmoaudio_ext::PlayerCommand as PipelineControl;
|
||||
|
||||
// ─── Handle vers le pipeline ─────────────────────────────────────────────────
|
||||
|
||||
/// Handle partageable vers le pipeline audio d'une instance
|
||||
/// Handle partageable vers le pipeline audio d'une instance.
|
||||
///
|
||||
/// Expose le `PlayerHandle` pour les commandes AVTransport et le `CancellationToken`
|
||||
/// pour l'arrêt complet du pipeline.
|
||||
#[derive(Clone)]
|
||||
pub struct PipelineHandle {
|
||||
pub control_tx: mpsc::Sender<PipelineControl>,
|
||||
pub player: PlayerHandle,
|
||||
pub stop_token: CancellationToken,
|
||||
/// Volume courant (géré localement, pas dans PlayerSource)
|
||||
state: SharedState,
|
||||
}
|
||||
|
||||
impl PipelineHandle {
|
||||
/// Envoie une commande de transport. Gère SetVolume/SetMute localement.
|
||||
pub async fn send(&self, cmd: PipelineControl) {
|
||||
let _ = self.control_tx.send(cmd).await;
|
||||
match cmd {
|
||||
PlayerCommand::LoadUri(uri) => self.player.load_uri(uri).await,
|
||||
PlayerCommand::LoadNextUri(uri) => self.player.load_next_uri(uri).await,
|
||||
PlayerCommand::Play => self.player.play().await,
|
||||
PlayerCommand::Pause => self.player.pause().await,
|
||||
PlayerCommand::Stop => self.player.stop().await,
|
||||
PlayerCommand::Seek(pos) => self.player.seek(pos).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +54,9 @@ impl PipelineHandle {
|
||||
/// Pipeline audio complet pour une instance WebRenderer.
|
||||
///
|
||||
/// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle`
|
||||
/// (multi-clients, chaque `subscribe()` retourne un nouveau flux depuis le point courant).
|
||||
/// (multi-client broadcast, chaque `subscribe()` crée un flux indépendant).
|
||||
pub struct InstancePipeline {
|
||||
/// Handle vers le sink OGG-FLAC — clonable, chaque subscribe() donne un flux live.
|
||||
/// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client.
|
||||
pub flac_handle: OggFlacStreamHandle,
|
||||
pub pipeline_handle: PipelineHandle,
|
||||
}
|
||||
@@ -76,83 +71,55 @@ impl InstancePipeline {
|
||||
udn: String,
|
||||
) -> Self {
|
||||
let stop_token = CancellationToken::new();
|
||||
let (control_tx, control_rx) = mpsc::channel::<PipelineControl>(32);
|
||||
|
||||
// Chaîne de traitement : ResamplingNode(96kHz) → ToI24Node → DirectFlacSink
|
||||
// La backpressure remonte naturellement depuis le pipe duplex jusqu'à la source.
|
||||
use pmoaudio::pipeline::AudioPipelineNode;
|
||||
|
||||
let (sink, flac_handle) = StreamingOggFlacSink::new(
|
||||
EncoderOptions::default(),
|
||||
DIRECT_OGG_FLAC_BITS_PER_SAMPLE,
|
||||
);
|
||||
|
||||
// Nœud de suivi de position : lit le timestamp des chunks sortant vers le sink
|
||||
let (mut position_tracker, position_handle) = PositionTrackerNode::new();
|
||||
position_tracker.register(sink.boxed());
|
||||
// Sink broadcast OGG-FLAC (multi-client, pacé à 0.5s max d'avance)
|
||||
let (sink, flac_handle) = StreamingOggFlacSink::new(EncoderOptions::default(), 24);
|
||||
|
||||
// Nœud de conversion de profondeur : tout type entier → I24
|
||||
let mut to_i24 = ToI24Node::new();
|
||||
to_i24.register(position_tracker.boxed());
|
||||
to_i24.register(sink.boxed());
|
||||
|
||||
// Nœud de rééchantillonnage : n'importe quel sample rate → 96 kHz
|
||||
let mut resampler = ResamplingNode::new(DIRECT_OGG_FLAC_SAMPLE_RATE);
|
||||
let mut resampler = ResamplingNode::new(96_000);
|
||||
resampler.register(to_i24.boxed());
|
||||
|
||||
// Le tx d'entrée du resampler est le point d'entrée du pipeline
|
||||
let segment_tx = resampler.get_tx().expect("ResamplingNode doit avoir un sender");
|
||||
// Source avec contrôle AVTransport complet
|
||||
let (mut player_source, player_handle) = PlayerSource::new();
|
||||
player_source.register(resampler.boxed());
|
||||
|
||||
let pipeline_handle = PipelineHandle {
|
||||
control_tx,
|
||||
stop_token: stop_token.clone(),
|
||||
};
|
||||
|
||||
// Lancer la chaîne resampler → to_i24 → sink en background
|
||||
// Lancer le pipeline en background
|
||||
let sink_stop = stop_token.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = resampler.boxed().run(sink_stop).await {
|
||||
if let Err(e) = player_source.boxed().run(sink_stop).await {
|
||||
warn!("Audio pipeline error: {:?}", e);
|
||||
}
|
||||
debug!("Sink task terminated");
|
||||
});
|
||||
|
||||
// Task de mise à jour de la position (toutes les secondes)
|
||||
{
|
||||
let state_pos = state.clone();
|
||||
let pos_stop = stop_token.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = pos_stop.cancelled() => break,
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {
|
||||
let pos = position_handle.current_position_sec();
|
||||
if pos > 0.0 {
|
||||
state_pos.write().position = Some(seconds_to_upnp_time(pos));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Task pipeline : reçoit les commandes UPnP et pilote les sources
|
||||
let stop_token_clone = stop_token.clone();
|
||||
let control_tx_clone = pipeline_handle.control_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
run_pipeline(
|
||||
segment_tx,
|
||||
control_rx,
|
||||
control_tx_clone,
|
||||
stop_token_clone,
|
||||
state,
|
||||
udn,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point,
|
||||
)
|
||||
.await;
|
||||
debug!("Pipeline task terminated");
|
||||
});
|
||||
|
||||
// Écouter les événements PlayerSource pour mettre à jour le state UPnP
|
||||
let event_rx = player_handle.subscribe_events();
|
||||
let state_clone = state.clone();
|
||||
let udn_clone = udn.clone();
|
||||
#[cfg(feature = "pmoserver")]
|
||||
let cp_clone = control_point.clone();
|
||||
tokio::spawn(async move {
|
||||
run_event_listener(
|
||||
event_rx,
|
||||
state_clone,
|
||||
udn_clone,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
cp_clone,
|
||||
).await;
|
||||
});
|
||||
|
||||
let pipeline_handle = PipelineHandle {
|
||||
player: player_handle,
|
||||
stop_token: stop_token.clone(),
|
||||
state,
|
||||
};
|
||||
|
||||
Self {
|
||||
flac_handle,
|
||||
pipeline_handle,
|
||||
@@ -160,270 +127,66 @@ impl InstancePipeline {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task principale du pipeline ─────────────────────────────────────────────
|
||||
// ─── Listener d'événements ────────────────────────────────────────────────────
|
||||
|
||||
async fn run_pipeline(
|
||||
segment_tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
mut control_rx: mpsc::Receiver<PipelineControl>,
|
||||
control_tx: mpsc::Sender<PipelineControl>,
|
||||
stop_token: CancellationToken,
|
||||
async fn run_event_listener(
|
||||
mut event_rx: tokio::sync::broadcast::Receiver<pmoaudio_ext::PlayerEvent>,
|
||||
state: SharedState,
|
||||
udn: String,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point: Arc<pmocontrol::ControlPoint>,
|
||||
) {
|
||||
let mut current_source_stop: Option<CancellationToken> = None;
|
||||
let mut current_uri: Option<String> = None;
|
||||
use pmoaudio_ext::PlayerEvent;
|
||||
use crate::messages::PlaybackState;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
info!(udn = %udn, "Pipeline stopping by cancellation");
|
||||
if let Some(src_stop) = current_source_stop.take() {
|
||||
src_stop.cancel();
|
||||
match event_rx.recv().await {
|
||||
Ok(event) => match event {
|
||||
PlayerEvent::Playing { uri, duration_sec } => {
|
||||
let mut s = state.write();
|
||||
s.playback_state = PlaybackState::Playing;
|
||||
s.current_uri = Some(uri);
|
||||
s.duration = duration_sec.map(seconds_to_upnp_time);
|
||||
s.position = None;
|
||||
// Effacer next_uri/next_metadata : la nouvelle piste est maintenant courante
|
||||
s.next_uri = None;
|
||||
s.next_metadata = None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
cmd = control_rx.recv() => {
|
||||
match cmd {
|
||||
None => {
|
||||
info!(udn = %udn, "Pipeline control channel closed");
|
||||
break;
|
||||
}
|
||||
|
||||
Some(PipelineControl::SourceEnded) => {
|
||||
// La source s'est terminée (EOF ou erreur) : libérer le slot
|
||||
debug!(udn = %udn, "Pipeline: SourceEnded — source slot freed");
|
||||
current_source_stop = None;
|
||||
}
|
||||
|
||||
Some(PipelineControl::LoadUri(uri)) => {
|
||||
info!(udn = %udn, uri = %uri, "Pipeline: LoadUri");
|
||||
if let Some(src_stop) = current_source_stop.take() {
|
||||
src_stop.cancel();
|
||||
}
|
||||
current_uri = Some(uri.clone());
|
||||
{
|
||||
let mut s = state.write();
|
||||
s.playback_state = PlaybackState::Transitioning;
|
||||
s.current_uri = Some(uri.clone());
|
||||
s.position = None;
|
||||
}
|
||||
let src_stop = stop_token.child_token();
|
||||
current_source_stop = Some(src_stop.clone());
|
||||
let tx = segment_tx.clone();
|
||||
let notify = control_tx.clone();
|
||||
let st = state.clone();
|
||||
let udn_c = udn.clone();
|
||||
#[cfg(feature = "pmoserver")]
|
||||
PlayerEvent::Paused { position_sec } => {
|
||||
let mut s = state.write();
|
||||
s.playback_state = PlaybackState::Paused;
|
||||
s.position = Some(seconds_to_upnp_time(position_sec));
|
||||
}
|
||||
PlayerEvent::Stopped => {
|
||||
let mut s = state.write();
|
||||
s.playback_state = PlaybackState::Stopped;
|
||||
s.position = None;
|
||||
}
|
||||
PlayerEvent::Position { position_sec } => {
|
||||
state.write().position = Some(seconds_to_upnp_time(position_sec));
|
||||
}
|
||||
PlayerEvent::TrackEnded => {
|
||||
#[cfg(feature = "pmoserver")]
|
||||
{
|
||||
let cp = control_point.clone();
|
||||
let udn_c = udn.clone();
|
||||
tokio::spawn(async move {
|
||||
stream_source(
|
||||
uri, 0.0, tx, src_stop, st, udn_c,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
cp,
|
||||
).await;
|
||||
let _ = notify.send(PipelineControl::SourceEnded).await;
|
||||
cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_c));
|
||||
});
|
||||
}
|
||||
|
||||
Some(PipelineControl::LoadNextUri(uri)) => {
|
||||
debug!(udn = %udn, uri = %uri, "Pipeline: LoadNextUri");
|
||||
state.write().next_uri = Some(uri);
|
||||
}
|
||||
|
||||
Some(PipelineControl::Play) => {
|
||||
// Redémarrer la source si elle n'est pas en cours
|
||||
if current_source_stop.is_none() {
|
||||
if let Some(uri) = current_uri.clone() {
|
||||
info!(udn = %udn, uri = %uri, "Pipeline: Play — restarting source");
|
||||
let src_stop = stop_token.child_token();
|
||||
current_source_stop = Some(src_stop.clone());
|
||||
let tx = segment_tx.clone();
|
||||
let notify = control_tx.clone();
|
||||
let st = state.clone();
|
||||
let udn_c = udn.clone();
|
||||
#[cfg(feature = "pmoserver")]
|
||||
let cp = control_point.clone();
|
||||
tokio::spawn(async move {
|
||||
stream_source(
|
||||
uri, 0.0, tx, src_stop, st, udn_c,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
cp,
|
||||
).await;
|
||||
let _ = notify.send(PipelineControl::SourceEnded).await;
|
||||
});
|
||||
} else {
|
||||
debug!(udn = %udn, "Pipeline: Play — no URI loaded, ignoring");
|
||||
}
|
||||
} else {
|
||||
debug!(udn = %udn, "Pipeline: Play — source already running");
|
||||
}
|
||||
}
|
||||
|
||||
Some(PipelineControl::Pause) => {
|
||||
debug!(udn = %udn, "Pipeline: Pause (not supported on live stream)");
|
||||
}
|
||||
|
||||
Some(PipelineControl::Stop) => {
|
||||
info!(udn = %udn, "Pipeline: Stop");
|
||||
if let Some(src_stop) = current_source_stop.take() {
|
||||
src_stop.cancel();
|
||||
}
|
||||
// Conserver current_uri pour permettre un Play ultérieur
|
||||
let mut s = state.write();
|
||||
s.playback_state = PlaybackState::Stopped;
|
||||
s.position = None;
|
||||
}
|
||||
|
||||
Some(PipelineControl::Seek(pos_sec)) => {
|
||||
info!(udn = %udn, pos = pos_sec, "Pipeline: Seek");
|
||||
if let Some(uri) = current_uri.clone() {
|
||||
if let Some(src_stop) = current_source_stop.take() {
|
||||
src_stop.cancel();
|
||||
}
|
||||
let src_stop = stop_token.child_token();
|
||||
current_source_stop = Some(src_stop.clone());
|
||||
let tx = segment_tx.clone();
|
||||
let notify = control_tx.clone();
|
||||
let st = state.clone();
|
||||
let udn_c = udn.clone();
|
||||
#[cfg(feature = "pmoserver")]
|
||||
let cp = control_point.clone();
|
||||
tokio::spawn(async move {
|
||||
stream_source(
|
||||
uri, pos_sec, tx, src_stop, st, udn_c,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
cp,
|
||||
).await;
|
||||
let _ = notify.send(PipelineControl::SourceEnded).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some(PipelineControl::SetVolume(vol)) => {
|
||||
state.write().volume = vol;
|
||||
}
|
||||
|
||||
Some(PipelineControl::SetMute(mute)) => {
|
||||
state.write().mute = mute;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task source ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn stream_source(
|
||||
uri: String,
|
||||
seek_sec: f64,
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
stop_token: CancellationToken,
|
||||
state: SharedState,
|
||||
udn: String,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point: Arc<pmocontrol::ControlPoint>,
|
||||
) {
|
||||
info!(udn = %udn, uri = %uri, seek = seek_sec, "Source task: opening URI");
|
||||
|
||||
match UriSource::open(&uri, seek_sec, stop_token.clone()).await {
|
||||
Ok(source) => {
|
||||
debug!(udn = %udn, "Source task: URI opened, duration={:?}", source.duration_sec());
|
||||
if let Some(dur) = source.duration_sec() {
|
||||
state.write().duration = Some(seconds_to_upnp_time(dur));
|
||||
}
|
||||
|
||||
// TrackBoundary avec métadonnées minimales
|
||||
let boundary = {
|
||||
let mut meta = MemoryTrackMetadata::new();
|
||||
let _ = meta.set_title(Some(uri.clone())).await;
|
||||
let meta_arc = Arc::new(tokio::sync::RwLock::new(meta));
|
||||
AudioSegment::new_track_boundary(0, seek_sec, meta_arc)
|
||||
};
|
||||
let _ = tx.send(boundary).await;
|
||||
|
||||
// L'URI est ouverte, le flux va commencer à couler.
|
||||
// Le sink bloquera naturellement si aucun client HTTP n'est connecté.
|
||||
{
|
||||
let mut s = state.write();
|
||||
s.playback_state = PlaybackState::Playing;
|
||||
if seek_sec > 0.0 {
|
||||
s.position = Some(seconds_to_upnp_time(seek_sec));
|
||||
}
|
||||
}
|
||||
|
||||
match source.emit_to_channel(&tx, &stop_token).await {
|
||||
Ok(true) => {
|
||||
debug!(udn = %udn, "Source task: EOF → TrackEnded");
|
||||
handle_track_ended(
|
||||
state, udn, tx, stop_token,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point,
|
||||
).await;
|
||||
}
|
||||
Ok(false) => {
|
||||
debug!(udn = %udn, "Source task: cancelled");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(udn = %udn, error = %e, "Source task error");
|
||||
PlayerEvent::Error(e) => {
|
||||
tracing::warn!(udn = %udn, "PlayerSource error: {}", e);
|
||||
state.write().playback_state = PlaybackState::Stopped;
|
||||
}
|
||||
},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(udn = %udn, "Event listener lagged {} events", n);
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(udn = %udn, error = %e, "Source task: failed to open URI");
|
||||
state.write().playback_state = PlaybackState::Stopped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TrackEnded : avancer current←next ───────────────────────────────────────
|
||||
|
||||
async fn handle_track_ended(
|
||||
state: SharedState,
|
||||
udn: String,
|
||||
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
stop_token: CancellationToken,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point: Arc<pmocontrol::ControlPoint>,
|
||||
) {
|
||||
let next_uri = {
|
||||
let mut s = state.write();
|
||||
let uri = s.next_uri.take();
|
||||
let meta = s.next_metadata.take();
|
||||
s.current_uri = uri.clone();
|
||||
s.current_metadata = meta;
|
||||
s.next_uri = None;
|
||||
s.next_metadata = None;
|
||||
s.position = None;
|
||||
s.duration = None;
|
||||
if uri.is_some() {
|
||||
s.playback_state = PlaybackState::Playing;
|
||||
} else {
|
||||
s.playback_state = PlaybackState::Stopped;
|
||||
}
|
||||
uri
|
||||
};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
if next_uri.is_some() {
|
||||
let cp = control_point.clone();
|
||||
let udn_clone = udn.clone();
|
||||
tokio::spawn(async move {
|
||||
cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_clone));
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(uri) = next_uri {
|
||||
info!(udn = %udn, uri = %uri, "TrackEnded: starting next track");
|
||||
Box::pin(stream_source(
|
||||
uri, 0.0, tx, stop_token, state, udn,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point,
|
||||
)).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,24 @@ pub async fn register_handler(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PositionUpdateRequest {
|
||||
pub position_sec: f64,
|
||||
pub duration_sec: Option<f64>,
|
||||
}
|
||||
|
||||
/// POST /api/webrenderer/{id}/position
|
||||
/// position_sec est ignoré (géré par PlayerEvent::Position côté serveur).
|
||||
/// duration_sec est utilisé comme fallback si la source ne connaît pas la durée (flux radio).
|
||||
pub async fn position_update_handler(
|
||||
State(registry): State<Arc<RendererRegistry>>,
|
||||
Path(instance_id): Path<String>,
|
||||
Json(req): Json<PositionUpdateRequest>,
|
||||
) -> impl IntoResponse {
|
||||
registry.update_duration(&instance_id, req.duration_sec);
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
/// DELETE /api/webrenderer/{id}
|
||||
pub async fn unregister_handler(
|
||||
State(registry): State<Arc<RendererRegistry>>,
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::error::WebRendererError;
|
||||
use crate::pipeline::{InstancePipeline, PipelineHandle};
|
||||
use crate::renderer::WebRendererFactory;
|
||||
use crate::state::{RendererState, SharedState};
|
||||
use crate::messages::PlaybackState;
|
||||
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmocontrol::{ControlPoint, DeviceId};
|
||||
@@ -29,7 +29,7 @@ pub struct WebRendererInstance {
|
||||
pub udn: String,
|
||||
pub device_instance: Arc<DeviceInstance>,
|
||||
pub state: SharedState,
|
||||
/// Handle vers le sink OGG-FLAC — clonable, chaque subscribe() donne un flux live.
|
||||
/// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client.
|
||||
pub flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle,
|
||||
pub pipeline: PipelineHandle,
|
||||
pub created_at: SystemTime,
|
||||
@@ -116,15 +116,16 @@ impl RendererRegistry {
|
||||
Ok((stream_url, udn))
|
||||
}
|
||||
|
||||
/// Retourne le OggFlacStreamHandle pour l'endpoint /stream (clonable).
|
||||
pub fn get_flac_handle(
|
||||
/// Retourne un OggFlacClientStream indépendant pour l'endpoint /stream.
|
||||
/// Chaque appel crée un nouveau subscriber broadcast — safe pour connexions simultanées.
|
||||
pub fn get_stream(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
) -> Option<pmoaudio_ext::sinks::OggFlacStreamHandle> {
|
||||
) -> Option<pmoaudio_ext::sinks::OggFlacClientStream> {
|
||||
self.instances
|
||||
.read()
|
||||
.get(instance_id)
|
||||
.map(|i| i.flac_handle.clone())
|
||||
.map(|i| i.flac_handle.subscribe())
|
||||
}
|
||||
|
||||
/// Retourne le PipelineHandle par UDN (pour les handlers UPnP)
|
||||
@@ -151,6 +152,24 @@ impl RendererRegistry {
|
||||
.map(|i| i.device_instance.clone())
|
||||
}
|
||||
|
||||
/// Met à jour la durée depuis le navigateur.
|
||||
/// La position est gérée par PlayerSource via PlayerEvent::Position.
|
||||
/// On n'utilise duration_sec que si la source ne la connaît pas (flux radio sans durée).
|
||||
pub fn update_duration(&self, instance_id: &str, duration_sec: Option<f64>) {
|
||||
let instances = self.instances.read();
|
||||
if let Some(instance) = instances.get(instance_id) {
|
||||
let mut s = instance.state.write();
|
||||
// N'écraser la durée que si elle n'est pas déjà connue (la source est prioritaire)
|
||||
if s.duration.is_none() {
|
||||
if let Some(dur) = duration_sec {
|
||||
if dur > 0.0 {
|
||||
s.duration = Some(crate::pipeline::seconds_to_upnp_time(dur));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_unregister(self: &Arc<Self>, instance_id: &str) {
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -248,7 +267,7 @@ impl RendererRegistry {
|
||||
|
||||
let mut server = server_arc.write().await;
|
||||
server
|
||||
.register_device(Arc::new(device))
|
||||
.register_device(Arc::new(device), false)
|
||||
.await
|
||||
.map_err(|e| WebRendererError::RegistrationError(e.to_string()))?
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
//! Handler HTTP GET /api/webrenderer/{id}/stream
|
||||
//!
|
||||
//! Sert le flux FLAC d'une instance WebRenderer via DirectFlacSink.
|
||||
//! Sert le flux OGG-FLAC d'une instance WebRenderer.
|
||||
//!
|
||||
//! Reconnectable : appelé à chaque Play, crée un nouveau pipe + encodeur.
|
||||
//! Safari envoie parfois Range: bytes=0-N avant de jouer.
|
||||
//! On ignore ce header et on répond toujours 200 chunked (flux live infini).
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{
|
||||
StatusCode,
|
||||
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
||||
HeaderMap, StatusCode,
|
||||
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, TRANSFER_ENCODING},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -20,21 +21,23 @@ use tracing::info;
|
||||
use crate::registry::RendererRegistry;
|
||||
|
||||
/// GET /api/webrenderer/{id}/stream
|
||||
///
|
||||
/// Crée un nouveau pipe FLAC à chaque connexion (chaque Play).
|
||||
/// Le flux reste ouvert jusqu'à ce que le client se déconnecte (Stop).
|
||||
/// Les morceaux s'enchaînent en gapless dans le même flux.
|
||||
///
|
||||
/// Safari envoie un Range header (bytes=0-) et exige Accept-Ranges: bytes.
|
||||
/// On répond 206 Partial Content si un Range header est présent, sinon 200.
|
||||
pub async fn stream_handler(
|
||||
State(registry): State<Arc<RendererRegistry>>,
|
||||
Path(instance_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
info!(instance_id = %instance_id, "FLAC stream client connecting");
|
||||
|
||||
let handle = match registry.get_flac_handle(&instance_id) {
|
||||
Some(h) => h,
|
||||
// Ignorer le header Range — flux live infini, non seekable.
|
||||
// On ne répond jamais 206 ni 416 : toujours 200 chunked.
|
||||
// Safari (et d'autres clients) envoient parfois Range: bytes=0-N ;
|
||||
// répondre 416 ou 206 leur fait croire à une ressource finie.
|
||||
if let Some(range) = headers.get("range") {
|
||||
info!(instance_id = %instance_id, "Range header ignored (live stream): {:?}", range);
|
||||
}
|
||||
|
||||
let stream = match registry.get_stream(&instance_id) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -44,18 +47,14 @@ pub async fn stream_handler(
|
||||
}
|
||||
};
|
||||
|
||||
let stream = handle.subscribe();
|
||||
|
||||
info!(instance_id = %instance_id, "FLAC stream started");
|
||||
|
||||
// Toujours 200 OK pour un flux live de taille inconnue.
|
||||
// Les navigateurs (Safari inclus) acceptent 200 pour l'audio streaming.
|
||||
// Un Content-Range invalide (bytes 0-*/*) ferait rejeter le flux.
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, "audio/ogg; codecs=flac")
|
||||
.header(CACHE_CONTROL, "no-store, no-transform")
|
||||
.header(CONNECTION, "keep-alive")
|
||||
.header(TRANSFER_ENCODING, "chunked")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.body(Body::from_stream(ReaderStream::new(stream)))
|
||||
.unwrap()
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.23
|
||||
0.3.24
|
||||
|
||||
Reference in New Issue
Block a user