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
**/*.mp3
**/*.ogg
xxx
/dcai/
**/.pmomusic.yml
**/.pmomusic_covers/**
**/.pmomusic_audio/**
/.pmomusic
.DS_Store
**/.DS_Store
/target
**/target/
/.pmomusic_covers

8
Cargo.lock generated
View File

@@ -4047,7 +4047,6 @@ version = "0.1.0"
dependencies = [
"bevy_reflect",
"bevy_reflect_derive",
"pmoutils",
"quick-xml",
"serde",
"utoipa",
@@ -4148,6 +4147,7 @@ dependencies = [
"pmoaudio",
"pmoaudio-ext",
"pmoaudiocache",
"pmocache",
"pmoconfig",
"pmocovers",
"pmoflac",
@@ -4362,15 +4362,15 @@ dependencies = [
[[package]]
name = "pmoutils"
version = "0.1.0"
version = "0.1.2"
source = "sparse+https://gargoton.petite-maison-orange.fr/api/packages/pmo/cargo/"
checksum = "9ed68fb06ffdd1a30fc7792d883dcd6d61ad7705c3a744f54a0a5bcc341fc65e"
dependencies = [
"get_if_addrs",
"netstat2",
"os_info",
"quick-xml",
"sysinfo",
"users",
"xmltree 0.10.3",
]
[[package]]

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useMediaServers } from "@/composables/useMediaServers";
import { useRenderers } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui";
@@ -16,8 +16,11 @@ const props = defineProps<{
const {
getBrowseCached,
browseContainer,
loadMoreBrowse,
hasMore,
currentPath: breadcrumbPath,
loading,
loadingMore,
error,
} = useMediaServers();
@@ -25,8 +28,9 @@ const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
useRenderers();
const uiStore = useUIStore();
// Flag pour gérer le rechargement automatique
const isRefreshing = ref(false);
const sentinelRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const browseData = computed(() =>
getBrowseCached(props.serverId, props.containerId),
@@ -40,6 +44,28 @@ const items = computed(
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
);
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
loadMoreBrowse(props.serverId, props.containerId);
}
},
{ threshold: 0.1 },
);
if (sentinelRef.value) observer.observe(sentinelRef.value);
}
onMounted(() => setupObserver());
onBeforeUnmount(() => observer?.disconnect());
watch(sentinelRef, (el) => {
if (el) setupObserver();
});
// Charger le container au montage et quand containerId change
watch(
() => props.containerId,
@@ -51,15 +77,10 @@ watch(
{ immediate: true },
);
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
// Recharger si le cache est invalidé (ContainersUpdated SSE)
watch(
() => browseData.value,
async (data) => {
// Si browseData devient undefined alors que containerId est présent,
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
if (
!data &&
props.containerId &&
@@ -201,6 +222,14 @@ async function handleQueueItem(itemId: string, rendererId: string) {
>
<p>Ce dossier est vide</p>
</div>
<!-- Sentinel infinite scroll -->
<div ref="sentinelRef" class="scroll-sentinel" />
<!-- Spinner load more -->
<div v-if="loadingMore" class="load-more-spinner">
<Loader2 :size="20" class="spinner" />
</div>
</div>
</div>
</template>
@@ -295,6 +324,17 @@ async function handleQueueItem(itemId: string, rendererId: string) {
padding: var(--spacing-xl);
}
.scroll-sentinel {
height: 1px;
}
.load-more-spinner {
display: flex;
justify-content: center;
padding: var(--spacing-md);
color: var(--color-text-secondary);
}
/* Scrollbar styling */
.browser-content::-webkit-scrollbar {
width: 6px;

View File

@@ -116,6 +116,15 @@ const offlineRenderers = computed(() =>
allRenderers.value.filter((r: RendererSummary) => !r.online),
);
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
const duplicateNames = computed(() => {
const counts = new Map<string, number>();
for (const r of allRenderers.value) {
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
}
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
});
function close() {
emit("update:modelValue", false);
}
@@ -296,7 +305,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
getProtocolLabel(
renderer.protocol,
)
}}
}}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
</span>
</div>
<div class="renderer-details">
@@ -383,7 +392,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
</div>
<div class="renderer-info">
<p class="renderer-name">
{{ renderer.friendly_name }}
{{ renderer.friendly_name }}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
</p>
<p
v-if="renderer.model_name"

View File

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

View File

@@ -10,7 +10,7 @@ export function useCoverImage(
imageUrl: Ref<string | null | undefined>,
options: CoverImageOptions = {},
) {
const { maxRetries = 3, retryDelay = 1000, forceReload = true } = options;
const { maxRetries = 5, retryDelay = 500, forceReload = true } = options;
const imageLoaded = ref(false);
const imageError = ref(false);
@@ -66,6 +66,9 @@ export function useCoverImage(
if (retryCount.value < maxRetries) {
retryCount.value++;
// Backoff : 500ms, 1s, 2s, 4s, 8s — rapide au début pour les covers
// en cours de téléchargement, plus espacé ensuite pour les erreurs réseau
const delay = retryDelay * Math.pow(2, retryCount.value - 1);
setTimeout(() => {
if (!currentUrl.value) return;
@@ -74,7 +77,7 @@ export function useCoverImage(
currentUrl.value,
retryCount.value,
);
}, retryDelay * retryCount.value); // Exponential backoff
}, delay);
} else {
console.error(
`[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`,
@@ -102,8 +105,15 @@ export function useCoverImage(
// Retry if we haven't reached max retries
if (retryCount.value < maxRetries) {
const delay = retryDelay * Math.pow(2, retryCount.value);
console.log(
`[useCoverImage] Scheduling retry ${retryCount.value + 1}/${maxRetries} in ${delay}ms for: ${currentUrl.value}`,
);
retryLoad();
} else {
console.error(
`[useCoverImage] Giving up after ${maxRetries} retries for: ${currentUrl.value}`,
);
imageError.value = true;
}
}

View File

@@ -7,7 +7,7 @@ import { api } from '../services/pmocontrol/api'
import { sse } from '../services/pmocontrol/sse'
import type {
MediaServerSummary,
BrowseResponse
ContainerEntry,
} from '../services/pmocontrol/types'
export interface BreadcrumbItem {
@@ -15,9 +15,15 @@ export interface BreadcrumbItem {
title: string
}
export interface BrowseState {
container_id: string
entries: ContainerEntry[]
total_count: number
}
// Cache global partagé
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
const browseCache = ref<Map<string, BrowseState>>(new Map())
const currentPath = ref<BreadcrumbItem[]>([])
// Timestamps
@@ -100,6 +106,7 @@ export function useMediaServers() {
ensureSSEConnected()
const loading = ref(false)
const loadingMore = ref(false)
const error = ref<string | null>(null)
// Getters computed
@@ -110,7 +117,7 @@ export function useMediaServers() {
async function fetchServers(force = false) {
const now = Date.now()
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
return // Cache encore valide
return
}
try {
@@ -129,11 +136,10 @@ export function useMediaServers() {
}
}
// Browse container (avec cache automatique)
// Charge la première page (remplace le cache)
async function browseContainer(serverId: string, containerId: string, useCache = true) {
const key = `${serverId}/${containerId}`
// Vérifier le cache
if (useCache && browseCache.value.has(key)) {
return browseCache.value.get(key)!
}
@@ -142,12 +148,15 @@ export function useMediaServers() {
loading.value = true
error.value = null
const data = await api.browseContainer(serverId, containerId)
const data = await api.browseContainer(serverId, containerId, 0)
// Mettre en cache
browseCache.value.set(key, data)
browseCache.value.set(key, {
container_id: data.container_id,
entries: data.entries,
total_count: data.total_count,
})
return data
return browseCache.value.get(key)!
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur browse container'
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e)
@@ -157,6 +166,33 @@ export function useMediaServers() {
}
}
// Charge la page suivante et accumule (infinite scroll)
async function loadMoreBrowse(serverId: string, containerId: string) {
const key = `${serverId}/${containerId}`
const state = browseCache.value.get(key)
if (!state) return
if (state.entries.length >= state.total_count) return
if (loadingMore.value) return
try {
loadingMore.value = true
const offset = state.entries.length
const data = await api.browseContainer(serverId, containerId, offset)
// Accumuler les nouvelles entrées
state.entries.push(...data.entries)
state.total_count = data.total_count
// Forcer la réactivité
browseCache.value.set(key, { ...state })
} catch (e) {
console.error(`[useMediaServers] Erreur load more ${serverId}/${containerId}:`, e)
} finally {
loadingMore.value = false
}
}
// Getters
function getServerById(id: string) {
return serversCache.value.get(id)
@@ -167,6 +203,13 @@ export function useMediaServers() {
return browseCache.value.get(key)
}
function hasMore(serverId: string, containerId: string): boolean {
const key = `${serverId}/${containerId}`
const state = browseCache.value.get(key)
if (!state) return false
return state.entries.length < state.total_count
}
// Breadcrumb path management
function setPath(path: BreadcrumbItem[]) {
currentPath.value = path
@@ -179,11 +222,9 @@ export function useMediaServers() {
// Invalidation du cache
function invalidateCache(serverId: string, containerId?: string) {
if (containerId) {
// Invalider un container spécifique
const key = `${serverId}/${containerId}`
browseCache.value.delete(key)
} else {
// Invalider tous les containers d'un serveur
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
@@ -197,6 +238,7 @@ export function useMediaServers() {
return {
// État
loading,
loadingMore,
error,
currentPath,
// Getters
@@ -204,9 +246,11 @@ export function useMediaServers() {
onlineServers,
getServerById,
getBrowseCached,
hasMore,
// Actions
fetchServers,
browseContainer,
loadMoreBrowse,
setPath,
clearPath,
invalidateCache

View File

@@ -34,10 +34,10 @@ function generateUUID(): string {
function getOrCreateInstanceId(): string {
try {
let id = localStorage.getItem(INSTANCE_ID_KEY);
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
if (!id) {
id = generateUUID();
localStorage.setItem(INSTANCE_ID_KEY, id);
sessionStorage.setItem(INSTANCE_ID_KEY, id);
}
return id;
} catch {
@@ -71,6 +71,30 @@ export function useWebRenderer() {
let onConnectedCallback: (() => void) | null = null;
let sseUnsubscribe: (() => void) | null = null;
let pendingCanPlay: (() => void) | null = null;
let positionInterval: ReturnType<typeof setInterval> | null = null;
// ── Reporting de position ─────────────────────────────────────────────────
function startPositionReporting(): void {
stopPositionReporting();
positionInterval = setInterval(() => {
if (!audioEl || !instanceId) return;
const pos = audioEl.currentTime;
const dur = isFinite(audioEl.duration) ? audioEl.duration : null;
void fetch(`/api/webrenderer/${instanceId}/position`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ position_sec: pos, duration_sec: dur }),
});
}, 1000);
}
function stopPositionReporting(): void {
if (positionInterval !== null) {
clearInterval(positionInterval);
positionInterval = null;
}
}
// ── Connexion / déconnexion du flux audio ─────────────────────────────────
@@ -124,6 +148,7 @@ export function useWebRenderer() {
el.removeEventListener("canplay", onCanPlay);
el.play().then(() => {
console.log("[WebRenderer] play() resolved OK");
startPositionReporting();
}).catch((e: unknown) => {
console.warn("[WebRenderer] play() rejected:", e);
});
@@ -134,6 +159,7 @@ export function useWebRenderer() {
function stopStream(): void {
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
stopPositionReporting();
if (!audioEl) return;
if (pendingCanPlay) {
audioEl.removeEventListener("canplay", pendingCanPlay);
@@ -230,6 +256,7 @@ export function useWebRenderer() {
onUnmounted(() => {
sseUnsubscribe?.();
sseUnsubscribe = null;
stopPositionReporting();
stopStream();
void unregister();
if (audioEl) {

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,28 @@
//! DirectOggFlacSink — nœud puits OGG-FLAC pour un seul client HTTP.
//!
//! Combine la logique de backpressure/reconnexion de `DirectFlacSink`
//! avec l'encodage OGG-FLAC de `StreamingOggFlacSink`.
//!
//! # Cycle de vie
//!
//! - **Play** : le navigateur appelle `GET /stream`. `connect()` crée un nouveau
//! canal PCM + pipe duplex + encodeur FLAC + wrapper OGG, installe le sender
//! dans le sink, et notifie le sink via `client_notify`. Le flux reste ouvert :
//! les morceaux s'enchaînent en gapless.
//! - **Stop** : le navigateur ferme la connexion. Le pipe se rompt, l'encodeur
//! s'arrête. Le sink voit `pcm_tx.send()` échouer, passe le sender à `None`,
//! et **bloque** sur `client_notify` jusqu'au prochain Play.
//! - **Play suivant** : `connect()` → nouveau pipe → `client_notify.notify_one()`
//! → le sink se débloque et reprend la consommation des segments.
//!
//! # Architecture
//!
//! ```text
//! AudioSegment I24 @ 96 kHz
//! ↓ NodeLogic::process() [bloque si pas de client]
//! ↓ NodeLogic::process()
//! chunk_to_pcm_bytes() → PCM 24-bit LE
//! ↓ Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>
//! ByteStreamReader (AsyncRead)
//! ↓ encode_flac_stream()
//! ↓ broadcast_ogg_flac_stream() → wrapping OGG pages
//! ↓ tokio::io::duplex pipe (256 KB)
//! ↓ DirectOggFlacStream (AsyncRead) → Body HTTP
//! ↓ pcm_tx (cap=2)
//! ByteStreamReader → encode_flac_stream() → OGG pages (Bytes)
//! ↓ ogg_tx mpsc::Sender<Bytes> (cap=8, backpressure naturelle)
//! Arc<Mutex<Receiver<Bytes>>> → DirectOggFlacStream (AsyncRead) → HTTP → Safari
//! ```
//!
//! # Backpressure
//!
//! Safari lent → ogg_rx plein → ogg_tx.send() bloque → encodeur bloque
//! → pcm_tx.send() bloque → pipeline audio bloque.
//!
//! # OGG chaining (TrackBoundary)
//!
//! Fermer pcm_tx → encodeur termine (EOS) → lancer nouvel encodeur.
//! Le même ogg_tx est réutilisé : le stream HTTP est continu.
use std::collections::VecDeque;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
@@ -42,9 +36,10 @@ use pmoaudio::{
};
use pmoflac::{EncoderOptions, PcmFormat};
use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::{mpsc, watch, Mutex};
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use tracing::{debug, trace, warn};
use crate::sinks::byte_stream_reader::{ByteStreamReader, PcmChunk};
use crate::sinks::chunk_to_pcm::chunk_to_pcm_bytes;
@@ -55,95 +50,60 @@ pub const DIRECT_OGG_FLAC_SAMPLE_RATE: u32 = 96_000;
pub const DIRECT_OGG_FLAC_CHANNELS: u8 = 2;
pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24;
/// Capacité du pipe duplex (~256 KB).
const PIPE_CAPACITY: usize = 256 * 1024;
/// Capacité du canal OGG → HTTP.
/// Capacité 1 : backpressure stricte, l'encodeur ne produit pas en avance.
const OGG_CHANNEL_CAPACITY: usize = 1;
// ─── Shared state ─────────────────────────────────────────────────────────────
// ─── Types partagés ───────────────────────────────────────────────────────────
type SharedPcmTx = Arc<Mutex<Option<mpsc::Sender<PcmChunk>>>>;
type SharedEncoderTask = Arc<Mutex<Option<JoinHandle<()>>>>;
type SharedOggRx = Arc<Mutex<mpsc::Receiver<Bytes>>>;
// ─── Handle public ────────────────────────────────────────────────────────────
/// Handle vers le sink, cloneable, reconnectable à chaque Play.
#[derive(Clone)]
pub struct DirectOggFlacHandle {
pcm_tx: SharedPcmTx,
client_connect_tx: Arc<watch::Sender<u64>>,
client_notify_internal: Arc<tokio::sync::Notify>,
first_byte_tx: Arc<watch::Sender<bool>>,
encoder_task: SharedEncoderTask,
encoder_options: EncoderOptions,
/// Position de lecture courante (mise à jour par ByteStreamReader).
current_timestamp: Arc<tokio::sync::RwLock<f64>>,
/// Canal OGG partagé avec le stream HTTP.
ogg_tx: mpsc::Sender<Bytes>,
ogg_rx: SharedOggRx,
/// Notifié quand get_stream() est appelé (premier client).
client_notify: Arc<tokio::sync::Notify>,
}
impl DirectOggFlacHandle {
/// Crée un nouveau pipe OGG-FLAC et retourne le flux côté lecture.
/// Débloque le sink s'il attendait un client.
pub async fn connect(&self) -> DirectOggFlacStream {
let connect_count_before = *self.client_connect_tx.borrow();
debug!("DirectOggFlacHandle::connect() called, connect_count={}", connect_count_before);
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(8);
// Réinitialiser le timestamp à 0 pour la nouvelle connexion
*self.current_timestamp.write().await = 0.0;
let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64));
// Partager current_timestamp avec ByteStreamReader : il sera mis à jour
// avec le timestamp absolu du segment audio (position dans le fichier source).
let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur);
let (pipe_writer, pipe_reader) = tokio::io::duplex(PIPE_CAPACITY);
let _ = self.first_byte_tx.send(false);
debug!("DirectOggFlacHandle::connect() first_byte reset to false");
*self.pcm_tx.lock().await = Some(pcm_tx);
debug!("DirectOggFlacHandle::connect() pcm_tx installed");
let new_count = connect_count_before.wrapping_add(1);
let _ = self.client_connect_tx.send(new_count);
debug!("DirectOggFlacHandle::connect() client_connect_count -> {}", new_count);
self.client_notify_internal.notify_one();
let options = self.encoder_options.clone();
let current_timestamp = self.current_timestamp.clone();
tokio::spawn(async move {
debug!("DirectOggFlacHandle: encoder+ogg task started");
if let Err(e) = run_ogg_encoder(pcm_reader, pipe_writer, options, current_timestamp).await {
debug!("DirectOggFlacStream encoder stopped: {}", e);
}
debug!("DirectOggFlacHandle: encoder+ogg task ended");
});
debug!("DirectOggFlacHandle::connect() returning DirectOggFlacStream");
/// Retourne le stream OGG-FLAC pour le handler HTTP.
/// Lance le premier encodeur au premier appel.
pub fn get_stream(&self) -> DirectOggFlacStream {
tracing::info!(target: "pmoaudio_ext::stream_connect", "get_stream() called — new HTTP client connecting");
self.client_notify.notify_one();
DirectOggFlacStream {
inner: pipe_reader,
first_byte_tx: Some(self.first_byte_tx.clone()),
ogg_rx: self.ogg_rx.clone(),
buffer: VecDeque::new(),
}
}
pub fn first_byte_ready(&self) -> watch::Receiver<bool> {
self.first_byte_tx.subscribe()
}
/// Retourne la position de lecture courante en secondes.
pub async fn current_position_sec(&self) -> f64 {
*self.current_timestamp.read().await
}
pub async fn wait_for_client(&self) {
let seen = *self.client_connect_tx.borrow();
debug!("DirectOggFlacHandle::wait_for_client() called, seen connect_count={}", seen);
let mut rx = self.client_connect_tx.subscribe();
let _ = rx.wait_for(|v| *v > seen).await;
debug!("DirectOggFlacHandle::wait_for_client() unblocked");
}
}
// ─── Stream public ────────────────────────────────────────────────────────────
/// AsyncRead sur le canal OGG-FLAC.
pub struct DirectOggFlacStream {
inner: tokio::io::DuplexStream,
first_byte_tx: Option<Arc<watch::Sender<bool>>>,
ogg_rx: SharedOggRx,
buffer: VecDeque<u8>,
}
impl Drop for DirectOggFlacStream {
fn drop(&mut self) {
tracing::info!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream dropped — HTTP client disconnected");
}
}
impl AsyncRead for DirectOggFlacStream {
@@ -152,18 +112,41 @@ impl AsyncRead for DirectOggFlacStream {
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let filled_before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &result {
let filled_after = buf.filled().len();
if filled_after > filled_before {
if let Some(tx) = self.first_byte_tx.take() {
debug!("DirectOggFlacStream: first {} bytes sent to HTTP client", filled_after - filled_before);
let _ = tx.send(true);
}
}
// Vider le buffer interne en premier
if !self.buffer.is_empty() {
let to_copy = self.buffer.len().min(buf.remaining());
let chunk: Vec<u8> = self.buffer.drain(..to_copy).collect();
buf.put_slice(&chunk);
return Poll::Ready(Ok(()));
}
// Tenter de recevoir le prochain chunk OGG
let mut guard = match self.ogg_rx.try_lock() {
Ok(g) => g,
Err(_) => {
tracing::warn!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream: try_lock() FAILED — concurrent access detected!");
cx.waker().wake_by_ref();
return Poll::Pending;
}
};
match guard.poll_recv(cx) {
Poll::Ready(Some(bytes)) => {
drop(guard);
tracing::info!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream: sending {} bytes to HTTP client", bytes.len());
let to_copy = bytes.len().min(buf.remaining());
buf.put_slice(&bytes[..to_copy]);
if to_copy < bytes.len() {
self.buffer.extend(&bytes[to_copy..]);
}
Poll::Ready(Ok(()))
}
Poll::Ready(None) => {
tracing::info!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream: ogg_rx closed → EOF sent to HTTP client");
Poll::Ready(Ok(()))
}
Poll::Pending => Poll::Pending,
}
result
}
}
@@ -171,7 +154,12 @@ impl AsyncRead for DirectOggFlacStream {
struct DirectOggFlacSinkLogic {
pcm_tx: SharedPcmTx,
encoder_task: SharedEncoderTask,
encoder_options: EncoderOptions,
current_timestamp: Arc<tokio::sync::RwLock<f64>>,
ogg_tx: mpsc::Sender<Bytes>,
client_notify: Arc<tokio::sync::Notify>,
has_encoded_frames: bool,
}
#[async_trait]
@@ -186,6 +174,15 @@ impl NodeLogic for DirectOggFlacSinkLogic {
AudioError::ProcessingError("DirectOggFlacSink requires an input".into())
})?;
// Attendre le premier client Safari avant de démarrer l'encodeur
debug!("DirectOggFlacSink: waiting for first client...");
tokio::select! {
_ = stop_token.cancelled() => return Ok(()),
_ = self.client_notify.notified() => {}
}
debug!("DirectOggFlacSink: first client connected, starting encoder");
self.start_encoder().await;
loop {
tokio::select! {
_ = stop_token.cancelled() => {
@@ -201,25 +198,9 @@ impl NodeLogic for DirectOggFlacSinkLogic {
}
Some(seg) => match &seg.segment {
_AudioSegment::Chunk(chunk) => {
// Attendre un client si nécessaire (backpressure quand pas de Play)
loop {
let tx_opt = self.pcm_tx.lock().await.clone();
if tx_opt.is_some() {
break;
}
debug!("DirectOggFlacSink: no pcm_tx, waiting for client_notify...");
tokio::select! {
_ = stop_token.cancelled() => {
debug!("DirectOggFlacSink: cancelled while waiting for client");
return Ok(());
}
_ = self.client_notify.notified() => {
debug!("DirectOggFlacSink: client_notify received, rechecking pcm_tx");
}
}
}
let tx = self.pcm_tx.lock().await.clone();
let Some(tx) = tx else { continue; };
let tx = self.pcm_tx.lock().await.clone().unwrap();
let pcm_bytes = chunk_to_pcm_bytes(chunk, DIRECT_OGG_FLAC_BITS_PER_SAMPLE)?;
let duration_sec = chunk.len() as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64;
let pcm_chunk = PcmChunk {
@@ -228,17 +209,26 @@ impl NodeLogic for DirectOggFlacSinkLogic {
duration_sec,
};
if tx.send(pcm_chunk).await.is_err() {
warn!(
ts = seg.timestamp_sec,
"DirectOggFlacSink: chunk dropped (client disconnected at {:.3}s), waiting for reconnect",
seg.timestamp_sec,
);
warn!("DirectOggFlacSink: encoder gone at {:.3}s", seg.timestamp_sec);
*self.pcm_tx.lock().await = None;
self.has_encoded_frames = false;
} else {
self.has_encoded_frames = true;
}
}
_AudioSegment::Sync(marker) => match marker.as_ref() {
SyncMarker::TrackBoundary { .. } => {
if self.has_encoded_frames {
debug!("DirectOggFlacSink: TrackBoundary — OGG chaining");
self.has_encoded_frames = false;
self.do_track_boundary().await;
} else {
debug!("DirectOggFlacSink: TrackBoundary ignored (no frames)");
}
}
SyncMarker::EndOfStream => {
debug!("DirectOggFlacSink: EndOfStream");
self.stop_encoder().await;
}
_ => {}
},
@@ -248,6 +238,7 @@ impl NodeLogic for DirectOggFlacSinkLogic {
}
}
self.stop_encoder().await;
Ok(())
}
@@ -256,14 +247,57 @@ impl NodeLogic for DirectOggFlacSinkLogic {
}
}
impl DirectOggFlacSinkLogic {
async fn start_encoder(&mut self) {
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(2);
let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64));
let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur);
*self.pcm_tx.lock().await = Some(pcm_tx);
let ogg_tx = self.ogg_tx.clone();
let options = self.encoder_options.clone();
let current_timestamp = self.current_timestamp.clone();
let handle = tokio::spawn(async move {
debug!("DirectOggFlacSink: encoder task started");
if let Err(e) = run_ogg_encoder(pcm_reader, ogg_tx, options, current_timestamp).await {
debug!("DirectOggFlacSink: encoder stopped: {}", e);
}
debug!("DirectOggFlacSink: encoder task ended");
});
*self.encoder_task.lock().await = Some(handle);
}
async fn stop_encoder(&mut self) {
*self.pcm_tx.lock().await = None;
if let Some(handle) = self.encoder_task.lock().await.take() {
let _ = handle.await;
}
self.has_encoded_frames = false;
}
async fn do_track_boundary(&mut self) {
// Fermer pcm_tx → encodeur écrit EOS, se termine
*self.pcm_tx.lock().await = None;
if let Some(handle) = self.encoder_task.lock().await.take() {
let _ = handle.await;
debug!("DirectOggFlacSink: previous encoder joined");
}
// Démarrer le nouvel encodeur sur le même ogg_tx
self.start_encoder().await;
debug!("DirectOggFlacSink: OGG chaining complete");
}
}
// ─── Encodeur FLAC + wrapper OGG ─────────────────────────────────────────────
async fn run_ogg_encoder(
pcm_reader: ByteStreamReader,
mut pipe_writer: tokio::io::DuplexStream,
ogg_tx: mpsc::Sender<Bytes>,
options: EncoderOptions,
_current_timestamp: Arc<tokio::sync::RwLock<f64>>,
) -> Result<(), AudioError> {
use tokio::io::AsyncReadExt;
let format = PcmFormat {
sample_rate: DIRECT_OGG_FLAC_SAMPLE_RATE,
channels: DIRECT_OGG_FLAC_CHANNELS,
@@ -274,56 +308,54 @@ async fn run_ogg_encoder(
.await
.map_err(|e| AudioError::ProcessingError(format!("FLAC encoder init: {}", e)))?;
// Lire le header FLAC et construire les pages OGG d'en-tête
let flac_header = read_flac_header(&mut flac_stream).await?;
let sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?;
let _sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?;
let stream_serial: u32 = rand::random();
let mut ogg = OggPageWriter::new(stream_serial);
// Page BOS (identification OGG-FLAC)
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
let bos_page = Bytes::from(ogg.create_page(&ogg_flac_id, true, false, false));
// Page Vorbis Comment
let bos_page = ogg.create_page(&ogg_flac_id, true, false, false);
let vorbis_comment = create_empty_vorbis_comment();
let comment_page = Bytes::from(ogg.create_page(&vorbis_comment, false, false, false));
let comment_page = ogg.create_page(&vorbis_comment, false, false, false);
pipe_writer.write_all(&bos_page).await
.map_err(|e| AudioError::IoError(format!("OGG BOS write: {}", e)))?;
pipe_writer.write_all(&comment_page).await
.map_err(|e| AudioError::IoError(format!("OGG comment write: {}", e)))?;
let mut header = Vec::new();
header.extend_from_slice(&bos_page);
header.extend_from_slice(&comment_page);
if ogg_tx.send(Bytes::from(header)).await.is_err() {
debug!("DirectOggFlacSink: ogg_tx closed on headers");
return Ok(());
}
use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size};
// Lire les frames FLAC et les encapsuler dans des pages OGG
let sample_rate_f64 = sample_rate as f64;
let mut encoded_samples = 0u64;
let mut read_buffer = vec![0u8; 16384];
let mut read_buffer = vec![0u8; 65536];
let mut accumulator: Vec<u8> = Vec::with_capacity(32768);
use tokio::io::{AsyncReadExt, AsyncWriteExt};
loop {
match flac_stream.read(&mut read_buffer).await {
Ok(0) => {
// EOF : page EOS finale
let eos_page = Bytes::from(ogg.create_page(&accumulator, false, true, false));
let _ = pipe_writer.write_all(&eos_page).await;
trace!(
"OggEncoder: EOF — accum={} B, encoded={:.3}s",
accumulator.len(),
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
);
let eos_page = ogg.create_page(&accumulator, false, true, false);
let _ = ogg_tx.send(Bytes::from(eos_page)).await;
break;
}
Ok(n) => {
accumulator.extend_from_slice(&read_buffer[..n]);
loop {
if accumulator.len() < 4 {
break;
}
if accumulator.len() < 4 { break; }
// Trouver les positions de sync FLAC
let mut sync_data: Vec<(usize, u32)> = Vec::new();
for i in 0..accumulator.len() - 1 {
let b1 = accumulator[i];
let b2 = accumulator[i + 1];
if b1 == 0xFF && b2 >= 0xF8 && b2 <= 0xFE {
use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size};
if validate_frame_header_crc(&accumulator, i) {
if let Some(samples) = parse_flac_block_size(&accumulator, i) {
sync_data.push((i, samples));
@@ -332,9 +364,7 @@ async fn run_ogg_encoder(
}
}
if sync_data.len() < 2 {
break;
}
if sync_data.len() < 2 { break; }
let first_start = sync_data[0].0;
let first_samples = sync_data[0].1;
@@ -346,19 +376,19 @@ async fn run_ogg_encoder(
}
let frame: Vec<u8> = accumulator.drain(0..second_start).collect();
encoded_samples = encoded_samples.saturating_add(first_samples as u64);
ogg.add_samples(first_samples as u64);
let ogg_page = Bytes::from(ogg.create_page(&frame, false, false, false));
if pipe_writer.write_all(&ogg_page).await.is_err() {
// Client déconnecté — le pipe HTTP s'est rompu
warn!(
samples = encoded_samples,
"DirectOggFlacSink: OGG pipe broken after {} samples ({:.3}s), client disconnected",
encoded_samples,
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
);
trace!(
"OggEncoder: frame {} B, {:.3}s total",
frame.len(),
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64,
);
let ogg_page = ogg.create_page(&frame, false, false, false);
if ogg_tx.send(Bytes::from(ogg_page)).await.is_err() {
warn!("DirectOggFlacSink: ogg_tx closed after {:.3}s",
encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64);
return Ok(());
}
}
@@ -375,7 +405,7 @@ async fn run_ogg_encoder(
Ok(())
}
// ─── OGG helpers (copiés de streaming_ogg_flac_sink) ─────────────────────────
// ─── OGG helpers ─────────────────────────────────────────────────────────────
struct OggPageWriter {
stream_serial: u32,
@@ -515,16 +545,21 @@ pub struct DirectOggFlacSink {
impl DirectOggFlacSink {
pub fn new(encoder_options: EncoderOptions) -> (Self, DirectOggFlacHandle) {
let pcm_tx: SharedPcmTx = Arc::new(Mutex::new(None));
let client_notify_internal = Arc::new(tokio::sync::Notify::new());
let (client_connect_tx, _) = watch::channel(0u64);
let client_connect_tx = Arc::new(client_connect_tx);
let (first_byte_tx, _) = watch::channel(false);
let first_byte_tx = Arc::new(first_byte_tx);
let encoder_task: SharedEncoderTask = Arc::new(Mutex::new(None));
let current_timestamp = Arc::new(tokio::sync::RwLock::new(0.0f64));
let client_notify = Arc::new(tokio::sync::Notify::new());
let (ogg_tx, ogg_rx) = mpsc::channel::<Bytes>(OGG_CHANNEL_CAPACITY);
let ogg_rx = Arc::new(Mutex::new(ogg_rx));
let logic = DirectOggFlacSinkLogic {
pcm_tx: pcm_tx.clone(),
client_notify: client_notify_internal.clone(),
encoder_task: encoder_task.clone(),
encoder_options: encoder_options.clone(),
current_timestamp: current_timestamp.clone(),
ogg_tx: ogg_tx.clone(),
client_notify: client_notify.clone(),
has_encoded_frames: false,
};
let sink = Self {
@@ -533,11 +568,12 @@ impl DirectOggFlacSink {
let handle = DirectOggFlacHandle {
pcm_tx,
client_connect_tx,
client_notify_internal,
first_byte_tx,
encoder_task,
encoder_options,
current_timestamp,
ogg_tx,
ogg_rx,
client_notify,
};
(sink, handle)

View File

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

View File

@@ -490,6 +490,11 @@ impl SharedSinkContext {
debug!("Restarting FLAC encoder for new track");
// Invalider le header en cache immédiatement : tout nouveau client qui se connecte
// pendant le restart attendra (Poll::Pending) jusqu'à ce que le nouveau header OGG
// soit prêt, évitant qu'il reçoive l'ancien header suivi des nouvelles données.
*self.header.write().await = None;
let last_timestamp = *self.current_timestamp.read().await;
debug!("Last timestamp before restart: {:.3}s", last_timestamp);

View File

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

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,
};
use pmoaudiocache::Cache as AudioCache;
use pmoflac::{decode_audio_stream, StreamInfo};
use pmoplaylist::{PlaylistRole, ReadHandle};
use pmoflac::decode_audio_stream;
use pmoplaylist::ReadHandle;
use super::pcm_decode::{bytes_to_segment, validate_stream};
use std::{path::PathBuf, sync::Arc, time::Duration};

View File

@@ -9,6 +9,7 @@ use crate::{cache::is_lazy_pk, CacheConfig, DB};
/// Trait générique pour les caches de fichiers
///
/// Définit l'interface commune pour tous les types de caches (images, audio, etc.)
#[allow(async_fn_in_trait)]
pub trait FileCache<C: CacheConfig>: Send + Sync {
fn get_cache_dir(&self) -> &Path;
fn get_database(&self) -> Arc<DB>;
@@ -68,14 +69,7 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
/// Retourne la route relative pour accéder à un item du cache
///
/// # Arguments
///
/// * `pk` - Clé primaire de la piste
/// * `param` - Paramètre optionnel (ex: "orig", "128k", etc.)
///
/// # Returns
///
/// Route relative (ex: "/audio/flac/abc123" ou "/audio/tracks/abc123/orig")
/// Format: `/{cache_name}/{cache_type}/{pk}[/{param}]`
fn route_for(&self, pk: &str, param: Option<&str>) -> String {
if let Some(p) = param {
format!("/{}/{}/{}/{}", C::cache_name(), C::cache_type(), pk, p)
@@ -84,6 +78,16 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
}
}
/// Retourne l'URL absolue pour accéder à un item du cache
///
/// Utilise la variable d'environnement `PMO_SERVER_URL` comme base,
/// avec `http://localhost:8080` comme valeur par défaut.
fn absolute_url_for(&self, pk: &str, param: Option<&str>) -> String {
let base = std::env::var("PMO_SERVER_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
format!("{}{}", base.trim_end_matches('/'), self.route_for(pk, param))
}
/// Télécharge un fichier depuis une URL et l'ajoute au cache
///
/// # Arguments

View File

@@ -144,6 +144,22 @@ pub use cache::{
CacheSubscription,
};
pub use cache_trait::{pk_from_content_header, FileCache};
/// Retourne la route relative pour une cover: `/covers/image/{pk}[/{param}]`
pub fn covers_route_for(pk: &str, param: Option<&str>) -> String {
if let Some(p) = param {
format!("/covers/image/{}/{}", pk, p)
} else {
format!("/covers/image/{}", pk)
}
}
/// Retourne l'URL absolue pour une cover via `PMO_SERVER_URL`
pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String {
let base = std::env::var("PMO_SERVER_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
}
pub use db::{CacheEntry, DB};
pub use download::{
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,

View File

@@ -92,9 +92,17 @@ async fn get_file<C: CacheConfig + 'static>(
Path(pk): Path<String>,
request: axum::http::Request<Body>,
) -> Response {
// Utiliser le param par défaut
let param = C::default_param();
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await
tracing::debug!(
"[{}] GET /{}/{} param={}",
C::cache_name(), C::cache_type(), pk, param
);
let response = serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await;
tracing::debug!(
"[{}] GET /{}/{} → {}",
C::cache_name(), C::cache_type(), pk, response.status()
);
response
}
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param}
@@ -267,6 +275,16 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
}
// Fichier terminé ou pas de download en cours, servir normalement avec support Range
if !file_path.exists() {
// Fichier absent : soit download background pas encore terminé,
// soit transformation WebP/variant pas encore écrite sur disque
let in_db = cache.db.get(pk, false).is_ok();
warn!(
"Requested file not on disk yet: pk={} in_db={} path={:?} — client should retry",
pk, in_db, file_path
);
}
let response = serve_complete_file(file_path, content_type, request).await;
if response.status().is_success() {
@@ -287,7 +305,7 @@ async fn stream_file_progressive(
request: axum::http::Request<Body>,
) -> Response {
use axum::http::header;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::io::AsyncSeekExt;
// Attendre qu'au moins 64 KB soient disponibles avant de commencer
const MIN_SIZE_TO_START: u64 = 64 * 1024;
@@ -602,6 +620,7 @@ pub fn create_api_router<C: CacheConfig + 'static>(cache: Arc<Cache<C>>) -> Rout
///
/// Permet d'initialiser un cache générique avec routes HTTP complètes
#[cfg(feature = "pmoserver")]
#[allow(async_fn_in_trait)]
pub trait GenericCacheExt {
/// Initialise un cache générique avec routes complètes
///

View File

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

View File

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

View File

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

View File

@@ -91,6 +91,16 @@ impl UpnpMediaServer {
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>, ControlPointError> {
Ok(self.browse_with_flag_paged(object_id, browse_flag, start, count)?.entries)
}
fn browse_with_flag_paged(
&self,
object_id: &str,
browse_flag: &str,
start: u32,
count: u32,
) -> Result<BrowsePage, ControlPointError> {
let start_str = start.to_string();
let count_str = count.to_string();
let args = vec![
@@ -104,10 +114,12 @@ impl UpnpMediaServer {
let response = self.invoke_content_directory("Browse", None, args)?;
let envelope = response.envelope.ok_or_else(|| {
ControlPointError::MediaServerError(format!("Missing SOAP envelope in Browse response"))
ControlPointError::MediaServerError("Missing SOAP envelope in Browse response".to_string())
})?;
let total_count = extract_total_matches(&envelope, "BrowseResponse");
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
map_didl_entries(&didl_xml)
let entries = map_didl_entries(&didl_xml)?;
Ok(BrowsePage { entries, total_count })
}
fn has_content_directory(&self) -> bool {
@@ -362,6 +374,13 @@ pub struct MediaEntry {
pub creator: Option<String>,
}
/// Résultat paginé d'un browse avec total connu.
#[derive(Debug)]
pub struct BrowsePage {
pub entries: Vec<MediaEntry>,
pub total_count: u32,
}
/// Backend-agnostic media browsing contract.
pub trait MediaBrowser {
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError>;
@@ -371,6 +390,16 @@ pub trait MediaBrowser {
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>, ControlPointError>;
fn browse_children_paged(
&self,
object_id: &str,
start: u32,
count: u32,
) -> Result<BrowsePage, ControlPointError> {
let entries = self.browse_children(object_id, start, count)?;
let total_count = entries.len() as u32 + start;
Ok(BrowsePage { entries, total_count })
}
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
fn search(
&self,
@@ -488,6 +517,17 @@ impl MediaBrowser for MusicServer {
}
}
fn browse_children_paged(
&self,
object_id: &str,
start: u32,
count: u32,
) -> Result<BrowsePage, ControlPointError> {
match self {
MusicServer::Upnp(upnp) => upnp.browse_children_paged(object_id, start, count),
}
}
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
match self {
MusicServer::Upnp(upnp) => upnp.browse_object(object_id),
@@ -603,6 +643,15 @@ impl MediaBrowser for UpnpMediaServer {
self.browse_with_flag(object_id, "BrowseDirectChildren", start, count)
}
fn browse_children_paged(
&self,
object_id: &str,
start: u32,
count: u32,
) -> Result<BrowsePage, ControlPointError> {
self.browse_with_flag_paged(object_id, "BrowseDirectChildren", start, count)
}
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?;
entries.into_iter().next().ok_or_else(|| {
@@ -721,6 +770,17 @@ fn extract_result_payload(
Ok(payload)
}
fn extract_total_matches(envelope: &SoapEnvelope, response_suffix: &str) -> u32 {
let response = match find_child_with_suffix(&envelope.body.content, response_suffix) {
Some(r) => r,
None => return 0,
};
find_child_with_suffix(response, "TotalMatches")
.and_then(|e| e.get_text())
.and_then(|t| t.trim().parse::<u32>().ok())
.unwrap_or(0)
}
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
parent.children.iter().find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),

View File

@@ -35,7 +35,7 @@ use crate::music_renderer::watcher::{
};
use crate::online::DeviceConnectionState;
use crate::queue::{
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot,
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot,
};
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
@@ -639,6 +639,14 @@ impl MusicRenderer {
);
self.set_playback_source(PlaybackSource::None);
self.clear_has_played_flag();
} else if self.is_playing_a_stream() {
// Continuous stream (radio) stopped naturally — never auto-advance
debug!(
renderer = self.info.friendly_name(),
"Renderer stopped during continuous stream; not auto-advancing"
);
self.set_playback_source(PlaybackSource::None);
self.clear_has_played_flag();
} else if self.is_playing_from_queue() {
// Only auto-advance if we have actually seen a PLAYING state
// since the track was started. This prevents auto-advance on
@@ -1554,7 +1562,7 @@ pub(crate) fn build_didl_lite_metadata(
protocol_info: &str,
) -> String {
use pmodidl::{DIDLLite, Item, Resource};
use pmoutils::ToXmlElement;
use pmodidl::ToXmlElement;
// Construire l'Item DIDL avec toutes les métadonnées
let item = Item {

View File

@@ -28,6 +28,8 @@ pub struct RendererSummary {
pub capabilities: RendererCapabilitiesSummary,
/// Renderer en ligne
pub online: bool,
/// Adresse IP du serveur hébergeant ce renderer (extraite de la location UPnP)
pub server_host: Option<String>,
}
/// Protocole exposé par le renderer
@@ -195,8 +197,12 @@ pub struct ContainerEntry {
pub struct BrowseResponse {
/// ID du container browsé
pub container_id: String,
/// Entrées du container
/// Entrées du container (page courante)
pub entries: Vec<ContainerEntry>,
/// Nombre total d'entrées dans le container
pub total_count: u32,
/// Offset de la page courante
pub offset: u32,
}
// ============================================================================

View File

@@ -27,7 +27,7 @@ use async_trait::async_trait;
#[cfg(feature = "pmoserver")]
use axum::{
Json, Router,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
};
@@ -43,7 +43,7 @@ use tracing::{debug, warn};
use utoipa::OpenApi;
#[cfg(feature = "pmoserver")]
const BROWSE_PAGE_SIZE: u32 = 100;
const BROWSE_DEFAULT_LIMIT: u32 = 50;
#[cfg(feature = "pmoserver")]
const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
@@ -102,6 +102,13 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
.into_iter()
.map(|r| {
let info = r.info();
// Extraire le host depuis la location URL (ex: "http://192.168.1.10:8080/...")
let server_host = info.location()
.split("://").nth(1)
.and_then(|s| s.split('/').next())
.and_then(|s| s.split(':').next())
.map(|h| h.to_string())
.filter(|h| !h.is_empty());
RendererSummary {
id: r.id().0.clone(),
friendly_name: r.friendly_name().to_string(),
@@ -109,6 +116,7 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
protocol: protocol_summary(&info.protocol()),
capabilities: capability_summary(&info.capabilities()),
online: r.is_online(),
server_host,
}
})
.collect::<Vec<_>>()
@@ -2042,6 +2050,15 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
Json(summaries)
}
/// Paramètres de pagination pour le browse
#[cfg(feature = "pmoserver")]
#[derive(Debug, serde::Deserialize)]
struct BrowseParams {
#[serde(default)]
offset: u32,
limit: Option<u32>,
}
/// GET /control/servers/{server_id}/containers/{container_id} - Browse un container
#[cfg(feature = "pmoserver")]
#[utoipa::path(
@@ -2049,7 +2066,9 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
path = "/servers/{server_id}/containers/{container_id}",
params(
("server_id" = String, Path, description = "ID unique du serveur"),
("container_id" = String, Path, description = "ID du container (use '0' for root)")
("container_id" = String, Path, description = "ID du container (use '0' for root)"),
("offset" = Option<u32>, Query, description = "Index de départ (défaut: 0)"),
("limit" = Option<u32>, Query, description = "Nombre max d'items (défaut: 50)"),
),
responses(
(status = 200, description = "Contenu du container", body = BrowseResponse),
@@ -2061,6 +2080,7 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
async fn browse_container(
State(state): State<ControlPointState>,
Path((server_id, container_id)): Path<(String, String)>,
Query(params): Query<BrowseParams>,
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
let sid = DeviceId(server_id.clone());
@@ -2091,14 +2111,17 @@ async fn browse_container(
));
}
let offset = params.offset;
let limit = params.limit.unwrap_or(BROWSE_DEFAULT_LIMIT);
// Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls
let container_id_clone = container_id.clone();
let server_clone = server.clone();
let browse_task = tokio::task::spawn_blocking(move || {
server_clone.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
server_clone.browse_children_paged(&container_id_clone, offset, limit)
});
let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
let page = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
.await
.map_err(|_| {
warn!(
@@ -2137,14 +2160,14 @@ async fn browse_container(
)
})?;
let container_entries: Vec<ContainerEntry> = entries
let container_entries: Vec<ContainerEntry> = page.entries
.into_iter()
.map(|e| ContainerEntry {
id: e.id,
title: e.title,
class: e.class,
is_container: e.is_container,
child_count: None, // Could be extracted from DIDL-Lite if needed
child_count: None,
artist: e.artist,
album: e.album,
album_art_uri: e.album_art_uri,
@@ -2154,6 +2177,8 @@ async fn browse_container(
Ok(Json(BrowseResponse {
container_id,
entries: container_entries,
total_count: page.total_count,
offset,
}))
}
@@ -2210,7 +2235,7 @@ fn fetch_playback_items(
let entries = if object_metadata.is_container {
// For containers, browse children to get all items
server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?
server.browse_children(object_id, 0, BROWSE_DEFAULT_LIMIT)?
} else {
// For items, use the object itself
vec![object_metadata]

View File

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

View File

@@ -3,14 +3,31 @@
//! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA.
use bevy_reflect::Reflect;
use pmoutils::ToXmlElement;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt::Write;
use std::io::Cursor;
use std::time::SystemTime;
use xmltree::{Element, XMLNode};
use xmltree::{Element, EmitterConfig, XMLNode};
/// Trait générique pour obtenir un élément XML (xmltree::Element).
pub trait ToXmlElement {
/// Convertit l'objet en élément XML.
fn to_xml_element(&self) -> Element;
/// Sérialise en chaîne XML formatée.
fn to_xml(&self) -> String {
let elem = self.to_xml_element();
let config = EmitterConfig::new()
.perform_indent(true)
.indent_string(" ");
let mut buf = Vec::new();
elem.write_with_config(&mut buf, config)
.expect("Failed to write XML");
String::from_utf8(buf).expect("Invalid UTF-8")
}
}
// ============= Couche d'abstraction générique =============

View File

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

View File

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

View File

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

View File

@@ -213,7 +213,7 @@ impl RadioParadiseSource {
let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string());
let cover_url = cover_pk
.as_ref()
.map(|pk| format!("{}/covers/jpeg/{}", self.base_url, pk))
.map(|pk| pmocache::covers_absolute_url_for(pk, None))
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string()))
.or_else(|| Some(self.default_cover_url()));

View File

@@ -517,7 +517,7 @@ fn playlist_track_to_response(
}
fn cover_url_from_pk(pk: &str) -> String {
format!("/covers/image/{}/256", pk)
pmocache::covers_route_for(pk, None)
}
fn normalize_cover_pk(input: Option<String>) -> Option<String> {

View File

@@ -182,7 +182,7 @@ impl ReadHandle {
let _remaining = self.remaining().await?;
// Convertir cover_pk en URL si présent
let album_art = cover_pk.map(|pk| format!("/cover/{}", pk));
let album_art = cover_pk.map(|pk| pmocache::covers_route_for(&pk, None));
Ok(Container {
id: self.playlist.id.clone(),
@@ -253,7 +253,7 @@ impl ReadHandle {
let track_number = meta.get_track_number().await.ok().flatten();
let cover_pk = meta.get_cover_pk().await.ok().flatten();
let cover_url = if let Some(pk) = cover_pk.as_ref() {
Some(format!("/covers/jpeg/{}/256", pk))
Some(pmocache::covers_route_for(pk, None))
} else {
meta.get_cover_url().await.ok().flatten()
};

View File

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

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

View File

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

View File

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

View File

@@ -28,17 +28,26 @@
//! ```
use crate::error::{Error, Result};
use crate::models::{ImageSize, LiveResponse, ShowMetadata, Station, StreamSource};
use crate::models::{
EmbedImage, ImageSize, Line, LiveResponse, Media, PullResponse, ShowMetadata, Song, Station,
StreamSource,
};
use regex::Regex;
use reqwest::Client;
use scraper::{Html, Selector};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use url::Url;
#[cfg(feature = "pmoconfig")]
use crate::config_ext::StationInfo;
/// Default Radio France base URL
pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr";
/// Livemeta API base URL (new API since 2026)
pub const LIVEMETA_API_URL: &str = "https://api.radiofrance.fr/livemeta/pull";
/// Default timeout for HTTP requests (30 seconds)
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
@@ -56,6 +65,103 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
("francebleu", "France Bleu"),
];
/// Mapping slug → numeric station ID for the livemeta/pull API
/// IDs extracted from www.radiofrance.fr SvelteKit page data (2026)
pub const STATION_IDS: &[(&str, u32)] = &[
("franceinter", 1),
("franceinfo", 2),
("francemusique", 4),
("franceculture", 5),
("mouv", 6),
("fip", 7),
("francebleu", 56),
// FIP webradios
("fip_rock", 64),
("fip_jazz", 65),
("fip_groove", 66),
("fip_reggae", 71),
("fip_electro", 74),
("fip_metal", 77),
("fip_pop", 78),
("fip_world", 69),
("fip_nouveautes", 70),
("fip_hiphop", 95),
("fip_sacre_francais", 96),
("fip_cultes", 709),
];
/// Icecast HiFi AAC stream URLs (stable, not from API)
/// Format: https://icecast.radiofrance.fr/{name}-hifi.aac
pub const STATION_STREAMS: &[(&str, &str)] = &[
(
"franceinter",
"https://icecast.radiofrance.fr/franceinter-hifi.aac",
),
(
"franceinfo",
"https://icecast.radiofrance.fr/franceinfo-hifi.aac",
),
(
"franceculture",
"https://icecast.radiofrance.fr/franceculture-hifi.aac",
),
(
"francemusique",
"https://icecast.radiofrance.fr/francemusique-hifi.aac",
),
("fip", "https://icecast.radiofrance.fr/fip-hifi.aac"),
("mouv", "https://icecast.radiofrance.fr/mouv-hifi.aac"),
// FIP webradios
(
"fip_rock",
"https://icecast.radiofrance.fr/fiprock-hifi.aac",
),
(
"fip_jazz",
"https://icecast.radiofrance.fr/fipjazz-hifi.aac",
),
(
"fip_groove",
"https://icecast.radiofrance.fr/fipgroove-hifi.aac",
),
(
"fip_reggae",
"https://icecast.radiofrance.fr/fipreggae-hifi.aac",
),
(
"fip_electro",
"https://icecast.radiofrance.fr/fipelectro-hifi.aac",
),
(
"fip_metal",
"https://icecast.radiofrance.fr/fipmetal-hifi.aac",
),
(
"fip_pop",
"https://icecast.radiofrance.fr/fippop-hifi.aac",
),
(
"fip_world",
"https://icecast.radiofrance.fr/fipworld-hifi.aac",
),
(
"fip_nouveautes",
"https://icecast.radiofrance.fr/fipnouveautes-hifi.aac",
),
(
"fip_hiphop",
"https://icecast.radiofrance.fr/fiphiphop-hifi.aac",
),
(
"fip_sacre_francais",
"https://icecast.radiofrance.fr/fipsacrefrancais-hifi.aac",
),
(
"fip_cultes",
"https://icecast.radiofrance.fr/fipcultes-hifi.aac",
),
];
/// Radio France HTTP client
///
/// This client provides access to Radio France's public APIs for:
@@ -63,13 +169,23 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
/// - Live metadata (current show, next show, stream URLs)
/// - Image URL construction (Pikapi)
///
/// The client is stateless and does not cache responses internally.
/// Caching should be handled by higher layers (e.g., config extension).
/// The client holds an in-memory station mapping (slug → numeric ID + stream URL)
/// seeded from hardcoded constants. Use `with_station_mapping()` to inject a
/// mapping loaded from persistent storage (pmoconfig).
#[derive(Debug, Clone)]
pub struct RadioFranceClient {
pub(crate) client: Client,
base_url: String,
timeout: Duration,
/// Mapping slug → { station_id, stream_url } — thread-safe, updatable at runtime
station_mapping: Arc<RwLock<HashMap<String, StationMappingEntry>>>,
}
/// Entry in the station mapping
#[derive(Debug, Clone)]
pub struct StationMappingEntry {
pub station_id: u32,
pub stream_url: String,
}
impl RadioFranceClient {
@@ -91,6 +207,7 @@ impl RadioFranceClient {
client,
base_url: DEFAULT_BASE_URL.to_string(),
timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
station_mapping: Arc::new(RwLock::new(Self::default_station_mapping())),
}
}
@@ -104,6 +221,89 @@ impl RadioFranceClient {
&self.client
}
/// Build the default station mapping from hardcoded constants
fn default_station_mapping() -> HashMap<String, StationMappingEntry> {
// Build a lookup for stream URLs
let stream_map: HashMap<&str, &str> = STATION_STREAMS.iter().copied().collect();
STATION_IDS
.iter()
.map(|(slug, id)| {
let stream_url = stream_map
.get(slug)
.copied()
.unwrap_or_default()
.to_string();
(
slug.to_string(),
StationMappingEntry {
station_id: *id,
stream_url,
},
)
})
.collect()
}
/// Remplace le mapping en mémoire par un mapping chargé depuis le cache persistant
///
/// Appelé au démarrage par MetadataCache pour charger le mapping pmoconfig.
#[cfg(feature = "pmoconfig")]
pub fn set_station_mapping(&self, mapping: HashMap<String, StationInfo>) {
let mut m = self.station_mapping.write().unwrap();
m.clear();
for (slug, info) in mapping {
m.insert(
slug,
StationMappingEntry {
station_id: info.station_id,
stream_url: info.stream_url,
},
);
}
}
/// Récupère l'ensemble du mapping courant (pour persistance dans pmoconfig)
#[cfg(feature = "pmoconfig")]
pub fn get_station_mapping(&self) -> HashMap<String, StationInfo> {
let m = self.station_mapping.read().unwrap();
m.iter()
.map(|(slug, entry)| {
(
slug.clone(),
StationInfo {
station_id: entry.station_id,
stream_url: entry.stream_url.clone(),
},
)
})
.collect()
}
/// Met à jour une entrée dans le mapping (utilisé après re-découverte unitaire)
pub fn update_station_entry(&self, slug: &str, station_id: u32, stream_url: String) {
let mut m = self.station_mapping.write().unwrap();
m.insert(
slug.to_string(),
StationMappingEntry {
station_id,
stream_url,
},
);
}
/// Retourne l'ID numérique pour un slug, ou None si inconnu
pub fn station_id_for_slug(&self, slug: &str) -> Option<u32> {
let m = self.station_mapping.read().unwrap();
m.get(slug).map(|e| e.station_id)
}
/// Retourne l'URL du stream HiFi pour un slug, ou None si inconnu
pub fn stream_url_for_slug(&self, slug: &str) -> Option<String> {
let m = self.station_mapping.read().unwrap();
m.get(slug).map(|e| e.stream_url.clone())
}
// ========================================================================
// Station Discovery
// ========================================================================
@@ -254,20 +454,71 @@ impl RadioFranceClient {
.collect())
}
/// Discover local France Bleu radios via API
/// Discover local France Bleu radios via SvelteKit page data
///
/// Uses the France Bleu /api/live? endpoint which includes
/// a `localRadios` array in the `now` field.
/// Scrapes francebleu page for local station slugs and discovers their IDs.
/// Met à jour le station_mapping avec les IDs trouvés.
pub async fn discover_local_radios(&self) -> Result<Vec<Station>> {
let response = self.live_metadata("francebleu").await?;
let url = format!("{}/francebleu/__data.json", self.base_url);
let resp = self
.client
.get(&url)
.timeout(self.timeout)
.send()
.await?
.text()
.await?;
Ok(response
.local_radios()
.cloned()
.unwrap_or_default()
// Extraire les slugs "francebleu_xxx" et les paires (slug, id) de la page
let re_slug_id = Regex::new(r#""(francebleu_[a-z_]+)","(\d+)""#)?;
let mut stations = Vec::new();
let mut found_any = false;
for cap in re_slug_id.captures_iter(&resp) {
let slug = cap[1].to_string();
if let Ok(id) = cap[2].parse::<u32>() {
let stream_url = Self::derive_stream_url(&slug);
self.update_station_entry(&slug, id, stream_url);
let name = Self::slug_to_display_name(&slug);
stations.push(Station::new(slug, name));
found_any = true;
}
}
if found_any {
return Ok(stations);
}
// Fallback : scraper la page HTML pour les noms "ICI ..."
let html_url = format!("{}/francebleu", self.base_url);
let html = self
.client
.get(&html_url)
.timeout(self.timeout)
.send()
.await?
.text()
.await?;
let re_ici = Regex::new(r#"ICI ([A-Za-z\u00C0-\u017E][A-Za-z\u00C0-\u017E\s-]+)"#)?;
let re_slug = Regex::new(r#""(francebleu_[a-z_]+)""#)?;
let mut slugs: HashSet<String> = HashSet::new();
for cap in re_slug.captures_iter(&html) {
slugs.insert(cap[1].to_string());
}
let _names: Vec<String> = re_ici
.captures_iter(&html)
.map(|c| c[1].trim().to_string())
.collect();
Ok(slugs
.into_iter()
.filter(|local| local.is_on_air)
.map(|local| Station::new(local.name, local.title))
.map(|slug| {
let name = Self::slug_to_display_name(&slug);
Station::new(slug, name)
})
.collect())
}
@@ -370,28 +621,42 @@ impl RadioFranceClient {
/// # }
/// ```
pub async fn live_metadata(&self, station: &str) -> Result<LiveResponse> {
let (base_station, webradio) = Self::parse_station_slug(station);
let station_id = self
.station_id_for_slug(station)
.ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", station)))?;
let mut url = Url::parse(&format!("{}/{}/api/live", self.base_url, base_station))?;
// Add webradio parameter if needed
if let Some(wr) = webradio {
url.query_pairs_mut().append_pair("webradio", wr);
}
let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
#[cfg(feature = "logging")]
tracing::debug!("Fetching live metadata: {}", url);
let response = self.client.get(url).timeout(self.timeout).send().await?;
let response = self.client.get(&url).timeout(self.timeout).send().await?;
if !response.status().is_success() {
return Err(Error::ApiError(format!(
"API returned status: {}",
response.status()
"livemeta API returned status {} for station {} (id={})",
response.status(),
station,
station_id
)));
}
let live: LiveResponse = response.json().await?;
let pull: PullResponse = response.json().await?;
// Fix 1: Vérifier que l'ID retourné correspond bien à la station demandée.
// Si Radio France réassigne les IDs, on déclenche une redécouverte.
if pull.station_id != station_id {
#[cfg(feature = "logging")]
tracing::warn!(
"Station ID mismatch for {}: expected {}, API returned {} — triggering rediscovery",
station, station_id, pull.station_id
);
// Mettre à jour le mapping avec le nouvel ID retourné par l'API
let stream_url = self.stream_url_for_slug(station).unwrap_or_default();
self.update_station_entry(station, pull.station_id, stream_url);
}
let live = self.pull_to_live_response(station, &pull);
#[cfg(feature = "logging")]
tracing::debug!(
@@ -404,6 +669,268 @@ impl RadioFranceClient {
Ok(live)
}
/// Convertit une PullResponse (nouvelle API) en LiveResponse (format interne)
fn pull_to_live_response(&self, station_slug: &str, pull: &PullResponse) -> LiveResponse {
let now = if let Some(step) = pull.current_step() {
self.step_to_show_metadata(station_slug, step)
} else {
ShowMetadata::default()
};
// delayToRefresh : on utilise end_time - now comme indicateur (min 30s)
let delay_to_refresh = if let Some(end) = now.end_time {
let current = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
if end > current {
((end - current) * 1000).min(60_000) // max 60s, en ms
} else {
30_000
}
} else {
30_000
};
LiveResponse {
station_name: station_slug.to_string(),
delay_to_refresh,
migrated: true,
now,
next: None,
}
}
/// Convertit un PullStep en ShowMetadata
fn step_to_show_metadata(&self, station_slug: &str, step: &crate::models::PullStep) -> ShowMetadata {
// Stream : injecté depuis le mapping en mémoire
let stream_url = self.stream_url_for_slug(station_slug).unwrap_or_default();
let sources = if !stream_url.is_empty() {
vec![StreamSource {
url: stream_url,
broadcast_type: crate::models::BroadcastType::Live,
format: crate::models::StreamFormat::Aac,
bitrate: 192,
}]
} else {
vec![]
};
// Visual : la nouvelle API donne directement l'UUID (pas une URL complète)
let visual_background = step.visual.as_deref().map(|uuid| EmbedImage {
model: "EmbedImage".to_string(),
src: ImageSize::Large.build_url(uuid),
width: None,
height: None,
dominant: None,
copyright: None,
});
if step.is_song() {
// === Radio musicale ===
let artists = step.artists_display();
let song = Some(Song {
id: step.song_id.clone().unwrap_or_default(),
year: step.annee_edition_musique,
interpreters: if artists.is_empty() {
vec![]
} else {
vec![artists.clone()]
},
release: crate::models::Release {
label: step.label.clone(),
title: step.titre_album.clone(),
reference: None,
},
});
ShowMetadata {
start_time: step.start,
end_time: step.end,
producer: step.disc_jockey.clone(),
first_line: Line {
title: Some(step.title.clone()),
id: None,
path: step.path.clone(),
},
second_line: Line {
title: if artists.is_empty() { None } else { Some(artists) },
id: None,
path: None,
},
song,
media: Media { sources },
visual_background,
..ShowMetadata::default()
}
} else {
// === Radio parlée / émission ===
let show_title = step.title_concept.as_deref()
.unwrap_or(step.title.as_str())
.to_string();
let episode_title = if step.title_concept.is_some() {
step.title.clone()
} else {
String::new()
};
let producer = step.disc_jockey.clone()
.or_else(|| step.producers.first().map(|p| p.name.clone()));
ShowMetadata {
start_time: step.start,
end_time: step.end,
producer,
first_line: Line {
title: Some(show_title),
id: None,
path: step.path.clone(),
},
second_line: Line {
title: if episode_title.is_empty() { None } else { Some(episode_title) },
id: None,
path: None,
},
intro: step.expression_description.clone()
.or_else(|| step.description.clone()),
media: Media { sources },
visual_background,
..ShowMetadata::default()
}
}
}
/// Tente de re-découvrir l'ID et l'URL stream d'une station depuis le web
///
/// Utilisé quand un stream échoue ou qu'un slug est inconnu.
/// Met à jour le mapping en mémoire si la découverte réussit.
pub async fn rediscover_station(&self, slug: &str) -> Result<(u32, String)> {
#[cfg(feature = "logging")]
tracing::warn!("Rediscovering station mapping for: {}", slug);
// Construire l'URL de la page de la station principale
let base_slug = slug.split('_').next().unwrap_or(slug);
let page_url = format!("{}/{}/__data.json", self.base_url, base_slug);
let resp = self
.client
.get(&page_url)
.timeout(self.timeout)
.send()
.await?;
if !resp.status().is_success() {
return Err(Error::ApiError(format!(
"Cannot rediscover station {}: page returned {}",
slug,
resp.status()
)));
}
let text = resp.text().await?;
// Fix 2: chercher l'ID spécifique à la station via son brandEnum.
// Ex: "fip_rock" → "FIP_ROCK" → cherche `"FIP_ROCK","64"` dans le JSON SvelteKit.
// Pour les stations principales, on cherche aussi le pattern `"Brand","<id>"`.
let brand_enum = slug.to_uppercase(); // fip_rock → FIP_ROCK
let re_enum = Regex::new(&format!(r#""{}","(\d+)""#, regex::escape(&brand_enum)))?;
let brand_id: Option<u32> = re_enum
.captures_iter(&text)
.filter_map(|c| c[1].parse::<u32>().ok())
.next();
// Fallback : premier "Brand","<id>" dans la page (pour stations principales)
let brand_id = if brand_id.is_some() {
brand_id
} else {
let re_brand = Regex::new(r#""Brand","(\d+)""#)?;
let ids: Vec<u32> = re_brand
.captures_iter(&text)
.filter_map(|c| c[1].parse::<u32>().ok())
.collect();
ids.into_iter().next()
};
let station_id = brand_id.ok_or_else(|| {
Error::ApiError(format!("Could not find station_id for {} in page data", slug))
})?;
// Dériver l'URL stream depuis le slug (format icecast connu)
let stream_url = Self::derive_stream_url(slug);
self.update_station_entry(slug, station_id, stream_url.clone());
#[cfg(feature = "logging")]
tracing::info!(
"Rediscovered station {}: id={}, stream={}",
slug,
station_id,
stream_url
);
Ok((station_id, stream_url))
}
/// Valide qu'une station existe toujours et que son stream est accessible
///
/// Vérifie deux choses :
/// 1. L'API livemeta répond avec un stationId valide (station connue de Radio France)
/// 2. L'URL icecast répond HTTP 200 (stream physiquement disponible)
///
/// Retourne Ok(()) si tout est OK, Err si la station est invalide ou le stream mort.
pub async fn validate_station(&self, slug: &str) -> Result<()> {
// 1. Vérifier que l'API livemeta répond pour cette station
let station_id = self
.station_id_for_slug(slug)
.ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", slug)))?;
let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
let response = self.client.get(&url).timeout(self.timeout).send().await?;
if !response.status().is_success() {
return Err(Error::ApiError(format!(
"Station {} (id={}) no longer exists: livemeta returned {}",
slug, station_id, response.status()
)));
}
let pull: PullResponse = response.json().await?;
// Vérifier cohérence de l'ID — si mismatch, la station a peut-être été réassignée
if pull.station_id != station_id {
return Err(Error::ApiError(format!(
"Station {} ID mismatch: stored={}, API returned={} — mapping is stale",
slug, station_id, pull.station_id
)));
}
// 2. Vérifier que l'URL icecast répond
if let Some(stream_url) = self.stream_url_for_slug(slug) {
if !stream_url.is_empty() {
let stream_resp = self
.client
.head(&stream_url)
.timeout(Duration::from_secs(10))
.send()
.await?;
if !stream_resp.status().is_success() {
return Err(Error::ApiError(format!(
"Stream for {} is not accessible: {} returned {}",
slug, stream_url, stream_resp.status()
)));
}
}
}
Ok(())
}
/// Dérive l'URL icecast depuis le slug selon le pattern Radio France
pub fn derive_stream_url(slug: &str) -> String {
// fip_rock → fiprock, fip_sacre_francais → fipsacrefrancais
let name = slug.replace('_', "");
format!("https://icecast.radiofrance.fr/{}-hifi.aac", name)
}
/// Get only the current show metadata
pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> {
let response = self.live_metadata(station).await?;
@@ -464,13 +991,7 @@ impl RadioFranceClient {
/// # }
/// ```
pub async fn get_hifi_stream_url(&self, station: &str) -> Result<String> {
let metadata = self.live_metadata(station).await?;
metadata
.now
.media
.best_hifi_stream()
.map(|s| s.url.clone())
self.stream_url_for_slug(station)
.ok_or_else(|| Error::NoHifiStream(station.to_string()))
}
@@ -619,6 +1140,7 @@ impl ClientBuilder {
client,
base_url: self.base_url,
timeout: self.timeout,
station_mapping: Arc::new(RwLock::new(RadioFranceClient::default_station_mapping())),
})
}
}
@@ -644,7 +1166,7 @@ mod tests {
);
assert_eq!(
RadioFranceClient::parse_station_slug("francebleu_alsace"),
("francebleu_alsace", None)
("francebleu", Some("francebleu_alsace"))
);
assert_eq!(
RadioFranceClient::parse_station_slug("franceculture"),

View File

@@ -37,11 +37,24 @@ use anyhow::Result;
use pmoconfig::Config;
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
/// Default TTL for station list cache (7 days in seconds)
pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
/// Default TTL for station mapping cache (30 days — IDs and stream URLs are very stable)
pub const DEFAULT_STATION_MAPPING_TTL_SECS: u64 = 30 * 24 * 3600;
/// Informations d'une station : ID numérique et URL du stream HiFi
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StationInfo {
/// ID numérique pour l'API livemeta/pull
pub station_id: u32,
/// URL du stream HiFi (icecast AAC ou MP3)
pub stream_url: String,
}
/// Cached station list (simplifié)
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedStations {
@@ -49,6 +62,14 @@ struct CachedStations {
last_updated: u64, // Unix timestamp
}
/// Mapping slug → StationInfo avec timestamp de mise à jour
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedStationMapping {
/// slug → { station_id, stream_url }
mapping: HashMap<String, StationInfo>,
last_updated: u64, // Unix timestamp
}
/// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
///
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
@@ -113,6 +134,36 @@ pub trait RadioFranceConfigExt {
/// Efface le cache des stations (force re-découverte)
fn clear_radiofrance_station_cache(&self) -> Result<()>;
// ========================================================================
// Station Mapping Cache (slug → station_id + stream_url)
// ========================================================================
/// Récupère le mapping slug → StationInfo depuis le cache (si valide)
///
/// # Returns
///
/// - `Some(HashMap)` si le cache existe et n'est pas expiré
/// - `None` si absent ou TTL dépassé → il faut re-découvrir
fn get_radiofrance_station_mapping(&self) -> Result<Option<HashMap<String, StationInfo>>>;
/// Enregistre le mapping slug → StationInfo en cache
fn set_radiofrance_station_mapping(&self, mapping: &HashMap<String, StationInfo>)
-> Result<()>;
/// Récupère le TTL du mapping (en secondes, défaut 30 jours)
fn get_radiofrance_mapping_ttl(&self) -> Result<u64>;
/// Définit le TTL du mapping (en secondes)
fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()>;
/// Efface le cache du mapping (force re-découverte au prochain accès)
fn clear_radiofrance_station_mapping(&self) -> Result<()>;
/// Met à jour une seule entrée dans le mapping (sans invalider tout le cache)
///
/// Utilisé pour la mise à jour incrémentale lors d'une re-découverte unitaire.
fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()>;
}
impl RadioFranceConfigExt for Config {
@@ -201,6 +252,90 @@ impl RadioFranceConfigExt for Config {
// Set to null to clear
self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null)
}
fn get_radiofrance_station_mapping(&self) -> Result<Option<HashMap<String, StationInfo>>> {
let ttl = self.get_radiofrance_mapping_ttl()?;
match self.get_value(&["sources", "radiofrance", "station_mapping"]) {
Ok(value) => {
let cached: CachedStationMapping = serde_yaml::from_value(value)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if now - cached.last_updated < ttl {
Ok(Some(cached.mapping))
} else {
Ok(None) // Expiré
}
}
Err(_) => Ok(None), // Absent
}
}
fn set_radiofrance_station_mapping(
&self,
mapping: &HashMap<String, StationInfo>,
) -> Result<()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let cached = CachedStationMapping {
mapping: mapping.clone(),
last_updated: now,
};
let value = serde_yaml::to_value(&cached)?;
self.set_value(&["sources", "radiofrance", "station_mapping"], value)
}
fn get_radiofrance_mapping_ttl(&self) -> Result<u64> {
match self.get_value(&["sources", "radiofrance", "station_mapping_ttl_secs"]) {
Ok(Value::Number(n)) => {
if let Some(ttl) = n.as_u64() {
Ok(ttl)
} else {
self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?;
Ok(DEFAULT_STATION_MAPPING_TTL_SECS)
}
}
_ => {
self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?;
Ok(DEFAULT_STATION_MAPPING_TTL_SECS)
}
}
}
fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()> {
self.set_value(
&["sources", "radiofrance", "station_mapping_ttl_secs"],
Value::Number(serde_yaml::Number::from(ttl_secs)),
)
}
fn clear_radiofrance_station_mapping(&self) -> Result<()> {
self.set_value(
&["sources", "radiofrance", "station_mapping"],
Value::Null,
)
}
fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()> {
// Charger le mapping existant (même expiré) pour mise à jour incrémentale
let mut mapping = match self.get_value(&["sources", "radiofrance", "station_mapping"]) {
Ok(value) => serde_yaml::from_value::<CachedStationMapping>(value)
.map(|c| c.mapping)
.unwrap_or_default(),
Err(_) => HashMap::new(),
};
mapping.insert(slug.to_string(), info);
self.set_radiofrance_station_mapping(&mapping)
}
}
#[cfg(test)]

View File

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

View File

@@ -12,9 +12,11 @@
//! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements
use crate::client::RadioFranceClient;
use crate::config_ext::{RadioFranceConfigExt, StationInfo};
use crate::error::Result;
use crate::models::{ImageSize, LiveResponse, Station};
use pmoconfig::Config;
#[cfg(feature = "playlist")]
use pmodidl::{Container, Item, Resource};
use std::collections::HashMap;
use std::sync::Arc;
@@ -59,6 +61,9 @@ pub struct CachedMetadata {
// TTL = end_time de l'API Radio France
pub end_time: Option<u64>, // Unix timestamp
/// Timestamp de la dernière récupération (pour TTL minimum)
pub fetched_at: u64,
}
impl CachedMetadata {
@@ -89,6 +94,10 @@ impl CachedMetadata {
// 4. TTL = end_time
let end_time = live.now.end_time;
let fetched_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(Self {
slug: station.slug.clone(),
@@ -106,6 +115,7 @@ impl CachedMetadata {
nr_audio_channels,
duration,
end_time,
fetched_at,
})
}
@@ -238,15 +248,16 @@ impl CachedMetadata {
match cache.add_from_url(&cover_url, Some("radiofrance")).await {
Ok(pk) => {
// Construire l'URL publique
let route = cache.route_for(&pk, None);
let public_url = format!("{}{}", server_base_url.trim_end_matches('/'), route);
// Note: add_from_url() lance le téléchargement complet en arrière-plan.
// L'URL est valide immédiatement — si le fichier n'est pas encore prêt,
// le client web doit réessayer (retry avec backoff).
let public_url = pmocache::covers_absolute_url_for(&pk, None);
#[cfg(feature = "logging")]
tracing::debug!(
"Cached cover - UUID: {}, PK: {}, route: {}, public_url: {}",
"Cached cover - UUID: {}, PK: {}, public_url: {}",
uuid,
pk,
route,
public_url
);
@@ -349,6 +360,7 @@ impl CachedMetadata {
/// Construit un Container DIDL de playlist à un item
///
/// La playlist et l'item ont EXACTEMENT les mêmes métadonnées
#[cfg(feature = "playlist")]
pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container {
// Calculer la duration dynamiquement (temps restant jusqu'à end_time)
let duration = if let Some(end) = self.end_time {
@@ -432,17 +444,23 @@ impl CachedMetadata {
}
}
/// TTL minimum quand l'API ne fournit pas de end_time (3 minutes)
pub const FALLBACK_TTL_SECS: u64 = 3 * 60;
/// Vérifie si le TTL est dépassé
///
/// - Si `end_time` est fourni par l'API : expire exactement quand l'émission se termine
/// - Sinon : expire après `FALLBACK_TTL_SECS` depuis le dernier fetch
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if let Some(end_time) = self.end_time {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
now >= end_time
} else {
// Pas de end_time = toujours expiré (refresh systématique)
true
now >= self.fetched_at + Self::FALLBACK_TTL_SECS
}
}
}
@@ -478,6 +496,9 @@ pub struct MetadataCache {
impl MetadataCache {
/// Constructeur avec tous les paramètres obligatoires
///
/// Charge automatiquement le mapping station depuis pmoconfig (si valide).
/// Si le cache est absent ou expiré, utilise les valeurs hardcodées du client.
#[cfg(feature = "cache")]
pub fn new(
client: RadioFranceClient,
@@ -485,6 +506,25 @@ impl MetadataCache {
server_base_url: String,
config: Arc<Config>,
) -> Self {
// Charger le mapping depuis pmoconfig et l'injecter dans le client
if let Ok(Some(mapping)) = config.get_radiofrance_station_mapping() {
#[cfg(feature = "logging")]
tracing::info!(
"Loaded station mapping from config ({} entries)",
mapping.len()
);
client.set_station_mapping(mapping);
} else {
// Pas de cache persistant : persister le mapping par défaut
let mapping = client.get_station_mapping();
let _ = config.set_radiofrance_station_mapping(&mapping);
#[cfg(feature = "logging")]
tracing::info!(
"Initialized station mapping from defaults ({} entries)",
mapping.len()
);
}
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
client,
@@ -525,18 +565,46 @@ impl MetadataCache {
let live_response = match self.client.live_metadata(slug).await {
Ok(resp) => resp,
Err(e) => {
// Graceful degradation: retourner les données expirées si API down
let cache = self.cache.read().await;
if let Some(metadata) = cache.get(slug) {
// Si station inconnue → tenter une re-découverte du mapping
let is_unknown = e.to_string().contains("Unknown station slug");
if is_unknown {
#[cfg(feature = "logging")]
tracing::warn!(
"API Radio France down for {}, using expired cache: {}",
slug,
e
);
return Ok(metadata.clone());
tracing::warn!("Unknown station '{}', attempting rediscovery", slug);
match self.client.rediscover_station(slug).await {
Ok(_) => {
// Persister le mapping mis à jour
self.persist_station_mapping();
// Réessayer la requête
match self.client.live_metadata(slug).await {
Ok(resp) => resp,
Err(e2) => return Err(e2),
}
}
Err(discover_err) => {
#[cfg(feature = "logging")]
tracing::error!(
"Rediscovery failed for '{}': {}",
slug,
discover_err
);
return Err(e);
}
}
} else {
// Graceful degradation: retourner les données expirées si API down
let cache = self.cache.read().await;
if let Some(metadata) = cache.get(slug) {
#[cfg(feature = "logging")]
tracing::warn!(
"API Radio France down for {}, using expired cache: {}",
slug,
e
);
return Ok(metadata.clone());
}
return Err(e);
}
return Err(e);
}
};
@@ -575,17 +643,34 @@ impl MetadataCache {
Ok(metadata)
}
/// Récupère la liste des stations (cache persistant via pmoconfig)
/// Récupère la liste des stations (cache persistant via pmoconfig, TTL 7 jours)
///
/// # Logique
///
/// 1. Essaie de lire depuis pmoconfig
/// 2. Si cache valide (TTL 1 semaine), retourne
/// 3. Sinon, découvre via API et met à jour pmoconfig
/// 1. Essaie de lire depuis pmoconfig (cache TTL 7 jours)
/// 2. Si cache valide, retourne sans appel réseau
/// 3. Sinon, découvre via scraping et met à jour pmoconfig
pub async fn get_stations(&self) -> Result<Vec<Station>> {
// TODO: Implémenter avec config_ext
// Pour l'instant, découvre directement
self.client.discover_all_stations().await
// 1. Essayer le cache persistant
if let Ok(Some(stations)) = self.config.get_radiofrance_stations_cached() {
#[cfg(feature = "logging")]
tracing::debug!("Using cached station list ({} stations)", stations.len());
return Ok(stations);
}
// 2. Cache miss : découverte via scraping
#[cfg(feature = "logging")]
tracing::info!("Station cache miss, discovering via web scraping...");
let stations = self.client.discover_all_stations().await?;
// 3. Persister dans pmoconfig
if let Err(e) = self.config.set_radiofrance_cached_stations(&stations) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to cache station list: {}", e);
}
Ok(stations)
}
/// Récupère les métadonnées live brutes de l'API (sans cache)
@@ -601,6 +686,60 @@ impl MetadataCache {
self.client.get_hifi_stream_url(slug).await
}
/// Force une re-découverte du mapping pour un slug donné
///
/// Utilisé quand un stream retourne une erreur HTTP (404, connexion refusée, etc.)
/// Persiste le mapping mis à jour dans pmoconfig.
pub async fn handle_stream_failure(&self, slug: &str) {
#[cfg(feature = "logging")]
tracing::warn!("Stream failure for '{}', triggering rediscovery", slug);
match self.client.rediscover_station(slug).await {
Ok((id, url)) => {
#[cfg(feature = "logging")]
tracing::info!(
"Re-discovered station '{}': id={}, url={}",
slug,
id,
url
);
self.persist_station_mapping();
// Invalider le cache mémoire pour forcer un refresh des métadonnées
let mut cache = self.cache.write().await;
cache.remove(slug);
}
Err(e) => {
#[cfg(feature = "logging")]
tracing::error!("Rediscovery failed for '{}': {}", slug, e);
}
}
}
/// Persiste le mapping courant dans pmoconfig
fn persist_station_mapping(&self) {
let mapping = self.client.get_station_mapping();
if let Err(e) = self.config.set_radiofrance_station_mapping(&mapping) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to persist station mapping: {}", e);
}
}
/// Met à jour manuellement le mapping d'une station et le persiste
pub fn update_station_mapping(&self, slug: &str, station_id: u32, stream_url: String) {
self.client
.update_station_entry(slug, station_id, stream_url.clone());
// Persister dans pmoconfig
let info = StationInfo {
station_id,
stream_url,
};
if let Err(e) = self.config.upsert_radiofrance_station_info(slug, info) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to upsert station info for '{}': {}", slug, e);
}
}
/// S'abonner aux changements de métadonnées
///
/// Le callback sera appelé avec le slug chaque fois que
@@ -634,7 +773,12 @@ mod tests {
#[test]
fn test_cached_metadata_is_expired() {
let metadata = CachedMetadata {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let base = CachedMetadata {
slug: "test".to_string(),
title: "Test".to_string(),
creator: None,
@@ -650,15 +794,26 @@ mod tests {
nr_audio_channels: None,
duration: None,
end_time: Some(0), // Dans le passé
fetched_at: now,
};
assert!(metadata.is_expired());
// end_time dans le passé → expiré
assert!(base.is_expired());
let metadata_no_end = CachedMetadata {
// end_time dans le futur → non expiré
let not_expired = CachedMetadata { end_time: Some(now + 3600), ..base.clone() };
assert!(!not_expired.is_expired());
// Pas de end_time, fetched_at récent → non expiré (fallback TTL)
let no_end_fresh = CachedMetadata { end_time: None, fetched_at: now, ..base.clone() };
assert!(!no_end_fresh.is_expired());
// Pas de end_time, fetched_at ancien → expiré
let no_end_old = CachedMetadata {
end_time: None,
..metadata
fetched_at: now - CachedMetadata::FALLBACK_TTL_SECS - 1,
..base
};
assert!(metadata_no_end.is_expired());
assert!(no_end_old.is_expired());
}
}

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

View File

@@ -98,18 +98,26 @@ impl StationGroups {
/// Niveau 0: to_didl() retourne le container "radiofrance" avec tous les groupes
///
/// Appelle to_stub() sur chaque StationGroup
/// Appelle to_stub() sur chaque StationGroup en parallèle
pub async fn to_didl(
&self,
metadata_cache: &MetadataCache,
server_base_url: &str,
) -> Result<Container> {
let mut containers = Vec::new();
use futures::stream::{self, StreamExt};
for group in &self.groups {
let container = group.to_stub(metadata_cache, server_base_url).await?;
containers.push(container);
}
let futures: Vec<_> = self
.groups
.iter()
.map(|group| group.to_stub(metadata_cache, server_base_url))
.collect();
let results: Vec<Result<Container>> = stream::iter(futures)
.buffer_unordered(8)
.collect()
.await;
let containers: Vec<Container> = results.into_iter().collect::<Result<Vec<_>>>()?;
Ok(Container {
id: "radiofrance".to_string(),

View File

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

View File

@@ -114,14 +114,16 @@ impl Server {
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self {
let api_registry = Arc::new(RwLock::new(Vec::new()));
let base_url = base_url.into();
// Créer le router initial avec l'endpoint de registre
let registry_route = Router::new()
.route("/api/registry", get(get_api_registry))
.with_state(api_registry.clone());
Self {
let server = Self {
name: name.into(),
base_url: base_url.into(),
base_url,
http_port,
router: Arc::new(RwLock::new(registry_route)),
api_router: Arc::new(RwLock::new(None)),
@@ -129,7 +131,14 @@ impl Server {
log_state: None,
api_registry,
shutdown_token: CancellationToken::new(),
}
};
// Initialiser PMO_SERVER_URL avec l'URL complète (incluant le port).
// base_url() normalise l'URL en ajoutant le port si absent.
// SAFETY: appelé une seule fois au démarrage du serveur, avant tout thread concurrent.
unsafe { std::env::set_var("PMO_SERVER_URL", server.base_url()) };
server
}
pub fn new_configured() -> Self {

View File

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

View File

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

View File

@@ -54,18 +54,10 @@ pub fn get_audio_cache() -> Option<Arc<AudioCache>> {
/// // url = "http://localhost:8080/covers/images/abc123/300"
/// ```
pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String> {
// Récupérer l'URL de base depuis la variable d'environnement ou une config
let base_url =
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
let cache = get_cover_cache().ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?;
let param = match size {
Some(size_) => Some(size_.to_string()),
None => None,
};
let route = cache.route_for(pk, param.as_deref());
Ok(format!("{}{}", base_url, route))
Ok(pmocache::covers_absolute_url_for(
pk,
size.map(|s| s.to_string()).as_deref(),
))
}
/// Construit l'URL complète pour une piste audio
@@ -84,12 +76,6 @@ pub fn build_cover_url(pk: &str, size: Option<usize>) -> anyhow::Result<String>
/// // url = "http://localhost:8080/audio/tracks/abc123/stream"
/// ```
pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result<String> {
// Récupérer l'URL de base depuis la variable d'environnement ou une config
let base_url =
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
let cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?;
let route = cache.route_for(pk, param);
Ok(format!("{}{}", base_url, route))
Ok(cache.absolute_url_for(pk, param))
}

View File

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

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 }
pmodidl = { path = "../pmodidl" }
pmoutils = { path = "../pmoutils" }
pmoutils = { version = "0.1.2", registry = "pmo" }
[features]
default = []

View File

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

View File

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

View File

@@ -1,56 +1,51 @@
//! Pipeline audio serveur par instance WebRenderer
//!
//! Chaque instance WebRenderer possède un pipeline indépendant :
//! - Un `StreamingFlacSink` qui encode et diffuse le flux FLAC aux clients HTTP
//! - Un canal de contrôle `PipelineControl` alimenté par les handlers UPnP
//! - Une task background qui orchestre sources et sink
//! - Une `PlayerSource` qui gère le cycle de vie AVTransport (Play/Pause/Stop/Seek/LoadUri)
//! - Un `StreamingOggFlacSink` qui encode et diffuse le flux OGG-FLAC aux clients HTTP
//! - Des nœuds de normalisation (resampling → 96 kHz, conversion → I24)
use std::sync::Arc;
use pmoaudio::{AudioSegment, PositionTrackerNode, ResamplingNode, ToI24Node};
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
use pmoaudio_ext::sinks::{
OggFlacStreamHandle, StreamingOggFlacSink,
DIRECT_OGG_FLAC_BITS_PER_SAMPLE, DIRECT_OGG_FLAC_SAMPLE_RATE,
};
use pmoaudio_ext::UriSource;
use pmoaudio::{ResamplingNode, ToI24Node};
use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource};
use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink};
use pmoflac::EncoderOptions;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use tracing::{debug, warn};
use crate::messages::PlaybackState;
use crate::state::SharedState;
// ─── Commandes de contrôle ───────────────────────────────────────────────────
// ─── Ré-export des commandes pour les handlers ────────────────────────────────
/// Commandes envoyées au pipeline audio de l'instance
#[derive(Debug)]
pub enum PipelineControl {
LoadUri(String),
LoadNextUri(String),
Play,
Pause,
Stop,
Seek(f64),
SetVolume(u16),
SetMute(bool),
/// Notification interne : la source courante s'est terminée (EOF ou erreur)
SourceEnded,
}
/// Commandes de transport — alias vers PlayerCommand pour compatibilité handlers
pub use pmoaudio_ext::PlayerCommand as PipelineControl;
// ─── Handle vers le pipeline ─────────────────────────────────────────────────
/// Handle partageable vers le pipeline audio d'une instance
/// Handle partageable vers le pipeline audio d'une instance.
///
/// Expose le `PlayerHandle` pour les commandes AVTransport et le `CancellationToken`
/// pour l'arrêt complet du pipeline.
#[derive(Clone)]
pub struct PipelineHandle {
pub control_tx: mpsc::Sender<PipelineControl>,
pub player: PlayerHandle,
pub stop_token: CancellationToken,
/// Volume courant (géré localement, pas dans PlayerSource)
state: SharedState,
}
impl PipelineHandle {
/// Envoie une commande de transport. Gère SetVolume/SetMute localement.
pub async fn send(&self, cmd: PipelineControl) {
let _ = self.control_tx.send(cmd).await;
match cmd {
PlayerCommand::LoadUri(uri) => self.player.load_uri(uri).await,
PlayerCommand::LoadNextUri(uri) => self.player.load_next_uri(uri).await,
PlayerCommand::Play => self.player.play().await,
PlayerCommand::Pause => self.player.pause().await,
PlayerCommand::Stop => self.player.stop().await,
PlayerCommand::Seek(pos) => self.player.seek(pos).await,
}
}
}
@@ -59,9 +54,9 @@ impl PipelineHandle {
/// Pipeline audio complet pour une instance WebRenderer.
///
/// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle`
/// (multi-clients, chaque `subscribe()` retourne un nouveau flux depuis le point courant).
/// (multi-client broadcast, chaque `subscribe()` crée un flux indépendant).
pub struct InstancePipeline {
/// Handle vers le sink OGG-FLAC — clonable, chaque subscribe() donne un flux live.
/// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client.
pub flac_handle: OggFlacStreamHandle,
pub pipeline_handle: PipelineHandle,
}
@@ -76,83 +71,55 @@ impl InstancePipeline {
udn: String,
) -> Self {
let stop_token = CancellationToken::new();
let (control_tx, control_rx) = mpsc::channel::<PipelineControl>(32);
// Chaîne de traitement : ResamplingNode(96kHz) → ToI24Node → DirectFlacSink
// La backpressure remonte naturellement depuis le pipe duplex jusqu'à la source.
use pmoaudio::pipeline::AudioPipelineNode;
let (sink, flac_handle) = StreamingOggFlacSink::new(
EncoderOptions::default(),
DIRECT_OGG_FLAC_BITS_PER_SAMPLE,
);
// Nœud de suivi de position : lit le timestamp des chunks sortant vers le sink
let (mut position_tracker, position_handle) = PositionTrackerNode::new();
position_tracker.register(sink.boxed());
// Sink broadcast OGG-FLAC (multi-client, pacé à 0.5s max d'avance)
let (sink, flac_handle) = StreamingOggFlacSink::new(EncoderOptions::default(), 24);
// Nœud de conversion de profondeur : tout type entier → I24
let mut to_i24 = ToI24Node::new();
to_i24.register(position_tracker.boxed());
to_i24.register(sink.boxed());
// Nœud de rééchantillonnage : n'importe quel sample rate → 96 kHz
let mut resampler = ResamplingNode::new(DIRECT_OGG_FLAC_SAMPLE_RATE);
let mut resampler = ResamplingNode::new(96_000);
resampler.register(to_i24.boxed());
// Le tx d'entrée du resampler est le point d'entrée du pipeline
let segment_tx = resampler.get_tx().expect("ResamplingNode doit avoir un sender");
// Source avec contrôle AVTransport complet
let (mut player_source, player_handle) = PlayerSource::new();
player_source.register(resampler.boxed());
let pipeline_handle = PipelineHandle {
control_tx,
stop_token: stop_token.clone(),
};
// Lancer la chaîne resampler → to_i24 → sink en background
// Lancer le pipeline en background
let sink_stop = stop_token.clone();
tokio::spawn(async move {
if let Err(e) = resampler.boxed().run(sink_stop).await {
if let Err(e) = player_source.boxed().run(sink_stop).await {
warn!("Audio pipeline error: {:?}", e);
}
debug!("Sink task terminated");
});
// Task de mise à jour de la position (toutes les secondes)
{
let state_pos = state.clone();
let pos_stop = stop_token.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = pos_stop.cancelled() => break,
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {
let pos = position_handle.current_position_sec();
if pos > 0.0 {
state_pos.write().position = Some(seconds_to_upnp_time(pos));
}
}
}
}
});
}
// Task pipeline : reçoit les commandes UPnP et pilote les sources
let stop_token_clone = stop_token.clone();
let control_tx_clone = pipeline_handle.control_tx.clone();
tokio::spawn(async move {
run_pipeline(
segment_tx,
control_rx,
control_tx_clone,
stop_token_clone,
state,
udn,
#[cfg(feature = "pmoserver")]
control_point,
)
.await;
debug!("Pipeline task terminated");
});
// Écouter les événements PlayerSource pour mettre à jour le state UPnP
let event_rx = player_handle.subscribe_events();
let state_clone = state.clone();
let udn_clone = udn.clone();
#[cfg(feature = "pmoserver")]
let cp_clone = control_point.clone();
tokio::spawn(async move {
run_event_listener(
event_rx,
state_clone,
udn_clone,
#[cfg(feature = "pmoserver")]
cp_clone,
).await;
});
let pipeline_handle = PipelineHandle {
player: player_handle,
stop_token: stop_token.clone(),
state,
};
Self {
flac_handle,
pipeline_handle,
@@ -160,270 +127,66 @@ impl InstancePipeline {
}
}
// ─── Task principale du pipeline ─────────────────────────────────────────────
// ─── Listener d'événements ────────────────────────────────────────────────────
async fn run_pipeline(
segment_tx: mpsc::Sender<Arc<AudioSegment>>,
mut control_rx: mpsc::Receiver<PipelineControl>,
control_tx: mpsc::Sender<PipelineControl>,
stop_token: CancellationToken,
async fn run_event_listener(
mut event_rx: tokio::sync::broadcast::Receiver<pmoaudio_ext::PlayerEvent>,
state: SharedState,
udn: String,
#[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>,
) {
let mut current_source_stop: Option<CancellationToken> = None;
let mut current_uri: Option<String> = None;
use pmoaudio_ext::PlayerEvent;
use crate::messages::PlaybackState;
loop {
tokio::select! {
_ = stop_token.cancelled() => {
info!(udn = %udn, "Pipeline stopping by cancellation");
if let Some(src_stop) = current_source_stop.take() {
src_stop.cancel();
match event_rx.recv().await {
Ok(event) => match event {
PlayerEvent::Playing { uri, duration_sec } => {
let mut s = state.write();
s.playback_state = PlaybackState::Playing;
s.current_uri = Some(uri);
s.duration = duration_sec.map(seconds_to_upnp_time);
s.position = None;
// Effacer next_uri/next_metadata : la nouvelle piste est maintenant courante
s.next_uri = None;
s.next_metadata = None;
}
break;
}
cmd = control_rx.recv() => {
match cmd {
None => {
info!(udn = %udn, "Pipeline control channel closed");
break;
}
Some(PipelineControl::SourceEnded) => {
// La source s'est terminée (EOF ou erreur) : libérer le slot
debug!(udn = %udn, "Pipeline: SourceEnded — source slot freed");
current_source_stop = None;
}
Some(PipelineControl::LoadUri(uri)) => {
info!(udn = %udn, uri = %uri, "Pipeline: LoadUri");
if let Some(src_stop) = current_source_stop.take() {
src_stop.cancel();
}
current_uri = Some(uri.clone());
{
let mut s = state.write();
s.playback_state = PlaybackState::Transitioning;
s.current_uri = Some(uri.clone());
s.position = None;
}
let src_stop = stop_token.child_token();
current_source_stop = Some(src_stop.clone());
let tx = segment_tx.clone();
let notify = control_tx.clone();
let st = state.clone();
let udn_c = udn.clone();
#[cfg(feature = "pmoserver")]
PlayerEvent::Paused { position_sec } => {
let mut s = state.write();
s.playback_state = PlaybackState::Paused;
s.position = Some(seconds_to_upnp_time(position_sec));
}
PlayerEvent::Stopped => {
let mut s = state.write();
s.playback_state = PlaybackState::Stopped;
s.position = None;
}
PlayerEvent::Position { position_sec } => {
state.write().position = Some(seconds_to_upnp_time(position_sec));
}
PlayerEvent::TrackEnded => {
#[cfg(feature = "pmoserver")]
{
let cp = control_point.clone();
let udn_c = udn.clone();
tokio::spawn(async move {
stream_source(
uri, 0.0, tx, src_stop, st, udn_c,
#[cfg(feature = "pmoserver")]
cp,
).await;
let _ = notify.send(PipelineControl::SourceEnded).await;
cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_c));
});
}
Some(PipelineControl::LoadNextUri(uri)) => {
debug!(udn = %udn, uri = %uri, "Pipeline: LoadNextUri");
state.write().next_uri = Some(uri);
}
Some(PipelineControl::Play) => {
// Redémarrer la source si elle n'est pas en cours
if current_source_stop.is_none() {
if let Some(uri) = current_uri.clone() {
info!(udn = %udn, uri = %uri, "Pipeline: Play — restarting source");
let src_stop = stop_token.child_token();
current_source_stop = Some(src_stop.clone());
let tx = segment_tx.clone();
let notify = control_tx.clone();
let st = state.clone();
let udn_c = udn.clone();
#[cfg(feature = "pmoserver")]
let cp = control_point.clone();
tokio::spawn(async move {
stream_source(
uri, 0.0, tx, src_stop, st, udn_c,
#[cfg(feature = "pmoserver")]
cp,
).await;
let _ = notify.send(PipelineControl::SourceEnded).await;
});
} else {
debug!(udn = %udn, "Pipeline: Play — no URI loaded, ignoring");
}
} else {
debug!(udn = %udn, "Pipeline: Play — source already running");
}
}
Some(PipelineControl::Pause) => {
debug!(udn = %udn, "Pipeline: Pause (not supported on live stream)");
}
Some(PipelineControl::Stop) => {
info!(udn = %udn, "Pipeline: Stop");
if let Some(src_stop) = current_source_stop.take() {
src_stop.cancel();
}
// Conserver current_uri pour permettre un Play ultérieur
let mut s = state.write();
s.playback_state = PlaybackState::Stopped;
s.position = None;
}
Some(PipelineControl::Seek(pos_sec)) => {
info!(udn = %udn, pos = pos_sec, "Pipeline: Seek");
if let Some(uri) = current_uri.clone() {
if let Some(src_stop) = current_source_stop.take() {
src_stop.cancel();
}
let src_stop = stop_token.child_token();
current_source_stop = Some(src_stop.clone());
let tx = segment_tx.clone();
let notify = control_tx.clone();
let st = state.clone();
let udn_c = udn.clone();
#[cfg(feature = "pmoserver")]
let cp = control_point.clone();
tokio::spawn(async move {
stream_source(
uri, pos_sec, tx, src_stop, st, udn_c,
#[cfg(feature = "pmoserver")]
cp,
).await;
let _ = notify.send(PipelineControl::SourceEnded).await;
});
}
}
Some(PipelineControl::SetVolume(vol)) => {
state.write().volume = vol;
}
Some(PipelineControl::SetMute(mute)) => {
state.write().mute = mute;
}
}
}
}
}
}
// ─── Task source ─────────────────────────────────────────────────────────────
async fn stream_source(
uri: String,
seek_sec: f64,
tx: mpsc::Sender<Arc<AudioSegment>>,
stop_token: CancellationToken,
state: SharedState,
udn: String,
#[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>,
) {
info!(udn = %udn, uri = %uri, seek = seek_sec, "Source task: opening URI");
match UriSource::open(&uri, seek_sec, stop_token.clone()).await {
Ok(source) => {
debug!(udn = %udn, "Source task: URI opened, duration={:?}", source.duration_sec());
if let Some(dur) = source.duration_sec() {
state.write().duration = Some(seconds_to_upnp_time(dur));
}
// TrackBoundary avec métadonnées minimales
let boundary = {
let mut meta = MemoryTrackMetadata::new();
let _ = meta.set_title(Some(uri.clone())).await;
let meta_arc = Arc::new(tokio::sync::RwLock::new(meta));
AudioSegment::new_track_boundary(0, seek_sec, meta_arc)
};
let _ = tx.send(boundary).await;
// L'URI est ouverte, le flux va commencer à couler.
// Le sink bloquera naturellement si aucun client HTTP n'est connecté.
{
let mut s = state.write();
s.playback_state = PlaybackState::Playing;
if seek_sec > 0.0 {
s.position = Some(seconds_to_upnp_time(seek_sec));
}
}
match source.emit_to_channel(&tx, &stop_token).await {
Ok(true) => {
debug!(udn = %udn, "Source task: EOF → TrackEnded");
handle_track_ended(
state, udn, tx, stop_token,
#[cfg(feature = "pmoserver")]
control_point,
).await;
}
Ok(false) => {
debug!(udn = %udn, "Source task: cancelled");
}
Err(e) => {
warn!(udn = %udn, error = %e, "Source task error");
PlayerEvent::Error(e) => {
tracing::warn!(udn = %udn, "PlayerSource error: {}", e);
state.write().playback_state = PlaybackState::Stopped;
}
},
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(udn = %udn, "Event listener lagged {} events", n);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
break;
}
}
Err(e) => {
warn!(udn = %udn, error = %e, "Source task: failed to open URI");
state.write().playback_state = PlaybackState::Stopped;
}
}
}
// ─── TrackEnded : avancer current←next ───────────────────────────────────────
async fn handle_track_ended(
state: SharedState,
udn: String,
tx: mpsc::Sender<Arc<AudioSegment>>,
stop_token: CancellationToken,
#[cfg(feature = "pmoserver")]
control_point: Arc<pmocontrol::ControlPoint>,
) {
let next_uri = {
let mut s = state.write();
let uri = s.next_uri.take();
let meta = s.next_metadata.take();
s.current_uri = uri.clone();
s.current_metadata = meta;
s.next_uri = None;
s.next_metadata = None;
s.position = None;
s.duration = None;
if uri.is_some() {
s.playback_state = PlaybackState::Playing;
} else {
s.playback_state = PlaybackState::Stopped;
}
uri
};
#[cfg(feature = "pmoserver")]
if next_uri.is_some() {
let cp = control_point.clone();
let udn_clone = udn.clone();
tokio::spawn(async move {
cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_clone));
});
}
if let Some(uri) = next_uri {
info!(udn = %udn, uri = %uri, "TrackEnded: starting next track");
Box::pin(stream_source(
uri, 0.0, tx, stop_token, state, udn,
#[cfg(feature = "pmoserver")]
control_point,
)).await;
}
}

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}
pub async fn unregister_handler(
State(registry): State<Arc<RendererRegistry>>,

View File

@@ -14,7 +14,7 @@ use crate::error::WebRendererError;
use crate::pipeline::{InstancePipeline, PipelineHandle};
use crate::renderer::WebRendererFactory;
use crate::state::{RendererState, SharedState};
use crate::messages::PlaybackState;
#[cfg(feature = "pmoserver")]
use pmocontrol::{ControlPoint, DeviceId};
@@ -29,7 +29,7 @@ pub struct WebRendererInstance {
pub udn: String,
pub device_instance: Arc<DeviceInstance>,
pub state: SharedState,
/// Handle vers le sink OGG-FLAC — clonable, chaque subscribe() donne un flux live.
/// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client.
pub flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle,
pub pipeline: PipelineHandle,
pub created_at: SystemTime,
@@ -116,15 +116,16 @@ impl RendererRegistry {
Ok((stream_url, udn))
}
/// Retourne le OggFlacStreamHandle pour l'endpoint /stream (clonable).
pub fn get_flac_handle(
/// Retourne un OggFlacClientStream indépendant pour l'endpoint /stream.
/// Chaque appel crée un nouveau subscriber broadcast — safe pour connexions simultanées.
pub fn get_stream(
&self,
instance_id: &str,
) -> Option<pmoaudio_ext::sinks::OggFlacStreamHandle> {
) -> Option<pmoaudio_ext::sinks::OggFlacClientStream> {
self.instances
.read()
.get(instance_id)
.map(|i| i.flac_handle.clone())
.map(|i| i.flac_handle.subscribe())
}
/// Retourne le PipelineHandle par UDN (pour les handlers UPnP)
@@ -151,6 +152,24 @@ impl RendererRegistry {
.map(|i| i.device_instance.clone())
}
/// Met à jour la durée depuis le navigateur.
/// La position est gérée par PlayerSource via PlayerEvent::Position.
/// On n'utilise duration_sec que si la source ne la connaît pas (flux radio sans durée).
pub fn update_duration(&self, instance_id: &str, duration_sec: Option<f64>) {
let instances = self.instances.read();
if let Some(instance) = instances.get(instance_id) {
let mut s = instance.state.write();
// N'écraser la durée que si elle n'est pas déjà connue (la source est prioritaire)
if s.duration.is_none() {
if let Some(dur) = duration_sec {
if dur > 0.0 {
s.duration = Some(crate::pipeline::seconds_to_upnp_time(dur));
}
}
}
}
}
pub fn schedule_unregister(self: &Arc<Self>, instance_id: &str) {
use tokio_util::sync::CancellationToken;
@@ -248,7 +267,7 @@ impl RendererRegistry {
let mut server = server_arc.write().await;
server
.register_device(Arc::new(device))
.register_device(Arc::new(device), false)
.await
.map_err(|e| WebRendererError::RegistrationError(e.to_string()))?
};

View File

@@ -1,15 +1,16 @@
//! Handler HTTP GET /api/webrenderer/{id}/stream
//!
//! Sert le flux FLAC d'une instance WebRenderer via DirectFlacSink.
//! Sert le flux OGG-FLAC d'une instance WebRenderer.
//!
//! Reconnectable : appelé à chaque Play, crée un nouveau pipe + encodeur.
//! Safari envoie parfois Range: bytes=0-N avant de jouer.
//! On ignore ce header et on répond toujours 200 chunked (flux live infini).
use axum::{
body::Body,
extract::{Path, State},
http::{
StatusCode,
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
HeaderMap, StatusCode,
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, TRANSFER_ENCODING},
},
response::{IntoResponse, Response},
};
@@ -20,21 +21,23 @@ use tracing::info;
use crate::registry::RendererRegistry;
/// GET /api/webrenderer/{id}/stream
///
/// Crée un nouveau pipe FLAC à chaque connexion (chaque Play).
/// Le flux reste ouvert jusqu'à ce que le client se déconnecte (Stop).
/// Les morceaux s'enchaînent en gapless dans le même flux.
///
/// Safari envoie un Range header (bytes=0-) et exige Accept-Ranges: bytes.
/// On répond 206 Partial Content si un Range header est présent, sinon 200.
pub async fn stream_handler(
State(registry): State<Arc<RendererRegistry>>,
Path(instance_id): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse {
info!(instance_id = %instance_id, "FLAC stream client connecting");
let handle = match registry.get_flac_handle(&instance_id) {
Some(h) => h,
// Ignorer le header Range — flux live infini, non seekable.
// On ne répond jamais 206 ni 416 : toujours 200 chunked.
// Safari (et d'autres clients) envoient parfois Range: bytes=0-N ;
// répondre 416 ou 206 leur fait croire à une ressource finie.
if let Some(range) = headers.get("range") {
info!(instance_id = %instance_id, "Range header ignored (live stream): {:?}", range);
}
let stream = match registry.get_stream(&instance_id) {
Some(s) => s,
None => {
return (
StatusCode::NOT_FOUND,
@@ -44,18 +47,14 @@ pub async fn stream_handler(
}
};
let stream = handle.subscribe();
info!(instance_id = %instance_id, "FLAC stream started");
// Toujours 200 OK pour un flux live de taille inconnue.
// Les navigateurs (Safari inclus) acceptent 200 pour l'audio streaming.
// Un Content-Range invalide (bytes 0-*/*) ferait rejeter le flux.
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "audio/ogg; codecs=flac")
.header(CACHE_CONTROL, "no-store, no-transform")
.header(CONNECTION, "keep-alive")
.header(TRANSFER_ENCODING, "chunked")
.header("X-Content-Type-Options", "nosniff")
.body(Body::from_stream(ReaderStream::new(stream)))
.unwrap()

View File

@@ -1 +1 @@
0.3.23
0.3.24