Merge pull request 'push-rxmurnsnnyys' (#78) from push-rxmurnsnnyys into main
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m32s

Reviewed-on: #78
This commit was merged in pull request #78.
This commit is contained in:
2026-03-24 15:26:22 +01:00
69 changed files with 2813 additions and 1574 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored
View File

@@ -16,13 +16,14 @@
**/*.mp4 **/*.mp4
**/*.mp3 **/*.mp3
**/*.ogg **/*.ogg
xxx xxx
/dcai/ /dcai/
**/.pmomusic.yml **/.pmomusic.yml
**/.pmomusic_covers/** **/.pmomusic_covers/**
**/.pmomusic_audio/** **/.pmomusic_audio/**
/.pmomusic /.pmomusic
.DS_Store **/.DS_Store
/target /target
**/target/ **/target/
/.pmomusic_covers /.pmomusic_covers

8
Cargo.lock generated
View File

@@ -4047,7 +4047,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"bevy_reflect", "bevy_reflect",
"bevy_reflect_derive", "bevy_reflect_derive",
"pmoutils",
"quick-xml", "quick-xml",
"serde", "serde",
"utoipa", "utoipa",
@@ -4148,6 +4147,7 @@ dependencies = [
"pmoaudio", "pmoaudio",
"pmoaudio-ext", "pmoaudio-ext",
"pmoaudiocache", "pmoaudiocache",
"pmocache",
"pmoconfig", "pmoconfig",
"pmocovers", "pmocovers",
"pmoflac", "pmoflac",
@@ -4362,15 +4362,15 @@ dependencies = [
[[package]] [[package]]
name = "pmoutils" 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 = [ dependencies = [
"get_if_addrs", "get_if_addrs",
"netstat2", "netstat2",
"os_info", "os_info",
"quick-xml",
"sysinfo", "sysinfo",
"users", "users",
"xmltree 0.10.3",
] ]
[[package]] [[package]]

View File

@@ -7,7 +7,6 @@ members = [
"pmowebrenderer", "pmowebrenderer",
"pmomediaserver", "pmomediaserver",
"pmoconfig", "pmoconfig",
"pmoutils",
"pmodidl", "pmodidl",
"pmoserver", "pmoserver",
"pmoapp", "pmoapp",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "PMOMusic" name = "PMOMusic"
version = "0.3.23" version = "0.3.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -74,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let renderer_instance = server let renderer_instance = server
.write() .write()
.await .await
.register_device(MEDIA_RENDERER.clone()) .register_device(MEDIA_RENDERER.clone(), true)
.await .await
.expect("Failed to register MediaRenderer"); .expect("Failed to register MediaRenderer");
@@ -85,7 +85,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let server_instance = server let server_instance = server
.write() .write()
.await .await
.register_device(MEDIA_SERVER.clone()) .register_device(MEDIA_SERVER.clone(), true)
.await .await
.expect("Failed to register MediaServer"); .expect("Failed to register MediaServer");

View File

@@ -74,9 +74,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -91,9 +91,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -108,9 +108,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -125,9 +125,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -142,9 +142,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -159,9 +159,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -176,9 +176,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -193,9 +193,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -210,9 +210,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -227,9 +227,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -244,9 +244,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -261,9 +261,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -278,9 +278,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -295,9 +295,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -312,9 +312,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -329,9 +329,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -346,9 +346,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -363,9 +363,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -380,9 +380,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -397,9 +397,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -414,9 +414,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -431,9 +431,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -448,9 +448,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -465,9 +465,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -482,9 +482,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -499,9 +499,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -529,9 +529,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
"integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -543,9 +543,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
"integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -557,9 +557,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
"integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -571,9 +571,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
"integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -585,9 +585,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
"integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -599,9 +599,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
"integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -613,13 +613,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
"integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -627,13 +630,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
"integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -641,13 +647,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
"integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -655,13 +664,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
"integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -669,13 +681,33 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
"integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true, "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", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -683,13 +715,33 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
"integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "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", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -697,13 +749,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
"integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -711,13 +766,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
"integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -725,13 +783,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
"integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -739,13 +800,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
"integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -753,9 +817,26 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
"integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", "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": [ "cpu": [
"x64" "x64"
], ],
@@ -763,13 +844,13 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "openbsd"
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
"integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -781,9 +862,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
"integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -795,9 +876,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
"integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -809,9 +890,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
"integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -823,9 +904,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
"integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1177,9 +1258,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.2.7", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@@ -1198,9 +1279,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.10", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -1211,32 +1292,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.10", "@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.25.10", "@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.25.10", "@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.25.10", "@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.25.10", "@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.25.10", "@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.25.10", "@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.25.10", "@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.25.10", "@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.25.10", "@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.25.10", "@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.25.10" "@esbuild/win32-x64": "0.27.4"
} }
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
@@ -1454,9 +1535,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.52.3", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
"integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1470,28 +1551,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.52.3", "@rollup/rollup-android-arm-eabi": "4.60.0",
"@rollup/rollup-android-arm64": "4.52.3", "@rollup/rollup-android-arm64": "4.60.0",
"@rollup/rollup-darwin-arm64": "4.52.3", "@rollup/rollup-darwin-arm64": "4.60.0",
"@rollup/rollup-darwin-x64": "4.52.3", "@rollup/rollup-darwin-x64": "4.60.0",
"@rollup/rollup-freebsd-arm64": "4.52.3", "@rollup/rollup-freebsd-arm64": "4.60.0",
"@rollup/rollup-freebsd-x64": "4.52.3", "@rollup/rollup-freebsd-x64": "4.60.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.52.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
"@rollup/rollup-linux-arm-musleabihf": "4.52.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.0",
"@rollup/rollup-linux-arm64-gnu": "4.52.3", "@rollup/rollup-linux-arm64-gnu": "4.60.0",
"@rollup/rollup-linux-arm64-musl": "4.52.3", "@rollup/rollup-linux-arm64-musl": "4.60.0",
"@rollup/rollup-linux-loong64-gnu": "4.52.3", "@rollup/rollup-linux-loong64-gnu": "4.60.0",
"@rollup/rollup-linux-ppc64-gnu": "4.52.3", "@rollup/rollup-linux-loong64-musl": "4.60.0",
"@rollup/rollup-linux-riscv64-gnu": "4.52.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.0",
"@rollup/rollup-linux-riscv64-musl": "4.52.3", "@rollup/rollup-linux-ppc64-musl": "4.60.0",
"@rollup/rollup-linux-s390x-gnu": "4.52.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.0",
"@rollup/rollup-linux-x64-gnu": "4.52.3", "@rollup/rollup-linux-riscv64-musl": "4.60.0",
"@rollup/rollup-linux-x64-musl": "4.52.3", "@rollup/rollup-linux-s390x-gnu": "4.60.0",
"@rollup/rollup-openharmony-arm64": "4.52.3", "@rollup/rollup-linux-x64-gnu": "4.60.0",
"@rollup/rollup-win32-arm64-msvc": "4.52.3", "@rollup/rollup-linux-x64-musl": "4.60.0",
"@rollup/rollup-win32-ia32-msvc": "4.52.3", "@rollup/rollup-openbsd-x64": "4.60.0",
"@rollup/rollup-win32-x64-gnu": "4.52.3", "@rollup/rollup-openharmony-arm64": "4.60.0",
"@rollup/rollup-win32-x64-msvc": "4.52.3", "@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" "fsevents": "~2.3.2"
} }
}, },
@@ -1564,13 +1648,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.7", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useMediaServers } from "@/composables/useMediaServers"; import { useMediaServers } from "@/composables/useMediaServers";
import { useRenderers } from "@/composables/useRenderers"; import { useRenderers } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui"; import { useUIStore } from "@/stores/ui";
@@ -16,8 +16,11 @@ const props = defineProps<{
const { const {
getBrowseCached, getBrowseCached,
browseContainer, browseContainer,
loadMoreBrowse,
hasMore,
currentPath: breadcrumbPath, currentPath: breadcrumbPath,
loading, loading,
loadingMore,
error, error,
} = useMediaServers(); } = useMediaServers();
@@ -25,8 +28,9 @@ const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
useRenderers(); useRenderers();
const uiStore = useUIStore(); const uiStore = useUIStore();
// Flag pour gérer le rechargement automatique
const isRefreshing = ref(false); const isRefreshing = ref(false);
const sentinelRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const browseData = computed(() => const browseData = computed(() =>
getBrowseCached(props.serverId, props.containerId), getBrowseCached(props.serverId, props.containerId),
@@ -40,6 +44,28 @@ const items = computed(
() => browseData.value?.entries.filter((e) => !e.is_container) || [], () => 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 // Charger le container au montage et quand containerId change
watch( watch(
() => props.containerId, () => props.containerId,
@@ -51,15 +77,10 @@ watch(
{ immediate: true }, { immediate: true },
); );
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE) // Recharger si le cache est invalidé (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
watch( watch(
() => browseData.value, () => browseData.value,
async (data) => { 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 ( if (
!data && !data &&
props.containerId && props.containerId &&
@@ -201,6 +222,14 @@ async function handleQueueItem(itemId: string, rendererId: string) {
> >
<p>Ce dossier est vide</p> <p>Ce dossier est vide</p>
</div> </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>
</div> </div>
</template> </template>
@@ -295,6 +324,17 @@ async function handleQueueItem(itemId: string, rendererId: string) {
padding: var(--spacing-xl); 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 */ /* Scrollbar styling */
.browser-content::-webkit-scrollbar { .browser-content::-webkit-scrollbar {
width: 6px; width: 6px;

View File

@@ -116,6 +116,15 @@ const offlineRenderers = computed(() =>
allRenderers.value.filter((r: RendererSummary) => !r.online), 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() { function close() {
emit("update:modelValue", false); emit("update:modelValue", false);
} }
@@ -296,7 +305,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
getProtocolLabel( getProtocolLabel(
renderer.protocol, renderer.protocol,
) )
}} }}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
</span> </span>
</div> </div>
<div class="renderer-details"> <div class="renderer-details">
@@ -383,7 +392,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
</div> </div>
<div class="renderer-info"> <div class="renderer-info">
<p class="renderer-name"> <p class="renderer-name">
{{ renderer.friendly_name }} {{ renderer.friendly_name }}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
</p> </p>
<p <p
v-if="renderer.model_name" v-if="renderer.model_name"

View File

@@ -18,9 +18,9 @@ import { useMediaServers } from "@/composables/useMediaServers";
import { useRenderers } from "@/composables/useRenderers"; import { useRenderers } from "@/composables/useRenderers";
import type { import type {
MediaServerSummary, MediaServerSummary,
BrowseResponse,
ContainerEntry, ContainerEntry,
} from "@/services/pmocontrol/types"; } from "@/services/pmocontrol/types";
import type { BrowseState } from "@/composables/useMediaServers";
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; // v-model pour contrôler l'ouverture modelValue: boolean; // v-model pour contrôler l'ouverture
@@ -47,7 +47,7 @@ const router = useRouter();
// État de navigation // État de navigation
const currentServer = ref<MediaServerSummary | null>(null); const currentServer = ref<MediaServerSummary | null>(null);
const browseData = ref<BrowseResponse | null>(null); const browseData = ref<BrowseState | null>(null);
const isLoading = ref(false); const isLoading = ref(false);
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert) // É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" :src="item.album_art_uri"
:alt="item.title" :alt="item.title"
class="cover-image" class="cover-image"
loading="lazy"
@load="handleImageLoad(item.id)" @load="handleImageLoad(item.id)"
@error="handleImageError(item.id)" @error="handleImageError(item.id)"
/> />

View File

@@ -10,7 +10,7 @@ export function useCoverImage(
imageUrl: Ref<string | null | undefined>, imageUrl: Ref<string | null | undefined>,
options: CoverImageOptions = {}, options: CoverImageOptions = {},
) { ) {
const { maxRetries = 3, retryDelay = 1000, forceReload = true } = options; const { maxRetries = 5, retryDelay = 500, forceReload = true } = options;
const imageLoaded = ref(false); const imageLoaded = ref(false);
const imageError = ref(false); const imageError = ref(false);
@@ -66,6 +66,9 @@ export function useCoverImage(
if (retryCount.value < maxRetries) { if (retryCount.value < maxRetries) {
retryCount.value++; 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(() => { setTimeout(() => {
if (!currentUrl.value) return; if (!currentUrl.value) return;
@@ -74,7 +77,7 @@ export function useCoverImage(
currentUrl.value, currentUrl.value,
retryCount.value, retryCount.value,
); );
}, retryDelay * retryCount.value); // Exponential backoff }, delay);
} else { } else {
console.error( console.error(
`[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`, `[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`,
@@ -102,8 +105,15 @@ export function useCoverImage(
// Retry if we haven't reached max retries // Retry if we haven't reached max retries
if (retryCount.value < maxRetries) { 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(); retryLoad();
} else { } else {
console.error(
`[useCoverImage] Giving up after ${maxRetries} retries for: ${currentUrl.value}`,
);
imageError.value = true; imageError.value = true;
} }
} }

View File

@@ -7,7 +7,7 @@ import { api } from '../services/pmocontrol/api'
import { sse } from '../services/pmocontrol/sse' import { sse } from '../services/pmocontrol/sse'
import type { import type {
MediaServerSummary, MediaServerSummary,
BrowseResponse ContainerEntry,
} from '../services/pmocontrol/types' } from '../services/pmocontrol/types'
export interface BreadcrumbItem { export interface BreadcrumbItem {
@@ -15,9 +15,15 @@ export interface BreadcrumbItem {
title: string title: string
} }
export interface BrowseState {
container_id: string
entries: ContainerEntry[]
total_count: number
}
// Cache global partagé // Cache global partagé
const serversCache = ref<Map<string, MediaServerSummary>>(new Map()) 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[]>([]) const currentPath = ref<BreadcrumbItem[]>([])
// Timestamps // Timestamps
@@ -100,6 +106,7 @@ export function useMediaServers() {
ensureSSEConnected() ensureSSEConnected()
const loading = ref(false) const loading = ref(false)
const loadingMore = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
// Getters computed // Getters computed
@@ -110,7 +117,7 @@ export function useMediaServers() {
async function fetchServers(force = false) { async function fetchServers(force = false) {
const now = Date.now() const now = Date.now()
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) { if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
return // Cache encore valide return
} }
try { 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) { async function browseContainer(serverId: string, containerId: string, useCache = true) {
const key = `${serverId}/${containerId}` const key = `${serverId}/${containerId}`
// Vérifier le cache
if (useCache && browseCache.value.has(key)) { if (useCache && browseCache.value.has(key)) {
return browseCache.value.get(key)! return browseCache.value.get(key)!
} }
@@ -142,12 +148,15 @@ export function useMediaServers() {
loading.value = true loading.value = true
error.value = null 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, {
browseCache.value.set(key, data) container_id: data.container_id,
entries: data.entries,
total_count: data.total_count,
})
return data return browseCache.value.get(key)!
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur browse container' error.value = e instanceof Error ? e.message : 'Erreur browse container'
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e) 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 // Getters
function getServerById(id: string) { function getServerById(id: string) {
return serversCache.value.get(id) return serversCache.value.get(id)
@@ -167,6 +203,13 @@ export function useMediaServers() {
return browseCache.value.get(key) 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 // Breadcrumb path management
function setPath(path: BreadcrumbItem[]) { function setPath(path: BreadcrumbItem[]) {
currentPath.value = path currentPath.value = path
@@ -179,11 +222,9 @@ export function useMediaServers() {
// Invalidation du cache // Invalidation du cache
function invalidateCache(serverId: string, containerId?: string) { function invalidateCache(serverId: string, containerId?: string) {
if (containerId) { if (containerId) {
// Invalider un container spécifique
const key = `${serverId}/${containerId}` const key = `${serverId}/${containerId}`
browseCache.value.delete(key) browseCache.value.delete(key)
} else { } else {
// Invalider tous les containers d'un serveur
const keysToDelete: string[] = [] const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => { browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) { if (key.startsWith(serverId + '/')) {
@@ -197,6 +238,7 @@ export function useMediaServers() {
return { return {
// État // État
loading, loading,
loadingMore,
error, error,
currentPath, currentPath,
// Getters // Getters
@@ -204,9 +246,11 @@ export function useMediaServers() {
onlineServers, onlineServers,
getServerById, getServerById,
getBrowseCached, getBrowseCached,
hasMore,
// Actions // Actions
fetchServers, fetchServers,
browseContainer, browseContainer,
loadMoreBrowse,
setPath, setPath,
clearPath, clearPath,
invalidateCache invalidateCache

View File

@@ -34,10 +34,10 @@ function generateUUID(): string {
function getOrCreateInstanceId(): string { function getOrCreateInstanceId(): string {
try { try {
let id = localStorage.getItem(INSTANCE_ID_KEY); let id = sessionStorage.getItem(INSTANCE_ID_KEY);
if (!id) { if (!id) {
id = generateUUID(); id = generateUUID();
localStorage.setItem(INSTANCE_ID_KEY, id); sessionStorage.setItem(INSTANCE_ID_KEY, id);
} }
return id; return id;
} catch { } catch {
@@ -71,6 +71,30 @@ export function useWebRenderer() {
let onConnectedCallback: (() => void) | null = null; let onConnectedCallback: (() => void) | null = null;
let sseUnsubscribe: (() => void) | null = null; let sseUnsubscribe: (() => void) | null = null;
let pendingCanPlay: (() => 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 ───────────────────────────────── // ── Connexion / déconnexion du flux audio ─────────────────────────────────
@@ -124,6 +148,7 @@ export function useWebRenderer() {
el.removeEventListener("canplay", onCanPlay); el.removeEventListener("canplay", onCanPlay);
el.play().then(() => { el.play().then(() => {
console.log("[WebRenderer] play() resolved OK"); console.log("[WebRenderer] play() resolved OK");
startPositionReporting();
}).catch((e: unknown) => { }).catch((e: unknown) => {
console.warn("[WebRenderer] play() rejected:", e); console.warn("[WebRenderer] play() rejected:", e);
}); });
@@ -134,6 +159,7 @@ export function useWebRenderer() {
function stopStream(): void { function stopStream(): void {
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState); console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
stopPositionReporting();
if (!audioEl) return; if (!audioEl) return;
if (pendingCanPlay) { if (pendingCanPlay) {
audioEl.removeEventListener("canplay", pendingCanPlay); audioEl.removeEventListener("canplay", pendingCanPlay);
@@ -230,6 +256,7 @@ export function useWebRenderer() {
onUnmounted(() => { onUnmounted(() => {
sseUnsubscribe?.(); sseUnsubscribe?.();
sseUnsubscribe = null; sseUnsubscribe = null;
stopPositionReporting();
stopStream(); stopStream();
void unregister(); void unregister();
if (audioEl) { if (audioEl) {

View File

@@ -418,9 +418,11 @@ class PMOControlAPI {
async browseContainer( async browseContainer(
serverId: string, serverId: string,
containerId: string, containerId: string,
offset = 0,
limit = 50,
): Promise<BrowseResponse> { ): Promise<BrowseResponse> {
return this.request<BrowseResponse>( return this.request<BrowseResponse>(
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`, `/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}?offset=${offset}&limit=${limit}`,
); );
} }

View File

@@ -32,6 +32,7 @@ export interface RendererSummary {
protocol: RendererProtocolSummary; protocol: RendererProtocolSummary;
capabilities: RendererCapabilitiesSummary; capabilities: RendererCapabilitiesSummary;
online: boolean; online: boolean;
server_host?: string;
} }
export interface RendererState { export interface RendererState {
@@ -119,6 +120,8 @@ export interface ContainerEntry {
export interface BrowseResponse { export interface BrowseResponse {
container_id: string; container_id: string;
entries: ContainerEntry[]; entries: ContainerEntry[];
total_count: number;
offset: number;
} }
// ============================================================================ // ============================================================================

View File

@@ -43,3 +43,6 @@ pub use sources::PlaylistSource;
#[cfg(feature = "http-stream")] #[cfg(feature = "http-stream")]
pub use sources::UriSource; pub use sources::UriSource;
#[cfg(feature = "http-stream")]
pub use sources::{PlayerCommand, PlayerEvent, PlayerHandle, PlayerSource};

View File

@@ -1,34 +1,28 @@
//! DirectOggFlacSink — nœud puits OGG-FLAC pour un seul client HTTP. //! 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 //! # Architecture
//! //!
//! ```text //! ```text
//! AudioSegment I24 @ 96 kHz //! AudioSegment I24 @ 96 kHz
//! ↓ NodeLogic::process() [bloque si pas de client] //! ↓ NodeLogic::process()
//! chunk_to_pcm_bytes() → PCM 24-bit LE //! chunk_to_pcm_bytes() → PCM 24-bit LE
//! ↓ Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>> //! ↓ pcm_tx (cap=2)
//! ByteStreamReader (AsyncRead) //! ByteStreamReader → encode_flac_stream() → OGG pages (Bytes)
//! ↓ encode_flac_stream() //! ↓ ogg_tx mpsc::Sender<Bytes> (cap=8, backpressure naturelle)
//! ↓ broadcast_ogg_flac_stream() → wrapping OGG pages //! Arc<Mutex<Receiver<Bytes>>> → DirectOggFlacStream (AsyncRead) → HTTP → Safari
//! ↓ tokio::io::duplex pipe (256 KB)
//! ↓ DirectOggFlacStream (AsyncRead) → Body HTTP
//! ``` //! ```
//!
//! # 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::io;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
@@ -42,9 +36,10 @@ use pmoaudio::{
}; };
use pmoflac::{EncoderOptions, PcmFormat}; use pmoflac::{EncoderOptions, PcmFormat};
use tokio::io::{AsyncRead, ReadBuf}; 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 tokio_util::sync::CancellationToken;
use tracing::{debug, warn}; use tracing::{debug, trace, warn};
use crate::sinks::byte_stream_reader::{ByteStreamReader, PcmChunk}; use crate::sinks::byte_stream_reader::{ByteStreamReader, PcmChunk};
use crate::sinks::chunk_to_pcm::chunk_to_pcm_bytes; 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_CHANNELS: u8 = 2;
pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24; pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24;
/// Capacité du pipe duplex (~256 KB). /// Capacité du canal OGG → HTTP.
const PIPE_CAPACITY: usize = 256 * 1024; /// 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 SharedPcmTx = Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>;
type SharedEncoderTask = Arc<Mutex<Option<JoinHandle<()>>>>;
type SharedOggRx = Arc<Mutex<mpsc::Receiver<Bytes>>>;
// ─── Handle public ──────────────────────────────────────────────────────────── // ─── Handle public ────────────────────────────────────────────────────────────
/// Handle vers le sink, cloneable, reconnectable à chaque Play.
#[derive(Clone)] #[derive(Clone)]
pub struct DirectOggFlacHandle { pub struct DirectOggFlacHandle {
pcm_tx: SharedPcmTx, pcm_tx: SharedPcmTx,
client_connect_tx: Arc<watch::Sender<u64>>, encoder_task: SharedEncoderTask,
client_notify_internal: Arc<tokio::sync::Notify>,
first_byte_tx: Arc<watch::Sender<bool>>,
encoder_options: EncoderOptions, encoder_options: EncoderOptions,
/// Position de lecture courante (mise à jour par ByteStreamReader).
current_timestamp: Arc<tokio::sync::RwLock<f64>>, 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 { impl DirectOggFlacHandle {
/// Crée un nouveau pipe OGG-FLAC et retourne le flux côté lecture. /// Retourne le stream OGG-FLAC pour le handler HTTP.
/// Débloque le sink s'il attendait un client. /// Lance le premier encodeur au premier appel.
pub async fn connect(&self) -> DirectOggFlacStream { pub fn get_stream(&self) -> DirectOggFlacStream {
let connect_count_before = *self.client_connect_tx.borrow(); tracing::info!(target: "pmoaudio_ext::stream_connect", "get_stream() called — new HTTP client connecting");
debug!("DirectOggFlacHandle::connect() called, connect_count={}", connect_count_before); self.client_notify.notify_one();
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");
DirectOggFlacStream { DirectOggFlacStream {
inner: pipe_reader, ogg_rx: self.ogg_rx.clone(),
first_byte_tx: Some(self.first_byte_tx.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 { pub async fn current_position_sec(&self) -> f64 {
*self.current_timestamp.read().await *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 ──────────────────────────────────────────────────────────── // ─── Stream public ────────────────────────────────────────────────────────────
/// AsyncRead sur le canal OGG-FLAC.
pub struct DirectOggFlacStream { pub struct DirectOggFlacStream {
inner: tokio::io::DuplexStream, ogg_rx: SharedOggRx,
first_byte_tx: Option<Arc<watch::Sender<bool>>>, 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 { impl AsyncRead for DirectOggFlacStream {
@@ -152,18 +112,41 @@ impl AsyncRead for DirectOggFlacStream {
cx: &mut Context<'_>, cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>, buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> { ) -> Poll<io::Result<()>> {
let filled_before = buf.filled().len(); // Vider le buffer interne en premier
let result = Pin::new(&mut self.inner).poll_read(cx, buf); if !self.buffer.is_empty() {
if let Poll::Ready(Ok(())) = &result { let to_copy = self.buffer.len().min(buf.remaining());
let filled_after = buf.filled().len(); let chunk: Vec<u8> = self.buffer.drain(..to_copy).collect();
if filled_after > filled_before { buf.put_slice(&chunk);
if let Some(tx) = self.first_byte_tx.take() { return Poll::Ready(Ok(()));
debug!("DirectOggFlacStream: first {} bytes sent to HTTP client", filled_after - filled_before); }
let _ = tx.send(true);
} // 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 { struct DirectOggFlacSinkLogic {
pcm_tx: SharedPcmTx, 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>, client_notify: Arc<tokio::sync::Notify>,
has_encoded_frames: bool,
} }
#[async_trait] #[async_trait]
@@ -186,6 +174,15 @@ impl NodeLogic for DirectOggFlacSinkLogic {
AudioError::ProcessingError("DirectOggFlacSink requires an input".into()) 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 { loop {
tokio::select! { tokio::select! {
_ = stop_token.cancelled() => { _ = stop_token.cancelled() => {
@@ -201,25 +198,9 @@ impl NodeLogic for DirectOggFlacSinkLogic {
} }
Some(seg) => match &seg.segment { Some(seg) => match &seg.segment {
_AudioSegment::Chunk(chunk) => { _AudioSegment::Chunk(chunk) => {
// Attendre un client si nécessaire (backpressure quand pas de Play) let tx = self.pcm_tx.lock().await.clone();
loop { let Some(tx) = tx else { continue; };
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().unwrap();
let pcm_bytes = chunk_to_pcm_bytes(chunk, DIRECT_OGG_FLAC_BITS_PER_SAMPLE)?; 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 duration_sec = chunk.len() as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64;
let pcm_chunk = PcmChunk { let pcm_chunk = PcmChunk {
@@ -228,17 +209,26 @@ impl NodeLogic for DirectOggFlacSinkLogic {
duration_sec, duration_sec,
}; };
if tx.send(pcm_chunk).await.is_err() { if tx.send(pcm_chunk).await.is_err() {
warn!( warn!("DirectOggFlacSink: encoder gone at {:.3}s", seg.timestamp_sec);
ts = seg.timestamp_sec,
"DirectOggFlacSink: chunk dropped (client disconnected at {:.3}s), waiting for reconnect",
seg.timestamp_sec,
);
*self.pcm_tx.lock().await = None; *self.pcm_tx.lock().await = None;
self.has_encoded_frames = false;
} else {
self.has_encoded_frames = true;
} }
} }
_AudioSegment::Sync(marker) => match marker.as_ref() { _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 => { SyncMarker::EndOfStream => {
debug!("DirectOggFlacSink: EndOfStream"); debug!("DirectOggFlacSink: EndOfStream");
self.stop_encoder().await;
} }
_ => {} _ => {}
}, },
@@ -248,6 +238,7 @@ impl NodeLogic for DirectOggFlacSinkLogic {
} }
} }
self.stop_encoder().await;
Ok(()) 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 ───────────────────────────────────────────── // ─── Encodeur FLAC + wrapper OGG ─────────────────────────────────────────────
async fn run_ogg_encoder( async fn run_ogg_encoder(
pcm_reader: ByteStreamReader, pcm_reader: ByteStreamReader,
mut pipe_writer: tokio::io::DuplexStream, ogg_tx: mpsc::Sender<Bytes>,
options: EncoderOptions, options: EncoderOptions,
_current_timestamp: Arc<tokio::sync::RwLock<f64>>, _current_timestamp: Arc<tokio::sync::RwLock<f64>>,
) -> Result<(), AudioError> { ) -> Result<(), AudioError> {
use tokio::io::AsyncReadExt;
let format = PcmFormat { let format = PcmFormat {
sample_rate: DIRECT_OGG_FLAC_SAMPLE_RATE, sample_rate: DIRECT_OGG_FLAC_SAMPLE_RATE,
channels: DIRECT_OGG_FLAC_CHANNELS, channels: DIRECT_OGG_FLAC_CHANNELS,
@@ -274,56 +308,54 @@ async fn run_ogg_encoder(
.await .await
.map_err(|e| AudioError::ProcessingError(format!("FLAC encoder init: {}", e)))?; .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 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 stream_serial: u32 = rand::random();
let mut ogg = OggPageWriter::new(stream_serial); let mut ogg = OggPageWriter::new(stream_serial);
// Page BOS (identification OGG-FLAC)
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?; let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
let bos_page = Bytes::from(ogg.create_page(&ogg_flac_id, true, false, false)); let bos_page = ogg.create_page(&ogg_flac_id, true, false, false);
// Page Vorbis Comment
let vorbis_comment = create_empty_vorbis_comment(); 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 let mut header = Vec::new();
.map_err(|e| AudioError::IoError(format!("OGG BOS write: {}", e)))?; header.extend_from_slice(&bos_page);
pipe_writer.write_all(&comment_page).await header.extend_from_slice(&comment_page);
.map_err(|e| AudioError::IoError(format!("OGG comment write: {}", e)))?; 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 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); let mut accumulator: Vec<u8> = Vec::with_capacity(32768);
use tokio::io::{AsyncReadExt, AsyncWriteExt};
loop { loop {
match flac_stream.read(&mut read_buffer).await { match flac_stream.read(&mut read_buffer).await {
Ok(0) => { Ok(0) => {
// EOF : page EOS finale trace!(
let eos_page = Bytes::from(ogg.create_page(&accumulator, false, true, false)); "OggEncoder: EOF — accum={} B, encoded={:.3}s",
let _ = pipe_writer.write_all(&eos_page).await; 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; break;
} }
Ok(n) => { Ok(n) => {
accumulator.extend_from_slice(&read_buffer[..n]); accumulator.extend_from_slice(&read_buffer[..n]);
loop { loop {
if accumulator.len() < 4 { if accumulator.len() < 4 { break; }
break;
}
// Trouver les positions de sync FLAC
let mut sync_data: Vec<(usize, u32)> = Vec::new(); let mut sync_data: Vec<(usize, u32)> = Vec::new();
for i in 0..accumulator.len() - 1 { for i in 0..accumulator.len() - 1 {
let b1 = accumulator[i]; let b1 = accumulator[i];
let b2 = accumulator[i + 1]; let b2 = accumulator[i + 1];
if b1 == 0xFF && b2 >= 0xF8 && b2 <= 0xFE { 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 validate_frame_header_crc(&accumulator, i) {
if let Some(samples) = parse_flac_block_size(&accumulator, i) { if let Some(samples) = parse_flac_block_size(&accumulator, i) {
sync_data.push((i, samples)); sync_data.push((i, samples));
@@ -332,9 +364,7 @@ async fn run_ogg_encoder(
} }
} }
if sync_data.len() < 2 { if sync_data.len() < 2 { break; }
break;
}
let first_start = sync_data[0].0; let first_start = sync_data[0].0;
let first_samples = sync_data[0].1; 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(); let frame: Vec<u8> = accumulator.drain(0..second_start).collect();
encoded_samples = encoded_samples.saturating_add(first_samples as u64); encoded_samples = encoded_samples.saturating_add(first_samples as u64);
ogg.add_samples(first_samples as u64); ogg.add_samples(first_samples as u64);
let ogg_page = Bytes::from(ogg.create_page(&frame, false, false, false)); trace!(
if pipe_writer.write_all(&ogg_page).await.is_err() { "OggEncoder: frame {} B, {:.3}s total",
// Client déconnecté — le pipe HTTP s'est rompu frame.len(),
warn!( encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
samples = encoded_samples, );
"DirectOggFlacSink: OGG pipe broken after {} samples ({:.3}s), client disconnected",
encoded_samples, let ogg_page = ogg.create_page(&frame, false, false, false);
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64, 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(()); return Ok(());
} }
} }
@@ -375,7 +405,7 @@ async fn run_ogg_encoder(
Ok(()) Ok(())
} }
// ─── OGG helpers (copiés de streaming_ogg_flac_sink) ───────────────────────── // ─── OGG helpers ─────────────────────────────────────────────────────────────
struct OggPageWriter { struct OggPageWriter {
stream_serial: u32, stream_serial: u32,
@@ -515,16 +545,21 @@ pub struct DirectOggFlacSink {
impl DirectOggFlacSink { impl DirectOggFlacSink {
pub fn new(encoder_options: EncoderOptions) -> (Self, DirectOggFlacHandle) { pub fn new(encoder_options: EncoderOptions) -> (Self, DirectOggFlacHandle) {
let pcm_tx: SharedPcmTx = Arc::new(Mutex::new(None)); let pcm_tx: SharedPcmTx = Arc::new(Mutex::new(None));
let client_notify_internal = Arc::new(tokio::sync::Notify::new()); let encoder_task: SharedEncoderTask = Arc::new(Mutex::new(None));
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 current_timestamp = Arc::new(tokio::sync::RwLock::new(0.0f64)); 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 { let logic = DirectOggFlacSinkLogic {
pcm_tx: pcm_tx.clone(), 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 { let sink = Self {
@@ -533,11 +568,12 @@ impl DirectOggFlacSink {
let handle = DirectOggFlacHandle { let handle = DirectOggFlacHandle {
pcm_tx, pcm_tx,
client_connect_tx, encoder_task,
client_notify_internal,
first_byte_tx,
encoder_options, encoder_options,
current_timestamp, current_timestamp,
ogg_tx,
ogg_rx,
client_notify,
}; };
(sink, handle) (sink, handle)

View File

@@ -88,10 +88,8 @@ impl IcyClientStream {
// Add cover URL if we have a cover_pk // Add cover URL if we have a cover_pk
if let Some(pk) = &meta.cover_pk { if let Some(pk) = &meta.cover_pk {
// Use relative URL /covers/image/{pk}/256 let cover_url = pmocache::covers_absolute_url_for(pk, None);
// This works when streaming from the same server that serves covers metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
// VLC and other players will resolve relative URLs correctly
metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk));
} else if let Some(url) = &meta.cover_url { } else if let Some(url) = &meta.cover_url {
// Fallback to external cover URL if no local pk // Fallback to external cover URL if no local pk
metadata_str.push_str(&format!("StreamUrl='{}';", url)); metadata_str.push_str(&format!("StreamUrl='{}';", url));

View File

@@ -490,6 +490,11 @@ impl SharedSinkContext {
debug!("Restarting FLAC encoder for new track"); 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; let last_timestamp = *self.current_timestamp.read().await;
debug!("Last timestamp before restart: {:.3}s", last_timestamp); debug!("Last timestamp before restart: {:.3}s", last_timestamp);

View File

@@ -19,3 +19,9 @@ mod uri_source;
#[cfg(feature = "http-stream")] #[cfg(feature = "http-stream")]
pub use uri_source::UriSource; pub use uri_source::UriSource;
#[cfg(feature = "http-stream")]
mod player_source;
#[cfg(feature = "http-stream")]
pub use player_source::{PlayerCommand, PlayerEvent, PlayerHandle, PlayerSource};

View 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
}
}

View File

@@ -116,8 +116,8 @@ use pmoaudio::{
AudioSegment, AudioSegment,
}; };
use pmoaudiocache::Cache as AudioCache; use pmoaudiocache::Cache as AudioCache;
use pmoflac::{decode_audio_stream, StreamInfo}; use pmoflac::decode_audio_stream;
use pmoplaylist::{PlaylistRole, ReadHandle}; use pmoplaylist::ReadHandle;
use super::pcm_decode::{bytes_to_segment, validate_stream}; use super::pcm_decode::{bytes_to_segment, validate_stream};
use std::{path::PathBuf, sync::Arc, time::Duration}; use std::{path::PathBuf, sync::Arc, time::Duration};

View File

@@ -9,6 +9,7 @@ use crate::{cache::is_lazy_pk, CacheConfig, DB};
/// Trait générique pour les caches de fichiers /// Trait générique pour les caches de fichiers
/// ///
/// Définit l'interface commune pour tous les types de caches (images, audio, etc.) /// 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 { pub trait FileCache<C: CacheConfig>: Send + Sync {
fn get_cache_dir(&self) -> &Path; fn get_cache_dir(&self) -> &Path;
fn get_database(&self) -> Arc<DB>; 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 /// Retourne la route relative pour accéder à un item du cache
/// ///
/// # Arguments /// Format: `/{cache_name}/{cache_type}/{pk}[/{param}]`
///
/// * `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")
fn route_for(&self, pk: &str, param: Option<&str>) -> String { fn route_for(&self, pk: &str, param: Option<&str>) -> String {
if let Some(p) = param { if let Some(p) = param {
format!("/{}/{}/{}/{}", C::cache_name(), C::cache_type(), pk, p) 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 /// Télécharge un fichier depuis une URL et l'ajoute au cache
/// ///
/// # Arguments /// # Arguments

View File

@@ -144,6 +144,22 @@ pub use cache::{
CacheSubscription, CacheSubscription,
}; };
pub use cache_trait::{pk_from_content_header, FileCache}; 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 db::{CacheEntry, DB};
pub use download::{ pub use download::{
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header, download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,

View File

@@ -92,9 +92,17 @@ async fn get_file<C: CacheConfig + 'static>(
Path(pk): Path<String>, Path(pk): Path<String>,
request: axum::http::Request<Body>, request: axum::http::Request<Body>,
) -> Response { ) -> Response {
// Utiliser le param par défaut
let param = C::default_param(); 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} /// 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 // 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; let response = serve_complete_file(file_path, content_type, request).await;
if response.status().is_success() { if response.status().is_success() {
@@ -287,7 +305,7 @@ async fn stream_file_progressive(
request: axum::http::Request<Body>, request: axum::http::Request<Body>,
) -> Response { ) -> Response {
use axum::http::header; use axum::http::header;
use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio::io::AsyncSeekExt;
// Attendre qu'au moins 64 KB soient disponibles avant de commencer // Attendre qu'au moins 64 KB soient disponibles avant de commencer
const MIN_SIZE_TO_START: u64 = 64 * 1024; 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 /// Permet d'initialiser un cache générique avec routes HTTP complètes
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
#[allow(async_fn_in_trait)]
pub trait GenericCacheExt { pub trait GenericCacheExt {
/// Initialise un cache générique avec routes complètes /// Initialise un cache générique avec routes complètes
/// ///

View File

@@ -5,7 +5,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
pmoutils ={ path = "../pmoutils" } pmoutils = { version = "0.1.2", registry = "pmo" }
serde = { workspace = true } serde = { workspace = true }
serde_yaml = { workspace = true } serde_yaml = { workspace = true }

View File

@@ -6,7 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
pmoupnp = { path = "../pmoupnp" } pmoupnp = { path = "../pmoupnp" }
pmodidl = { path = "../pmodidl" } pmodidl = { path = "../pmodidl" }
pmoutils = { path = "../pmoutils" } pmoutils = { version = "0.1.2", registry = "pmo" }
quick-xml = { workspace = true } quick-xml = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
ureq = "3.1.4" ureq = "3.1.4"

View File

@@ -26,7 +26,7 @@ use crate::openapi::{
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView, CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
RendererStateView, RendererStateView,
}; };
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
use crate::registry::DeviceRegistry; use crate::registry::DeviceRegistry;
/// Control point minimal : /// Control point minimal :

View File

@@ -91,6 +91,16 @@ impl UpnpMediaServer {
start: u32, start: u32,
count: u32, count: u32,
) -> Result<Vec<MediaEntry>, ControlPointError> { ) -> 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 start_str = start.to_string();
let count_str = count.to_string(); let count_str = count.to_string();
let args = vec![ let args = vec![
@@ -104,10 +114,12 @@ impl UpnpMediaServer {
let response = self.invoke_content_directory("Browse", None, args)?; let response = self.invoke_content_directory("Browse", None, args)?;
let envelope = response.envelope.ok_or_else(|| { 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")?; 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 { fn has_content_directory(&self) -> bool {
@@ -362,6 +374,13 @@ pub struct MediaEntry {
pub creator: Option<String>, 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. /// Backend-agnostic media browsing contract.
pub trait MediaBrowser { pub trait MediaBrowser {
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError>; fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError>;
@@ -371,6 +390,16 @@ pub trait MediaBrowser {
start: u32, start: u32,
count: u32, count: u32,
) -> Result<Vec<MediaEntry>, ControlPointError>; ) -> 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 browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
fn search( fn search(
&self, &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> { fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
match self { match self {
MusicServer::Upnp(upnp) => upnp.browse_object(object_id), 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) 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> { fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?; let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?;
entries.into_iter().next().ok_or_else(|| { entries.into_iter().next().ok_or_else(|| {
@@ -721,6 +770,17 @@ fn extract_result_payload(
Ok(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> { fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
parent.children.iter().find_map(|node| match node { parent.children.iter().find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),

View File

@@ -35,7 +35,7 @@ use crate::music_renderer::watcher::{
}; };
use crate::online::DeviceConnectionState; use crate::online::DeviceConnectionState;
use crate::queue::{ use crate::queue::{
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot, EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot,
}; };
use crate::{DeviceId, DeviceIdentity, DeviceOnline}; use crate::{DeviceId, DeviceIdentity, DeviceOnline};
@@ -639,6 +639,14 @@ impl MusicRenderer {
); );
self.set_playback_source(PlaybackSource::None); self.set_playback_source(PlaybackSource::None);
self.clear_has_played_flag(); 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() { } else if self.is_playing_from_queue() {
// Only auto-advance if we have actually seen a PLAYING state // Only auto-advance if we have actually seen a PLAYING state
// since the track was started. This prevents auto-advance on // since the track was started. This prevents auto-advance on
@@ -1554,7 +1562,7 @@ pub(crate) fn build_didl_lite_metadata(
protocol_info: &str, protocol_info: &str,
) -> String { ) -> String {
use pmodidl::{DIDLLite, Item, Resource}; use pmodidl::{DIDLLite, Item, Resource};
use pmoutils::ToXmlElement; use pmodidl::ToXmlElement;
// Construire l'Item DIDL avec toutes les métadonnées // Construire l'Item DIDL avec toutes les métadonnées
let item = Item { let item = Item {

View File

@@ -28,6 +28,8 @@ pub struct RendererSummary {
pub capabilities: RendererCapabilitiesSummary, pub capabilities: RendererCapabilitiesSummary,
/// Renderer en ligne /// Renderer en ligne
pub online: bool, 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 /// Protocole exposé par le renderer
@@ -195,8 +197,12 @@ pub struct ContainerEntry {
pub struct BrowseResponse { pub struct BrowseResponse {
/// ID du container browsé /// ID du container browsé
pub container_id: String, pub container_id: String,
/// Entrées du container /// Entrées du container (page courante)
pub entries: Vec<ContainerEntry>, pub entries: Vec<ContainerEntry>,
/// Nombre total d'entrées dans le container
pub total_count: u32,
/// Offset de la page courante
pub offset: u32,
} }
// ============================================================================ // ============================================================================

View File

@@ -27,7 +27,7 @@ use async_trait::async_trait;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
routing::{get, post}, routing::{get, post},
}; };
@@ -43,7 +43,7 @@ use tracing::{debug, warn};
use utoipa::OpenApi; use utoipa::OpenApi;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
const BROWSE_PAGE_SIZE: u32 = 100; const BROWSE_DEFAULT_LIMIT: u32 = 50;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); 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() .into_iter()
.map(|r| { .map(|r| {
let info = r.info(); 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 { RendererSummary {
id: r.id().0.clone(), id: r.id().0.clone(),
friendly_name: r.friendly_name().to_string(), 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()), protocol: protocol_summary(&info.protocol()),
capabilities: capability_summary(&info.capabilities()), capabilities: capability_summary(&info.capabilities()),
online: r.is_online(), online: r.is_online(),
server_host,
} }
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -2042,6 +2050,15 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
Json(summaries) 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 /// GET /control/servers/{server_id}/containers/{container_id} - Browse un container
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
#[utoipa::path( #[utoipa::path(
@@ -2049,7 +2066,9 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
path = "/servers/{server_id}/containers/{container_id}", path = "/servers/{server_id}/containers/{container_id}",
params( params(
("server_id" = String, Path, description = "ID unique du serveur"), ("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( responses(
(status = 200, description = "Contenu du container", body = BrowseResponse), (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( async fn browse_container(
State(state): State<ControlPointState>, State(state): State<ControlPointState>,
Path((server_id, container_id)): Path<(String, String)>, Path((server_id, container_id)): Path<(String, String)>,
Query(params): Query<BrowseParams>,
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
let sid = DeviceId(server_id.clone()); 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 // Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls
let container_id_clone = container_id.clone(); let container_id_clone = container_id.clone();
let server_clone = server.clone(); let server_clone = server.clone();
let browse_task = tokio::task::spawn_blocking(move || { 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 .await
.map_err(|_| { .map_err(|_| {
warn!( warn!(
@@ -2137,14 +2160,14 @@ async fn browse_container(
) )
})?; })?;
let container_entries: Vec<ContainerEntry> = entries let container_entries: Vec<ContainerEntry> = page.entries
.into_iter() .into_iter()
.map(|e| ContainerEntry { .map(|e| ContainerEntry {
id: e.id, id: e.id,
title: e.title, title: e.title,
class: e.class, class: e.class,
is_container: e.is_container, is_container: e.is_container,
child_count: None, // Could be extracted from DIDL-Lite if needed child_count: None,
artist: e.artist, artist: e.artist,
album: e.album, album: e.album,
album_art_uri: e.album_art_uri, album_art_uri: e.album_art_uri,
@@ -2154,6 +2177,8 @@ async fn browse_container(
Ok(Json(BrowseResponse { Ok(Json(BrowseResponse {
container_id, container_id,
entries: container_entries, entries: container_entries,
total_count: page.total_count,
offset,
})) }))
} }
@@ -2210,7 +2235,7 @@ fn fetch_playback_items(
let entries = if object_metadata.is_container { let entries = if object_metadata.is_container {
// For containers, browse children to get all items // 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 { } else {
// For items, use the object itself // For items, use the object itself
vec![object_metadata] vec![object_metadata]

View File

@@ -10,5 +10,4 @@ utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
quick-xml = { workspace = true } quick-xml = { workspace = true }
bevy_reflect = "0.17.1" bevy_reflect = "0.17.1"
bevy_reflect_derive = "0.17.1" bevy_reflect_derive = "0.17.1"
pmoutils = { path = "../pmoutils" }
xmltree = "0.10" xmltree = "0.10"

View File

@@ -3,14 +3,31 @@
//! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA. //! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA.
use bevy_reflect::Reflect; use bevy_reflect::Reflect;
use pmoutils::ToXmlElement;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashSet; use std::collections::HashSet;
use std::fmt::Write; use std::fmt::Write;
use std::io::Cursor; use std::io::Cursor;
use std::time::SystemTime; 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 ============= // ============= Couche d'abstraction générique =============

View File

@@ -18,7 +18,7 @@ quick-xml = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
pmoutils = { path = "../pmoutils" } pmoutils = { version = "0.1.2", registry = "pmo" }
# Optional dependencies # Optional dependencies
axum = { version = "0.8", optional = true } axum = { version = "0.8", optional = true }

View File

@@ -13,7 +13,7 @@
use pmodidl::{Container, DIDLLite}; use pmodidl::{Container, DIDLLite};
use pmosource::api::{get_source as get_source_from_registry, list_all_sources}; use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
use pmosource::{BrowseResult, MusicSource, MusicSourceError}; use pmosource::{BrowseResult, MusicSource, MusicSourceError};
use pmoutils::ToXmlElement; use pmodidl::ToXmlElement;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;

View File

@@ -63,6 +63,7 @@ pmoplaylist = { path = "../pmoplaylist" }
pmoconfig = { path = "../pmoconfig", optional = true } pmoconfig = { path = "../pmoconfig", optional = true }
# Cache support (OBLIGATOIRE - architecture refactorisée) # Cache support (OBLIGATOIRE - architecture refactorisée)
pmocache = { path = "../pmocache" }
pmocovers = { path = "../pmocovers" } pmocovers = { path = "../pmocovers" }
pmoaudiocache = { path = "../pmoaudiocache" } pmoaudiocache = { path = "../pmoaudiocache" }

View File

@@ -213,7 +213,7 @@ impl RadioParadiseSource {
let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string()); let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string());
let cover_url = cover_pk let cover_url = cover_pk
.as_ref() .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(|| json["cover_url"].as_str().map(|s| s.to_string()))
.or_else(|| Some(self.default_cover_url())); .or_else(|| Some(self.default_cover_url()));

View File

@@ -517,7 +517,7 @@ fn playlist_track_to_response(
} }
fn cover_url_from_pk(pk: &str) -> String { 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> { fn normalize_cover_pk(input: Option<String>) -> Option<String> {

View File

@@ -182,7 +182,7 @@ impl ReadHandle {
let _remaining = self.remaining().await?; let _remaining = self.remaining().await?;
// Convertir cover_pk en URL si présent // 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 { Ok(Container {
id: self.playlist.id.clone(), id: self.playlist.id.clone(),
@@ -253,7 +253,7 @@ impl ReadHandle {
let track_number = meta.get_track_number().await.ok().flatten(); let track_number = meta.get_track_number().await.ok().flatten();
let cover_pk = meta.get_cover_pk().await.ok().flatten(); let cover_pk = meta.get_cover_pk().await.ok().flatten();
let cover_url = if let Some(pk) = cover_pk.as_ref() { 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 { } else {
meta.get_cover_url().await.ok().flatten() meta.get_cover_url().await.ok().flatten()
}; };

View File

@@ -15,7 +15,7 @@ use serde::de::DeserializeOwned;
use serde_json::Value; use serde_json::Value;
use std::sync::RwLock; use std::sync::RwLock;
use std::time::Duration; use std::time::Duration;
use tracing::{debug, warn}; use tracing::debug;
pub use spoofer::Spoofer; pub use spoofer::Spoofer;

View File

@@ -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 { if let Some(ref image_url) = album.image {
match cover_cache.add_from_url(image_url, None).await { match cover_cache.add_from_url(image_url, None).await {
Ok(pk) => { Ok(pk) => {
album.image_cached = Some(format!("/covers/images/{}", pk)); album.image_cached = Some(pmocache::covers_route_for(&pk, None));
} }
Err(e) => { Err(e) => {
tracing::warn!("Failed to cache album image: {}", e); tracing::warn!("Failed to cache album image: {}", e);

View File

@@ -19,6 +19,39 @@ use std::time::{Duration, SystemTime};
/// TTL pour les playlists d'albums (7 jours) /// TTL pour les playlists d'albums (7 jours)
const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600); 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) /// Default image for Qobuz (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); 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 /// Browse Favourites - retourne 4 sous-containers
async fn browse_favourites(&self) -> Result<BrowseResult> { async fn browse_favourites(&self) -> Result<BrowseResult> {
let containers = vec![ let containers = vec![
@@ -873,6 +977,7 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let albums = self.cache_album_covers(albums).await;
let containers: Vec<Container> = albums let containers: Vec<Container> = albums
.into_iter() .into_iter()
.filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok()) .filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok())
@@ -890,6 +995,7 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let tracks = self.cache_covers(tracks).await;
let items: Vec<Item> = tracks let items: Vec<Item> = tracks
.into_iter() .into_iter()
.filter_map(|track| track.to_didl_item("qobuz:favorites:tracks").ok()) .filter_map(|track| track.to_didl_item("qobuz:favorites:tracks").ok())
@@ -907,23 +1013,21 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let artists = self.cache_covers(artists).await;
let containers: Vec<Container> = artists let containers: Vec<Container> = artists
.into_iter() .into_iter()
.filter_map(|artist| { .map(|artist| Container {
// Créer un container pour chaque artiste id: format!("qobuz:artist:{}", artist.id),
Some(Container { parent_id: "qobuz:favorites:artists".to_string(),
id: format!("qobuz:artist:{}", artist.id), restricted: Some("1".to_string()),
parent_id: "qobuz:favorites:artists".to_string(), child_count: None,
restricted: Some("1".to_string()), searchable: Some("1".to_string()),
child_count: None, title: artist.name.clone(),
searchable: Some("1".to_string()), class: "object.container".to_string(),
title: artist.name.clone(), artist: Some(artist.name.clone()),
class: "object.container".to_string(), album_art: artist.image_cached.clone(),
artist: Some(artist.name.clone()), containers: vec![],
album_art: artist.image_cached.clone().or_else(|| artist.image.clone()), items: vec![],
containers: vec![],
items: vec![],
})
}) })
.collect(); .collect();
@@ -939,6 +1043,7 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let playlists = self.cache_playlist_covers(playlists).await;
let containers: Vec<Container> = playlists let containers: Vec<Container> = playlists
.into_iter() .into_iter()
.filter_map(|playlist| playlist.to_didl_container("qobuz:favorites:playlists").ok()) .filter_map(|playlist| playlist.to_didl_container("qobuz:favorites:playlists").ok())
@@ -1061,76 +1166,56 @@ impl QobuzSource {
} }
} }
/// Browse Discover Playlists (all featured playlists) /// Helper : récupère des albums featured, cache les covers, retourne des containers DIDL
async fn browse_discover_playlists(&self) -> Result<BrowseResult> { 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 let playlists = self
.inner .inner
.client .client
.get_featured_playlists(None, None) .get_featured_playlists(genre_id, None)
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let playlists = self.cache_playlist_covers(playlists).await;
let containers: Vec<Container> = playlists let containers: Vec<Container> = playlists
.into_iter() .into_iter()
.filter_map(|playlist| playlist.to_didl_container("qobuz:discover:playlists").ok()) .filter_map(|playlist| playlist.to_didl_container(parent_id).ok())
.collect(); .collect();
Ok(BrowseResult::Containers(containers)) 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> { async fn browse_discover_albums_ideal(&self) -> Result<BrowseResult> {
let albums = self self.browse_featured_albums(None, "ideal-discography", "qobuz:discover:albums:ideal").await
.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))
} }
/// Browse Discover Albums (Qobuzissime)
async fn browse_discover_albums_qobuzissime(&self) -> Result<BrowseResult> { async fn browse_discover_albums_qobuzissime(&self) -> Result<BrowseResult> {
let albums = self self.browse_featured_albums(None, "qobuzissims", "qobuz:discover:albums:qobuzissime").await
.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))
} }
/// Browse Discover Albums (New Releases)
async fn browse_discover_albums_new(&self) -> Result<BrowseResult> { async fn browse_discover_albums_new(&self) -> Result<BrowseResult> {
let albums = self self.browse_featured_albums(None, "new-releases", "qobuz:discover:albums:new").await
.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))
} }
/// Browse Discover Artists (Featured Artists) /// Browse Discover Artists (Featured Artists)
@@ -1142,6 +1227,7 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let artists = self.cache_covers(artists).await;
let containers: Vec<Container> = artists let containers: Vec<Container> = artists
.into_iter() .into_iter()
.map(|artist| Container { .map(|artist| Container {
@@ -1153,7 +1239,7 @@ impl QobuzSource {
title: artist.name.clone(), title: artist.name.clone(),
class: "object.container".to_string(), class: "object.container".to_string(),
artist: Some(artist.name.clone()), artist: Some(artist.name.clone()),
album_art: artist.image_cached.clone().or_else(|| artist.image.clone()), album_art: artist.image_cached.clone(),
containers: vec![], containers: vec![],
items: vec![], items: vec![],
}) })
@@ -1171,6 +1257,7 @@ impl QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let playlists = self.cache_covers(playlists).await;
let parent_id = format!("qobuz:discover:playlists:{}", tag); let parent_id = format!("qobuz:discover:playlists:{}", tag);
let containers: Vec<Container> = playlists let containers: Vec<Container> = playlists
.into_iter() .into_iter()
@@ -1324,112 +1411,28 @@ impl QobuzSource {
} }
} }
/// Browse Genre New Releases
async fn browse_genre_new_releases(&self, genre_id: &str) -> Result<BrowseResult> { async fn browse_genre_new_releases(&self, genre_id: &str) -> Result<BrowseResult> {
let albums = self self.browse_featured_albums(Some(genre_id), "new-releases", &format!("qobuz:genre:{}:new-releases", genre_id)).await
.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))
} }
/// Browse Genre Ideal Discography
async fn browse_genre_ideal_discography(&self, genre_id: &str) -> Result<BrowseResult> { async fn browse_genre_ideal_discography(&self, genre_id: &str) -> Result<BrowseResult> {
let albums = self self.browse_featured_albums(Some(genre_id), "ideal-discography", &format!("qobuz:genre:{}:ideal", genre_id)).await
.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))
} }
/// Browse Genre Qobuzissime
async fn browse_genre_qobuzissime(&self, genre_id: &str) -> Result<BrowseResult> { async fn browse_genre_qobuzissime(&self, genre_id: &str) -> Result<BrowseResult> {
let albums = self self.browse_featured_albums(Some(genre_id), "qobuzissims", &format!("qobuz:genre:{}:qobuzissime", genre_id)).await
.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))
} }
/// Browse Genre Editor Picks
async fn browse_genre_editor_picks(&self, genre_id: &str) -> Result<BrowseResult> { async fn browse_genre_editor_picks(&self, genre_id: &str) -> Result<BrowseResult> {
let albums = self self.browse_featured_albums(Some(genre_id), "editor-picks", &format!("qobuz:genre:{}:editor-picks", genre_id)).await
.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))
} }
/// Browse Genre Press Awards
async fn browse_genre_press_awards(&self, genre_id: &str) -> Result<BrowseResult> { async fn browse_genre_press_awards(&self, genre_id: &str) -> Result<BrowseResult> {
let albums = self self.browse_featured_albums(Some(genre_id), "press-awards", &format!("qobuz:genre:{}:press-awards", genre_id)).await
.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))
} }
/// Browse Genre Playlists
async fn browse_genre_playlists(&self, genre_id: &str) -> Result<BrowseResult> { async fn browse_genre_playlists(&self, genre_id: &str) -> Result<BrowseResult> {
let playlists = self self.browse_featured_playlists(Some(genre_id), &format!("qobuz:genre:{}:playlists", genre_id)).await
.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))
} }
/// Parse object_id to determine what to browse /// Parse object_id to determine what to browse
@@ -1620,7 +1623,6 @@ impl MusicSource for QobuzSource {
} }
ObjectIdType::Playlist(playlist_id) => { ObjectIdType::Playlist(playlist_id) => {
// Get tracks in playlist
let tracks = self let tracks = self
.inner .inner
.client .client
@@ -1628,6 +1630,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let tracks = self.cache_covers(tracks).await;
let items: Vec<Item> = tracks let items: Vec<Item> = tracks
.into_iter() .into_iter()
.filter_map(|track| { .filter_map(|track| {
@@ -1641,7 +1644,6 @@ impl MusicSource for QobuzSource {
} }
ObjectIdType::Artist(artist_id) => { ObjectIdType::Artist(artist_id) => {
// Get albums by artist
let albums = self let albums = self
.inner .inner
.client .client
@@ -1649,6 +1651,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let albums = self.cache_covers(albums).await;
let containers: Vec<Container> = albums let containers: Vec<Container> = albums
.into_iter() .into_iter()
.filter_map(|album| { .filter_map(|album| {
@@ -1722,12 +1725,9 @@ impl MusicSource for QobuzSource {
// Get cover URL from cache if available // Get cover URL from cache if available
let cover_url = if let Some(ref album) = track.album { let cover_url = if let Some(ref album) = track.album {
if let Some(ref cached) = album.image_cached { if let Some(ref image) = album.image {
Some(format!("{}{}", self.inner.base_url, cached))
} else if let Some(ref image) = album.image {
// Try to cache it
if let Ok(pk) = self.inner.cache_manager.cache_cover(image).await { 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 { } else {
Some(image.clone()) Some(image.clone())
} }
@@ -1802,6 +1802,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let all_tracks = self.cache_covers(all_tracks).await;
let items: Vec<Item> = all_tracks let items: Vec<Item> = all_tracks
.into_iter() .into_iter()
.skip(offset) .skip(offset)
@@ -1821,15 +1822,16 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Convert albums to containers and tracks to items let (albums, tracks) = tokio::join!(
let containers: Vec<Container> = results self.cache_covers(results.albums),
.albums self.cache_covers(results.tracks),
);
let containers: Vec<Container> = albums
.into_iter() .into_iter()
.filter_map(|album| album.to_didl_container("qobuz").ok()) .filter_map(|album| album.to_didl_container("qobuz").ok())
.collect(); .collect();
let items: Vec<Item> = results let items: Vec<Item> = tracks
.tracks
.into_iter() .into_iter()
.filter_map(|track| track.to_didl_item("qobuz").ok()) .filter_map(|track| track.to_didl_item("qobuz").ok())
.collect(); .collect();
@@ -2043,6 +2045,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
let playlists = self.cache_covers(playlists).await;
let containers: Vec<Container> = playlists let containers: Vec<Container> = playlists
.into_iter() .into_iter()
.filter_map(|playlist| playlist.to_didl_container("qobuz").ok()) .filter_map(|playlist| playlist.to_didl_container("qobuz").ok())
@@ -2125,6 +2128,7 @@ impl MusicSource for QobuzSource {
.await .await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let albums = self.cache_covers(albums).await;
let containers: Vec<Container> = albums let containers: Vec<Container> = albums
.into_iter() .into_iter()
.skip(offset) .skip(offset)

View File

@@ -34,12 +34,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if let Some(intro) = &metadata.now.intro { if let Some(intro) = &metadata.now.intro {
let short_intro = if intro.len() > 100 { let short_intro: String = intro.chars().take(100).collect();
format!("{}...", &intro[..100]) let suffix = if intro.chars().count() > 100 { "..." } else { "" };
} else { println!(" Description: {}{}", short_intro, suffix);
intro.clone()
};
println!(" Description: {}", short_intro);
} }
// Song info (for music stations) // Song info (for music stations)

View File

@@ -28,17 +28,26 @@
//! ``` //! ```
use crate::error::{Error, Result}; 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 regex::Regex;
use reqwest::Client; use reqwest::Client;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use std::time::Duration; use std::time::Duration;
use url::Url;
#[cfg(feature = "pmoconfig")]
use crate::config_ext::StationInfo;
/// Default Radio France base URL /// Default Radio France base URL
pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr"; 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) /// Default timeout for HTTP requests (30 seconds)
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
@@ -56,6 +65,103 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
("francebleu", "France Bleu"), ("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 /// Radio France HTTP client
/// ///
/// This client provides access to Radio France's public APIs for: /// 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) /// - Live metadata (current show, next show, stream URLs)
/// - Image URL construction (Pikapi) /// - Image URL construction (Pikapi)
/// ///
/// The client is stateless and does not cache responses internally. /// The client holds an in-memory station mapping (slug → numeric ID + stream URL)
/// Caching should be handled by higher layers (e.g., config extension). /// seeded from hardcoded constants. Use `with_station_mapping()` to inject a
/// mapping loaded from persistent storage (pmoconfig).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RadioFranceClient { pub struct RadioFranceClient {
pub(crate) client: Client, pub(crate) client: Client,
base_url: String, base_url: String,
timeout: Duration, 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 { impl RadioFranceClient {
@@ -91,6 +207,7 @@ impl RadioFranceClient {
client, client,
base_url: DEFAULT_BASE_URL.to_string(), base_url: DEFAULT_BASE_URL.to_string(),
timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), 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 &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 // Station Discovery
// ======================================================================== // ========================================================================
@@ -254,20 +454,71 @@ impl RadioFranceClient {
.collect()) .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 /// Scrapes francebleu page for local station slugs and discovers their IDs.
/// a `localRadios` array in the `now` field. /// Met à jour le station_mapping avec les IDs trouvés.
pub async fn discover_local_radios(&self) -> Result<Vec<Station>> { 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 // Extraire les slugs "francebleu_xxx" et les paires (slug, id) de la page
.local_radios() let re_slug_id = Regex::new(r#""(francebleu_[a-z_]+)","(\d+)""#)?;
.cloned() let mut stations = Vec::new();
.unwrap_or_default() 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() .into_iter()
.filter(|local| local.is_on_air) .map(|slug| {
.map(|local| Station::new(local.name, local.title)) let name = Self::slug_to_display_name(&slug);
Station::new(slug, name)
})
.collect()) .collect())
} }
@@ -370,28 +621,42 @@ impl RadioFranceClient {
/// # } /// # }
/// ``` /// ```
pub async fn live_metadata(&self, station: &str) -> Result<LiveResponse> { 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))?; let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
// Add webradio parameter if needed
if let Some(wr) = webradio {
url.query_pairs_mut().append_pair("webradio", wr);
}
#[cfg(feature = "logging")] #[cfg(feature = "logging")]
tracing::debug!("Fetching live metadata: {}", url); 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() { if !response.status().is_success() {
return Err(Error::ApiError(format!( return Err(Error::ApiError(format!(
"API returned status: {}", "livemeta API returned status {} for station {} (id={})",
response.status() 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")] #[cfg(feature = "logging")]
tracing::debug!( tracing::debug!(
@@ -404,6 +669,268 @@ impl RadioFranceClient {
Ok(live) 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 /// Get only the current show metadata
pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> { pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> {
let response = self.live_metadata(station).await?; 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> { pub async fn get_hifi_stream_url(&self, station: &str) -> Result<String> {
let metadata = self.live_metadata(station).await?; self.stream_url_for_slug(station)
metadata
.now
.media
.best_hifi_stream()
.map(|s| s.url.clone())
.ok_or_else(|| Error::NoHifiStream(station.to_string())) .ok_or_else(|| Error::NoHifiStream(station.to_string()))
} }
@@ -619,6 +1140,7 @@ impl ClientBuilder {
client, client,
base_url: self.base_url, base_url: self.base_url,
timeout: self.timeout, timeout: self.timeout,
station_mapping: Arc::new(RwLock::new(RadioFranceClient::default_station_mapping())),
}) })
} }
} }
@@ -644,7 +1166,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
RadioFranceClient::parse_station_slug("francebleu_alsace"), RadioFranceClient::parse_station_slug("francebleu_alsace"),
("francebleu_alsace", None) ("francebleu", Some("francebleu_alsace"))
); );
assert_eq!( assert_eq!(
RadioFranceClient::parse_station_slug("franceculture"), RadioFranceClient::parse_station_slug("franceculture"),

View File

@@ -37,11 +37,24 @@ use anyhow::Result;
use pmoconfig::Config; use pmoconfig::Config;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml::Value; use serde_yaml::Value;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
/// Default TTL for station list cache (7 days in seconds) /// Default TTL for station list cache (7 days in seconds)
pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600; 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é) /// Cached station list (simplifié)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedStations { struct CachedStations {
@@ -49,6 +62,14 @@ struct CachedStations {
last_updated: u64, // Unix timestamp 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 /// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
/// ///
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques /// 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) /// Efface le cache des stations (force re-découverte)
fn clear_radiofrance_station_cache(&self) -> Result<()>; 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 { impl RadioFranceConfigExt for Config {
@@ -201,6 +252,90 @@ impl RadioFranceConfigExt for Config {
// Set to null to clear // Set to null to clear
self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null) 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)] #[cfg(test)]

View File

@@ -122,7 +122,7 @@ pub use models::{
}; };
#[cfg(feature = "pmoconfig")] #[cfg(feature = "pmoconfig")]
pub use config_ext::RadioFranceConfigExt; pub use config_ext::{RadioFranceConfigExt, StationInfo};
#[cfg(feature = "pmoconfig")] #[cfg(feature = "pmoconfig")]
pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback}; pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback};

View File

@@ -12,9 +12,11 @@
//! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements //! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements
use crate::client::RadioFranceClient; use crate::client::RadioFranceClient;
use crate::config_ext::{RadioFranceConfigExt, StationInfo};
use crate::error::Result; use crate::error::Result;
use crate::models::{ImageSize, LiveResponse, Station}; use crate::models::{ImageSize, LiveResponse, Station};
use pmoconfig::Config; use pmoconfig::Config;
#[cfg(feature = "playlist")]
use pmodidl::{Container, Item, Resource}; use pmodidl::{Container, Item, Resource};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -59,6 +61,9 @@ pub struct CachedMetadata {
// TTL = end_time de l'API Radio France // TTL = end_time de l'API Radio France
pub end_time: Option<u64>, // Unix timestamp pub end_time: Option<u64>, // Unix timestamp
/// Timestamp de la dernière récupération (pour TTL minimum)
pub fetched_at: u64,
} }
impl CachedMetadata { impl CachedMetadata {
@@ -89,6 +94,10 @@ impl CachedMetadata {
// 4. TTL = end_time // 4. TTL = end_time
let end_time = live.now.end_time; let end_time = live.now.end_time;
let fetched_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(Self { Ok(Self {
slug: station.slug.clone(), slug: station.slug.clone(),
@@ -106,6 +115,7 @@ impl CachedMetadata {
nr_audio_channels, nr_audio_channels,
duration, duration,
end_time, end_time,
fetched_at,
}) })
} }
@@ -238,15 +248,16 @@ impl CachedMetadata {
match cache.add_from_url(&cover_url, Some("radiofrance")).await { match cache.add_from_url(&cover_url, Some("radiofrance")).await {
Ok(pk) => { Ok(pk) => {
// Construire l'URL publique // Construire l'URL publique
let route = cache.route_for(&pk, None); // Note: add_from_url() lance le téléchargement complet en arrière-plan.
let public_url = format!("{}{}", server_base_url.trim_end_matches('/'), route); // 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")] #[cfg(feature = "logging")]
tracing::debug!( tracing::debug!(
"Cached cover - UUID: {}, PK: {}, route: {}, public_url: {}", "Cached cover - UUID: {}, PK: {}, public_url: {}",
uuid, uuid,
pk, pk,
route,
public_url public_url
); );
@@ -349,6 +360,7 @@ impl CachedMetadata {
/// Construit un Container DIDL de playlist à un item /// Construit un Container DIDL de playlist à un item
/// ///
/// La playlist et l'item ont EXACTEMENT les mêmes métadonnées /// 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 { pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container {
// Calculer la duration dynamiquement (temps restant jusqu'à end_time) // Calculer la duration dynamiquement (temps restant jusqu'à end_time)
let duration = if let Some(end) = self.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é /// 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 { 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 { if let Some(end_time) = self.end_time {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
now >= end_time now >= end_time
} else { } else {
// Pas de end_time = toujours expiré (refresh systématique) now >= self.fetched_at + Self::FALLBACK_TTL_SECS
true
} }
} }
} }
@@ -478,6 +496,9 @@ pub struct MetadataCache {
impl MetadataCache { impl MetadataCache {
/// Constructeur avec tous les paramètres obligatoires /// 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")] #[cfg(feature = "cache")]
pub fn new( pub fn new(
client: RadioFranceClient, client: RadioFranceClient,
@@ -485,6 +506,25 @@ impl MetadataCache {
server_base_url: String, server_base_url: String,
config: Arc<Config>, config: Arc<Config>,
) -> Self { ) -> 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 { Self {
cache: Arc::new(RwLock::new(HashMap::new())), cache: Arc::new(RwLock::new(HashMap::new())),
client, client,
@@ -525,18 +565,46 @@ impl MetadataCache {
let live_response = match self.client.live_metadata(slug).await { let live_response = match self.client.live_metadata(slug).await {
Ok(resp) => resp, Ok(resp) => resp,
Err(e) => { Err(e) => {
// Graceful degradation: retourner les données expirées si API down // Si station inconnue → tenter une re-découverte du mapping
let cache = self.cache.read().await; let is_unknown = e.to_string().contains("Unknown station slug");
if let Some(metadata) = cache.get(slug) { if is_unknown {
#[cfg(feature = "logging")] #[cfg(feature = "logging")]
tracing::warn!( tracing::warn!("Unknown station '{}', attempting rediscovery", slug);
"API Radio France down for {}, using expired cache: {}",
slug, match self.client.rediscover_station(slug).await {
e Ok(_) => {
); // Persister le mapping mis à jour
return Ok(metadata.clone()); 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) 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 /// # Logique
/// ///
/// 1. Essaie de lire depuis pmoconfig /// 1. Essaie de lire depuis pmoconfig (cache TTL 7 jours)
/// 2. Si cache valide (TTL 1 semaine), retourne /// 2. Si cache valide, retourne sans appel réseau
/// 3. Sinon, découvre via API et met à jour pmoconfig /// 3. Sinon, découvre via scraping et met à jour pmoconfig
pub async fn get_stations(&self) -> Result<Vec<Station>> { pub async fn get_stations(&self) -> Result<Vec<Station>> {
// TODO: Implémenter avec config_ext // 1. Essayer le cache persistant
// Pour l'instant, découvre directement if let Ok(Some(stations)) = self.config.get_radiofrance_stations_cached() {
self.client.discover_all_stations().await #[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) /// 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 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 /// S'abonner aux changements de métadonnées
/// ///
/// Le callback sera appelé avec le slug chaque fois que /// Le callback sera appelé avec le slug chaque fois que
@@ -634,7 +773,12 @@ mod tests {
#[test] #[test]
fn test_cached_metadata_is_expired() { 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(), slug: "test".to_string(),
title: "Test".to_string(), title: "Test".to_string(),
creator: None, creator: None,
@@ -650,15 +794,26 @@ mod tests {
nr_audio_channels: None, nr_audio_channels: None,
duration: None, duration: None,
end_time: Some(0), // Dans le passé 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, end_time: None,
..metadata fetched_at: now - CachedMetadata::FALLBACK_TTL_SECS - 1,
..base
}; };
assert!(no_end_old.is_expired());
assert!(metadata_no_end.is_expired());
} }
} }

View File

@@ -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)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct LiveResponse { pub struct LiveResponse {
/// Station name (slug) /// Station slug
pub station_name: String, pub station_name: String,
/// Recommended delay before next refresh (milliseconds) /// Recommended delay before next refresh (milliseconds)
pub delay_to_refresh: u64, pub delay_to_refresh: u64,
@@ -59,7 +162,7 @@ impl LiveResponse {
} }
/// Metadata for a show or track currently playing /// Metadata for a show or track currently playing
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ShowMetadata { pub struct ShowMetadata {
/// Whether to display music program info /// Whether to display music program info

View File

@@ -98,18 +98,26 @@ impl StationGroups {
/// Niveau 0: to_didl() retourne le container "radiofrance" avec tous les groupes /// 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( pub async fn to_didl(
&self, &self,
metadata_cache: &MetadataCache, metadata_cache: &MetadataCache,
server_base_url: &str, server_base_url: &str,
) -> Result<Container> { ) -> Result<Container> {
let mut containers = Vec::new(); use futures::stream::{self, StreamExt};
for group in &self.groups { let futures: Vec<_> = self
let container = group.to_stub(metadata_cache, server_base_url).await?; .groups
containers.push(container); .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 { Ok(Container {
id: "radiofrance".to_string(), id: "radiofrance".to_string(),

View File

@@ -50,6 +50,7 @@ impl RadioFranceState {
/// Ok(()) /// Ok(())
/// } /// }
/// ``` /// ```
#[allow(async_fn_in_trait)]
pub trait RadioFranceExt { pub trait RadioFranceExt {
/// Initialise l'extension Radio France et enregistre les routes HTTP /// Initialise l'extension Radio France et enregistre les routes HTTP
/// ///

View File

@@ -114,14 +114,16 @@ impl Server {
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self { 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 api_registry = Arc::new(RwLock::new(Vec::new()));
let base_url = base_url.into();
// Créer le router initial avec l'endpoint de registre // Créer le router initial avec l'endpoint de registre
let registry_route = Router::new() let registry_route = Router::new()
.route("/api/registry", get(get_api_registry)) .route("/api/registry", get(get_api_registry))
.with_state(api_registry.clone()); .with_state(api_registry.clone());
Self { let server = Self {
name: name.into(), name: name.into(),
base_url: base_url.into(), base_url,
http_port, http_port,
router: Arc::new(RwLock::new(registry_route)), router: Arc::new(RwLock::new(registry_route)),
api_router: Arc::new(RwLock::new(None)), api_router: Arc::new(RwLock::new(None)),
@@ -129,7 +131,14 @@ impl Server {
log_state: None, log_state: None,
api_registry, api_registry,
shutdown_token: CancellationToken::new(), 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 { pub fn new_configured() -> Self {

View File

@@ -919,7 +919,7 @@ async fn stream_source_item_metadata(
Query(params): Query<ObjectQuery>, Query(params): Query<ObjectQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use futures::stream::{self, Stream}; use futures::stream;
use std::convert::Infallible; use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
use tokio_stream::StreamExt as _; use tokio_stream::StreamExt as _;

View File

@@ -6,7 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
pmoconfig = { path = "../pmoconfig" } pmoconfig = { path = "../pmoconfig" }
pmodidl = { path = "../pmodidl"} pmodidl = { path = "../pmodidl"}
pmoutils = { path = "../pmoutils" } pmoutils = { version = "0.1.2", registry = "pmo" }
pmoserver = { path = "../pmoserver" } pmoserver = { path = "../pmoserver" }
pmocovers = { path = "../pmocovers", features = ["pmoserver"] } pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] } pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] }

View File

@@ -54,18 +54,10 @@ pub fn get_audio_cache() -> Option<Arc<AudioCache>> {
/// // url = "http://localhost:8080/covers/images/abc123/300" /// // url = "http://localhost:8080/covers/images/abc123/300"
/// ``` /// ```
pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String> { 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 Ok(pmocache::covers_absolute_url_for(
let base_url = pk,
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); size.map(|s| s.to_string()).as_deref(),
))
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))
} }
/// Construit l'URL complète pour une piste audio /// 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" /// // url = "http://localhost:8080/audio/tracks/abc123/stream"
/// ``` /// ```
pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result<String> { 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 cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
Ok(cache.absolute_url_for(pk, param))
let route = cache.route_for(pk, param);
Ok(format!("{}{}", base_url, route))
} }

View File

@@ -97,6 +97,7 @@ pub trait UpnpServerExt {
async fn register_device( async fn register_device(
&mut self, &mut self,
device: Arc<Device>, device: Arc<Device>,
with_ssdp: bool,
) -> Result<Arc<DeviceInstance>, DeviceError>; ) -> Result<Arc<DeviceInstance>, DeviceError>;
/// Retourne le nombre de devices enregistrés. /// Retourne le nombre de devices enregistrés.
@@ -232,6 +233,7 @@ impl UpnpServerExt for Server {
async fn register_device( async fn register_device(
&mut self, &mut self,
device: Arc<Device>, device: Arc<Device>,
with_ssdp: bool,
) -> Result<Arc<DeviceInstance>, DeviceError> { ) -> Result<Arc<DeviceInstance>, DeviceError> {
use tracing::info; use tracing::info;
@@ -259,8 +261,8 @@ impl UpnpServerExt for Server {
.register(di.clone()) .register(di.clone())
.map_err(|e| DeviceError::UrlRegistrationError(e))?; .map_err(|e| DeviceError::UrlRegistrationError(e))?;
// Annoncer via SSDP (si initialisé) // Annoncer via SSDP (si initialisé et demandé)
if self.ssdp_enabled() { if with_ssdp && self.ssdp_enabled() {
let ssdp_opt = SSDP_SERVER.read().unwrap(); let ssdp_opt = SSDP_SERVER.read().unwrap();
if let Some(ref ssdp) = *ssdp_opt { if let Some(ref ssdp) = *ssdp_opt {
use crate::config_ext::UpnpConfigExt; use crate::config_ext::UpnpConfigExt;
@@ -428,7 +430,7 @@ impl UpnpServerExt for Server {
let kind = e.kind(); let kind = e.kind();
if kind == std::io::ErrorKind::AddrInUse { if kind == std::io::ErrorKind::AddrInUse {
let port = crate::ssdp::SSDP_PORT; 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!( error!(
"❌ SSDP initialization failed: port {} is already in use by \ "❌ SSDP initialization failed: port {} is already in use by \
PID {} ({}) owned by {}: {}", PID {} ({}) owned by {}: {}",
@@ -507,7 +509,7 @@ mod tests {
"Test Renderer".to_string(), "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 // Vérifier que le device est dans le registre
assert_eq!(server.device_count(), 1); assert_eq!(server.device_count(), 1);

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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")
}
}

View File

@@ -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,
})
}

View File

@@ -40,7 +40,7 @@ thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
pmodidl = { path = "../pmodidl" } pmodidl = { path = "../pmodidl" }
pmoutils = { path = "../pmoutils" } pmoutils = { version = "0.1.2", registry = "pmo" }
[features] [features]
default = [] default = []

View File

@@ -15,7 +15,7 @@ use pmocontrol::ControlPoint;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
use crate::error::WebRendererError; use crate::error::WebRendererError;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
use crate::register::{register_handler, unregister_handler}; use crate::register::{position_update_handler, register_handler, unregister_handler};
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
use crate::registry::RendererRegistry; use crate::registry::RendererRegistry;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
@@ -48,9 +48,10 @@ impl WebRendererExt for pmoserver::Server {
) )
.await; .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() let dynamic_router = Router::new()
.route("/{id}/stream", get(stream_handler)) .route("/{id}/stream", get(stream_handler))
.route("/{id}/position", post(position_update_handler))
.route("/{id}", delete(unregister_handler)) .route("/{id}", delete(unregister_handler))
.with_state(registry.clone()); .with_state(registry.clone());
self.add_router("/api/webrenderer", dynamic_router).await; 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!("WebRenderer server-side streaming endpoints registered");
tracing::info!(" POST /api/webrenderer/register"); tracing::info!(" POST /api/webrenderer/register");
tracing::info!(" GET /api/webrenderer/{{id}}/stream"); tracing::info!(" GET /api/webrenderer/{{id}}/stream");
tracing::info!(" POST /api/webrenderer/{{id}}/position");
tracing::info!(" DELETE /api/webrenderer/{{id}}"); tracing::info!(" DELETE /api/webrenderer/{{id}}");
Ok(()) Ok(())
} }

View File

@@ -8,10 +8,10 @@ use std::sync::Arc;
use pmodidl::DIDLLite; use pmodidl::DIDLLite;
use pmoupnp::actions::{ActionData, ActionError, ActionHandler, get_value}; use pmoupnp::actions::{ActionData, ActionError, ActionHandler, get_value};
use pmoupnp::{get, set}; use pmoupnp::{get, set};
use pmoutils::ToXmlElement; use pmodidl::ToXmlElement;
use crate::messages::PlaybackState; 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; use crate::state::SharedState;
type ActionFuture = type ActionFuture =
@@ -226,13 +226,11 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
// ─── RenderingControl Handlers ────────────────────────────────────────────── // ─── 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 { Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
let state = state.clone(); let state = state.clone();
Box::pin(async move { Box::pin(async move {
let volume: u16 = get!(&data, "DesiredVolume", u16); let volume: u16 = get!(&data, "DesiredVolume", u16);
pipeline.send(PipelineControl::SetVolume(volume)).await;
state.write().volume = volume; state.write().volume = volume;
Ok(data) 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 { Arc::new(move |data: ActionData| -> ActionFuture {
let pipeline = pipeline.clone();
let state = state.clone(); let state = state.clone();
Box::pin(async move { Box::pin(async move {
let mute: bool = get!(&data, "DesiredMute", bool); let mute: bool = get!(&data, "DesiredMute", bool);
pipeline.send(PipelineControl::SetMute(mute)).await;
state.write().mute = mute; state.write().mute = mute;
Ok(data) Ok(data)
}) })

View File

@@ -1,56 +1,51 @@
//! Pipeline audio serveur par instance WebRenderer //! Pipeline audio serveur par instance WebRenderer
//! //!
//! Chaque instance WebRenderer possède un pipeline indépendant : //! Chaque instance WebRenderer possède un pipeline indépendant :
//! - Un `StreamingFlacSink` qui encode et diffuse le flux FLAC aux clients HTTP //! - Une `PlayerSource` qui gère le cycle de vie AVTransport (Play/Pause/Stop/Seek/LoadUri)
//! - Un canal de contrôle `PipelineControl` alimenté par les handlers UPnP //! - Un `StreamingOggFlacSink` qui encode et diffuse le flux OGG-FLAC aux clients HTTP
//! - Une task background qui orchestre sources et sink //! - Des nœuds de normalisation (resampling → 96 kHz, conversion → I24)
use std::sync::Arc; use std::sync::Arc;
use pmoaudio::{AudioSegment, PositionTrackerNode, ResamplingNode, ToI24Node}; use pmoaudio::{ResamplingNode, ToI24Node};
use pmometadata::{MemoryTrackMetadata, TrackMetadata}; use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource};
use pmoaudio_ext::sinks::{ use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink};
OggFlacStreamHandle, StreamingOggFlacSink,
DIRECT_OGG_FLAC_BITS_PER_SAMPLE, DIRECT_OGG_FLAC_SAMPLE_RATE,
};
use pmoaudio_ext::UriSource;
use pmoflac::EncoderOptions; use pmoflac::EncoderOptions;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn}; use tracing::{debug, warn};
use crate::messages::PlaybackState;
use crate::state::SharedState; use crate::state::SharedState;
// ─── Commandes de contrôle ─────────────────────────────────────────────────── // ─── Ré-export des commandes pour les handlers ────────────────────────────────
/// Commandes envoyées au pipeline audio de l'instance /// Commandes de transport — alias vers PlayerCommand pour compatibilité handlers
#[derive(Debug)] pub use pmoaudio_ext::PlayerCommand as PipelineControl;
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,
}
// ─── Handle vers le pipeline ───────────────────────────────────────────────── // ─── 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)] #[derive(Clone)]
pub struct PipelineHandle { pub struct PipelineHandle {
pub control_tx: mpsc::Sender<PipelineControl>, pub player: PlayerHandle,
pub stop_token: CancellationToken, pub stop_token: CancellationToken,
/// Volume courant (géré localement, pas dans PlayerSource)
state: SharedState,
} }
impl PipelineHandle { impl PipelineHandle {
/// Envoie une commande de transport. Gère SetVolume/SetMute localement.
pub async fn send(&self, cmd: PipelineControl) { 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. /// Pipeline audio complet pour une instance WebRenderer.
/// ///
/// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle` /// 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 { 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 flac_handle: OggFlacStreamHandle,
pub pipeline_handle: PipelineHandle, pub pipeline_handle: PipelineHandle,
} }
@@ -76,83 +71,55 @@ impl InstancePipeline {
udn: String, udn: String,
) -> Self { ) -> Self {
let stop_token = CancellationToken::new(); 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; use pmoaudio::pipeline::AudioPipelineNode;
let (sink, flac_handle) = StreamingOggFlacSink::new( // Sink broadcast OGG-FLAC (multi-client, pacé à 0.5s max d'avance)
EncoderOptions::default(), let (sink, flac_handle) = StreamingOggFlacSink::new(EncoderOptions::default(), 24);
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());
// Nœud de conversion de profondeur : tout type entier → I24 // Nœud de conversion de profondeur : tout type entier → I24
let mut to_i24 = ToI24Node::new(); 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 // 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()); resampler.register(to_i24.boxed());
// Le tx d'entrée du resampler est le point d'entrée du pipeline // Source avec contrôle AVTransport complet
let segment_tx = resampler.get_tx().expect("ResamplingNode doit avoir un sender"); let (mut player_source, player_handle) = PlayerSource::new();
player_source.register(resampler.boxed());
let pipeline_handle = PipelineHandle { // Lancer le pipeline en background
control_tx,
stop_token: stop_token.clone(),
};
// Lancer la chaîne resampler → to_i24 → sink en background
let sink_stop = stop_token.clone(); let sink_stop = stop_token.clone();
tokio::spawn(async move { 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); 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"); 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 { Self {
flac_handle, flac_handle,
pipeline_handle, pipeline_handle,
@@ -160,270 +127,66 @@ impl InstancePipeline {
} }
} }
// ─── Task principale du pipeline ───────────────────────────────────────────── // ─── Listener d'événements ────────────────────────────────────────────────────
async fn run_pipeline( async fn run_event_listener(
segment_tx: mpsc::Sender<Arc<AudioSegment>>, mut event_rx: tokio::sync::broadcast::Receiver<pmoaudio_ext::PlayerEvent>,
mut control_rx: mpsc::Receiver<PipelineControl>,
control_tx: mpsc::Sender<PipelineControl>,
stop_token: CancellationToken,
state: SharedState, state: SharedState,
udn: String, udn: String,
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>, control_point: Arc<pmocontrol::ControlPoint>,
) { ) {
let mut current_source_stop: Option<CancellationToken> = None; use pmoaudio_ext::PlayerEvent;
let mut current_uri: Option<String> = None; use crate::messages::PlaybackState;
loop { loop {
tokio::select! { match event_rx.recv().await {
_ = stop_token.cancelled() => { Ok(event) => match event {
info!(udn = %udn, "Pipeline stopping by cancellation"); PlayerEvent::Playing { uri, duration_sec } => {
if let Some(src_stop) = current_source_stop.take() { let mut s = state.write();
src_stop.cancel(); 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; PlayerEvent::Paused { position_sec } => {
} let mut s = state.write();
s.playback_state = PlaybackState::Paused;
cmd = control_rx.recv() => { s.position = Some(seconds_to_upnp_time(position_sec));
match cmd { }
None => { PlayerEvent::Stopped => {
info!(udn = %udn, "Pipeline control channel closed"); let mut s = state.write();
break; s.playback_state = PlaybackState::Stopped;
} s.position = None;
}
Some(PipelineControl::SourceEnded) => { PlayerEvent::Position { position_sec } => {
// La source s'est terminée (EOF ou erreur) : libérer le slot state.write().position = Some(seconds_to_upnp_time(position_sec));
debug!(udn = %udn, "Pipeline: SourceEnded — source slot freed"); }
current_source_stop = None; PlayerEvent::TrackEnded => {
} #[cfg(feature = "pmoserver")]
{
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")]
let cp = control_point.clone(); let cp = control_point.clone();
let udn_c = udn.clone();
tokio::spawn(async move { tokio::spawn(async move {
stream_source( cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_c));
uri, 0.0, tx, src_stop, st, udn_c,
#[cfg(feature = "pmoserver")]
cp,
).await;
let _ = notify.send(PipelineControl::SourceEnded).await;
}); });
} }
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;
}
} }
} PlayerEvent::Error(e) => {
} tracing::warn!(udn = %udn, "PlayerSource error: {}", e);
}
}
// ─── 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");
state.write().playback_state = PlaybackState::Stopped; 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;
} }
} }

View File

@@ -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} /// DELETE /api/webrenderer/{id}
pub async fn unregister_handler( pub async fn unregister_handler(
State(registry): State<Arc<RendererRegistry>>, State(registry): State<Arc<RendererRegistry>>,

View File

@@ -14,7 +14,7 @@ use crate::error::WebRendererError;
use crate::pipeline::{InstancePipeline, PipelineHandle}; use crate::pipeline::{InstancePipeline, PipelineHandle};
use crate::renderer::WebRendererFactory; use crate::renderer::WebRendererFactory;
use crate::state::{RendererState, SharedState}; use crate::state::{RendererState, SharedState};
use crate::messages::PlaybackState;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
use pmocontrol::{ControlPoint, DeviceId}; use pmocontrol::{ControlPoint, DeviceId};
@@ -29,7 +29,7 @@ pub struct WebRendererInstance {
pub udn: String, pub udn: String,
pub device_instance: Arc<DeviceInstance>, pub device_instance: Arc<DeviceInstance>,
pub state: SharedState, 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 flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle,
pub pipeline: PipelineHandle, pub pipeline: PipelineHandle,
pub created_at: SystemTime, pub created_at: SystemTime,
@@ -116,15 +116,16 @@ impl RendererRegistry {
Ok((stream_url, udn)) Ok((stream_url, udn))
} }
/// Retourne le OggFlacStreamHandle pour l'endpoint /stream (clonable). /// Retourne un OggFlacClientStream indépendant pour l'endpoint /stream.
pub fn get_flac_handle( /// Chaque appel crée un nouveau subscriber broadcast — safe pour connexions simultanées.
pub fn get_stream(
&self, &self,
instance_id: &str, instance_id: &str,
) -> Option<pmoaudio_ext::sinks::OggFlacStreamHandle> { ) -> Option<pmoaudio_ext::sinks::OggFlacClientStream> {
self.instances self.instances
.read() .read()
.get(instance_id) .get(instance_id)
.map(|i| i.flac_handle.clone()) .map(|i| i.flac_handle.subscribe())
} }
/// Retourne le PipelineHandle par UDN (pour les handlers UPnP) /// Retourne le PipelineHandle par UDN (pour les handlers UPnP)
@@ -151,6 +152,24 @@ impl RendererRegistry {
.map(|i| i.device_instance.clone()) .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) { pub fn schedule_unregister(self: &Arc<Self>, instance_id: &str) {
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -248,7 +267,7 @@ impl RendererRegistry {
let mut server = server_arc.write().await; let mut server = server_arc.write().await;
server server
.register_device(Arc::new(device)) .register_device(Arc::new(device), false)
.await .await
.map_err(|e| WebRendererError::RegistrationError(e.to_string()))? .map_err(|e| WebRendererError::RegistrationError(e.to_string()))?
}; };

View File

@@ -1,15 +1,16 @@
//! Handler HTTP GET /api/webrenderer/{id}/stream //! 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::{ use axum::{
body::Body, body::Body,
extract::{Path, State}, extract::{Path, State},
http::{ http::{
StatusCode, HeaderMap, StatusCode,
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, TRANSFER_ENCODING},
}, },
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@@ -20,21 +21,23 @@ use tracing::info;
use crate::registry::RendererRegistry; use crate::registry::RendererRegistry;
/// GET /api/webrenderer/{id}/stream /// 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( pub async fn stream_handler(
State(registry): State<Arc<RendererRegistry>>, State(registry): State<Arc<RendererRegistry>>,
Path(instance_id): Path<String>, Path(instance_id): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse { ) -> impl IntoResponse {
info!(instance_id = %instance_id, "FLAC stream client connecting"); info!(instance_id = %instance_id, "FLAC stream client connecting");
let handle = match registry.get_flac_handle(&instance_id) { // Ignorer le header Range — flux live infini, non seekable.
Some(h) => h, // 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 => { None => {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -44,18 +47,14 @@ pub async fn stream_handler(
} }
}; };
let stream = handle.subscribe();
info!(instance_id = %instance_id, "FLAC stream started"); 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() Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(CONTENT_TYPE, "audio/ogg; codecs=flac") .header(CONTENT_TYPE, "audio/ogg; codecs=flac")
.header(CACHE_CONTROL, "no-store, no-transform") .header(CACHE_CONTROL, "no-store, no-transform")
.header(CONNECTION, "keep-alive") .header(CONNECTION, "keep-alive")
.header(TRANSFER_ENCODING, "chunked")
.header("X-Content-Type-Options", "nosniff") .header("X-Content-Type-Options", "nosniff")
.body(Body::from_stream(ReaderStream::new(stream))) .body(Body::from_stream(ReaderStream::new(stream)))
.unwrap() .unwrap()

View File

@@ -1 +1 @@
0.3.23 0.3.24