Compare commits

..

4 Commits

Author SHA1 Message Date
github-actions[bot] 5f59f2315b chore(nix): refresh lock + npmDepsHash for v1.47.0 2026-06-04 23:03:19 +00:00
github-actions[bot] 4658314619 chore(release): finalize release version 1.47.0 2026-06-04 22:46:07 +00:00
github-actions[bot] 65b7f2cc9d chore(nix): refresh lock + npmDepsHash for v1.47.0-rc.5 2026-06-04 22:23:04 +00:00
github-actions[bot] 0792ee6297 chore(release): bump next channel to 1.47.0-rc.5 2026-06-04 22:05:42 +00:00
323 changed files with 7817 additions and 14548 deletions
+6 -54
View File
@@ -5,65 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **🛡️ A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account and a Windows code-signing certificate. If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
> **🛡️ A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account (now active, which is why macOS builds are signed + notarized for everyone starting with this release) and a Windows code-signing certificate (ordered, currently in validation). If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
>
## [1.48.0]
## Added
### Sidebar — pin Now Playing to the top
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1000](https://github.com/Psychotoxical/psysonic/pull/1000), suggested by [@PHLAK](https://github.com/PHLAK)**
* New **Settings → Sidebar** toggle moves the "Now Playing" entry to the top of the sidebar instead of the bottom (off by default).
## Changed
### Dependencies — npm and Rust refresh
**By [@cucadmuh](https://github.com/cucadmuh), PR [#997](https://github.com/Psychotoxical/psysonic/pull/997)**
* Frontend and Tauri npm dependencies bumped (React, Vite, Vitest, i18next, axios, Tauri plugins); test stack upgraded to **jsdom** 29.
* Rust workspace: `id3` 1.17, `reqwest` 0.13.4, `sysinfo` 0.39, **zip** 8 for library backups. Symphonia 0.6 and `mach2` 0.6 remain deferred (upstream constraints).
### Audio — Symphonia 0.6 upgrade
**By [@cucadmuh](https://github.com/cucadmuh), PR [#999](https://github.com/Psychotoxical/psysonic/pull/999)**
* Audio decode + analysis pipeline ported to **Symphonia 0.6** (new `AudioDecoder` API, `GenericAudioBufferRef`, `Time`/`Timestamp` units); `symphonia-adapter-libopus` bumped to **0.3** and the vendored `symphonia-format-isomp4` 0.5 patch dropped in favour of upstream ISO-BMFF fixes.
* `rodio`'s `symphonia-all` feature dropped so the workspace no longer pulls a duplicate `symphonia-core`.
## Fixed
### Servers — complete border on the active server card
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#998](https://github.com/Psychotoxical/psysonic/pull/998)**
* The active server card under **Settings → Servers** now draws its border on all four sides; previously only the left and right edges showed.
### Audio streaming — start latency and stall recovery
**By [@cucadmuh](https://github.com/cucadmuh), PR [#999](https://github.com/Psychotoxical/psysonic/pull/999)**
* Ranged-HTTP FLAC/MP3/OGG streams start playing as soon as enough data is buffered again, instead of waiting for the whole file to download (Symphonia 0.6's trailing-metadata probe scan is skipped for progressive non-MP4 streams).
* The streaming format probe now runs under a 20s timeout on a worker thread, so a stalled stream (e.g. right after a server switch) no longer blocks playback start until a manual player restart.
> **⚠️ Windows users:** This is one of the last releases with an unsigned Windows installer. Until the certificate clears validation, any SmartScreen or antivirus warning on the installer is a false positive — the binary itself is safe.
>
> **🎉 macOS users:** Starting with **v1.40.0**, Psysonic is signed + notarized and can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Install now" when the update notification appears.
>
> **📦 Version jump 1.34.x → 1.40.0:** The 1.34.x patch series was bumped a lot as each small feature landed. 1.40.0 consolidates the last few weeks of work — macOS signing + auto-updater, the Device-Sync overhaul, theme work and contrast audits — into a single coherent release. The next major bump (2.0.0) is planned once Windows code-signing + Windows auto-updater are active as well.
## [1.47.0]
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
## Added
### Servers — edit existing profiles
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"lastModified": 1780243769,
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-7BvKeTkZzAQoBVm2vw2oZRsRKWN/Du1pn89U1rtc47k="
"npmDepsHash": "sha256-r+dan77izwx/QjbT/JXJNfIxUn6YV0FWso+F3y/TLEc="
}
+426 -480
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.47.0",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
@@ -62,11 +62,11 @@
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"@vitest/coverage-v8": "^4.1.7",
"esbuild": "^0.28.0",
"jsdom": "^29.1.1",
"jsdom": "^26.1.0",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vitest": "^4.1.8"
"vitest": "^4.1.7"
}
}
+194 -274
View File
@@ -10,13 +10,13 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.9.1"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpubits",
"cpufeatures 0.3.0",
"cpufeatures",
]
[[package]]
@@ -95,6 +95,12 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -476,16 +482,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
"zeroize",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -703,11 +699,11 @@ dependencies = [
[[package]]
name = "cipher"
version = "0.5.2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.2.2",
"crypto-common",
"inout",
]
@@ -720,12 +716,6 @@ dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "cocoa"
version = "0.24.1"
@@ -793,17 +783,11 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "cookie"
@@ -933,12 +917,6 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -948,15 +926,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -1010,15 +979,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.36.0"
@@ -1058,15 +1018,6 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1216,21 +1167,9 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
"zeroize",
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1251,7 +1190,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1485,7 +1424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1950,12 +1889,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -2219,11 +2156,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.13.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.11.3",
"digest",
]
[[package]]
@@ -2287,15 +2224,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.9.0"
@@ -2480,9 +2408,9 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "id3"
version = "1.17.0"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70d6d9f23a28c6feab60d7b4a339e004809019ec9d5fd964b3f4c655ff9bc141"
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
dependencies = [
"bitflags 2.11.1",
"byteorder",
@@ -2576,11 +2504,11 @@ dependencies = [
[[package]]
name = "inout"
version = "0.2.2"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"hybrid-array",
"generic-array",
]
[[package]]
@@ -2863,6 +2791,26 @@ dependencies = [
"winapi",
]
[[package]]
name = "liblzma"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899"
dependencies = [
"liblzma-sys",
]
[[package]]
name = "liblzma-sys"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -2973,15 +2921,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce716bf1a316f47a280fc76295f6495b5bea4752bca01c3b3885e101b1c23c02"
dependencies = [
"sha2 0.11.0",
]
[[package]]
name = "mach2"
version = "0.5.0"
@@ -3129,7 +3068,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -3630,7 +3569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -3797,11 +3736,11 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.13.0"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.11.3",
"digest",
"hmac",
]
@@ -4114,7 +4053,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.48.0-dev"
version = "1.47.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4142,7 +4081,7 @@ dependencies = [
"souvlaki",
"symphonia",
"symphonia-adapter-libopus",
"sysinfo",
"sysinfo 0.38.4",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@@ -4162,12 +4101,12 @@ dependencies = [
"webp",
"windows 0.62.2",
"zbus 5.16.0",
"zip 8.6.0",
"zip",
]
[[package]]
name = "psysonic-analysis"
version = "1.48.0-dev"
version = "1.47.0"
dependencies = [
"ebur128",
"futures-util",
@@ -4186,7 +4125,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.48.0-dev"
version = "1.47.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4216,7 +4155,7 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.48.0-dev"
version = "1.47.0"
dependencies = [
"libc",
"serde",
@@ -4225,7 +4164,7 @@ dependencies = [
[[package]]
name = "psysonic-integration"
version = "1.48.0-dev"
version = "1.47.0"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4242,7 +4181,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.48.0-dev"
version = "1.47.0"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4252,14 +4191,12 @@ dependencies = [
"serde_json",
"tauri",
"tokio",
"twox-hash",
"unicode-normalization",
"wiremock",
]
[[package]]
name = "psysonic-syncfs"
version = "1.48.0-dev"
version = "1.47.0"
dependencies = [
"futures-util",
"id3",
@@ -4271,7 +4208,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sysinfo",
"sysinfo 0.39.3",
"tauri",
"tauri-plugin-process",
"tauri-plugin-shell",
@@ -4545,12 +4482,6 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"
@@ -4559,9 +4490,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.13.4"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -4658,6 +4589,7 @@ dependencies = [
"cpal",
"dasp_sample",
"num-rational",
"symphonia",
"thiserror 2.0.18",
]
@@ -4768,7 +4700,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4826,7 +4758,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5168,19 +5100,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
"cpufeatures",
"digest",
]
[[package]]
@@ -5190,19 +5111,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
"cpufeatures",
"digest",
]
[[package]]
@@ -5310,7 +5220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5465,9 +5375,9 @@ dependencies = [
[[package]]
name = "symphonia"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
@@ -5478,7 +5388,9 @@ dependencies = [
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-caf",
"symphonia-format-isomp4",
"symphonia-format-mkv",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
@@ -5486,9 +5398,9 @@ dependencies = [
[[package]]
name = "symphonia-adapter-libopus"
version = "0.3.0"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6febe6f88f9a9483db7e5b72a2dad916d8f8eb588d18905a39c861319fc7fa1"
checksum = "2bfc8e95f95c23ed1b5328eb66920ad28d9968c797f9c7aa755d4b45a5f47a41"
dependencies = [
"log",
"opusic-sys",
@@ -5497,44 +5409,44 @@ dependencies = [
[[package]]
name = "symphonia-bundle-flac"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03"
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76"
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
dependencies = [
"lazy_static",
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebbdfd76d6cc5a601c6292a44357c5b7c82f2cd7cdc0f171421f5c5cff0ea1f"
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
dependencies = [
"log",
"symphonia-core",
@@ -5542,20 +5454,19 @@ dependencies = [
[[package]]
name = "symphonia-codec-alac"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8"
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
dependencies = [
"log",
"symphonia-core",
@@ -5563,69 +5474,80 @@ dependencies = [
[[package]]
name = "symphonia-codec-vorbis"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-common"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55"
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-core"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"bitflags 2.11.1",
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
"num-complex",
"smallvec",
]
[[package]]
name = "symphonia-format-caf"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e"
version = "0.5.5"
dependencies = [
"encoding_rs",
"log",
"symphonia-common",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-mkv"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433"
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
dependencies = [
"extended",
"log",
@@ -5635,17 +5557,26 @@ dependencies = [
[[package]]
name = "symphonia-metadata"
version = "0.6.0"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"regex-lite",
"smallvec",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "syn"
version = "1.0.109"
@@ -5688,6 +5619,20 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "sysinfo"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.62.2",
]
[[package]]
name = "sysinfo"
version = "0.39.3"
@@ -5695,8 +5640,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.62.2",
@@ -5874,7 +5817,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2",
"syn 2.0.117",
"tauri-utils",
"thiserror 2.0.18",
@@ -6063,7 +6006,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip 4.6.1",
"zip",
]
[[package]]
@@ -6191,7 +6134,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6266,7 +6209,6 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"js-sys",
"num-conv",
"powerfmt",
"serde_core",
@@ -6594,7 +6536,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6603,21 +6545,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
dependencies = [
"rand 0.9.4",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -6638,7 +6565,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset 0.9.1",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6694,15 +6621,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
@@ -7138,7 +7056,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -7872,7 +7790,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2 0.10.9",
"sha2",
"soup3",
"tao-macros",
"thiserror 2.0.18",
@@ -8003,7 +7921,7 @@ dependencies = [
"rand 0.8.6",
"serde",
"serde_repr",
"sha1 0.10.6",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
@@ -8146,6 +8064,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerotrie"
@@ -8185,35 +8117,23 @@ name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.4.2",
"getrandom 0.3.4",
"hmac",
"indexmap 2.14.0",
"lzma-rust2",
"liblzma",
"memchr",
"pbkdf2",
"ppmd-rust",
"sha1 0.11.0",
"sha1",
"time",
"typed-path",
"zeroize",
"zopfli",
"zstd",
+15 -14
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.48.0-dev"
version = "1.47.0"
edition = "2021"
rust-version = "1.95"
@@ -51,8 +51,8 @@ tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.22", default-features = false, features = ["playback"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
md5 = "0.8"
@@ -67,13 +67,13 @@ discord-rich-presence = "1.1"
url = "2"
thread-priority = "3"
lofty = "0.24"
sysinfo = { version = "0.39", default-features = false, features = ["disk", "system"] }
id3 = "1.17"
symphonia-adapter-libopus = "0.3"
sysinfo = { version = "0.38", default-features = false, features = ["disk", "system"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.9"
rusqlite = { version = "0.40", features = ["bundled"] }
ebur128 = "0.1"
dasp_sample = "0.11.0"
zip = "8"
zip = "4.6.1"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
webp = "0.3"
@@ -84,7 +84,7 @@ libc = "0.2"
mach2 = "0.5"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
zbus = { version = "5.15", default-features = false, features = ["blocking-api", "async-io"] }
# Match wry/tauris WebKitGTK stack — used only to turn off kinetic wheel scrolling.
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
webkit2gtk-nvidia-quirk = "1.3"
@@ -102,10 +102,11 @@ windows = { version = "0.62", features = [
"Win32_UI_WindowsAndMessaging",
] }
# NOTE: The local `symphonia-format-isomp4` path patch (0.5-based) was removed for
# the Symphonia 0.6 migration. Symphonia 0.6 upstream already covers the esds
# missing-descriptor and SL predefined=null fixes. The malformed-trak skip and
# moov-at-end tail scan are validated against the fixture corpus on stock 0.6; if a
# case regresses, re-create the patch from the 0.6 isomp4 source and re-add the
# [patch.crates-io] entry here. See workdocs task 2026-05-symphonia-0.6-migration.
[patch.crates-io]
# Local patch for Symphonia's isomp4 demuxer:
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
# of failing the entire probe
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
@@ -17,8 +17,8 @@ futures-util = "0.3"
ebur128 = "0.1"
md5 = "0.8"
rusqlite = { version = "0.40", features = ["bundled"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
symphonia-adapter-libopus = "0.3"
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
symphonia-adapter-libopus = "0.2.9"
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
[dev-dependencies]
@@ -2,12 +2,13 @@ use std::io::Cursor;
use std::time::Instant;
use ebur128::{EbuR128, Mode as Ebur128Mode};
use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, TrackType};
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::core::units::Time;
use tauri::{Manager, Runtime};
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
@@ -298,7 +299,7 @@ fn analyze_loudness_and_waveform(
/// when the container reports total track length.
struct DecodeSession {
format: Box<dyn FormatReader>,
decoder: Box<dyn AudioDecoder>,
decoder: Box<dyn Decoder>,
track_id: u32,
timeline_hint: Option<u64>,
}
@@ -333,25 +334,25 @@ fn open_decode_session(bytes: &[u8], format_hint: Option<&str>) -> Option<Decode
if let Some(ext) = format_hint.or(sniffed.as_deref()) {
hint.with_extension(ext);
}
let format = symphonia::default::get_probe()
.probe(&hint, mss, FormatOptions::default(), MetadataOptions::default())
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.ok()?;
// Prefer an audio track that reports both sample rate and channels; fall back to
// the first audio track with a known codec (skips e.g. MJPEG cover-art tracks).
let format = probed.format;
let track = format
.tracks()
.iter()
.find(|t| {
t.codec_params
.as_ref()
.and_then(|c| c.audio())
.is_some_and(|a| a.sample_rate.is_some() && a.channels.is_some())
.default_track()
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.or_else(|| {
format.tracks().iter().find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
&& t.codec_params.channels.is_some()
})
})
.or_else(|| format.first_track_known_codec(TrackType::Audio))?;
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
let track_id = track.id;
let timeline_hint = track.num_frames.filter(|&n| n > 0);
let audio_params = track.codec_params.as_ref()?.audio()?.clone();
let decoder = match make_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false)) {
let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
let codec_params = track.codec_params.clone();
let decoder = match make_decoder(&codec_params, &DecoderOptions::default()) {
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
@@ -371,9 +372,8 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -
let mut total: u64 = 0;
let mut loop_i: u32 = 0;
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -382,12 +382,14 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let n_ch = decoded.spec().channels().count();
let spec = *decoded.spec();
let n_ch = spec.channels.count();
if n_ch == 0 {
continue;
}
decoded.copy_to_vec_interleaved(&mut samples_buf);
let n = samples_buf.len();
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let n = samples.samples().len();
if n < n_ch || !n.is_multiple_of(n_ch) {
continue;
}
@@ -458,9 +460,8 @@ fn decode_scan_pcm(
}
let bin_grid_frames = decoded_frames.max(1);
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -470,14 +471,15 @@ fn decode_scan_pcm(
Err(_) => break,
};
let n_ch = decoded.spec().channels().count();
let spec = *decoded.spec();
let n_ch = spec.channels.count();
if n_ch == 0 {
continue;
}
if loudness_target_lufs.is_some() && ebu.is_none() {
let ch = decoded.spec().channels().count() as u32;
let sr = decoded.spec().rate();
let ch = spec.channels.count() as u32;
let sr = spec.rate;
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
Ok(v) => {
ebu = Some(v);
@@ -495,8 +497,9 @@ fn decode_scan_pcm(
}
}
decoded.copy_to_vec_interleaved(&mut samples_buf);
let slice = samples_buf.as_slice();
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let slice = samples.samples();
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
continue;
}
@@ -528,7 +531,7 @@ fn decode_scan_pcm(
if loudness_target_lufs.is_some() {
if let Some(e) = ebu.as_mut() {
match e.add_frames_f32(&samples_buf) {
match e.add_frames_f32(samples.samples()) {
Ok(_) => fed_any_frames = true,
Err(err) => {
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
@@ -631,11 +634,9 @@ pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option<f64> {
let session = open_decode_session(bytes, None)?;
let sample_rate = session
.format
.default_track(TrackType::Audio)
.default_track()
.or_else(|| session.format.tracks().first())
.and_then(|t| t.codec_params.as_ref())
.and_then(|c| c.audio())
.and_then(|a| a.sample_rate)
.and_then(|t| t.codec_params.sample_rate)
.filter(|&sr| sr > 0)?;
let frames = session.timeline_hint?;
Some(frames as f64 / sample_rate as f64)
@@ -658,8 +659,7 @@ pub fn decode_mono_pcm_window(
} = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?;
if start_sec.is_finite() && start_sec > 0.0 {
let time = Time::try_from_secs_f64(start_sec.max(0.0))
.ok_or_else(|| "pcm window: invalid seek time".to_string())?;
let time: Time = start_sec.max(0.0).into();
format
.seek(
SeekMode::Accurate,
@@ -693,7 +693,7 @@ pub fn decode_mono_pcm_limited(
fn decode_mono_pcm_from_session(
format: &mut Box<dyn FormatReader>,
decoder: &mut Box<dyn AudioDecoder>,
decoder: &mut Box<dyn Decoder>,
track_id: u32,
max_seconds: Option<f64>,
) -> Result<(Vec<f32>, f32), String> {
@@ -701,10 +701,9 @@ fn decode_mono_pcm_from_session(
let mut sample_rate = 0_f32;
let mut max_frames: Option<u64> = None;
let mut loop_i: u32 = 0;
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -714,12 +713,13 @@ fn decode_mono_pcm_from_session(
Err(_) => break,
};
let n_ch = decoded.spec().channels().count();
let spec = *decoded.spec();
let n_ch = spec.channels.count();
if n_ch == 0 {
continue;
}
if sample_rate <= 0.0 {
sample_rate = decoded.spec().rate() as f32;
sample_rate = spec.rate as f32;
if sample_rate <= 0.0 {
return Err("invalid sample rate".to_string());
}
@@ -732,8 +732,9 @@ fn decode_mono_pcm_from_session(
});
}
decoded.copy_to_vec_interleaved(&mut samples_buf);
let slice = samples_buf.as_slice();
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let slice = samples.samples();
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
continue;
}
@@ -1,22 +1,21 @@
//! Symphonia codec registry — mirrors `psysonic-audio::codec` (Opus via libopus).
use std::sync::OnceLock;
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
use symphonia::core::codecs::registry::CodecRegistry;
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut registry = CodecRegistry::new();
symphonia::default::register_enabled_codecs(&mut registry);
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
pub(crate) fn make_decoder(
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
params: &symphonia::core::codecs::CodecParameters,
opts: &DecoderOptions,
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make(params, opts)
}
+4 -4
View File
@@ -15,9 +15,9 @@ serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
rodio = { version = "0.22", default-features = false, features = ["playback"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
symphonia-adapter-libopus = "0.3"
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
symphonia-adapter-libopus = "0.2.9"
ringbuf = "0.5"
biquad = "0.6"
dasp_sample = "0.11.0"
@@ -25,7 +25,7 @@ md5 = "0.8"
url = "2"
thread-priority = "3"
lofty = "0.24"
id3 = "1.17"
id3 = "1.16.4"
pitch_shift = "2.1"
[target.'cfg(unix)'.dependencies]
+6 -7
View File
@@ -1,22 +1,21 @@
//! Symphonia codec registry (incl. Opus) and radio decoder factory.
use std::sync::OnceLock;
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
use symphonia::core::codecs::registry::CodecRegistry;
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut registry = CodecRegistry::new();
symphonia::default::register_enabled_codecs(&mut registry);
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
pub(crate) fn try_make_radio_decoder(
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
params: &symphonia::core::codecs::CodecParameters,
opts: &DecoderOptions,
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make(params, opts)
}
+98 -300
View File
@@ -1,20 +1,19 @@
//! Symphonia `SizedDecoder`, gapless trim, and `build_source` / `build_streaming_source`.
use std::io::{Cursor, Read, Seek};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::Arc;
use std::time::Duration;
use rodio::source::UniformSourceIterator;
use rodio::Source;
use symphonia::core::{
audio::{AudioSpec, GenericAudioBufferRef},
codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions},
formats::probe::Hint,
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{DecoderOptions, CODEC_TYPE_NULL},
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
common::Limit,
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
meta::MetadataOptions,
units::{Time, Timestamp},
probe::Hint,
units::{self, Time},
};
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
@@ -53,46 +52,6 @@ impl MediaSource for SizedCursorSource {
fn byte_len(&self) -> Option<u64> { Some(self.len) }
}
// ─── ProbeSeekGate — temporarily hide seekability during probing ──────────────
//
// Symphonia 0.6's `Probe::probe` scans for *trailing* metadata (ID3v1/APEv2/…)
// whenever the source reports `is_seekable() == true` and a known `byte_len()`.
// That scan seeks to the end of the stream. For a progressive ranged-HTTP source
// this forces a download all the way to EOF before the first sample can play
// (FLAC/MP3/OGG regressed to "won't start until fully downloaded").
//
// These formats are demuxed sequentially from the start, and their seek paths
// re-check `is_seekable()` dynamically, so we can advertise the source as
// non-seekable for the duration of the probe (skipping the trailing scan) and
// flip it back to seekable afterwards to preserve scrubbing. MP4/ISO-BMFF is
// excluded because its demuxer captures seekability at construction and relies
// on seeking to locate `moov` (its tail is prefetched separately instead).
struct ProbeSeekGate {
inner: Box<dyn MediaSource>,
seekable: Arc<AtomicBool>,
}
impl Read for ProbeSeekGate {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for ProbeSeekGate {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for ProbeSeekGate {
fn is_seekable(&self) -> bool {
self.seekable.load(Ordering::Relaxed) && self.inner.is_seekable()
}
fn byte_len(&self) -> Option<u64> {
self.inner.byte_len()
}
}
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
//
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
@@ -106,19 +65,19 @@ impl MediaSource for ProbeSeekGate {
/// playback is genuinely lossless.
pub(crate) fn log_codec_resolution(
tag: &str,
params: &AudioCodecParameters,
params: &symphonia::core::codecs::CodecParameters,
container_hint: Option<&str>,
) {
let codec_name = symphonia::default::get_codecs()
.get_audio_decoder(params.codec)
.map(|d| d.codec.info.short_name)
.get_codec(params.codec)
.map(|d| d.short_name)
.unwrap_or("?");
let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into());
let bits = params.bits_per_sample
.or(params.bits_per_coded_sample)
.map(|b| format!("{}-bit", b))
.unwrap_or_else(|| "?-bit".into());
let ch = params.channels.as_ref()
let ch = params.channels
.map(|c| format!("{}ch", c.count()))
.unwrap_or_else(|| "?ch".into());
let lossless = codec_name.starts_with("pcm")
@@ -140,20 +99,14 @@ const DECODE_MAX_RETRIES: usize = 3;
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
/// playable track (VLC-style frame dropping).
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
/// Wall-clock cap for the streaming `probe()` call. A ranged-HTTP source whose
/// download stalls (e.g. right after a server switch) can otherwise block the
/// probe — and therefore playback start — indefinitely. On timeout we abort with
/// an error so the player can recover/retry instead of hanging until a restart.
const STREAM_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
pub(crate) struct SizedDecoder {
decoder: Box<dyn AudioDecoder>,
decoder: Box<dyn symphonia::core::codecs::Decoder>,
current_frame_offset: usize,
format: Box<dyn FormatReader>,
total_duration: Option<Time>,
/// Interleaved f32 samples of the currently decoded packet.
buffer: Vec<f32>,
spec: AudioSpec,
buffer: SampleBuffer<f32>,
spec: SignalSpec,
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
/// successfully decoded frame. Used to detect fully undecodable streams.
consecutive_decode_errors: usize,
@@ -180,15 +133,22 @@ impl SizedDecoder {
if let Some(ext) = format_hint {
hint.with_extension(ext);
}
let format_opts = FormatOptions::default();
let format_opts = FormatOptions {
// Disable gapless parsing — Symphonia 0.5.5 crashes on `edts` atoms
// present in older iTunes-purchased M4A files.
enable_gapless: false,
..Default::default()
};
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
let meta_opts =
MetadataOptions::default().limit_visual_bytes(Limit::Maximum(8 * 1024 * 1024));
let meta_opts = symphonia::core::meta::MetadataOptions {
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
..Default::default()
};
let mut format = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &meta_opts)
.map_err(|e| {
let hint_str = format_hint.unwrap_or("unknown");
// Always print the raw Symphonia error to the terminal for diagnosis.
@@ -200,45 +160,30 @@ impl SizedDecoder {
}
})?;
let track = format
let track = probed.format
.tracks()
.iter()
// Explicitly select only audio tracks: must have an audio codec and a
// Explicitly select only audio tracks: must have a valid codec and a
// sample_rate. This skips MJPEG cover-art streams that iTunes M4A
// files embed as a secondary video track.
.find(|t| {
t.codec_params
.as_ref()
.and_then(|c| c.audio())
.is_some_and(|a| a.sample_rate.is_some())
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
})
.ok_or_else(|| {
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", format.tracks().len());
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len());
"no playable audio track found in file".to_string()
})?;
let track_id = track.id;
// Encoder-delay-aware total duration (timebase units → Time).
let total_duration = track
.time_base
.zip(track.num_frames)
.and_then(|(base, frames)| {
Timestamp::try_from(frames).ok().and_then(|ts| base.calc_time(ts))
});
let total_duration = track.codec_params.time_base
.zip(track.codec_params.n_frames)
.map(|(base, frames)| base.calc_time(frames));
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| "selected track has no audio codec parameters".to_string())?
.clone();
log_codec_resolution("bytes", &track.codec_params, format_hint);
log_codec_resolution("bytes", &audio_params, format_hint);
// Gapless trimming is performed by `build_source` (iTunSMPB), so disable
// the decoder's built-in trimming to avoid double-trimming.
let mut decoder = psysonic_codec_registry()
.make_audio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| {
crate::app_eprintln!("[psysonic] codec init failed: {e}");
if e.to_string().to_lowercase().contains("unsupported") {
@@ -248,15 +193,15 @@ impl SizedDecoder {
}
})?;
let mut format = probed.format;
// Decode the first packet to initialise spec + buffer.
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
let mut decode_errors: usize = 0;
let decoded = loop {
let packet = match format.next_packet() {
Ok(Some(p)) => p,
// Clean EOF before any decodable packet.
Ok(None) => break decoder.last_decoded(),
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(_)) => {
break decoder.last_decoded();
}
@@ -265,8 +210,8 @@ impl SizedDecoder {
return Err(format!("could not read audio data: {e}"));
}
};
if packet.track_id != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id, track_id);
if packet.track_id() != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id);
continue;
}
match decoder.decode(&packet) {
@@ -285,8 +230,8 @@ impl SizedDecoder {
}
};
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
Ok(SizedDecoder {
decoder,
@@ -307,107 +252,34 @@ impl SizedDecoder {
format_hint: Option<&str>,
source_tag: &str,
) -> Result<Self, String> {
// For non-MP4 progressive streams, hide seekability during the probe so
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
// and block until the whole file is downloaded). Re-enabled right after.
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
// prefetched separately).
let stream_len = media.byte_len();
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
None => media,
};
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
// read() call frequency into the ring buffer, easing I/O spikes.
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
let format_opts = FormatOptions::default();
let meta_opts = MetadataOptions::default();
let mut hint = Hint::new();
if let Some(ext) = format_hint { hint.with_extension(ext); }
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &MetadataOptions::default())
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
crate::app_deprintln!(
"[stream] {source_tag}: probe start (hint={}, stream_len={})",
format_hint.unwrap_or("?"),
stream_len.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
);
let probe_start = std::time::Instant::now();
// Run the probe on a dedicated thread guarded by a timeout. If a ranged
// source stalls (download never reaches the bytes Symphonia needs), the
// probe blocks forever; without this guard playback start would hang until
// the user restarts the player. On timeout we abandon the worker thread
// (it unblocks once the underlying read errors/returns) and surface an
// error so the caller can retry.
let hint_ext = format_hint.map(|s| s.to_string());
let tag_owned = source_tag.to_string();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("symphonia-probe".into())
.spawn(move || {
let mut hint = Hint::new();
if let Some(ext) = &hint_ext {
hint.with_extension(ext);
}
let result = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
.map_err(|e| format!("{tag_owned}: format probe failed: {e}"));
// Receiver is gone if we already timed out — ignore the send error.
let _ = tx.send(result);
})
.map_err(|e| format!("{source_tag}: failed to spawn probe thread: {e}"))?;
let mut format = match rx.recv_timeout(STREAM_PROBE_TIMEOUT) {
Ok(Ok(format)) => format,
Ok(Err(e)) => return Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
crate::app_eprintln!(
"[stream] {source_tag}: probe timed out after {STREAM_PROBE_TIMEOUT:?} \
(stream stalled?) — aborting so the player can retry"
);
return Err(format!(
"{source_tag}: format probe timed out after {STREAM_PROBE_TIMEOUT:?}"
));
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return Err(format!("{source_tag}: probe thread ended unexpectedly"));
}
};
crate::app_deprintln!(
"[stream] {source_tag}: probe done in {} ms",
probe_start.elapsed().as_millis()
);
// Trailing-metadata scan is done; restore real seekability for scrubbing.
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format.tracks().iter()
.find(|t| t.codec_params.as_ref().and_then(|c| c.audio()).is_some())
let track = probed.format.tracks().iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
let track_id = track.id;
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| format!("{source_tag}: track has no audio codec parameters"))?
.clone();
log_codec_resolution(source_tag, &audio_params, format_hint);
log_codec_resolution(source_tag, &track.codec_params, format_hint);
// Live streams have no known total frame count → total_duration = None.
let total_duration = None;
let mut decoder = try_make_radio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
let mut format = probed.format;
let mut errors = 0usize;
let decoded = loop {
let packet = match format.next_packet() {
Ok(Some(p)) => p,
Ok(None) => break decoder.last_decoded(),
Ok(p) => p,
Err(_) => break decoder.last_decoded(),
};
if packet.track_id != track_id { continue; }
if packet.track_id() != track_id { continue; }
match decoder.decode(&packet) {
Ok(d) => break d,
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
@@ -420,15 +292,16 @@ impl SizedDecoder {
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
}
};
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
}
#[inline]
fn make_buffer(decoded: &GenericAudioBufferRef<'_>) -> Vec<f32> {
let mut buffer = Vec::new();
decoded.copy_to_vec_interleaved(&mut buffer);
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
let duration = units::Duration::from(decoded.capacity() as u64);
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
buffer
}
@@ -438,43 +311,29 @@ impl SizedDecoder {
&mut self,
seek_res: symphonia::core::formats::SeekedTo,
) -> Result<(), String> {
// Number of frames between where the demuxer landed and the requested ts.
let mut samples_to_pass: u64 = seek_res
.required_ts
.get()
.saturating_sub(seek_res.actual_ts.get())
.max(0) as u64;
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
let packet = loop {
let candidate = match self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?
{
Some(p) => p,
// EOF while refining — nothing more to skip.
None => return Ok(()),
};
if candidate.dur.get() > samples_to_pass {
let candidate = self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?;
if candidate.dur() > samples_to_pass {
break candidate;
}
samples_to_pass -= candidate.dur.get();
samples_to_pass -= candidate.dur();
};
let mut decoded = self.decoder.decode(&packet);
for _ in 0..DECODE_MAX_RETRIES {
if decoded.is_err() {
let p = match self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?
{
Some(p) => p,
None => break,
};
let p = self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?;
decoded = self.decoder.decode(&p);
}
}
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels().count();
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
Ok(())
}
}
@@ -490,12 +349,12 @@ impl Iterator for SizedDecoder {
// drop the frame and advance to the next packet. IO errors and a
// clean end-of-stream both terminate the iterator normally.
loop {
let packet = self.format.next_packet().ok()??;
let packet = self.format.next_packet().ok()?;
match self.decoder.decode(&packet) {
Ok(decoded) => {
self.consecutive_decode_errors = 0;
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = 0;
break;
}
@@ -526,7 +385,7 @@ impl Iterator for SizedDecoder {
}
}
let sample = *self.buffer.get(self.current_frame_offset)?;
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
self.current_frame_offset += 1;
Some(sample)
}
@@ -535,24 +394,25 @@ impl Iterator for SizedDecoder {
impl Source for SizedDecoder {
#[inline]
fn current_span_len(&self) -> Option<usize> {
Some(self.buffer.len())
Some(self.buffer.samples().len())
}
#[inline]
fn channels(&self) -> rodio::ChannelCount {
std::num::NonZeroU16::new(self.spec.channels().count() as u16)
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
.unwrap_or(std::num::NonZeroU16::MIN)
}
#[inline]
fn sample_rate(&self) -> rodio::SampleRate {
std::num::NonZeroU32::new(self.spec.rate()).unwrap_or(std::num::NonZeroU32::MIN)
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
}
#[inline]
fn total_duration(&self) -> Option<Duration> {
self.total_duration
.map(|t| Duration::from_secs_f64(t.as_secs_f64().max(0.0)))
self.total_duration.map(|Time { seconds, frac }| {
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
})
}
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
@@ -560,19 +420,20 @@ impl Source for SizedDecoder {
.total_duration()
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
let target_secs = if seek_beyond_end {
let time: Time = if seek_beyond_end {
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
// Step back a tiny bit — some demuxers can't seek to the exact end.
let total = self
.total_duration
.map(|t| t.as_secs_f64())
.unwrap_or_else(|| pos.as_secs_f64());
(total - 0.0001).max(0.0)
let mut secs = t.seconds;
let mut frac = t.frac - 0.0001;
if frac < 0.0 {
secs = secs.saturating_sub(1);
frac = 1.0 - frac;
}
Time { seconds: secs, frac }
} else {
pos.as_secs_f64()
pos.as_secs_f64().into()
};
let time = Time::try_from_secs_f64(target_secs).unwrap_or(Time::ZERO);
let to_skip = self.current_frame_offset % self.channels().get() as usize;
let seek_res = self
@@ -980,8 +841,8 @@ mod tests {
fn sized_decoder_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new(wav, Some("wav"), false).expect("WAV decode setup");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
assert_eq!(decoder.spec.rate, 44_100);
assert_eq!(decoder.spec.channels.count(), 1);
}
#[test]
@@ -996,84 +857,21 @@ mod tests {
let _decoder = SizedDecoder::new(wav, Some("wav"), true).expect("WAV decode with hi-res");
}
// ── new_streaming + ProbeSeekGate ────────────────────────────────────────
fn seekable_source(bytes: Vec<u8>) -> Box<dyn MediaSource> {
let len = bytes.len() as u64;
Box::new(SizedCursorSource { inner: Cursor::new(bytes), len })
}
#[test]
fn new_streaming_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream")
.expect("streaming WAV decode setup");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
// Live streams report no total duration.
assert!(decoder.total_duration.is_none());
}
#[test]
fn new_streaming_returns_err_for_garbage_input() {
let result = SizedDecoder::new_streaming(
seekable_source(vec![0x00u8; 64]),
None,
"test-stream",
);
assert!(result.is_err());
}
#[test]
fn probe_seek_gate_toggles_seekability() {
let wav = synthetic_wav_bytes(0.1);
let len = wav.len() as u64;
let flag = Arc::new(AtomicBool::new(false));
let gate = ProbeSeekGate {
inner: seekable_source(wav),
seekable: flag.clone(),
};
// Hidden during probe …
assert!(!gate.is_seekable());
// … restored afterwards.
flag.store(true, Ordering::Relaxed);
assert!(gate.is_seekable());
// byte_len always passes through to the inner source.
assert_eq!(gate.byte_len(), Some(len));
}
#[test]
fn probe_seek_gate_read_and_seek_pass_through() {
let bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
let mut gate = ProbeSeekGate {
inner: seekable_source(bytes),
seekable: Arc::new(AtomicBool::new(true)),
};
let mut buf = [0u8; 4];
let n = gate.read(&mut buf).expect("read");
assert_eq!(n, 4);
assert_eq!(&buf, &[1, 2, 3, 4]);
let pos = gate.seek(std::io::SeekFrom::Start(6)).expect("seek");
assert_eq!(pos, 6);
let n = gate.read(&mut buf).expect("read after seek");
assert_eq!(&buf[..n], &[7, 8]);
}
// ── log_codec_resolution ─────────────────────────────────────────────────
#[test]
fn log_codec_resolution_does_not_panic_for_valid_params() {
let mut params = AudioCodecParameters::new();
params.codec = symphonia::core::codecs::audio::well_known::CODEC_ID_PCM_S16LE;
let mut params = symphonia::core::codecs::CodecParameters::new();
params.codec = symphonia::core::codecs::CODEC_TYPE_PCM_S16LE;
params.sample_rate = Some(44_100);
params.bits_per_sample = Some(16);
params.channels = Some(symphonia::core::audio::Channels::Discrete(1));
params.channels = Some(symphonia::core::audio::Channels::FRONT_LEFT);
log_codec_resolution("test-tag", &params, Some("wav"));
}
#[test]
fn log_codec_resolution_handles_unknown_codec_gracefully() {
let params = AudioCodecParameters::new();
let params = symphonia::core::codecs::CodecParameters::new();
log_codec_resolution("unknown", &params, None);
}
}
+8 -104
View File
@@ -9,11 +9,7 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, resolve_playback_format_hint,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::helpers::MASTER_HEADROOM;
use super::sources::PriorityBoostSource;
// ────────────────────────────────────────────────────────────────────────────
@@ -94,44 +90,19 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
}
}
/// `format=` query param on Subsonic stream URLs (transcode targets).
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
/// a `format=flac` query param for `.opus` files (server transcodes); for
/// everything else we guess from the URL's `format=` value or fall back to None.
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
url.split('?')
.nth(1)?
.split('&')
.find_map(|kv| {
let (k, v) = kv.split_once('=')?;
if k.eq_ignore_ascii_case("format") {
Some(v.to_string())
} else {
None
}
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
})
}
/// Symphonia container hint for preview downloads — mirrors main playback:
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
pub(crate) fn resolve_preview_format_hint(
url: &str,
content_type: Option<&str>,
content_disposition: Option<&str>,
stream_suffix: Option<&str>,
bytes: &[u8],
) -> Option<String> {
let media_hint = content_type
.and_then(content_type_to_hint)
.or_else(|| {
content_disposition.and_then(format_hint_from_content_disposition)
});
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
resolve_playback_format_hint(
url_hint.as_deref(),
stream_suffix,
media_hint.as_deref(),
Some(bytes),
)
}
#[tauri::command]
pub async fn audio_preview_play(
id: String,
@@ -139,7 +110,6 @@ pub async fn audio_preview_play(
start_sec: f64,
duration_sec: f64,
volume: f32,
format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -177,24 +147,13 @@ pub async fn audio_preview_play(
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let response = preview_http
let bytes = preview_http
.get(&url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.map_err(|e| format!("preview: HTTP {e}"))?
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
@@ -206,13 +165,7 @@ pub async fn audio_preview_play(
}
// ── Decode ───────────────────────────────────────────────────────────────
let hint = resolve_preview_format_hint(
&url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix.as_deref(),
&bytes,
);
let hint = preview_format_hint_from_url(&url);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
@@ -318,55 +271,6 @@ pub async fn audio_preview_play(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
Some("audio/mpeg"),
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("mp3"));
}
#[test]
fn resolve_preview_format_hint_uses_subsonic_suffix() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
Some("flac"),
&[0x00, 0x01, 0x02, 0x03],
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn preview_format_hint_from_url_reads_format_query_param() {
assert_eq!(
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
Some("opus".into())
);
}
}
#[tauri::command]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
@@ -15,8 +15,6 @@ serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] }
twox-hash = "2"
unicode-normalization = "0.1"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "test-util"] }
@@ -1,10 +0,0 @@
-- Plain All Albums browse: read from `album` with ORDER BY name (not track GROUP BY).
CREATE INDEX IF NOT EXISTS idx_album_server_name_browse
ON album(server_id, name COLLATE NOCASE);
-- Scoped album EXISTS probes: (server, album, library) on live tracks.
CREATE INDEX IF NOT EXISTS idx_track_server_album_library_browse
ON track(server_id, album_id, library_id)
WHERE deleted = 0
AND album_id IS NOT NULL
AND album_id != '';
@@ -22,68 +22,11 @@ use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
use crate::repos;
use crate::search::{
aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr,
fts_album_prefix_any_token_match_query, fts_album_title_prefix_any_token_match_query,
fts_column_prefix_query, fts_query_meets_min_len, fts_track_prefix_match_query,
library_scope_filter_sql, like_any_token_contains_clause, like_contains, like_name_tokens,
PAGE_LIMIT_MAX,
fts_album_prefix_match_query, fts_album_title_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len,
fts_track_prefix_match_query, library_scope_equals_sql, like_contains, PAGE_LIMIT_MAX,
};
use crate::store::LibraryStore;
fn effective_library_scope_ids(req: &LibraryAdvancedSearchRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s.to_string()])
.unwrap_or_default()
}
fn push_library_scope_where(w: &mut WhereBuilder, table: &str, scope_ids: &[String]) {
if scope_ids.is_empty() {
return;
}
let (sql, params) = library_scope_filter_sql(table, scope_ids);
if let Some(clause) = sql {
if params.len() == 1 {
w.push_param(&clause, params[0].clone());
} else {
w.push_params(&clause, params);
}
}
}
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
fn push_album_table_library_scope(
w: &mut WhereBuilder,
album_alias: &str,
scope_ids: &[String],
) {
if scope_ids.is_empty() {
return;
}
let (clause, params) = library_scope_filter_sql("t_scope", scope_ids);
let Some(scope_clause) = clause else {
return;
};
w.push_params(
&format!(
"EXISTS (SELECT 1 FROM track t_scope \
WHERE t_scope.server_id = {album_alias}.server_id \
AND t_scope.album_id = {album_alias}.id \
AND t_scope.deleted = 0 \
AND {scope_clause})"
),
params,
);
}
/// `bpm` dual-storage resolution (§5.13.4): prefer analysis `track_fact(bpm)`,
/// then hot `track.bpm` tag, then other fact sources.
fn bpm_resolved_sql() -> String {
@@ -118,8 +61,8 @@ fn fts_candidate_pool_size(limit: u32, offset: u32) -> i64 {
need.saturating_mul(20).clamp(256, 10_000)
}
/// FTS rowid pick scoped to the active server (and optional library folders).
fn scoped_fts_rowid_subquery_sql(pool: i64, scope_ids: &[String]) -> String {
/// FTS rowid pick scoped to the active server (and optional library folder).
fn scoped_fts_rowid_subquery_sql(pool: i64, library_scope: Option<&str>) -> String {
let alias = "t_fts";
let mut sql = format!(
"SELECT f.rowid FROM track_fts f \
@@ -128,21 +71,20 @@ fn scoped_fts_rowid_subquery_sql(pool: i64, scope_ids: &[String]) -> String {
AND {alias}.server_id = ? \
AND {alias}.deleted = 0"
);
if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
if library_scope.is_some() {
sql.push_str(" AND ");
sql.push_str(&clause);
sql.push_str(&library_scope_equals_sql(alias));
}
sql.push_str(&format!(" ORDER BY bm25(track_fts) LIMIT {pool}"));
sql
}
fn scoped_fts_pick_join_sql(pool: i64, scope_ids: &[String]) -> String {
fn scoped_fts_pick_join_sql(pool: i64, library_scope: Option<&str>) -> String {
let alias = "t_fts";
let scope_sql = if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
format!(" AND {clause}")
} else {
String::new()
};
let mut scope_sql = String::new();
if library_scope.is_some() {
scope_sql = format!(" AND {}", library_scope_equals_sql(alias));
}
format!(
"track t INNER JOIN (\
SELECT f.rowid, bm25(track_fts) AS fts_rank \
@@ -157,10 +99,14 @@ fn scoped_fts_pick_join_sql(pool: i64, scope_ids: &[String]) -> String {
)
}
fn scoped_fts_subquery_bind(server_id: &str, scope_ids: &[String]) -> Vec<SqlValue> {
fn scoped_fts_subquery_bind(
server_id: &str,
library_scope: Option<&str>,
) -> Vec<SqlValue> {
let mut params = vec![SqlValue::Text(server_id.to_string())];
let (_, scope_params) = library_scope_filter_sql("t_fts", scope_ids);
params.extend(scope_params);
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
params.push(SqlValue::Text(scope.to_string()));
}
params
}
@@ -271,7 +217,10 @@ fn build_track(
let mut w = WhereBuilder::new();
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
@@ -297,8 +246,8 @@ fn build_track(
if let Some(q) = text.and_then(fts_track_prefix_match_query) {
applied.insert("text".to_string());
let pool = fts_candidate_pool_size(limit, offset);
let scope_ids = effective_library_scope_ids(req);
let from = scoped_fts_pick_join_sql(pool, &scope_ids);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let from = scoped_fts_pick_join_sql(pool, scope.as_deref());
let order = order_clause(&req.sort, EntityKind::Track)
.unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string());
return query_rows_fts(
@@ -306,7 +255,7 @@ fn build_track(
&cols,
&from,
&q,
&scoped_fts_subquery_bind(&req.server_id, &scope_ids),
&scoped_fts_subquery_bind(&req.server_id, scope.as_deref()),
&w,
&order,
limit,
@@ -358,208 +307,12 @@ fn server_has_indexed_tracks(store: &LibraryStore, server_id: &str) -> Result<bo
fn fts_album_text_match_query(req: &LibraryAdvancedSearchRequest, text: &str) -> Option<String> {
if req.query_album_title_only == Some(true) {
fts_album_title_prefix_any_token_match_query(text)
fts_album_title_prefix_match_query(text)
} else {
fts_album_prefix_any_token_match_query(text)
fts_album_prefix_match_query(text)
}
}
fn push_album_name_like_any_token(
w: &mut WhereBuilder,
column: &str,
text: &str,
applied: &mut BTreeSet<String>,
) {
let Some((sql, params)) = like_any_token_contains_clause(column, text) else {
return;
};
w.push_params(
&sql,
params.into_iter().map(SqlValue::Text).collect(),
);
applied.insert("text".to_string());
}
/// `album` row or any child track tag may carry the searchable title.
fn push_album_table_text_match(
w: &mut WhereBuilder,
text: &str,
applied: &mut BTreeSet<String>,
) {
let tokens = like_name_tokens(text);
if tokens.is_empty() {
return;
}
let mut parts = Vec::new();
let mut params = Vec::new();
for token in tokens {
let pat = like_contains(&token);
parts.push(
"(a.name COLLATE NOCASE LIKE ? ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM track t_mt \
WHERE t_mt.server_id = a.server_id AND t_mt.album_id = a.id \
AND t_mt.deleted = 0 \
AND t_mt.album COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(),
);
params.push(SqlValue::Text(pat.clone()));
params.push(SqlValue::Text(pat));
}
w.push_params(&format!("({})", parts.join(" OR ")), params);
applied.insert("text".to_string());
}
/// Track group match: hot `t.album` tag or synced `album.name`.
fn push_track_group_text_match(
w: &mut WhereBuilder,
text: &str,
applied: &mut BTreeSet<String>,
) {
let tokens = like_name_tokens(text);
if tokens.is_empty() {
return;
}
let mut parts = Vec::new();
let mut params = Vec::new();
for token in tokens {
let pat = like_contains(&token);
parts.push(
"(t.album COLLATE NOCASE LIKE ? ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM album a_mt \
WHERE a_mt.server_id = t.server_id AND a_mt.id = t.album_id \
AND a_mt.name COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(),
);
params.push(SqlValue::Text(pat.clone()));
params.push(SqlValue::Text(pat));
}
w.push_params(&format!("({})", parts.join(" OR ")), params);
applied.insert("text".to_string());
}
/// Synced `album` rows + scope filters — plain browse only (no free-text query).
fn try_build_album_from_table(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<Option<(Vec<LibraryAlbumDto>, u32)>, String> {
if scalar_requires_lossless_track_grouping(scalar)
|| scalar_requires_track_derived_entities(scalar)
{
return Ok(None);
}
if !crate::album_browse::album_table_usable(store, &req.server_id)? {
return Ok(None);
}
let table = build_album_from_table(store, req, None, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(Some(table));
}
Ok(None)
}
fn album_text_hit_key(a: &LibraryAlbumDto) -> (String, String) {
(a.server_id.clone(), a.id.clone())
}
/// Prefer synced `album` rows; fill gaps from FTS / track-derived groups.
fn merge_album_text_hits(
table: Vec<LibraryAlbumDto>,
fts: Vec<LibraryAlbumDto>,
tracks: Vec<LibraryAlbumDto>,
) -> Vec<LibraryAlbumDto> {
let mut by_key: std::collections::HashMap<(String, String), LibraryAlbumDto> =
std::collections::HashMap::new();
for a in tracks {
by_key.entry(album_text_hit_key(&a)).or_insert(a);
}
for a in fts {
by_key.entry(album_text_hit_key(&a)).or_insert(a);
}
for a in table {
by_key.insert(album_text_hit_key(&a), a);
}
let mut out: Vec<LibraryAlbumDto> = by_key.into_values().collect();
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
out
}
/// All Albums text search — union `album` LIKE, track FTS, and track GROUP BY
/// (substring + prefix). The `album`-table fast path alone misses titles that
/// only appear on track rows (Navidrome parity / Live Search).
#[allow(clippy::too_many_arguments)]
fn build_album_text_search(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: &str,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
applied.insert("text".to_string());
let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX);
let scope_ids = effective_library_scope_ids(req);
let mut scratch = BTreeSet::new();
let mut table = Vec::new();
// Match `list_albums`: multi-folder scope uses track GROUP BY, not album+EXISTS.
if scope_ids.len() <= 1
&& !scalar_requires_lossless_track_grouping(scalar)
&& !scalar_requires_track_derived_entities(scalar)
&& crate::album_browse::album_table_usable(store, &req.server_id)?
{
table = build_album_from_table(
store,
req,
Some(text),
scalar,
fetch,
0,
true,
&mut scratch,
)?
.0;
}
let mut fts = Vec::new();
let mut tracks = Vec::new();
if server_has_indexed_tracks(store, &req.server_id)? {
if fts_query_meets_min_len(text) {
if let Some(q) = fts_album_text_match_query(req, text) {
fts = build_album_from_fts(
store, req, &q, scalar, fetch, 0, true, &mut scratch,
)?
.0;
}
}
tracks = build_album_from_tracks(
store,
req,
Some(text),
scalar,
fetch,
0,
true,
&mut scratch,
true,
)?
.0;
}
let merged = merge_album_text_hits(table, fts, tracks);
let total = merged.len() as u32;
let page = merged
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, if skip_totals { 0 } else { total }))
}
#[allow(clippy::too_many_arguments)]
fn build_album(
store: &LibraryStore,
@@ -581,19 +334,12 @@ fn build_album(
if req.starred_only == Some(true) {
return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
}
if let Some(t) = text {
return build_album_text_search(
store, req, t, scalar, limit, offset, skip_totals, applied,
);
}
if let Some(table) = try_build_album_from_table(
store, req, scalar, limit, offset, skip_totals, applied,
)? {
return Ok(table);
}
if server_has_indexed_tracks(store, &req.server_id)? {
if let Some(q) = text.and_then(|t| fts_album_text_match_query(req, t)) {
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
}
return build_album_from_tracks(
store, req, None, scalar, limit, offset, skip_totals, applied, false,
store, req, text, scalar, limit, offset, skip_totals, applied, false,
);
}
if !scalar_requires_track_derived_entities(scalar) {
@@ -621,12 +367,13 @@ fn build_album_from_table(
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
// `album` has no `library_id`; scope is enforced via EXISTS on `track`.
// `album` has no `library_id` / `deleted` columns, so `libraryScope` is
// a track-only filter (P20) and does not narrow album-table results.
let mut w = WhereBuilder::new();
w.push_param("a.server_id = ?", SqlValue::Text(req.server_id.clone()));
push_album_table_library_scope(&mut w, "a", &effective_library_scope_ids(req));
if let Some(t) = text {
push_album_table_text_match(&mut w, t, applied);
w.push_param("a.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Album)? {
@@ -687,9 +434,13 @@ fn build_album_from_tracks(
AND a.id = t.album_id AND a.song_count IS NOT NULL)",
);
}
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
if let Some(t) = text {
push_track_group_text_match(&mut w, t, applied);
w.push_param("t.album LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
@@ -708,9 +459,7 @@ fn build_album_from_tracks(
applied,
);
let select = "t.server_id, t.album_id, \
MAX(COALESCE((SELECT a2.name FROM album a2 WHERE a2.server_id = t.server_id AND a2.id = t.album_id), t.album)), \
MAX(t.artist), MAX(t.artist_id), \
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at)";
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
@@ -809,7 +558,10 @@ fn build_artist_from_tracks(
w.push_raw(
"NOT EXISTS (SELECT 1 FROM artist ar WHERE ar.server_id = t.server_id AND ar.id = t.artist_id)",
);
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
if let Some(t) = text {
w.push_param("t.artist LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
@@ -853,94 +605,110 @@ fn build_album_from_fts(
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
applied.insert("text".to_string());
let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX);
let pool = fts_candidate_pool_size(fetch, 0);
let scope_ids = effective_library_scope_ids(req);
let need = limit.saturating_add(offset) as i64;
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let mut extra = WhereBuilder::new();
extra.push_raw("t.deleted = 0");
extra.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
extra.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
push_library_scope_where(&mut extra, "t", &scope_ids);
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p
},
);
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
extra.push(frag);
w.push(frag);
}
}
if req.starred_only == Some(true) {
extra.push_raw("t.starred_at IS NOT NULL");
w.push_raw("t.starred_at IS NOT NULL");
applied.insert("starred".to_string());
}
push_album_id_allowlist(
&mut extra,
&mut w,
"t.album_id",
req.restrict_album_ids.as_deref(),
applied,
);
let extra_sql = extra.where_sql();
let scope_tail = if let (Some(clause), _) = library_scope_filter_sql("t_fts", &scope_ids) {
format!(" AND {clause}")
} else {
String::new()
};
let where_sql = w.where_sql();
store.with_read_conn(|conn| {
let sql = format!(
"SELECT t.server_id, t.album_id, \
COALESCE(a.name, t.album), t.artist, t.artist_id, t.year, \
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
FROM (\
SELECT f.rowid, bm25(track_fts) AS fts_rank \
FROM track_fts f \
JOIN track t_fts ON t_fts.rowid = f.rowid \
WHERE track_fts MATCH ? \
AND t_fts.server_id = ? \
AND t_fts.deleted = 0{scope_tail} \
ORDER BY fts_rank \
LIMIT {pool}\
) fts_pick \
JOIN track t ON t.rowid = fts_pick.rowid \
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id \
WHERE {extra_sql} \
GROUP BY t.album_id \
ORDER BY MIN(fts_pick.fts_rank) \
LIMIT ? OFFSET ?",
FROM track t \
WHERE {where_sql}"
);
let mut params: Vec<SqlValue> = Vec::new();
params.push(SqlValue::Text(fts.to_string()));
params.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids));
params.extend(extra.params);
params.push(SqlValue::Integer(fetch as i64));
params.push(SqlValue::Integer(offset as i64));
let params = w.params.clone();
let mut stmt = conn.prepare(&sql)?;
let albums: Vec<LibraryAlbumDto> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: None,
duration_sec: None,
year: r.get(5)?,
genre: r.get(6)?,
cover_art_id: r.get(7)?,
starred_at: r.get(8)?,
synced_at: r.get(9)?,
raw_json: Value::Null,
})
let rows: Vec<AlbumBrowseTrackRow> =
stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
r.get(9)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut seen = HashSet::new();
let mut deduped: Vec<LibraryAlbumDto> = Vec::new();
for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows {
if !seen.insert(album_id.clone()) {
continue;
}
deduped.push(LibraryAlbumDto {
server_id,
id: album_id,
name: album,
artist,
artist_id,
song_count: None,
duration_sec: None,
year,
genre,
cover_art_id,
starred_at,
synced_at,
raw_json: Value::Null,
});
if deduped.len() >= need as usize {
break;
}
}
let total = if skip_totals {
0
} else {
albums.len() as u32
deduped.len() as u32
};
Ok((albums, total))
let page = deduped
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, total))
})
}
@@ -959,24 +727,27 @@ fn build_artist_from_fts(
applied.insert("text".to_string());
let need = limit.saturating_add(offset) as i64;
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
let scope_ids = effective_library_scope_ids(req);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, &scope_ids)
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids));
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p
},
);
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''");
push_library_scope_where(&mut w, "t", &scope_ids);
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
@@ -1624,17 +1395,6 @@ mod tests {
.unwrap();
}
fn mark_album_catalog_row(store: &LibraryStore, server: &str, id: &str) {
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 1 WHERE server_id = ?1 AND id = ?2",
rusqlite::params![server, id],
)
})
.unwrap();
}
fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) {
store
.with_conn("misc", |c| {
@@ -1651,7 +1411,6 @@ mod tests {
LibraryAdvancedSearchRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
query: None,
entity_types: entities.to_vec(),
filters: Vec::new(),
@@ -1716,8 +1475,6 @@ mod tests {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al1", "Aurora Nights", None, None);
insert_album(&store, "s1", "al2", "Other", None, None);
mark_album_catalog_row(&store, "s1", "al1");
mark_album_catalog_row(&store, "s1", "al2");
insert_artist(&store, "s1", "ar1", "Aurora Quartet");
let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]);
r.query = Some("aurora".into());
@@ -1728,169 +1485,6 @@ mod tests {
assert_eq!(resp.artists[0].id, "ar1");
}
#[test]
fn album_title_search_matches_any_query_word_via_like() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None);
insert_album(&store, "s1", "al_other", "Wish You Were Here", None, None);
mark_album_catalog_row(&store, "s1", "al_moon");
mark_album_catalog_row(&store, "s1", "al_other");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("moon side".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_moon");
}
#[test]
fn album_title_search_matches_later_word_only() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None);
mark_album_catalog_row(&store, "s1", "al_moon");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("moon".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_moon");
}
#[test]
fn album_title_search_matches_synced_name_when_track_tag_differs() {
let store = LibraryStore::open_in_memory();
insert_album(
&store,
"s1",
"al_kerrang",
"Kerrang! Metallica Master of Puppets Revisited",
None,
None,
);
mark_album_catalog_row(&store, "s1", "al_kerrang");
let mut tr = track("s1", "t1", "A", "Various", "Master of Puppets Revisited");
tr.album_id = Some("al_kerrang".into());
TrackRepository::new(&store).upsert_batch(&[tr]).unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_kerrang");
}
fn track_with_lib(
server: &str,
id: &str,
album_id: &str,
album: &str,
library_id: Option<&str>,
) -> TrackRow {
let mut t = track(server, id, "A", "art-1", album);
t.album_id = Some(album_id.into());
t.library_id = library_id.map(str::to_string);
t
}
#[test]
fn album_title_text_search_respects_single_library_scope() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_lib("s1", "t1", "al-in", "Metallica", Some("lib-a")),
track_with_lib("s1", "t2", "al-out", "Metallica Covers", Some("lib-b")),
])
.unwrap();
insert_album(&store, "s1", "al-in", "Metallica", None, None);
insert_album(&store, "s1", "al-out", "Metallica Covers", None, None);
mark_album_catalog_row(&store, "s1", "al-in");
mark_album_catalog_row(&store, "s1", "al-out");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
r.library_scope_ids = Some(vec!["lib-a".into()]);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al-in");
}
#[test]
fn album_title_text_search_unions_multi_library_scope() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_lib("s1", "t1", "al-a", "Alpha Metallica", Some("lib-a")),
track_with_lib("s1", "t2", "al-b", "Beta Metallica", Some("lib-b")),
track_with_lib("s1", "t3", "al-c", "Gamma Other", Some("lib-c")),
])
.unwrap();
insert_album(&store, "s1", "al-a", "Alpha Metallica", None, None);
insert_album(&store, "s1", "al-b", "Beta Metallica", None, None);
mark_album_catalog_row(&store, "s1", "al-a");
mark_album_catalog_row(&store, "s1", "al-b");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
r.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 2, "expected {ids:?}");
assert!(ids.contains(&"al-a"));
assert!(ids.contains(&"al-b"));
}
#[test]
fn album_title_text_search_unions_sparse_table_and_track_catalog() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_self", "Metallica", None, None);
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'",
[],
)
})
.unwrap();
let mut plays = track("s1", "t1", "A", "Apocalyptica", "Plays Metallica Vol. 2");
plays.album_id = Some("al_plays".into());
let mut blacklist = track("s1", "t2", "B", "Various", "The Metallica Blacklist");
blacklist.album_id = Some("al_black".into());
let mut other = track("s1", "t3", "C", "Pink Floyd", "Wish You Were Here");
other.album_id = Some("al_wish".into());
TrackRepository::new(&store)
.upsert_batch(&[plays, blacklist, other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 3);
assert!(ids.contains(&"al_self"));
assert!(ids.contains(&"al_plays"));
assert!(ids.contains(&"al_black"));
}
#[test]
fn album_title_search_fts_matches_any_word_on_track_catalog() {
let store = LibraryStore::open_in_memory();
let mut moon = track("s1", "t1", "Breathe", "Pink Floyd", "The Dark Side of the Moon");
moon.album_id = Some("al_moon".into());
let mut other = track("s1", "t2", "Shine", "Pink Floyd", "Wish You Were Here");
other.album_id = Some("al_wish".into());
TrackRepository::new(&store)
.upsert_batch(&[moon, other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("wish moon".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"al_moon"));
assert!(ids.contains(&"al_wish"));
}
#[test]
fn text_query_derives_album_and_artist_from_tracks_when_tables_empty() {
let store = LibraryStore::open_in_memory();
@@ -1,499 +0,0 @@
//! Paginated All Albums browse from the local index (plain, no filters).
//!
//! Prefers the synced `album` table (≈5k rows) with LIMIT/OFFSET. Falls back to
//! track `GROUP BY` only when the album catalog is not populated (N1 ingest).
use crate::dto::{
LibraryAlbumBrowseRequest, LibraryAlbumBrowseResponse, LibraryAlbumDto, LibrarySortClause,
SortDir,
};
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
s.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
}
fn effective_scope_ids(req: &LibraryAlbumBrowseRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn album_table_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "a.name COLLATE NOCASE",
"artist" => "a.artist COLLATE NOCASE",
"year" => "a.year",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("a.name COLLATE NOCASE ASC".to_string());
}
keys.push("a.id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn track_group_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
"year" => "COALESCE(a.year, la.year)",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn push_album_id_allowlist(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
ids: Option<&[String]>,
) {
let Some(ids) = ids else {
return;
};
if ids.is_empty() {
where_clauses.push("1 = 0".to_string());
return;
}
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
where_clauses.push(format!("{column} IN ({placeholders})"));
for id in ids {
params.push(SqlValue::Text(id.clone()));
}
}
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
fn push_album_table_library_scope(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
scope_ids: &[String],
) {
if scope_ids.is_empty() {
return;
}
let (clause, scope_params) = library_scope_filter_sql("t_scope", scope_ids);
let Some(scope_clause) = clause else {
return;
};
where_clauses.push(format!(
"EXISTS (SELECT 1 FROM track t_scope \
WHERE t_scope.server_id = a.server_id \
AND t_scope.album_id = a.id \
AND t_scope.deleted = 0 \
AND {scope_clause})"
));
params.extend(scope_params);
}
fn map_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null),
})
}
pub(crate) fn album_table_usable(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
store
.with_read_conn(|c| {
c.query_row(
"SELECT EXISTS(
SELECT 1 FROM album
WHERE server_id = ?1 AND song_count IS NOT NULL
LIMIT 1
)",
rusqlite::params![server_id],
|r| r.get(0),
)
})
.map_err(|e| e.to_string())
}
fn list_albums_from_table(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = album_table_order_sql(&req.sort);
let mut where_clauses = vec!["a.server_id = ?1".to_string()];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
push_album_table_library_scope(&mut where_clauses, &mut params, &scope_ids);
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"a.id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
a.server_id, \
a.id, \
a.name, \
a.artist, \
a.artist_id, \
a.song_count, \
a.duration_sec, \
a.year, \
a.genre, \
a.cover_art_id, \
a.starred_at, \
a.synced_at, \
a.raw_json \
FROM album a \
WHERE {where_sql} \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn list_albums_from_tracks(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = track_group_order_sql(&req.sort);
let mut where_clauses = vec![
"t.deleted = 0".to_string(),
"t.server_id = ?1".to_string(),
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
params.extend(scope_params);
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
COALESCE(a.year, la.year), \
COALESCE(a.genre, la.genre), \
COALESCE(a.cover_art_id, la.cover_art_id), \
COALESCE(a.starred_at, la.starred_at), \
COALESCE(a.synced_at, la.synced_at), \
a.raw_json \
FROM ( \
SELECT \
t.server_id, \
t.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
MAX(t.cover_art_id) AS cover_art_id, \
MAX(t.starred_at) AS starred_at, \
MAX(t.synced_at) AS synced_at, \
COUNT(*) AS track_count, \
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
FROM track t \
WHERE {where_sql} \
GROUP BY t.server_id, t.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn browse_is_scoped(req: &LibraryAlbumBrowseRequest) -> bool {
!effective_scope_ids(req).is_empty()
|| req
.restrict_album_ids
.as_ref()
.is_some_and(|ids| !ids.is_empty())
}
pub fn list_albums(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let scope_ids = effective_scope_ids(req);
// Unscoped, or a single library: `album` table + EXISTS (fast on ~5k rows).
// Multi-library union: filter tracks by `library_id IN (...)` then GROUP BY.
if album_table_usable(store, &req.server_id)?
&& (!browse_is_scoped(req) || scope_ids.len() == 1)
{
return list_albums_from_table(store, req);
}
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(LibraryAlbumBrowseResponse {
albums: Vec::new(),
has_more: false,
source: "local".to_string(),
});
}
list_albums_from_tracks(store, req)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
fn track(server: &str, id: &str, album_id: &str, album: &str, library_id: Option<&str>) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: format!("{album} track"),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some("art-1".into()),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: Some("mp3".into()),
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: library_id.map(String::from),
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
fn req(server: &str, limit: u32, offset: u32) -> LibraryAlbumBrowseRequest {
LibraryAlbumBrowseRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
}
fn seed_album(
store: &LibraryStore,
server_id: &str,
id: &str,
name: &str,
song_count: i64,
) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, artist, song_count, duration_sec, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 'Band', ?4, 400, 1, '{}')",
rusqlite::params![server_id, id, name, song_count],
)
})
.unwrap();
}
#[test]
fn lists_albums_grouped_from_tracks() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", None),
track("s1", "t2", "al-2", "Beta", None),
])
.unwrap();
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert!(!resp.has_more);
}
#[test]
fn prefers_album_table_when_synced_catalog_exists() {
let store = LibraryStore::open_in_memory();
seed_album(&store, "s1", "al-1", "Alpha", 10);
seed_album(&store, "s1", "al-2", "Beta", 8);
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].name, "Alpha");
assert_eq!(resp.albums[1].name, "Beta");
}
#[test]
fn library_scope_narrows_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "In", Some("lib-a")),
track("s1", "t2", "al-2", "Out", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "In", 1);
seed_album(&store, "s1", "al-2", "Out", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al-1");
}
#[test]
fn multi_library_scope_unions_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Beta", Some("lib-b")),
track("s1", "t3", "al-3", "Gamma", Some("lib-c")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
seed_album(&store, "s1", "al-2", "Beta", 1);
seed_album(&store, "s1", "al-3", "Gamma", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
#[test]
fn multi_library_scope_includes_track_only_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Zulu", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
}
@@ -6,7 +6,7 @@ use tauri::State;
use crate::dto::CatalogYearBoundsDto;
use crate::dto::GenreAlbumCountDto;
use crate::runtime::LibraryRuntime;
use crate::search::library_scope_filter_sql;
use crate::search::library_scope_equals_sql;
use crate::store::LibraryStore;
#[derive(Debug, Clone, serde::Deserialize)]
@@ -106,34 +106,11 @@ pub fn library_get_catalog_year_bounds(
catalog_year_bounds_for_server(&runtime.store, &server_id)
}
fn effective_genre_count_scope_ids(
library_scope: Option<&str>,
library_scope_ids: Option<&[String]>,
) -> Vec<String> {
if let Some(ids) = library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
library_scope
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| vec![s.to_string()])
.unwrap_or_default()
}
pub(crate) fn genre_album_counts_for_server(
store: &LibraryStore,
server_id: &str,
library_scope: Option<&str>,
library_scope_ids: Option<&[String]>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
let scope_ids = effective_genre_count_scope_ids(library_scope, library_scope_ids);
store
.with_read_conn(|conn| {
let mut sql = String::from(
@@ -145,9 +122,9 @@ pub(crate) fn genre_album_counts_for_server(
);
let mut params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())];
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
sql.push_str(&format!(" AND {clause}"));
params.extend(scope_params);
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
sql.push_str(&format!(" AND {}", library_scope_equals_sql("t")));
params.push(rusqlite::types::Value::Text(scope.to_string()));
}
sql.push_str(
" GROUP BY t.genre COLLATE NOCASE \
@@ -174,13 +151,11 @@ pub fn library_get_genre_album_counts(
runtime: State<'_, LibraryRuntime>,
server_id: String,
library_scope: Option<String>,
library_scope_ids: Option<Vec<String>>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
genre_album_counts_for_server(
&runtime.store,
&server_id,
library_scope.as_deref(),
library_scope_ids.as_deref(),
)
}
@@ -332,7 +307,7 @@ mod tests {
.upsert_batch(&rock_one)
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None, None).unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
assert_eq!(counts.len(), 2);
assert_eq!(counts[0].value, "Rock");
assert_eq!(counts[0].album_count, 2);
@@ -355,7 +330,7 @@ mod tests {
.upsert_batch(&[scoped, other])
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1"), None).unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
assert_eq!(counts.len(), 1);
assert_eq!(counts[0].value, "Rock");
assert_eq!(counts[0].album_count, 1);
@@ -20,16 +20,10 @@ use crate::cover_resolve::CoverEntryDto;
use crate::cross_server;
use crate::dto::{
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryClusterAdvancedSearchRequest, LibraryClusterListTracksRequest, LibraryClusterResolveRequest,
LibraryClusterResolveResponse, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse,
LibraryClusterScopeRequest, LibraryClusterPlayerStatsRequest, LibraryClusterPlayerStatsDayDetailRequest,
LibraryClusterEntityDetailRequest, LibraryClusterAlbumDetailResponse,
LibraryClusterArtistDetailResponse,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
PlaySessionInputDto, PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto,
PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
TrackArtifactDto, TrackFactDto, TrackRefDto,
};
use crate::live_search;
@@ -479,16 +473,6 @@ pub async fn library_advanced_search(
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
}
#[tauri::command]
pub async fn library_cluster_advanced_search(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::server_cluster::run_cluster_advanced_search(&store, request))
.await
}
#[tauri::command]
pub async fn library_list_lossless_albums(
runtime: State<'_, LibraryRuntime>,
@@ -498,15 +482,6 @@ pub async fn library_list_lossless_albums(
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryAlbumBrowseRequest,
) -> Result<crate::dto::LibraryAlbumBrowseResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::album_browse::list_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums_by_genre(
runtime: State<'_, LibraryRuntime>,
@@ -571,265 +546,6 @@ pub async fn library_search_cross_server(
cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref())
}
#[tauri::command]
pub async fn library_cluster_list_tracks(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryTracksEnvelope, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_tracks(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_albums(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryClusterAlbumsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_albums(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_artists(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryClusterArtistsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_artists(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorites(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryTracksEnvelope, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_tracks(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorite_albums(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryClusterAlbumsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_albums(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorite_artists(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryClusterArtistsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_artists(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub fn library_cluster_player_stats_year_summary(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsRequest,
) -> Result<PlaySessionYearSummaryDto, String> {
crate::server_cluster::cluster_year_summary(
&runtime.store,
&request.servers_ordered,
request.year,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_heatmap(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsRequest,
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
crate::server_cluster::cluster_heatmap(
&runtime.store,
&request.servers_ordered,
request.year,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_day_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsDayDetailRequest,
) -> Result<PlaySessionDayDetailDto, String> {
crate::server_cluster::cluster_day_detail(
&runtime.store,
&request.servers_ordered,
&request.date_iso,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_recent_days(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
crate::server_cluster::cluster_recent_days(
&runtime.store,
&request.servers_ordered,
request.limit.unwrap_or(30),
)
}
#[tauri::command]
pub fn library_cluster_player_stats_most_played(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<Vec<PlaySessionMostPlayedDto>, String> {
crate::server_cluster::cluster_most_played(
&runtime.store,
&request.servers_ordered,
request.limit.unwrap_or(50),
)
}
#[tauri::command]
pub async fn library_cluster_resolve_candidates(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterResolveRequest,
) -> Result<LibraryClusterResolveResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || {
if let Some(key) = request.cluster_key.filter(|k| !k.is_empty()) {
let candidates = crate::server_cluster::resolve_candidates_by_cluster_key(
&store,
&request.servers_ordered,
&key,
)?;
return Ok(LibraryClusterResolveResponse {
candidates,
cluster_key: Some(key),
});
}
let server_id = request
.server_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
let track_id = request
.track_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
let cluster_key =
crate::server_cluster::cluster_key_for_track(&store, server_id, track_id)?;
let candidates = crate::server_cluster::resolve_candidates_for_track(
&store,
&request.servers_ordered,
server_id,
track_id,
)?;
Ok(LibraryClusterResolveResponse {
candidates,
cluster_key,
})
})
.await
}
#[tauri::command]
pub async fn library_cluster_album_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterEntityDetailRequest,
) -> Result<LibraryClusterAlbumDetailResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let server_id = request.server_id;
let entity_id = request.entity_id;
library_spawn_blocking(move || {
crate::server_cluster::cluster_album_detail(&store, &servers_ordered, &server_id, &entity_id)
})
.await
}
#[tauri::command]
pub async fn library_cluster_artist_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterEntityDetailRequest,
) -> Result<LibraryClusterArtistDetailResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let server_id = request.server_id;
let entity_id = request.entity_id;
library_spawn_blocking(move || {
crate::server_cluster::cluster_artist_detail(&store, &servers_ordered, &server_id, &entity_id)
})
.await
}
#[tauri::command]
pub async fn library_search_cluster(
runtime: State<'_, LibraryRuntime>,
query: String,
limit: Option<u32>,
offset: Option<u32>,
servers_ordered: Vec<String>,
) -> Result<LibraryCrossServerSearchResponse, String> {
let store = Arc::clone(&runtime.store);
let limit = limit.unwrap_or(100);
let offset = offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::run_cluster_search(&store, &query, limit, offset, &servers_ordered)
})
.await
}
// ── helpers ──────────────────────────────────────────────────────────
fn hydrate_refs(
@@ -1197,12 +913,6 @@ async fn library_sync_start_inner(
};
if let Some(runtime) = app_for_emit.try_state::<LibraryRuntime>() {
let _ = runtime.store.checkpoint_wal("sync.checkpoint");
if outcome.ok {
let _ = crate::server_cluster::rebuild_cluster_keys_for_server(
&runtime.store,
&server_id_for_emit,
);
}
}
let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome);
@@ -4,7 +4,6 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use crate::filter::{EntityKind, FilterOp};
use crate::repos::TrackRow;
@@ -366,14 +365,6 @@ pub struct PlaySessionRecentDayDto {
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionMostPlayedDto {
pub track: LibraryTrackDto,
pub track_play_count: u32,
pub total_listened_sec: f64,
}
/// Earliest/latest calendar years with at least one session (local TZ).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -399,38 +390,6 @@ pub struct GenreAlbumCountDto {
pub song_count: u32,
}
/// `library_list_albums` request — paginated plain All Albums browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_album_browse_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_album_browse_limit() -> u32 {
30
}
/// `library_list_albums` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseResponse {
pub albums: Vec<LibraryAlbumDto>,
pub has_more: bool,
pub source: String,
}
/// `library_list_albums_by_genre` request — paginated genre album browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -440,8 +399,6 @@ pub struct LibraryGenreAlbumsRequest {
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
#[serde(default = "default_genre_album_limit")]
pub limit: u32,
@@ -563,9 +520,6 @@ pub struct LibraryAdvancedSearchRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Multiple music-folder ids (OR). Takes precedence over `library_scope` when non-empty.
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub query: Option<String>,
pub entity_types: Vec<EntityKind>,
@@ -651,14 +605,6 @@ pub struct LibraryLosslessAlbumsRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Multiple music-folder ids (OR). Preferred over `library_scope` when length > 1.
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
/// Navidrome-scoped album ids from getAlbumList2 (authoritative when track `library_id` is sparse).
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_lossless_limit")]
pub limit: u32,
#[serde(default)]
@@ -712,158 +658,6 @@ pub struct LibraryCrossServerSearchResponse {
pub servers_searched: Vec<String>,
}
/// Cluster candidate row for playback / write fan-out resolution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterCandidateDto {
pub server_id: String,
pub track_id: String,
pub duration_sec: i64,
pub priority_rank: u32,
pub is_winner: bool,
}
/// `library_cluster_list_tracks` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterListTracksRequest {
/// Ordered member server ids (index 0 = highest priority).
pub servers_ordered: Vec<String>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
#[serde(default)]
pub library_scopes: HashMap<String, Vec<String>>,
}
/// `library_cluster_advanced_search` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAdvancedSearchRequest {
/// Ordered member server ids (index 0 = highest priority).
pub servers_ordered: Vec<String>,
#[serde(default)]
pub query: Option<String>,
pub entity_types: Vec<EntityKind>,
#[serde(default)]
pub filters: Vec<LibraryFilterClause>,
#[serde(default)]
pub starred_only: Option<bool>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
/// Per-member album allowlists from getAlbumList2 (`server_id` → album ids).
#[serde(default)]
pub restrict_album_scopes: HashMap<String, Vec<String>>,
#[serde(default)]
pub query_album_title_only: Option<bool>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
pub limit: u32,
#[serde(default)]
pub offset: u32,
#[serde(default)]
pub skip_totals: bool,
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
#[serde(default)]
pub library_scopes: HashMap<String, Vec<String>>,
}
/// Merged album browse response for cluster scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAlbumsResponse {
pub albums: Vec<LibraryAlbumDto>,
pub has_more: bool,
}
/// Merged artist browse response for cluster scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterArtistsResponse {
pub artists: Vec<LibraryArtistDto>,
pub has_more: bool,
}
/// Cluster player stats / favorites scope request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterScopeRequest {
pub servers_ordered: Vec<String>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterPlayerStatsRequest {
pub servers_ordered: Vec<String>,
pub year: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterPlayerStatsDayDetailRequest {
pub servers_ordered: Vec<String>,
pub date_iso: String,
}
/// `library_cluster_resolve_candidates` request — provide cluster_key OR seed track.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterResolveRequest {
pub servers_ordered: Vec<String>,
#[serde(default)]
pub cluster_key: Option<String>,
#[serde(default)]
pub server_id: Option<String>,
#[serde(default)]
pub track_id: Option<String>,
}
/// `library_cluster_resolve_candidates` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterResolveResponse {
pub candidates: Vec<LibraryClusterCandidateDto>,
#[serde(default)]
pub cluster_key: Option<String>,
}
/// `library_cluster_album_detail` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterEntityDetailRequest {
pub servers_ordered: Vec<String>,
pub server_id: String,
pub entity_id: String,
}
/// Virtual aggregate album detail (spec §4).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAlbumDetailResponse {
pub album: LibraryAlbumDto,
pub tracks: Vec<LibraryTrackDto>,
pub owner_server_id: String,
pub related_albums: Vec<LibraryAlbumDto>,
}
/// Virtual aggregate artist detail (spec §4).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterArtistDetailResponse {
pub artist: LibraryArtistDto,
pub albums: Vec<LibraryAlbumDto>,
pub top_tracks: Vec<LibraryTrackDto>,
pub owner_server_id: String,
#[serde(default)]
pub artist_key: Option<String>,
}
/// Read `MAX(server_updated_at)` for non-deleted tracks on this server
/// — used by `SyncStateDto` so callers can show "tracks watermark" in
/// Settings without a separate column.
@@ -7,7 +7,7 @@ use crate::dto::{
LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibrarySortClause,
SortDir,
};
use crate::search::library_scope_filter_sql;
use crate::search::library_scope_equals_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
@@ -18,22 +18,6 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
.map(String::from)
}
fn effective_genre_scope_ids(req: &LibraryGenreAlbumsRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
@@ -133,10 +117,9 @@ pub fn list_albums_by_genre(
SqlValue::Text(genre.to_string()),
];
let scope_ids = effective_genre_scope_ids(req);
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
params.extend(scope_params);
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
where_clauses.push(library_scope_equals_sql("t"));
params.push(SqlValue::Text(scope));
}
let where_sql = where_clauses.join(" AND ");
@@ -270,7 +253,6 @@ mod tests {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: Some("lib1".into()),
library_scope_ids: None,
sort: vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
@@ -290,7 +272,6 @@ mod tests {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: None,
library_scope_ids: None,
sort: vec![],
limit: 1,
offset: 0,
@@ -9,7 +9,6 @@
pub(crate) mod bulk_ingest;
pub mod advanced_search;
pub mod album_browse;
pub mod album_compilation_filter;
pub mod browse_support;
mod advanced_search_mood;
@@ -34,7 +33,6 @@ pub mod payload;
pub mod repos;
pub mod runtime;
pub mod search;
pub mod server_cluster;
pub mod store;
pub mod sync;
pub(crate) mod track_fts;
@@ -2,12 +2,9 @@
//!
//! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`.
use crate::dto::{
LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse, LibrarySortClause,
SortDir,
};
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
use crate::lossless_formats::track_is_lossless_sql;
use crate::search::library_scope_filter_sql;
use crate::search::library_scope_equals_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
@@ -18,45 +15,6 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
.map(String::from)
}
fn effective_lossless_scope_ids(req: &LibraryLosslessAlbumsRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn lossless_album_order(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
return "ORDER BY la.max_bit_depth DESC, \
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, la.album_id ASC"
.to_string();
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
/// Paginated lossless albums for one server. Returns empty when the index has
/// no matching tracks — caller may fall back to the Navidrome song-stream walk.
pub fn list_lossless_albums(
@@ -79,25 +37,12 @@ pub fn list_lossless_albums(
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_lossless_scope_ids(req);
if !scope_ids.is_empty() {
let match_expr = crate::search::library_scope_match_sql("t");
where_clauses.push(format!("({match_expr}) IS NOT NULL AND TRIM({match_expr}) != ''"));
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
for p in scope_params {
params.push(p);
}
}
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
where_clauses.push(clause);
params.push(SqlValue::Text(scope));
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let order_sql = lossless_album_order(&req.sort);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
@@ -136,7 +81,9 @@ pub fn list_lossless_albums(
GROUP BY t.server_id, t.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \
ORDER BY la.max_bit_depth DESC, \
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, \
la.album_id ASC \
LIMIT ? OFFSET ?"
);
@@ -159,26 +106,6 @@ pub fn list_lossless_albums(
})
}
fn push_album_id_allowlist(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
ids: Option<&[String]>,
) {
let Some(ids) = ids else {
return;
};
if ids.is_empty() {
where_clauses.push("1 = 0".to_string());
return;
}
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
where_clauses.push(format!("{column} IN ({placeholders})"));
for id in ids {
params.push(SqlValue::Text(id.clone()));
}
}
fn empty_response() -> LibraryLosslessAlbumsResponse {
LibraryLosslessAlbumsResponse {
albums: Vec::new(),
@@ -276,9 +203,6 @@ mod tests {
LibraryLosslessAlbumsRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
@@ -347,79 +271,6 @@ mod tests {
assert_eq!(resp.albums[0].id, "al1");
}
#[test]
fn library_scope_ids_union_narrows_results() {
let store = LibraryStore::open_in_memory();
let mut a = track_with_suffix("s1", "t1", "al1", "A", "flac", 16);
a.library_id = Some("lib1".into());
let mut b = track_with_suffix("s1", "t2", "al2", "B", "flac", 16);
b.library_id = Some("lib2".into());
let mut c = track_with_suffix("s1", "t3", "al3", "C", "flac", 16);
c.library_id = Some("lib3".into());
TrackRepository::new(&store)
.upsert_batch(&[a, b, c])
.unwrap();
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib1".into(), "lib3".into()]);
let resp = list_lossless_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al1");
assert_eq!(resp.albums[1].id, "al3");
}
#[test]
fn name_sort_overrides_bit_depth_default() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_z", "Zulu", "flac", 24),
track_with_suffix("s1", "t2", "al_a", "Alpha", "flac", 16),
])
.unwrap();
let mut sorted = req("s1", 50, 0);
sorted.sort = vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
}];
let resp = list_lossless_albums(&store, &sorted).unwrap();
assert_eq!(resp.albums[0].id, "al_a");
assert_eq!(resp.albums[1].id, "al_z");
}
#[test]
fn matches_suffix_from_raw_json_when_column_null() {
let store = LibraryStore::open_in_memory();
let mut row = track_with_suffix("s1", "t1", "al_json", "Json", "mp3", 0);
row.suffix = None;
row.raw_json = r#"{"suffix":"flac","bitDepth":24}"#.into();
TrackRepository::new(&store)
.upsert_batch(&[row])
.unwrap();
let resp = list_lossless_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_json");
}
#[test]
fn restrict_album_ids_narrows_lossless_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_keep", "Keep", "flac", 24),
track_with_suffix("s1", "t2", "al_drop", "Drop", "flac", 24),
])
.unwrap();
let mut restricted = req("s1", 50, 0);
restricted.restrict_album_ids = Some(vec!["al_keep".into()]);
let resp = list_lossless_albums(&store, &restricted).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_keep");
}
#[test]
fn pagination_sets_has_more() {
let store = LibraryStore::open_in_memory();
@@ -7,22 +7,14 @@ pub const LOSSLESS_SUFFIXES: &[&str] = &[
"flac", "wav", "wave", "aiff", "aif", "dsf", "dff", "ape", "wv", "shn", "tta",
];
/// Effective suffix — hot `track.suffix`, then Navidrome `raw_json.suffix`.
pub fn track_suffix_expr(table_alias: &str) -> String {
format!(
"LOWER(COALESCE(NULLIF({table_alias}.suffix, ''), \
CAST(json_extract({table_alias}.raw_json, '$.suffix') AS TEXT), ''))"
)
}
/// `track_suffix_expr IN ('flac', …)` for SQL WHERE clauses.
/// `LOWER(alias.suffix) IN ('flac', …)` for SQL WHERE clauses.
pub fn track_is_lossless_sql(table_alias: &str) -> String {
let list = LOSSLESS_SUFFIXES
.iter()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ");
format!("{} IN ({list})", track_suffix_expr(table_alias))
format!("LOWER({table_alias}.suffix) IN ({list})")
}
/// Album has at least one indexed lossless track (same allowlist as browse).
@@ -58,6 +50,6 @@ mod tests {
let sql = track_is_lossless_sql("t");
assert!(sql.contains("'flac'"));
assert!(sql.contains("'tta'"));
assert!(sql.contains("json_extract(t.raw_json, '$.suffix')"));
assert!(sql.starts_with("LOWER(t.suffix) IN ("));
}
}
@@ -171,11 +171,6 @@ pub(crate) fn fts_album_title_prefix_match_query(raw: &str) -> Option<String> {
fts_prefix_token_expr(raw).map(|tokens| format!("album : {tokens}"))
}
/// All Albums title search — any query word may prefix-match the album column.
pub(crate) fn fts_album_title_prefix_any_token_match_query(raw: &str) -> Option<String> {
fts_prefix_token_or_expr(raw).map(|tokens| format!("album : ({tokens})"))
}
/// Live Search album match — any query word may hit album or album_artist (Navidrome parity).
pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option<String> {
fts_prefix_token_or_expr(raw).map(|tokens| {
@@ -231,35 +226,6 @@ pub(crate) fn library_scope_equals_sql(table_alias: &str) -> String {
format!("{} = ?", library_scope_match_sql(table_alias))
}
/// `library_id` filter for one or more Navidrome music-folder scopes.
pub(crate) fn library_scope_filter_sql(
table_alias: &str,
scope_ids: &[String],
) -> (Option<String>, Vec<rusqlite::types::Value>) {
use rusqlite::types::Value as SqlValue;
if scope_ids.is_empty() {
return (None, Vec::new());
}
if scope_ids.len() == 1 {
return (
Some(library_scope_equals_sql(table_alias)),
vec![SqlValue::Text(scope_ids[0].clone())],
);
}
let match_sql = library_scope_match_sql(table_alias);
let placeholders = (0..scope_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(", ");
(
Some(format!("{match_sql} IN ({placeholders})")),
scope_ids
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect(),
)
}
pub(crate) fn aliased_track_columns(alias: &str) -> String {
crate::repos::track_columns()
.split(',')
@@ -354,36 +320,6 @@ pub(crate) fn like_contains(raw: &str) -> String {
format!("%{escaped}%")
}
/// Whitespace-split tokens for substring LIKE (any non-empty segment).
pub(crate) fn like_name_tokens(raw: &str) -> Vec<String> {
raw.split_whitespace()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
/// `(col LIKE ? OR …)` — any query word may match as a case-insensitive substring.
pub(crate) fn like_any_token_contains_clause(column: &str, raw: &str) -> Option<(String, Vec<String>)> {
let tokens = like_name_tokens(raw);
if tokens.is_empty() {
return None;
}
let col = format!("{column} COLLATE NOCASE");
if tokens.len() == 1 {
return Some((
format!("{col} LIKE ? ESCAPE '\\'"),
vec![like_contains(&tokens[0])],
));
}
let parts: Vec<String> = tokens
.iter()
.map(|_| format!("{col} LIKE ? ESCAPE '\\'"))
.collect();
let params: Vec<String> = tokens.iter().map(|t| like_contains(t)).collect();
Some((format!("({})", parts.join(" OR ")), params))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -526,23 +462,6 @@ mod tests {
);
}
#[test]
fn fts_album_title_prefix_any_token_match_query_or_words() {
assert_eq!(
fts_album_title_prefix_any_token_match_query("dark side").as_deref(),
Some("album : (\"dark\"* OR \"side\"*)")
);
}
#[test]
fn like_any_token_contains_clause_ors_words() {
let (sql, params) = like_any_token_contains_clause("a.name", "dark side").unwrap();
assert!(sql.contains(" OR "));
assert_eq!(params.len(), 2);
assert_eq!(params[0], "%dark%");
assert_eq!(params[1], "%side%");
}
#[test]
fn fts_track_match_query_or_across_display_columns() {
let q = fts_track_match_query("manowar").unwrap();
@@ -1,595 +0,0 @@
//! Cluster-scope advanced search: run per-server advanced search, then merge
//! winners by cluster identity keys with server-priority precedence.
use std::collections::{BTreeSet, HashMap, HashSet};
use rusqlite::types::Value as SqlValue;
use crate::advanced_search::run_advanced_search;
use crate::dto::{
LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto,
LibraryClusterAdvancedSearchRequest, LibrarySearchTotals, LibraryTrackDto,
};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
pub fn run_cluster_advanced_search(
store: &LibraryStore,
req: LibraryClusterAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
if req.servers_ordered.is_empty() {
return Ok(empty_response(req.skip_totals));
}
let page_limit = req.limit.clamp(1, PAGE_LIMIT_MAX);
let page_offset = req.offset as usize;
let per_server_limit = req
.limit
.saturating_add(req.offset)
.clamp(1, PAGE_LIMIT_MAX);
let mut all_tracks: Vec<LibraryTrackDto> = Vec::new();
let mut all_albums: Vec<LibraryAlbumDto> = Vec::new();
let mut all_artists: Vec<LibraryArtistDto> = Vec::new();
let mut applied_filters = BTreeSet::new();
for server_id in &req.servers_ordered {
let server_req = LibraryAdvancedSearchRequest {
server_id: server_id.clone(),
library_scope: None,
library_scope_ids: req
.library_scopes
.get(server_id)
.filter(|ids| !ids.is_empty())
.cloned(),
query: req.query.clone(),
entity_types: req.entity_types.clone(),
filters: req.filters.clone(),
starred_only: req.starred_only,
restrict_album_ids: req
.restrict_album_scopes
.get(server_id)
.filter(|ids| !ids.is_empty())
.cloned()
.or_else(|| req.restrict_album_ids.clone()),
query_album_title_only: req.query_album_title_only,
sort: req.sort.clone(),
limit: per_server_limit,
offset: 0,
skip_totals: true,
};
let resp = run_advanced_search(store, &server_req)?;
all_tracks.extend(resp.tracks);
all_albums.extend(resp.albums);
all_artists.extend(resp.artists);
applied_filters.extend(resp.applied_filters);
}
let merged_tracks = merge_tracks_by_cluster_key(store, all_tracks)?;
let merged_albums = if req
.query
.as_ref()
.is_some_and(|q| !q.trim().is_empty())
{
dedupe_album_search_hits(all_albums)
} else {
merge_albums_by_album_key(store, all_albums)?
};
let merged_artists = merge_artists_by_artist_key(store, all_artists)?;
let totals = if req.skip_totals {
LibrarySearchTotals::default()
} else {
LibrarySearchTotals {
artists: merged_artists.len() as u32,
albums: merged_albums.len() as u32,
tracks: merged_tracks.len() as u32,
}
};
Ok(LibraryAdvancedSearchResponse {
artists: merged_artists
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
albums: merged_albums
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
tracks: merged_tracks
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
totals,
applied_filters: applied_filters.into_iter().collect(),
source: "local".to_string(),
})
}
fn empty_response(skip_totals: bool) -> LibraryAdvancedSearchResponse {
LibraryAdvancedSearchResponse {
artists: Vec::new(),
albums: Vec::new(),
tracks: Vec::new(),
totals: if skip_totals {
LibrarySearchTotals::default()
} else {
LibrarySearchTotals {
artists: 0,
albums: 0,
tracks: 0,
}
},
applied_filters: Vec::new(),
source: "local".to_string(),
}
}
fn merge_tracks_by_cluster_key(
store: &LibraryStore,
tracks: Vec<LibraryTrackDto>,
) -> Result<Vec<LibraryTrackDto>, String> {
let refs: Vec<(String, String)> = tracks
.iter()
.map(|t| (t.server_id.clone(), t.id.clone()))
.collect();
let key_map = lookup_track_cluster_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for track in tracks {
let key = key_map
.get(&(track.server_id.clone(), track.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", track.server_id, track.id));
if seen.insert(key) {
out.push(track);
}
}
Ok(out)
}
/// Text search — keep distinct `(server_id, album_id)` rows; do not collapse
/// same-server albums that share an `album_key` (tribute / variant titles).
fn dedupe_album_search_hits(albums: Vec<LibraryAlbumDto>) -> Vec<LibraryAlbumDto> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for album in albums {
if seen.insert((album.server_id.clone(), album.id.clone())) {
out.push(album);
}
}
out
}
fn merge_albums_by_album_key(
store: &LibraryStore,
albums: Vec<LibraryAlbumDto>,
) -> Result<Vec<LibraryAlbumDto>, String> {
let refs: Vec<(String, String)> = albums
.iter()
.map(|a| (a.server_id.clone(), a.id.clone()))
.collect();
let key_map = lookup_album_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for album in albums {
let key = key_map
.get(&(album.server_id.clone(), album.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", album.server_id, album.id));
if seen.insert(key) {
out.push(album);
}
}
Ok(out)
}
fn merge_artists_by_artist_key(
store: &LibraryStore,
artists: Vec<LibraryArtistDto>,
) -> Result<Vec<LibraryArtistDto>, String> {
let refs: Vec<(String, String)> = artists
.iter()
.map(|a| (a.server_id.clone(), a.id.clone()))
.collect();
let key_map = lookup_artist_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for artist in artists {
let key = key_map
.get(&(artist.server_id.clone(), artist.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", artist.server_id, artist.id));
if seen.insert(key) {
out.push(artist);
}
}
Ok(out)
}
fn lookup_track_cluster_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, k.cluster_key
FROM wanted w
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = w.server_id AND k.track_id = w.entity_id"
),
)
}
fn lookup_album_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, MIN(k.album_key)
FROM wanted w
LEFT JOIN track t
ON t.server_id = w.server_id AND t.album_id = w.entity_id AND t.deleted = 0
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
GROUP BY w.server_id, w.entity_id"
),
)
}
fn lookup_artist_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, MIN(k.artist_key)
FROM wanted w
LEFT JOIN track t
ON t.server_id = w.server_id
AND COALESCE(NULLIF(t.artist_id, ''), t.artist) = w.entity_id
AND t.deleted = 0
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
GROUP BY w.server_id, w.entity_id"
),
)
}
fn lookup_keys_with_values(
store: &LibraryStore,
refs: &[(String, String)],
query_sql: &str,
) -> Result<HashMap<(String, String), Option<String>>, String> {
if refs.is_empty() {
return Ok(HashMap::new());
}
let values_sql = std::iter::repeat_n("(?, ?)", refs.len()).collect::<Vec<_>>().join(", ");
let sql = format!("WITH wanted(server_id, entity_id) AS (VALUES {values_sql}) {query_sql}");
let mut bind: Vec<SqlValue> = Vec::with_capacity(refs.len() * 2);
for (server_id, entity_id) in refs {
bind.push(SqlValue::Text(server_id.clone()));
bind.push(SqlValue::Text(entity_id.clone()));
}
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(rusqlite::params_from_iter(bind.iter()))?;
let mut out = HashMap::new();
while let Some(row) = rows.next()? {
let server_id: String = row.get(0)?;
let entity_id: String = row.get(1)?;
let key: Option<String> = row.get(2)?;
out.insert((server_id, entity_id), key);
}
Ok(out)
})
}
#[cfg(test)]
mod tests {
use crate::filter::EntityKind;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
use super::*;
fn track(server: &str, id: &str, artist: &str, artist_id: &str, album: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(artist_id.into()),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2024),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merges_tracks_by_cluster_key_with_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "LP", "alb-1"),
track("s2", "t2", "Band", "art-2", "LP", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Track],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].server_id, "s1");
assert_eq!(resp.totals.tracks, 1);
assert_eq!(resp.totals.albums, 0);
assert_eq!(resp.totals.artists, 0);
}
fn insert_album(store: &LibraryStore, server: &str, id: &str, name: &str) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 1, '{}')",
rusqlite::params![server, id, name],
)
})
.unwrap();
}
#[test]
fn cluster_text_search_respects_per_member_library_scope() {
let store = LibraryStore::open_in_memory();
let mut in_scope = track("s1", "t1", "Band", "art-1", "Metallica", "alb-in");
in_scope.library_id = Some("lib-a".into());
let mut out_scope = track("s1", "t2", "Band", "art-2", "Metallica Tribute", "alb-out");
out_scope.library_id = Some("lib-b".into());
TrackRepository::new(&store)
.upsert_batch(&[in_scope, out_scope])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into()]);
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: scopes,
},
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "alb-in");
}
#[test]
fn cluster_metallica_text_search_unions_table_and_track_catalog() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_self", "Metallica");
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'",
[],
)
})
.unwrap();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Apocalyptica", "art-1", "Plays Metallica Vol. 2", "al_plays"),
track("s1", "t2", "Various", "art-2", "The Metallica Blacklist", "al_black"),
track("s1", "t3", "Pink Floyd", "art-3", "Wish You Were Here", "al_wish"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 3, "expected {ids:?}");
assert!(ids.contains(&"al_self"));
assert!(ids.contains(&"al_plays"));
assert!(ids.contains(&"al_black"));
}
#[test]
fn text_search_keeps_distinct_same_server_albums_with_shared_album_key() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "Metallica", "alb-1"),
track("s1", "t2", "Band", "art-2", "Plays Metallica Vol. 2", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.albums.len(), 2);
}
#[test]
fn merges_albums_by_album_key_with_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "LP", "alb-1"),
track("s2", "t2", "Band", "art-2", "LP", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn applies_offset_after_merge() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band A", "art-a1", "LP A", "alb-a1"),
track("s2", "t2", "Band A", "art-a2", "LP A", "alb-a2"),
track("s1", "t3", "Band B", "art-b1", "LP B", "alb-b1"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Track],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 1,
offset: 1,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.totals.tracks, 2);
}
}
@@ -1,171 +0,0 @@
//! Attached `library-cluster.db` schema and metadata.
use std::path::{Path, PathBuf};
use rusqlite::{params, Connection, OptionalExtension};
pub const CLUSTER_DB_FILENAME: &str = "library-cluster.db";
pub const ATTACH_ALIAS: &str = "cluster";
pub const NORM_VERSION: &str = "1";
pub fn cluster_db_path(library_db_path: &Path) -> PathBuf {
library_db_path.with_file_name(CLUSTER_DB_FILENAME)
}
fn escape_sqlite_path(path: &str) -> String {
path.replace('\'', "''")
}
fn init_cluster_on_attached(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {ATTACH_ALIAS}.track_cluster_key (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
cluster_key TEXT NOT NULL,
album_key TEXT,
artist_key TEXT,
duration_sec INTEGER,
PRIMARY KEY (server_id, track_id)
)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_cluster_key \
ON track_cluster_key(cluster_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_album_key \
ON track_cluster_key(album_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_artist_key \
ON track_cluster_key(artist_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {ATTACH_ALIAS}.cluster_meta (
key TEXT PRIMARY KEY,
value TEXT
)"
),
[],
)?;
init_cluster_meta(conn)?;
Ok(())
}
fn init_cluster_file(cluster_path: &Path) -> rusqlite::Result<()> {
let cluster_conn = Connection::open(cluster_path)?;
cluster_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS track_cluster_key (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
cluster_key TEXT NOT NULL,
album_key TEXT,
artist_key TEXT,
duration_sec INTEGER,
PRIMARY KEY (server_id, track_id)
);
CREATE INDEX IF NOT EXISTS idx_cluster_key ON track_cluster_key(cluster_key);
CREATE INDEX IF NOT EXISTS idx_album_key ON track_cluster_key(album_key);
CREATE INDEX IF NOT EXISTS idx_artist_key ON track_cluster_key(artist_key);
CREATE TABLE IF NOT EXISTS cluster_meta (
key TEXT PRIMARY KEY,
value TEXT
);",
)?;
cluster_conn.execute(
"INSERT OR IGNORE INTO cluster_meta (key, value) VALUES ('norm_version', ?1)",
params![NORM_VERSION],
)?;
Ok(())
}
/// Create or migrate the cluster file, then attach it to `conn`.
pub fn attach_cluster_database(conn: &Connection, cluster_path: &Path) -> rusqlite::Result<()> {
if let Some(parent) = cluster_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
Some(e.to_string()),
)
})?;
}
init_cluster_file(cluster_path)?;
let path_str = escape_sqlite_path(&cluster_path.to_string_lossy());
conn.execute_batch(&format!(
"ATTACH DATABASE '{path_str}' AS {ATTACH_ALIAS}"
))?;
Ok(())
}
/// In-memory cluster DB for tests — attach then init schema on the alias.
pub fn attach_cluster_database_uri(conn: &Connection, uri: &str) -> rusqlite::Result<()> {
let path_str = escape_sqlite_path(uri);
conn.execute_batch(&format!(
"ATTACH DATABASE '{path_str}' AS {ATTACH_ALIAS}"
))?;
init_cluster_on_attached(conn)
}
pub fn ensure_cluster_schema(conn: &Connection) -> rusqlite::Result<()> {
init_cluster_on_attached(conn)
}
pub fn init_cluster_meta(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
&format!(
"INSERT OR IGNORE INTO {ATTACH_ALIAS}.cluster_meta (key, value) VALUES ('norm_version', ?1)"
),
params![NORM_VERSION],
)?;
Ok(())
}
pub fn stored_norm_version(conn: &Connection) -> rusqlite::Result<Option<String>> {
conn.query_row(
&format!(
"SELECT value FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|row| row.get(0),
)
.optional()
}
pub fn needs_norm_rebuild(conn: &Connection) -> rusqlite::Result<bool> {
Ok(stored_norm_version(conn)?.as_deref() != Some(NORM_VERSION))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_creates_cluster_tables_on_attach() {
let conn = Connection::open_in_memory().unwrap();
attach_cluster_database_uri(&conn, "file:cluster_mem?mode=memory&cache=shared").unwrap();
let count: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.sqlite_master \
WHERE type='table' AND name='track_cluster_key'"
),
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,135 +0,0 @@
//! Derive `cluster_key`, `album_key`, and `artist_key` from track metadata.
use std::hash::{Hash, Hasher};
use twox_hash::XxHash64;
use super::norm::norm_field;
const FIELD_SEP: u8 = 0x1f;
/// Precomputed keys for one track. `None` when any required field normalizes empty.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackClusterKeys {
pub cluster_key: String,
pub album_key: String,
pub artist_key: String,
}
fn effective_artist<'a>(artist: Option<&'a str>, album_artist: Option<&'a str>) -> Option<&'a str> {
artist
.filter(|s| !s.is_empty())
.or_else(|| album_artist.filter(|s| !s.is_empty()))
}
fn effective_album_artist<'a>(
album_artist: Option<&'a str>,
artist: Option<&'a str>,
) -> Option<&'a str> {
album_artist
.filter(|s| !s.is_empty())
.or_else(|| artist.filter(|s| !s.is_empty()))
}
fn hash_parts(parts: &[&str], sep: Option<u8>) -> String {
let mut hasher = XxHash64::with_seed(0);
for (i, part) in parts.iter().enumerate() {
if i > 0 {
if let Some(sep) = sep {
sep.hash(&mut hasher);
}
}
part.hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
}
/// Compute cluster identity keys from raw track fields (spec §2.12.5).
pub fn compute_track_cluster_keys(
artist: Option<&str>,
album_artist: Option<&str>,
title: &str,
album: &str,
) -> Option<TrackClusterKeys> {
let artist_src = effective_artist(artist, album_artist)?;
let norm_artist = norm_field(artist_src);
let norm_title = norm_field(title);
let norm_album = norm_field(album);
if norm_artist.is_empty() || norm_title.is_empty() || norm_album.is_empty() {
return None;
}
let cluster_key = hash_parts(&[&norm_artist, &norm_title, &norm_album], Some(FIELD_SEP));
let album_artist_src = effective_album_artist(album_artist, artist).unwrap_or(artist_src);
let norm_album_artist = norm_field(album_artist_src);
let album_key = hash_parts(&[&norm_album_artist, &norm_album], None);
let artist_key = hash_parts(&[&norm_artist], None);
Some(TrackClusterKeys {
cluster_key,
album_key,
artist_key,
})
}
/// Stable cross-server artist merge key from display name alone (spec §2.5).
pub fn artist_key_from_display_name(name: &str) -> Option<String> {
let norm = norm_field(name);
if norm.is_empty() {
return None;
}
Some(hash_parts(&[&norm], None))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_metadata_yields_same_keys() {
let a = compute_track_cluster_keys(
Some("Artist"),
None,
"Title",
"Album",
)
.unwrap();
let b = compute_track_cluster_keys(
Some("artist"),
None,
"title",
"album",
)
.unwrap();
assert_eq!(a, b);
}
#[test]
fn artist_falls_back_to_album_artist_for_cluster_key() {
let with = compute_track_cluster_keys(None, Some("Band"), "Song", "LP").unwrap();
let direct = compute_track_cluster_keys(Some("Band"), None, "Song", "LP").unwrap();
assert_eq!(with.cluster_key, direct.cluster_key);
}
#[test]
fn empty_title_or_album_yields_none() {
assert!(compute_track_cluster_keys(Some("A"), None, "", "Album").is_none());
assert!(compute_track_cluster_keys(Some("A"), None, "Title", "").is_none());
assert!(compute_track_cluster_keys(None, None, "Title", "Album").is_none());
}
#[test]
fn artist_key_from_display_name_matches_track_derived_key() {
let from_track = compute_track_cluster_keys(Some("Pink Floyd"), None, "x", "y").unwrap();
let from_name = artist_key_from_display_name("Pink Floyd").unwrap();
assert_eq!(from_track.artist_key, from_name);
}
#[test]
fn punctuation_insensitive_cluster_key() {
let a = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time", "Dark Side").unwrap();
let b = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time!", "Dark Side.").unwrap();
assert_eq!(a.cluster_key, b.cluster_key);
}
}
@@ -1,89 +0,0 @@
//! Per-member music-folder (`library_scope`) filters for merged cluster queries.
use std::collections::HashMap;
use rusqlite::types::Value as SqlValue;
use crate::search::{library_scope_equals_sql, library_scope_match_sql};
/// `(sql_suffix, bind_params)` — AND ( (server + optional scope) OR … ).
pub(crate) fn scope_filter_sql_and_params(
table_alias: &str,
servers_ordered: &[String],
scopes: &HashMap<String, Vec<String>>,
) -> (String, Vec<SqlValue>) {
if scopes.is_empty() {
return (String::new(), Vec::new());
}
let mut parts = Vec::with_capacity(servers_ordered.len());
let mut params = Vec::new();
for sid in servers_ordered {
if let Some(scope_ids) = scopes.get(sid).filter(|v| !v.is_empty()) {
if scope_ids.len() == 1 {
let eq = library_scope_equals_sql(table_alias);
parts.push(format!("({table_alias}.server_id = ? AND {eq})"));
params.push(SqlValue::Text(sid.clone()));
params.push(SqlValue::Text(scope_ids[0].clone()));
} else {
let match_sql = library_scope_match_sql(table_alias);
let ph = (0..scope_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(", ");
parts.push(format!(
"({table_alias}.server_id = ? AND {match_sql} IN ({ph}))"
));
params.push(SqlValue::Text(sid.clone()));
for id in scope_ids {
params.push(SqlValue::Text(id.clone()));
}
}
} else {
parts.push(format!("({table_alias}.server_id = ?)"));
params.push(SqlValue::Text(sid.clone()));
}
}
(format!(" AND ({})", parts.join(" OR ")), params)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::search::library_scope_filter_sql;
#[test]
fn scope_filter_empty_when_no_scopes() {
let (sql, params) = scope_filter_sql_and_params("t", &["s1".into()], &HashMap::new());
assert!(sql.is_empty());
assert!(params.is_empty());
}
#[test]
fn scope_filter_binds_scoped_and_unscoped_members() {
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into()]);
let (sql, params) = scope_filter_sql_and_params(
"t",
&["s1".into(), "s2".into()],
&scopes,
);
assert!(sql.contains("t.server_id = ?"));
assert_eq!(params.len(), 3);
}
#[test]
fn scope_filter_multi_folder_on_one_member() {
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into(), "lib-b".into()]);
let (sql, params) = scope_filter_sql_and_params("t", &["s1".into()], &scopes);
assert!(sql.contains(" IN ("));
assert_eq!(params.len(), 3);
}
#[test]
fn single_scope_uses_equals() {
let (sql, params) = library_scope_filter_sql("t", &["lib-a".into()]);
assert!(sql.unwrap().contains("= ?"));
assert_eq!(params.len(), 1);
}
}
@@ -1,204 +0,0 @@
//! Merged track listing for cluster scope (spec §4 Tier 1).
use rusqlite::types::Value as SqlValue;
use crate::dto::{LibraryTrackDto, LibraryTracksEnvelope};
use crate::repos;
use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::{in_list_sql, priority_case_sql};
/// List merged tracks — one row per `cluster_key` (priority winner), solo rows
/// for empty-key tracks and duration outliers.
pub fn list_merged_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryTracksEnvelope, String> {
if servers_ordered.is_empty() {
return Ok(LibraryTracksEnvelope {
tracks: vec![],
total: 0,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}){scope_sql}
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY t.title COLLATE NOCASE, t.server_id, t.id
LIMIT ? OFFSET ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.append(&mut scope_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let tracks: Vec<LibraryTrackDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryTracksEnvelope {
total: tracks.len() as u32,
tracks,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, title: &str, artist: &str, album: &str, dur: i64) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: None,
album: album.into(),
album_id: None,
album_artist: Some(artist.into()),
duration_sec: dur,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merge_collapses_same_cluster_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP", 200),
track("s2", "t2", "Song", "Band", "LP", 201),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s1");
}
#[test]
fn unavailable_priority_falls_through() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP", 200),
track("s2", "t2", "Song", "Band", "LP", 201),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s2");
}
#[test]
fn empty_key_tracks_never_merge() {
let store = LibraryStore::open_in_memory();
store
.with_conn_mut("misc", |c| {
c.execute(
"INSERT INTO track (server_id, id, title, artist, album, duration_sec, synced_at, raw_json) \
VALUES ('s1', 't1', 'A', '', 'X', 1, 1, '{}'), \
('s2', 't2', 'A', '', 'X', 1, 1, '{}')",
[],
)
})
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 2);
}
}
@@ -1,285 +0,0 @@
//! Merged album listing for cluster scope (spec §4 Tier 1 — dedup by `album_key`).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{LibraryAlbumDto, LibraryClusterAlbumsResponse};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::merge::ALBUM_ROLLUP_AND_PARTITION_CTE;
use super::priority::{in_list_sql, priority_case_sql};
pub fn list_merged_albums(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryClusterAlbumsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterAlbumsResponse {
albums: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.album_id,
k.album_key,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND t.album_id IS NOT NULL AND t.album_id != ''{scope_sql}
),
{ALBUM_ROLLUP_AND_PARTITION_CTE}
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT
t.server_id,
t.album_id,
COALESCE(a.name, t.album),
COALESCE(a.artist, t.artist),
COALESCE(a.artist_id, t.artist_id),
COALESCE(a.song_count, (
SELECT COUNT(*) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.duration_sec, (
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.year, t.year),
COALESCE(a.genre, t.genre),
COALESCE(a.cover_art_id, t.cover_art_id),
COALESCE(a.starred_at, t.starred_at),
COALESCE(a.synced_at, t.synced_at),
a.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
WHERE w.rn = 1
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.append(&mut scope_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryClusterAlbumsResponse { albums, has_more })
}
fn map_album_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(
server: &str,
id: &str,
title: &str,
artist: &str,
album: &str,
album_id: &str,
) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merge_collapses_same_album_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track("s2", "t2", "B", "Band", "LP", "alb2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into(), "s2".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn rollup_collapses_multiple_tracks_per_server_album() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track("s1", "t2", "B", "Band", "LP", "alb1"),
track("s1", "t3", "C", "Band", "LP", "alb1"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "alb1");
}
fn track_no_key(server: &str, id: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some(format!("art-{server}")),
album: "LP".into(),
album_id: Some(album_id.into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn rollup_uses_album_key_when_some_tracks_lack_keys() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track_no_key("s1", "t2", "alb1"),
track("s2", "t3", "B", "Band", "LP", "alb2"),
track_no_key("s2", "t4", "alb2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into(), "s2".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
}
@@ -1,230 +0,0 @@
//! Merged artist listing for cluster scope (spec §4 Tier 1 — dedup by `artist_key`).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{LibraryArtistDto, LibraryClusterArtistsResponse};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::priority::{in_list_sql, priority_case_sql};
pub fn list_merged_artists(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryClusterArtistsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterArtistsResponse {
artists: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("c.server_id", servers_ordered);
let (scope_sql, scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
// Artist-first catalog: one row per artist (not per track), then merge by
// `artist_key`. The previous track-scan + window over every row was O(tracks)
// with correlated album counts and blocked the Artists browse page on large libs.
let sql = format!(
"WITH artist_keys AS (
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
MIN(k.artist_key) AS artist_key
FROM track t
INNER JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders}){scope_sql}
AND k.artist_key IS NOT NULL
GROUP BY t.server_id, artist_ref
),
track_artists AS (
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS id,
MAX(t.artist) AS name,
COUNT(DISTINCT CASE
WHEN t.album_id IS NOT NULL AND t.album_id != '' THEN t.album_id
END) AS album_count,
MAX(t.synced_at) AS synced_at,
CAST(NULL AS TEXT) AS raw_json
FROM track t
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders}){scope_sql}
AND COALESCE(t.artist, '') != ''
AND NOT EXISTS (
SELECT 1 FROM artist ar
WHERE ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
)
GROUP BY t.server_id, COALESCE(NULLIF(t.artist_id, ''), t.artist)
),
catalog AS (
SELECT ar.server_id, ar.id, ar.name, ar.album_count, ar.synced_at, ar.raw_json
FROM artist ar
WHERE ar.server_id IN ({in_placeholders})
UNION ALL
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM track_artists
),
candidates AS (
SELECT
c.server_id,
c.id,
c.name,
c.album_count,
c.synced_at,
c.raw_json,
({priority_sql}) AS priority_rank,
CASE
WHEN ak.artist_key IS NOT NULL THEN ak.artist_key
ELSE 'solo:' || c.server_id || ':' || c.id
END AS merge_key
FROM catalog c
LEFT JOIN artist_keys ak
ON ak.server_id = c.server_id AND ak.artist_ref = c.id
),
winners AS (
SELECT
server_id,
id,
name,
album_count,
synced_at,
raw_json,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM candidates
)
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM winners
WHERE rn = 1
ORDER BY name COLLATE NOCASE, server_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.extend(in_params.iter().cloned());
params.extend(scope_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(scope_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(priority_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let artists: Vec<LibraryArtistDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
let has_more = artists.len() as u32 == limit;
Ok(LibraryClusterArtistsResponse { artists, has_more })
}
fn map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, artist: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: "LP".into(),
album_id: Some("alb1".into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merge_collapses_same_artist_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
}
#[test]
fn prefers_artist_table_album_count_when_present() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn("test", |conn| {
conn.execute(
"INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \
VALUES ('s1', 'art-s1', 'Band', 3, 1, '{}'), \
('s2', 'art-s2', 'Band', 2, 1, '{}')",
[],
)
})
.unwrap();
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
assert_eq!(resp.artists[0].album_count, Some(3));
}
}
@@ -1,452 +0,0 @@
//! Merged favorites — starred on any member counts (spec §4 Tier 2).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{
LibraryAlbumDto, LibraryArtistDto, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse, LibraryTrackDto,
LibraryTracksEnvelope,
};
use crate::repos;
use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::{in_list_sql, priority_case_sql};
/// Merged starred tracks — one row per merge group when **any** member is starred.
pub fn list_merged_favorite_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryTracksEnvelope, String> {
if servers_ordered.is_empty() {
return Ok(LibraryTracksEnvelope {
tracks: vec![],
total: 0,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
t.starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
starred_merge AS (
SELECT DISTINCT p.merge_key
FROM partitioned p
JOIN candidates c ON c.tid = p.tid
WHERE c.starred_at IS NOT NULL
),
winners AS (
SELECT p.tid, p.merge_key,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY t.title COLLATE NOCASE, t.server_id, t.id
LIMIT ? OFFSET ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let tracks: Vec<LibraryTrackDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryTracksEnvelope {
total: tracks.len() as u32,
tracks,
})
}
/// Merged favorite albums — one row per album merge group when any member is starred.
pub fn list_merged_favorite_albums(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryClusterAlbumsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterAlbumsResponse {
albums: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.album_id,
k.album_key,
COALESCE(a.starred_at, t.starred_at) AS starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
LEFT JOIN album a
ON a.server_id = t.server_id AND a.id = t.album_id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND t.album_id IS NOT NULL AND t.album_id != ''
),
album_rollup AS (
SELECT
c.server_id,
c.album_id,
MIN(c.tid) AS tid,
MIN(c.priority_rank) AS priority_rank,
MAX(c.album_key) AS album_key,
MAX(c.starred_at) AS starred_at
FROM candidates c
GROUP BY c.server_id, c.album_id
),
partitioned AS (
SELECT
r.tid,
CASE
WHEN r.album_key IS NOT NULL THEN r.album_key
ELSE 'solo:' || r.server_id || ':' || r.album_id
END AS merge_key,
r.priority_rank,
r.starred_at
FROM album_rollup r
),
starred_merge AS (
SELECT DISTINCT merge_key
FROM partitioned
WHERE starred_at IS NOT NULL
),
winners AS (
SELECT p.tid,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT
t.server_id,
t.album_id,
COALESCE(a.name, t.album),
COALESCE(a.artist, t.artist),
COALESCE(a.artist_id, t.artist_id),
COALESCE(a.song_count, (
SELECT COUNT(*) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.duration_sec, (
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.year, t.year),
COALESCE(a.genre, t.genre),
COALESCE(a.cover_art_id, t.cover_art_id),
COALESCE(a.starred_at, t.starred_at),
COALESCE(a.synced_at, t.synced_at),
a.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
WHERE w.rn = 1
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryClusterAlbumsResponse {
has_more: albums.len() as u32 == limit,
albums,
})
}
/// Merged favorite artists — one row per artist merge group when any member track is starred.
pub fn list_merged_favorite_artists(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryClusterArtistsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterArtistsResponse {
artists: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
k.artist_key,
t.starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND COALESCE(t.artist, '') != ''
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref
ELSE c.artist_key
END AS merge_key,
c.priority_rank,
c.starred_at
FROM candidates c
),
starred_merge AS (
SELECT DISTINCT merge_key
FROM partitioned
WHERE starred_at IS NOT NULL
),
winners AS (
SELECT p.tid,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist),
COALESCE(ar.name, t.artist),
COALESCE(ar.album_count, (
SELECT COUNT(DISTINCT c.album_id) FROM track c
WHERE c.server_id = t.server_id
AND c.deleted = 0
AND c.album_id IS NOT NULL
AND (c.artist_id = t.artist_id OR c.artist = t.artist)
)),
COALESCE(ar.synced_at, t.synced_at),
ar.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN artist ar ON ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
WHERE w.rn = 1
ORDER BY COALESCE(ar.name, t.artist) COLLATE NOCASE, t.server_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let artists: Vec<LibraryArtistDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryClusterArtistsResponse {
has_more: artists.len() as u32 == limit,
artists,
})
}
fn map_album_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
fn map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, starred: bool) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some("a1".into()),
album: "LP".into(),
album_id: Some("alb1".into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: if starred { Some(1) } else { None },
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn starred_on_lower_priority_still_surfaces_merged_row() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_favorite_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s1");
}
#[test]
fn unstarred_merge_group_excluded() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", false)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_favorite_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert!(env.tracks.is_empty());
}
#[test]
fn favorite_albums_merge_when_any_member_starred() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_favorite_albums(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn favorite_artists_merge_when_any_member_starred() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp =
list_merged_favorite_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
}
}
@@ -1,89 +0,0 @@
//! Duration guard and partition keys for cluster merge (spec §2.3).
pub const DURATION_TOLERANCE_SEC: i64 = 5;
/// Roll up per-track candidates to one row per `(server_id, album_id)` before
/// partitioning by `album_key` (spec §4 — album lists dedup by `album_key`).
pub const ALBUM_ROLLUP_AND_PARTITION_CTE: &str = "
album_rollup AS (
SELECT
c.server_id,
c.album_id,
MIN(c.tid) AS tid,
MIN(c.priority_rank) AS priority_rank,
MAX(c.album_key) AS album_key
FROM candidates c
GROUP BY c.server_id, c.album_id
),
partitioned AS (
SELECT
r.tid,
CASE
WHEN r.album_key IS NOT NULL THEN r.album_key
ELSE 'solo:' || r.server_id || ':' || r.album_id
END AS merge_key,
r.priority_rank
FROM album_rollup r
),
";
/// Synthetic partition for tracks without a `cluster_key` row (never merged).
pub fn solo_partition_key(server_id: &str, track_id: &str) -> String {
format!("solo:{server_id}:{track_id}")
}
/// Within one `cluster_key` group, split rows that fall outside ± tolerance of
/// the reference (priority-1 available candidate duration). Returns partition
/// keys: merged survivors share `cluster_key`; outliers get solo keys.
pub fn duration_partitions(
cluster_key: &str,
rows: &[(String, String, i64, u32)],
) -> Vec<(String, String, String)> {
// (server_id, track_id, duration_sec, priority_rank)
if rows.is_empty() {
return Vec::new();
}
let mut sorted = rows.to_vec();
sorted.sort_by_key(|(_, _, _, rank)| *rank);
let reference_duration = sorted[0].2;
let mut merged: Vec<&(String, String, i64, u32)> = Vec::new();
let mut outliers: Vec<&(String, String, i64, u32)> = Vec::new();
for row in &sorted {
if (row.2 - reference_duration).abs() <= DURATION_TOLERANCE_SEC {
merged.push(row);
} else {
outliers.push(row);
}
}
let mut out = Vec::new();
if !merged.is_empty() {
merged.sort_by_key(|(_, _, _, rank)| *rank);
let (sid, tid, _, _) = merged[0];
out.push((cluster_key.to_string(), sid.clone(), tid.clone()));
}
for (sid, tid, _, _) in outliers {
out.push((solo_partition_key(sid, tid), sid.clone(), tid.clone()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outlier_splits_to_solo_partition() {
let rows = vec![
("s1".into(), "t1".into(), 180, 0),
("s2".into(), "t2".into(), 182, 1),
("s3".into(), "t3".into(), 240, 2),
];
let parts = duration_partitions("ck1", &rows);
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].0, "ck1");
assert_eq!(parts[0].1, "s1");
assert_eq!(parts[1].0, "solo:s3:t3");
}
}
@@ -1,44 +0,0 @@
//! Server cluster identity — derived `cluster_key` / `album_key` / `artist_key`
//! in a separate attached SQLite DB (`library-cluster.db`). Distinct from
//! `repos/play_session/cluster.rs` (listening-session time-gap grouping).
mod detail;
mod advanced_search;
mod db;
mod keys;
mod library_scope;
mod list;
mod list_albums;
mod list_artists;
mod list_favorites;
mod merge;
mod norm;
mod play_stats;
mod priority;
mod rebuild;
mod resolve;
mod search;
pub use detail::{cluster_album_detail, cluster_artist_detail};
pub use advanced_search::run_cluster_advanced_search;
pub use db::{
attach_cluster_database, attach_cluster_database_uri, cluster_db_path, ensure_cluster_schema,
init_cluster_meta, needs_norm_rebuild, ATTACH_ALIAS, CLUSTER_DB_FILENAME, NORM_VERSION,
};
pub use keys::{compute_track_cluster_keys, TrackClusterKeys};
pub use list::list_merged_tracks;
pub use list_albums::list_merged_albums;
pub use list_artists::list_merged_artists;
pub use list_favorites::list_merged_favorite_tracks;
pub use list_favorites::{list_merged_favorite_albums, list_merged_favorite_artists};
pub use merge::DURATION_TOLERANCE_SEC;
pub use play_stats::{
cluster_day_detail, cluster_heatmap, cluster_most_played, cluster_recent_days, cluster_year_summary,
};
pub use rebuild::{
rebuild_all_cluster_keys, rebuild_cluster_keys_for_server, rebuild_if_norm_version_stale,
};
pub use resolve::{
cluster_key_for_track, resolve_candidates_by_cluster_key, resolve_candidates_for_track,
};
pub use search::{run_cluster_random_tracks, run_cluster_search};
@@ -1,43 +0,0 @@
//! Cheap Unicode normalization for cluster identity keys (spec §2.2).
use unicode_normalization::UnicodeNormalization;
/// NFD → drop combining marks → lowercase → letters/digits only.
pub fn norm_field(raw: &str) -> String {
raw.nfd()
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
.flat_map(|c| c.to_lowercase())
.filter(|c| c.is_alphanumeric())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_diacritics_and_punctuation() {
assert_eq!(norm_field("Café"), "cafe");
assert_eq!(norm_field("Mötley Crüe"), "motleycrue");
assert_eq!(norm_field("Hello, World!"), "helloworld");
}
#[test]
fn lowercases_and_removes_whitespace() {
assert_eq!(norm_field(" Pink FLOYD "), "pinkfloyd");
assert_eq!(norm_field("The\tBeatles\n"), "thebeatles");
}
#[test]
fn preserves_unicode_letters_and_digits() {
assert_eq!(norm_field("Sigur Rós"), "sigurros");
assert_eq!(norm_field("Track 99"), "track99");
}
#[test]
fn empty_or_non_alnum_only_becomes_empty() {
assert_eq!(norm_field(""), "");
assert_eq!(norm_field(" "), "");
assert_eq!(norm_field("---"), "");
}
}
@@ -1,595 +0,0 @@
//! Cluster-scoped player statistics — aggregate `play_session` across members (spec §4 Tier 2).
use std::collections::HashMap;
use rusqlite::types::Value as SqlValue;
use crate::dto::{
PlaySessionDayDetailDto, PlaySessionDayTotalsDto, PlaySessionDayTrackDto, PlaySessionHeatmapDayDto,
PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearSummaryDto,
};
use crate::repos;
use crate::search::aliased_track_columns;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::in_list_sql;
use super::priority::priority_case_sql;
const RECENT_DAYS_LIMIT_MAX: u32 = 90;
const MOST_PLAYED_LIMIT_MAX: u32 = 200;
#[derive(Default)]
struct DayAgg {
total_listened_sec: f64,
track_play_count: u32,
full_count: u32,
partial_count: u32,
plays: Vec<(i64, f64)>,
}
fn server_filter_sql(servers_ordered: &[String]) -> Result<(String, Vec<SqlValue>), String> {
if servers_ordered.is_empty() {
return Err("servers_ordered required".into());
}
let (placeholders, params) = in_list_sql(servers_ordered);
Ok((format!("ps.server_id IN ({placeholders})"), params))
}
fn unique_track_expr() -> &'static str {
"COALESCE(k.cluster_key, ps.server_id || ':' || ps.track_id)"
}
fn count_listening_sessions(plays: &[(i64, f64)]) -> u32 {
const GAP_MS: i64 = 30 * 60 * 1000;
if plays.is_empty() {
return 0;
}
let mut sorted = plays.to_vec();
sorted.sort_by_key(|p| p.0);
let mut sessions = 1u32;
let mut prev_end = sorted[0].0 + (sorted[0].1 * 1000.0) as i64;
for (started, listened) in sorted.iter().skip(1) {
if *started - prev_end > GAP_MS {
sessions += 1;
}
let end = *started + (*listened * 1000.0) as i64;
prev_end = prev_end.max(end);
}
sessions
}
fn validate_date_iso(date_iso: &str) -> Result<(), String> {
if date_iso.len() != 10 || date_iso.as_bytes()[4] != b'-' || date_iso.as_bytes()[7] != b'-' {
return Err("dateIso must be YYYY-MM-DD".into());
}
let year: i32 = date_iso[0..4]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let month: u32 = date_iso[5..7]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let day: u32 = date_iso[8..10]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err("dateIso must be YYYY-MM-DD".into());
}
Ok(())
}
pub fn cluster_year_summary(
store: &LibraryStore,
servers_ordered: &[String],
year: i32,
) -> Result<PlaySessionYearSummaryDto, String> {
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
let year_str = year.to_string();
let unique = unique_track_expr();
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT \
COALESCE(SUM(ps.listened_sec), 0.0), \
COUNT(*), \
COUNT(DISTINCT {unique}), \
COUNT(DISTINCT date(ps.started_at_ms / 1000, 'unixepoch', 'localtime')), \
COALESCE(SUM(CASE WHEN ps.completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN ps.completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session ps \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = ps.server_id AND k.track_id = ps.track_id \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?",
);
params.push(SqlValue::Text(year_str.clone()));
let totals = conn.query_row(
&sql,
rusqlite::params_from_iter(params.iter()),
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
row.get::<_, i64>(4)? as u32,
row.get::<_, i64>(5)? as u32,
))
},
)?;
let plays_sql = format!(
"SELECT ps.started_at_ms, ps.listened_sec \
FROM play_session ps \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ? \
ORDER BY ps.started_at_ms ASC",
);
let mut play_params = params[..params.len() - 1].to_vec();
play_params.push(SqlValue::Text(year_str));
let mut stmt = conn.prepare(&plays_sql)?;
let plays = stmt
.query_map(rusqlite::params_from_iter(play_params.iter()), |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let (
total_listened_sec,
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
) = totals;
Ok(PlaySessionYearSummaryDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
})
})
.map_err(|e| e.to_string())
}
pub fn cluster_heatmap(
store: &LibraryStore,
servers_ordered: &[String],
year: i32,
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
params.push(SqlValue::Text(year.to_string()));
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT \
date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') AS d, \
COUNT(*) AS n \
FROM play_session ps \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ? \
GROUP BY d \
ORDER BY d ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok(PlaySessionHeatmapDayDto {
date: row.get(0)?,
track_play_count: row.get::<_, i64>(1)? as u32,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())
}
pub fn cluster_day_detail(
store: &LibraryStore,
servers_ordered: &[String],
date_iso: &str,
) -> Result<PlaySessionDayDetailDto, String> {
validate_date_iso(date_iso)?;
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
params.push(SqlValue::Text(date_iso.to_string()));
store
.with_read_conn(|conn| {
let totals_sql = format!(
"SELECT \
COALESCE(SUM(ps.listened_sec), 0.0), \
COUNT(*), \
COALESCE(SUM(CASE WHEN ps.completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN ps.completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session ps \
WHERE {server_sql} \
AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?",
);
let (total_listened_sec, track_play_count, full_count, partial_count) = conn.query_row(
&totals_sql,
rusqlite::params_from_iter(params.iter()),
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
))
},
)?;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let rows_sql = format!(
"WITH sessions AS (
SELECT
ps.started_at_ms,
ps.listened_sec,
ps.completion,
COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key
FROM play_session ps
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = ps.server_id AND k.track_id = ps.track_id
WHERE {server_sql}
AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?
),
candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT merge_key, tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}, s.listened_sec, s.completion, s.started_at_ms
FROM sessions s
JOIN winners w ON w.merge_key = s.merge_key AND w.rn = 1
JOIN track t ON t.rowid = w.tid
ORDER BY s.started_at_ms DESC",
tol = DURATION_TOLERANCE_SEC,
);
let mut rows_params = params.clone();
rows_params.append(&mut priority_params);
rows_params.append(&mut in_params);
let track_col_count = repos::track_columns().split(',').count();
let mut stmt = conn.prepare(&rows_sql)?;
let tracks = stmt
.query_map(rusqlite::params_from_iter(rows_params.iter()), |row| {
let track = repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?;
Ok(PlaySessionDayTrackDto {
server_id: track.server_id,
track_id: track.id,
title: track.title,
artist: track.artist,
listened_sec: row.get(track_col_count)?,
completion: row.get(track_col_count + 1)?,
started_at_ms: row.get(track_col_count + 2)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let plays: Vec<(i64, f64)> = tracks
.iter()
.map(|t| (t.started_at_ms, t.listened_sec))
.collect();
Ok(PlaySessionDayDetailDto {
totals: PlaySessionDayTotalsDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
full_count,
partial_count,
},
tracks,
})
})
.map_err(|e| e.to_string())
}
pub fn cluster_recent_days(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
let limit = limit.clamp(1, RECENT_DAYS_LIMIT_MAX);
let (server_sql, params) = server_filter_sql(servers_ordered)?;
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT
date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') AS d,
ps.started_at_ms,
ps.listened_sec,
ps.completion
FROM play_session ps
WHERE {server_sql}
ORDER BY d DESC, ps.started_at_ms ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, f64>(2)?,
row.get::<_, String>(3)?,
))
})?;
let mut by_day: HashMap<String, DayAgg> = HashMap::new();
for row in rows {
let (date, started_at_ms, listened_sec, completion) = row?;
let agg = by_day.entry(date).or_default();
agg.total_listened_sec += listened_sec;
agg.track_play_count += 1;
if completion == "full" {
agg.full_count += 1;
} else {
agg.partial_count += 1;
}
agg.plays.push((started_at_ms, listened_sec));
}
let mut out: Vec<PlaySessionRecentDayDto> = by_day
.into_iter()
.map(|(date, agg)| PlaySessionRecentDayDto {
date,
total_listened_sec: agg.total_listened_sec,
session_count: count_listening_sessions(&agg.plays),
track_play_count: agg.track_play_count,
full_count: agg.full_count,
partial_count: agg.partial_count,
})
.collect();
out.sort_by(|a, b| b.date.cmp(&a.date));
out.truncate(limit as usize);
Ok(out)
})
.map_err(|e| e.to_string())
}
pub fn cluster_most_played(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<PlaySessionMostPlayedDto>, String> {
let limit = limit.clamp(1, MOST_PLAYED_LIMIT_MAX);
let (server_sql, mut stats_params) = server_filter_sql(servers_ordered)?;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let col_count = repos::track_columns().split(',').count();
let sql = format!(
"WITH session_counts AS (
SELECT
COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key,
COUNT(*) AS track_play_count,
COALESCE(SUM(ps.listened_sec), 0.0) AS total_listened_sec
FROM play_session ps
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = ps.server_id AND k.track_id = ps.track_id
WHERE {server_sql}
GROUP BY merge_key
),
candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT merge_key, tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}, sc.track_play_count, sc.total_listened_sec
FROM session_counts sc
JOIN winners w ON w.merge_key = sc.merge_key AND w.rn = 1
JOIN track t ON t.rowid = w.tid
ORDER BY sc.track_play_count DESC, sc.total_listened_sec DESC, t.title COLLATE NOCASE ASC
LIMIT ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut stats_params);
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let track =
repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?;
Ok(PlaySessionMostPlayedDto {
track,
track_play_count: row.get::<_, i64>(col_count)? as u32,
total_listened_sec: row.get(col_count + 1)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: album.into(),
album_id: Some(format!("alb-{server}")),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn day_detail_merges_track_identity_to_cluster_winner() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP"),
track("s2", "t2", "Song", "Band", "LP"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("test", |conn| {
conn.execute(
"INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason)
VALUES (?1, ?2, ?3, 120.0, 120.0, 'full', 'ended')",
rusqlite::params!["s2", "t2", 1_000i64],
)?;
Ok(())
})
.unwrap();
let detail = cluster_day_detail(&store, &["s1".into(), "s2".into()], "1970-01-01").unwrap();
assert_eq!(detail.tracks.len(), 1);
assert_eq!(detail.tracks[0].server_id, "s1");
assert_eq!(detail.tracks[0].track_id, "t1");
assert_eq!(detail.totals.track_play_count, 1);
}
#[test]
fn most_played_aggregates_cluster_members() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP"),
track("s2", "t2", "Song", "Band", "LP"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("test", |conn| {
conn.execute(
"INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason)
VALUES
('s1', 't1', 1700000000000, 60.0, 60.0, 'partial', 'ended'),
('s2', 't2', 1700000100000, 90.0, 90.0, 'full', 'ended')",
[],
)?;
Ok(())
})
.unwrap();
let rows = cluster_most_played(&store, &["s1".into(), "s2".into()], 10).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].track.server_id, "s1");
assert_eq!(rows[0].track_play_count, 2);
}
}
@@ -1,44 +0,0 @@
//! Priority rank SQL from an ordered server list (index 0 = highest).
use rusqlite::types::Value as SqlValue;
/// Build `CASE server_col WHEN ? THEN 0 … ELSE 9999 END` plus bind values.
pub fn priority_case_sql(server_col: &str, servers_ordered: &[String]) -> (String, Vec<SqlValue>) {
if servers_ordered.is_empty() {
return ("9999".to_string(), Vec::new());
}
let mut sql = format!("CASE {server_col}");
let mut params = Vec::with_capacity(servers_ordered.len());
for (rank, sid) in servers_ordered.iter().enumerate() {
sql.push_str(&format!(" WHEN ? THEN {rank}"));
params.push(SqlValue::Text(sid.clone()));
}
sql.push_str(" ELSE 9999 END");
(sql, params)
}
/// `server_id IN (?,?,…)` placeholders and bind values.
pub fn in_list_sql(servers: &[String]) -> (String, Vec<SqlValue>) {
if servers.is_empty() {
return ("0".to_string(), Vec::new());
}
let placeholders = vec!["?"; servers.len()].join(", ");
let params = servers
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect();
(placeholders, params)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn priority_case_orders_servers() {
let (sql, params) = priority_case_sql("t.server_id", &["a".into(), "b".into()]);
assert!(sql.contains("WHEN ? THEN 0"));
assert!(sql.contains("WHEN ? THEN 1"));
assert_eq!(params.len(), 2);
}
}
@@ -1,362 +0,0 @@
//! Batch rebuild of `track_cluster_key` — source of truth for cluster identity.
use rusqlite::{params, Transaction};
use crate::store::LibraryStore;
use super::db::{self, ATTACH_ALIAS, NORM_VERSION};
use super::keys::compute_track_cluster_keys;
const REBUILD_BATCH_SIZE: usize = 5_000;
struct TrackRow {
server_id: String,
track_id: String,
artist: Option<String>,
album_artist: Option<String>,
title: String,
album: String,
duration_sec: i64,
}
fn fetch_live_tracks(
tx: &Transaction<'_>,
server_id: Option<&str>,
) -> rusqlite::Result<Vec<TrackRow>> {
let (sql, bind): (&str, Option<&str>) = match server_id {
Some(_) => (
"SELECT server_id, id, artist, album_artist, title, album, duration_sec \
FROM track WHERE deleted = 0 AND server_id = ?1",
server_id,
),
None => (
"SELECT server_id, id, artist, album_artist, title, album, duration_sec \
FROM track WHERE deleted = 0",
None,
),
};
let mut stmt = tx.prepare(sql)?;
let rows = match bind {
Some(sid) => stmt.query_map(params![sid], map_track_row)?.collect(),
None => stmt.query_map([], map_track_row)?.collect(),
};
rows
}
fn map_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TrackRow> {
Ok(TrackRow {
server_id: row.get(0)?,
track_id: row.get(1)?,
artist: row.get(2)?,
album_artist: row.get(3)?,
title: row.get(4)?,
album: row.get(5)?,
duration_sec: row.get(6)?,
})
}
fn upsert_batch(tx: &Transaction<'_>, batch: &[(TrackRow, super::keys::TrackClusterKeys)]) -> rusqlite::Result<()> {
let mut stmt = tx.prepare(&format!(
"INSERT INTO {ATTACH_ALIAS}.track_cluster_key \
(server_id, track_id, cluster_key, album_key, artist_key, duration_sec) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
ON CONFLICT(server_id, track_id) DO UPDATE SET \
cluster_key = excluded.cluster_key, \
album_key = excluded.album_key, \
artist_key = excluded.artist_key, \
duration_sec = excluded.duration_sec"
))?;
for (row, keys) in batch {
stmt.execute(params![
row.server_id,
row.track_id,
keys.cluster_key,
keys.album_key,
keys.artist_key,
row.duration_sec,
])?;
}
Ok(())
}
fn rebuild_in_tx(
tx: &Transaction<'_>,
server_id: Option<&str>,
clear_scope: bool,
) -> rusqlite::Result<u32> {
if clear_scope {
match server_id {
Some(sid) => {
tx.execute(
&format!("DELETE FROM {ATTACH_ALIAS}.track_cluster_key WHERE server_id = ?1"),
params![sid],
)?;
}
None => {
tx.execute(
&format!("DELETE FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
)?;
}
}
}
let tracks = fetch_live_tracks(tx, server_id)?;
let mut written = 0u32;
let mut batch: Vec<(TrackRow, super::keys::TrackClusterKeys)> = Vec::new();
for row in tracks {
let Some(keys) = compute_track_cluster_keys(
row.artist.as_deref(),
row.album_artist.as_deref(),
&row.title,
&row.album,
) else {
continue;
};
batch.push((row, keys));
if batch.len() >= REBUILD_BATCH_SIZE {
upsert_batch(tx, &batch)?;
written = written.saturating_add(batch.len() as u32);
batch.clear();
}
}
if !batch.is_empty() {
upsert_batch(tx, &batch)?;
written = written.saturating_add(batch.len() as u32);
}
tx.execute(
&format!(
"INSERT INTO {ATTACH_ALIAS}.cluster_meta (key, value) VALUES ('norm_version', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
),
params![NORM_VERSION],
)?;
Ok(written)
}
/// Rebuild cluster keys for every live track in the library index.
pub fn rebuild_all_cluster_keys(store: &LibraryStore) -> Result<u32, String> {
store.with_conn_mut("cluster_rebuild_all", |conn| {
let tx = conn.transaction()?;
let count = rebuild_in_tx(&tx, None, true)?;
tx.commit()?;
Ok(count)
})
}
/// Rebuild cluster keys for one server's live tracks (deletes stale rows first).
pub fn rebuild_cluster_keys_for_server(
store: &LibraryStore,
server_id: &str,
) -> Result<u32, String> {
store.with_conn_mut("cluster_rebuild_server", |conn| {
let tx = conn.transaction()?;
let count = rebuild_in_tx(&tx, Some(server_id), true)?;
tx.commit()?;
Ok(count)
})
}
/// Rebuild when `cluster_meta.norm_version` lags the compiled rules.
pub fn rebuild_if_norm_version_stale(store: &LibraryStore) -> Result<Option<u32>, String> {
let stale = store.with_conn("cluster_norm_check", db::needs_norm_rebuild)?;
if !stale {
return Ok(None);
}
rebuild_all_cluster_keys(store).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::LibraryStore;
use rusqlite::params;
fn seed_track(
store: &LibraryStore,
server: &str,
id: &str,
artist: &str,
title: &str,
album: &str,
duration: i64,
) {
store
.with_conn_mut("misc", |conn| {
conn.execute(
"INSERT INTO track (server_id, id, title, artist, album, duration_sec, synced_at, raw_json) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, '{}')",
params![server, id, title, artist, album, duration],
)
})
.unwrap();
}
#[test]
fn rebuild_is_idempotent() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Artist", "Song", "Album", 200);
seed_track(&store, "s2", "t2", "Artist", "Song", "Album", 205);
let first = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(first, 2);
let count_after: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count_after, 2);
let second = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(second, 2);
let count_again: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count_again, 2);
}
#[test]
fn tracks_with_empty_fields_get_no_row() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "", "Song", "Album", 100);
seed_track(&store, "s1", "t2", "Artist", "Song", "Album", 100);
let written = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(written, 1);
let count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn same_cluster_key_across_servers_after_rebuild() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Band", "Hit", "LP", 180);
seed_track(&store, "s2", "t9", "Band", "Hit", "LP", 182);
rebuild_all_cluster_keys(&store).unwrap();
let keys: Vec<String> = store
.with_conn("misc", |c| {
let mut stmt = c.prepare(&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key ORDER BY server_id"
))?;
let rows: rusqlite::Result<Vec<String>> =
stmt.query_map([], |r| r.get(0))?.collect();
rows
})
.unwrap();
assert_eq!(keys.len(), 2);
assert_eq!(keys[0], keys[1]);
}
#[test]
fn per_server_rebuild_replaces_stale_rows() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "A", "One", "X", 1);
seed_track(&store, "s2", "t1", "B", "Two", "Y", 2);
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("misc", |conn| {
conn.execute(
"UPDATE track SET title = 'Updated' WHERE server_id = 's1' AND id = 't1'",
[],
)
})
.unwrap();
rebuild_cluster_keys_for_server(&store, "s1").unwrap();
let title_norm_row: Option<String> = store
.with_conn("misc", |c| {
use rusqlite::OptionalExtension;
c.query_row(
&format!(
"SELECT k.cluster_key FROM {ATTACH_ALIAS}.track_cluster_key k \
JOIN track t ON t.server_id = k.server_id AND t.id = k.track_id \
WHERE k.server_id = 's1' AND k.track_id = 't1'"
),
[],
|r| r.get(0),
)
.optional()
})
.unwrap();
assert!(title_norm_row.is_some());
let s2_count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key WHERE server_id = 's2'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(s2_count, 1);
}
#[test]
fn norm_version_bump_triggers_full_rebuild() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Artist", "Old", "Album", 1);
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("misc", |conn| {
conn.execute(
&format!(
"UPDATE {ATTACH_ALIAS}.cluster_meta SET value = '0' WHERE key = 'norm_version'"
),
[],
)
})
.unwrap();
assert!(
store
.with_conn("misc", db::needs_norm_rebuild)
.unwrap()
);
let rebuilt = rebuild_if_norm_version_stale(&store).unwrap();
assert_eq!(rebuilt, Some(1));
let version: String = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT value FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(version, NORM_VERSION);
}
}
@@ -1,198 +0,0 @@
//! Resolve cluster candidates for playback / writes (spec §56).
use rusqlite::types::Value as SqlValue;
use rusqlite::OptionalExtension;
use crate::dto::LibraryClusterCandidateDto;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::duration_partitions;
use super::priority::priority_case_sql;
/// All `(server_id, track_id)` rows sharing a `cluster_key`, ordered by priority.
pub fn resolve_candidates_by_cluster_key(
store: &LibraryStore,
servers_ordered: &[String],
cluster_key: &str,
) -> Result<Vec<LibraryClusterCandidateDto>, String> {
if servers_ordered.is_empty() || cluster_key.is_empty() {
return Ok(Vec::new());
}
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let in_placeholders = vec!["?"; servers_ordered.len()].join(", ");
let mut in_params: Vec<SqlValue> = servers_ordered
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect();
let sql = format!(
"SELECT t.server_id, t.id, COALESCE(k.duration_sec, t.duration_sec), ({priority_sql})
FROM {ATTACH_ALIAS}.track_cluster_key k
JOIN track t ON t.server_id = k.server_id AND t.id = k.track_id
WHERE k.cluster_key = ? AND t.deleted = 0 AND t.server_id IN ({in_placeholders})
ORDER BY 4, t.server_id, t.id"
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.push(SqlValue::Text(cluster_key.to_string()));
params.append(&mut in_params);
let rows: Vec<(String, String, i64, u32)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get::<_, i64>(3)? as u32))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
let with_rank = rows;
let partitions = duration_partitions(cluster_key, &with_rank);
let winner = partitions.first();
Ok(with_rank
.into_iter()
.map(|(server_id, track_id, duration_sec, priority_rank)| {
let is_winner = winner
.map(|(_, ws, wt)| ws == &server_id && wt == &track_id)
.unwrap_or(false);
LibraryClusterCandidateDto {
server_id,
track_id,
duration_sec,
priority_rank,
is_winner,
}
})
.collect())
}
/// Resolve `cluster_key` from a seed track, then return candidates.
pub fn resolve_candidates_for_track(
store: &LibraryStore,
servers_ordered: &[String],
server_id: &str,
track_id: &str,
) -> Result<Vec<LibraryClusterCandidateDto>, String> {
let cluster_key: Option<String> = store.with_read_conn(|conn| {
conn.query_row(
&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key \
WHERE server_id = ?1 AND track_id = ?2"
),
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
.optional()
})?;
let Some(cluster_key) = cluster_key else {
return Ok(vec![LibraryClusterCandidateDto {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
duration_sec: track_duration(store, server_id, track_id)?,
priority_rank: servers_ordered
.iter()
.position(|s| s == server_id)
.unwrap_or(9999) as u32,
is_winner: true,
}]);
};
resolve_candidates_by_cluster_key(store, servers_ordered, &cluster_key)
}
fn track_duration(store: &LibraryStore, server_id: &str, track_id: &str) -> Result<i64, String> {
store
.with_read_conn(|conn| {
conn.query_row(
"SELECT duration_sec FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0",
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
})
.map_err(|e| e.to_string())
}
/// Lookup cluster key for a track (for search/seed mapping).
pub fn cluster_key_for_track(
store: &LibraryStore,
server_id: &str,
track_id: &str,
) -> Result<Option<String>, String> {
store
.with_read_conn(|conn| {
conn.query_row(
&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key \
WHERE server_id = ?1 AND track_id = ?2"
),
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
.optional()
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn tr(server: &str, id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: None,
album: "LP".into(),
album_id: None,
album_artist: Some("Band".into()),
duration_sec: 180,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn resolve_orders_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[tr("s1", "t1"), tr("s2", "t2")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let key = cluster_key_for_track(&store, "s1", "t1").unwrap().unwrap();
let cands = resolve_candidates_by_cluster_key(&store, &["s1".into(), "s2".into()], &key).unwrap();
assert_eq!(cands.len(), 2);
assert!(cands[0].is_winner);
assert_eq!(cands[0].server_id, "s1");
}
}
@@ -1,243 +0,0 @@
//! Cluster-mode cross-server search — dedup by `cluster_key` + priority (spec §5).
use std::collections::HashSet;
use rusqlite::types::Value as SqlValue;
use crate::dto::{LibraryCrossServerSearchResponse, LibraryTrackDto};
use crate::repos;
use crate::search::{aliased_track_columns, fts_query, like_contains, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::solo_partition_key;
use super::priority::{in_list_sql, priority_case_sql};
const FUZZY_PER_SERVER_CAP: usize = 20;
/// FTS union over ordered servers; dedup by `cluster_key` (not canonical id).
pub fn run_cluster_search(
store: &LibraryStore,
query: &str,
limit: u32,
offset: u32,
servers_ordered: &[String],
) -> Result<LibraryCrossServerSearchResponse, String> {
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset as usize;
if servers_ordered.is_empty() {
return Ok(LibraryCrossServerSearchResponse::default());
}
let Some(fts) = fts_query(query) else {
return Ok(LibraryCrossServerSearchResponse::default());
};
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let canonical_idx = repos::track_columns().split(',').count();
let sql = format!(
"SELECT {cols}, k.cluster_key, ({priority_sql}) AS priority_rank \
FROM track_fts f \
JOIN track t ON t.rowid = f.rowid \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = t.server_id AND k.track_id = t.id \
WHERE track_fts MATCH ? AND t.deleted = 0 AND t.server_id IN ({in_placeholders}) \
ORDER BY bm25(track_fts) LIMIT ?"
);
let mut params: Vec<SqlValue> = Vec::new();
params.push(SqlValue::Text(fts));
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer((limit as i64).saturating_mul(4)));
let rows: Vec<(LibraryTrackDto, Option<String>, u32)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
let cluster_key: Option<String> = r.get(canonical_idx)?;
let rank: i64 = r.get(canonical_idx + 1)?;
Ok((track, cluster_key, rank as u32))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
let mut best_by_key: std::collections::HashMap<String, (LibraryTrackDto, u32)> =
std::collections::HashMap::new();
for (track, cluster_key, priority_rank) in rows {
let dedup_key = cluster_key
.clone()
.unwrap_or_else(|| solo_partition_key(&track.server_id, &track.id));
match best_by_key.get(&dedup_key) {
Some((_, best_rank)) if *best_rank <= priority_rank => {}
_ => {
best_by_key.insert(dedup_key, (track, priority_rank));
}
}
}
let hits: Vec<LibraryTrackDto> = best_by_key
.into_values()
.map(|(t, _)| t)
.skip(offset)
.take(limit as usize)
.collect();
let hit_keys: HashSet<(String, String)> = hits
.iter()
.map(|t| (t.server_id.clone(), t.id.clone()))
.collect();
let fuzzy = fuzzy_cluster_matches(
store,
servers_ordered,
query.trim(),
&hit_keys,
limit as usize,
)?;
Ok(LibraryCrossServerSearchResponse {
hits,
fuzzy,
servers_searched: servers_ordered.to_vec(),
})
}
/// Random merged track sample across cluster scope.
pub fn run_cluster_random_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<LibraryTrackDto>, String> {
if servers_ordered.is_empty() {
return Ok(Vec::new());
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY RANDOM()
LIMIT ?",
tol = super::merge::DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
fn fuzzy_cluster_matches(
store: &LibraryStore,
targets: &[String],
query: &str,
hit_keys: &HashSet<(String, String)>,
overall_cap: usize,
) -> Result<Vec<LibraryTrackDto>, String> {
let like = like_contains(query);
let cols = aliased_track_columns("t");
let key_idx = repos::track_columns().split(',').count();
let sql = format!(
"SELECT {cols}, k.cluster_key \
FROM track t \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = t.server_id AND k.track_id = t.id \
WHERE t.server_id = ? AND t.deleted = 0 AND t.title LIKE ? ESCAPE '\\' \
ORDER BY t.title COLLATE NOCASE ASC LIMIT ?"
);
let mut out: Vec<LibraryTrackDto> = Vec::new();
let mut seen_keys: HashSet<String> = HashSet::new();
for server in targets {
if out.len() >= overall_cap {
break;
}
let bound = [
SqlValue::Text(server.clone()),
SqlValue::Text(like.clone()),
SqlValue::Integer(FUZZY_PER_SERVER_CAP as i64),
];
let rows: Vec<(LibraryTrackDto, Option<String>)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(bound.iter()), |r| {
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
let cluster_key: Option<String> = r.get(key_idx)?;
Ok((track, cluster_key))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
for (track, cluster_key) in rows {
if out.len() >= overall_cap {
break;
}
if hit_keys.contains(&(track.server_id.clone(), track.id.clone())) {
continue;
}
let dedup_key = cluster_key
.clone()
.unwrap_or_else(|| solo_partition_key(&track.server_id, &track.id));
if !seen_keys.insert(dedup_key) {
continue;
}
out.push(track);
}
}
Ok(out)
}
+5 -66
View File
@@ -7,11 +7,9 @@ use std::time::Duration;
use rusqlite::{params, Connection, OpenFlags};
use tauri::Manager;
use crate::server_cluster::{attach_cluster_database, attach_cluster_database_uri, cluster_db_path};
/// Current head of the embedded migrations. Bump each time a new
/// `migrations/NNN_*.sql` is added.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 2;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
@@ -24,15 +22,10 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 2;
pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
const MIGRATION_002_ALBUM_BROWSE_INDEX: &str =
include_str!("../migrations/002_album_browse_index.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
const MIGRATIONS: &[(i64, &str)] = &[
(1, INITIAL_SQL),
(2, MIGRATION_002_ALBUM_BROWSE_INDEX),
];
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome {
@@ -47,17 +40,9 @@ pub(crate) enum MigrationOutcome {
/// In-memory tests share one DB across the read/write pair in a single store.
static IN_MEMORY_DB_COUNTER: AtomicU64 = AtomicU64::new(0);
fn in_memory_uri(prefix: &str) -> String {
fn in_memory_uri() -> String {
let n = IN_MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("file:psysonic_{prefix}_mem_{n}?mode=memory&cache=shared")
}
fn in_memory_library_uri() -> String {
in_memory_uri("library")
}
fn in_memory_cluster_uri() -> String {
in_memory_uri("cluster")
format!("file:psysonic_library_mem_{n}?mode=memory&cache=shared")
}
pub struct LibraryStore {
@@ -82,12 +67,10 @@ impl LibraryStore {
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_write_connection(&write_conn).map_err(|e| e.to_string())?;
run_migrations(&write_conn).map_err(|e| e.to_string())?;
attach_cluster_file(&write_conn, db_path).map_err(|e| e.to_string())?;
checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?;
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&read_conn).map_err(|e| e.to_string())?;
attach_cluster_file(&read_conn, db_path).map_err(|e| e.to_string())?;
Ok(Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
@@ -97,15 +80,12 @@ impl LibraryStore {
/// Build an in-memory DB with the production schema applied.
pub fn open_in_memory() -> Self {
let uri = in_memory_library_uri();
let cluster_uri = in_memory_cluster_uri();
let uri = in_memory_uri();
let write_conn = Connection::open(&uri).expect("in-memory write connection");
configure_write_connection(&write_conn).expect("write pragmas");
run_migrations(&write_conn).expect("schema migration");
attach_cluster_database_uri(&write_conn, &cluster_uri).expect("cluster attach write");
let read_conn = Connection::open(&uri).expect("in-memory read connection");
configure_read_connection(&read_conn).expect("read pragmas");
attach_cluster_database_uri(&read_conn, &cluster_uri).expect("cluster attach read");
Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
@@ -237,11 +217,9 @@ impl LibraryStore {
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_write, active_path).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_read, active_path).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(Some(backup))
@@ -275,11 +253,9 @@ impl LibraryStore {
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_write, active_path).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_read, active_path).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(())
@@ -309,10 +285,6 @@ fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) {
}
}
fn attach_cluster_file(conn: &Connection, library_db_path: &Path) -> rusqlite::Result<()> {
attach_cluster_database(conn, &cluster_db_path(library_db_path))
}
fn library_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
let db_dir = base.join("databases").join("library");
@@ -506,39 +478,6 @@ fn handle_breaking_schema_bump(
mod tests {
use super::*;
#[test]
fn cluster_db_attached_on_open_in_memory() {
use crate::server_cluster::ATTACH_ALIAS;
let store = LibraryStore::open_in_memory();
let count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.sqlite_master \
WHERE type='table' AND name='track_cluster_key'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count, 1);
let read_count: i64 = store
.with_read_conn(|c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(read_count, 1);
}
#[test]
fn read_conn_sees_committed_writes_from_write_conn() {
let store = LibraryStore::open_in_memory();
+1 -1
View File
@@ -22,7 +22,7 @@ sysinfo = { version = "0.39", default-features = false, features = ["disk"] }
url = "2"
md5 = "0.8"
lofty = "0.24"
id3 = "1.17"
id3 = "1.16.4"
[dev-dependencies]
tempfile = { workspace = true }
@@ -0,0 +1 @@
{"v":1}
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "6d533f26150953a882a6a111ebd13f0abf7129d5"
},
"path_in_vcs": "symphonia-format-isomp4"
}
+94
View File
@@ -0,0 +1,94 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bytemuck"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677"
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
@@ -0,0 +1,59 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
rust-version = "1.53"
name = "symphonia-format-isomp4"
version = "0.5.5"
authors = ["Philip Deljanov <philip.deljanov@gmail.com>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Pure Rust ISO/MP4 demuxer (a part of project Symphonia)."
homepage = "https://github.com/pdeljanov/Symphonia"
readme = "README.md"
keywords = [
"audio",
"media",
"demuxer",
"mp4",
"iso",
]
categories = [
"multimedia",
"multimedia::audio",
"multimedia::encoding",
]
license = "MPL-2.0"
repository = "https://github.com/pdeljanov/Symphonia"
[lib]
name = "symphonia_format_isomp4"
path = "src/lib.rs"
[dependencies.encoding_rs]
version = "0.8.17"
[dependencies.log]
version = "0.4"
[dependencies.symphonia-core]
version = "0.5.5"
[dependencies.symphonia-metadata]
version = "0.5.5"
[dependencies.symphonia-utils-xiph]
version = "0.5.5"
@@ -0,0 +1,20 @@
[package]
name = "symphonia-format-isomp4"
version = "0.5.5"
description = "Pure Rust ISO/MP4 demuxer (a part of project Symphonia)."
homepage = "https://github.com/pdeljanov/Symphonia"
repository = "https://github.com/pdeljanov/Symphonia"
authors = ["Philip Deljanov <philip.deljanov@gmail.com>"]
license = "MPL-2.0"
readme = "README.md"
categories = ["multimedia", "multimedia::audio", "multimedia::encoding"]
keywords = ["audio", "media", "demuxer", "mp4", "iso"]
edition = "2018"
rust-version = "1.53"
[dependencies]
encoding_rs = "0.8.17"
log = "0.4"
symphonia-core = { version = "0.5.5", path = "../symphonia-core" }
symphonia-metadata = { version = "0.5.5", path = "../symphonia-metadata" }
symphonia-utils-xiph = { version = "0.5.5", path = "../symphonia-utils-xiph" }
@@ -0,0 +1,15 @@
# Symphonia ISO/MP4 Demuxer
[![Docs](https://docs.rs/symphonia-format-isomp4/badge.svg)](https://docs.rs/symphonia-format-isomp4)
ISO/MP4 demuxer for Project Symphonia.
**Note:** This crate is part of Project Symphonia. Please use the [`symphonia`](https://crates.io/crates/symphonia) crate instead of this one directly.
## License
Symphonia is provided under the MPL v2.0 license. Please refer to the LICENSE file for more details.
## Contributing
Symphonia is a free and open-source project that welcomes contributions! To get started, please read our [Contribution Guidelines](https://github.com/pdeljanov/Symphonia/tree/master/CONTRIBUTING.md).
@@ -0,0 +1,60 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::codecs::{CodecParameters, CODEC_TYPE_ALAC};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct AlacAtom {
/// Atom header.
header: AtomHeader,
/// ALAC extra data (magic cookie).
extra_data: Box<[u8]>,
}
impl Atom for AlacAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, flags) = AtomHeader::read_extra(reader)?;
if version != 0 {
return unsupported_error("isomp4 (alac): unsupported alac version");
}
if flags != 0 {
return decode_error("isomp4 (alac): flags not zero");
}
if header.data_len <= AtomHeader::EXTRA_DATA_SIZE {
return decode_error("isomp4 (alac): invalid alac atom length");
}
// The ALAC magic cookie (aka extra data) is either 24 or 48 bytes long.
let magic_len = match header.data_len - AtomHeader::EXTRA_DATA_SIZE {
len @ 24 | len @ 48 => len as usize,
_ => return decode_error("isomp4 (alac): invalid magic cookie length"),
};
// Read the magic cookie.
let extra_data = reader.read_boxed_slice_exact(magic_len)?;
Ok(AlacAtom { header, extra_data })
}
}
impl AlacAtom {
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
codec_params.for_codec(CODEC_TYPE_ALAC).with_extra_data(self.extra_data.clone());
}
}
@@ -0,0 +1,41 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Chunk offset atom (64-bit version).
#[allow(dead_code)]
#[derive(Debug)]
pub struct Co64Atom {
/// Atom header.
header: AtomHeader,
pub chunk_offsets: Vec<u64>,
}
impl Atom for Co64Atom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let entry_count = reader.read_be_u32()?;
// TODO: Apply a limit.
let mut chunk_offsets = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
chunk_offsets.push(reader.read_be_u64()?);
}
Ok(Co64Atom { header, chunk_offsets })
}
}
@@ -0,0 +1,29 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Composition time atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct CttsAtom {
/// Atom header.
header: AtomHeader,
}
impl Atom for CttsAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(_reader: &mut B, _header: AtomHeader) -> Result<Self> {
todo!()
}
}
@@ -0,0 +1,43 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, ElstAtom};
/// Edits atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct EdtsAtom {
header: AtomHeader,
pub elst: Option<ElstAtom>,
}
impl Atom for EdtsAtom {
fn header(&self) -> AtomHeader {
self.header
}
#[allow(clippy::single_match)]
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut elst = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::EditList => {
elst = Some(iter.read_atom::<ElstAtom>()?);
}
_ => (),
}
}
Ok(EdtsAtom { header, elst })
}
}
@@ -0,0 +1,71 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use symphonia_core::util::bits;
use crate::atoms::{Atom, AtomHeader};
/// Edit list entry.
#[derive(Debug)]
#[allow(dead_code)]
pub struct ElstEntry {
segment_duration: u64,
media_time: i64,
media_rate_int: i16,
media_rate_frac: i16,
}
/// Edit list atom.
#[derive(Debug)]
#[allow(dead_code)]
pub struct ElstAtom {
header: AtomHeader,
entries: Vec<ElstEntry>,
}
impl Atom for ElstAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, _) = AtomHeader::read_extra(reader)?;
// TODO: Apply a limit.
let entry_count = reader.read_be_u32()?;
let mut entries = Vec::new();
for _ in 0..entry_count {
let (segment_duration, media_time) = match version {
0 => (
u64::from(reader.read_be_u32()?),
i64::from(bits::sign_extend_leq32_to_i32(reader.read_be_u32()?, 32)),
),
1 => (
reader.read_be_u64()?,
bits::sign_extend_leq64_to_i64(reader.read_be_u64()?, 64),
),
_ => return decode_error("isomp4: invalid tkhd version"),
};
let media_rate_int = bits::sign_extend_leq16_to_i16(reader.read_be_u16()?, 16);
let media_rate_frac = bits::sign_extend_leq16_to_i16(reader.read_be_u16()?, 16);
entries.push(ElstEntry {
segment_duration,
media_time,
media_rate_int,
media_rate_frac,
});
}
Ok(ElstAtom { header, entries })
}
}
@@ -0,0 +1,368 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::codecs::{
CodecParameters, CodecType, CODEC_TYPE_AAC, CODEC_TYPE_MP3, CODEC_TYPE_NULL,
};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::{FiniteStream, ReadBytes, ScopedStream};
use crate::atoms::{Atom, AtomHeader};
use log::{debug, warn};
const ES_DESCRIPTOR: u8 = 0x03;
const DECODER_CONFIG_DESCRIPTOR: u8 = 0x04;
const DECODER_SPECIFIC_DESCRIPTOR: u8 = 0x05;
const SL_CONFIG_DESCRIPTOR: u8 = 0x06;
const MIN_DESCRIPTOR_SIZE: u64 = 2;
fn read_descriptor_header<B: ReadBytes>(reader: &mut B) -> Result<(u8, u32)> {
let tag = reader.read_u8()?;
let mut size = 0;
for _ in 0..4 {
let val = reader.read_u8()?;
size = (size << 7) | u32::from(val & 0x7f);
if val & 0x80 == 0 {
break;
}
}
Ok((tag, size))
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct EsdsAtom {
/// Atom header.
header: AtomHeader,
/// Elementary stream descriptor.
descriptor: ESDescriptor,
}
impl Atom for EsdsAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let mut descriptor = None;
let mut scoped = ScopedStream::new(reader, header.data_len - AtomHeader::EXTRA_DATA_SIZE);
while scoped.bytes_available() > MIN_DESCRIPTOR_SIZE {
let (desc, desc_len) = read_descriptor_header(&mut scoped)?;
match desc {
ES_DESCRIPTOR => {
descriptor = Some(ESDescriptor::read(&mut scoped, desc_len)?);
}
_ => {
warn!("unknown descriptor in esds atom, desc={}", desc);
scoped.ignore_bytes(desc_len as u64)?;
}
}
}
// Ignore remainder of the atom.
scoped.ignore()?;
// Guard against malformed esds atoms that contain no ES_DESCRIPTOR (old iTunes files).
let descriptor = descriptor
.ok_or_else(|| symphonia_core::errors::Error::DecodeError("isomp4: missing es descriptor in esds atom"))?;
Ok(EsdsAtom { header, descriptor })
}
}
impl EsdsAtom {
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
codec_params.for_codec(self.descriptor.dec_config.codec_type);
if let Some(ds_config) = &self.descriptor.dec_config.dec_specific_info {
codec_params.with_extra_data(ds_config.extra_data.clone());
}
}
}
pub trait ObjectDescriptor: Sized {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self>;
}
/*
class ES_Descriptor extends BaseDescriptor : bit(8) tag=ES_DescrTag {
bit(16) ES_ID;
bit(1) streamDependenceFlag;
bit(1) URL_Flag;
bit(1) OCRstreamFlag;
bit(5) streamPriority;
if (streamDependenceFlag)
bit(16) dependsOn_ES_ID;
if (URL_Flag) {
bit(8) URLlength;
bit(8) URLstring[URLlength];
}
if (OCRstreamFlag)
bit(16) OCR_ES_Id;
DecoderConfigDescriptor decConfigDescr;
SLConfigDescriptor slConfigDescr;
IPI_DescrPointer ipiPtr[0 .. 1];
IP_IdentificationDataSet ipIDS[0 .. 255];
IPMP_DescriptorPointer ipmpDescrPtr[0 .. 255];
LanguageDescriptor langDescr[0 .. 255];
QoS_Descriptor qosDescr[0 .. 1];
RegistrationDescriptor regDescr[0 .. 1];
ExtensionDescriptor extDescr[0 .. 255];
}
*/
#[allow(dead_code)]
#[derive(Debug)]
pub struct ESDescriptor {
pub es_id: u16,
pub dec_config: DecoderConfigDescriptor,
pub sl_config: SLDescriptor,
}
impl ObjectDescriptor for ESDescriptor {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
let es_id = reader.read_be_u16()?;
let es_flags = reader.read_u8()?;
// Stream dependence flag.
if es_flags & 0x80 != 0 {
let _depends_on_es_id = reader.read_u16()?;
}
// URL flag.
if es_flags & 0x40 != 0 {
let url_len = reader.read_u8()?;
reader.ignore_bytes(u64::from(url_len))?;
}
// OCR stream flag.
if es_flags & 0x20 != 0 {
let _ocr_es_id = reader.read_u16()?;
}
let mut dec_config = None;
let mut sl_config = None;
let mut scoped = ScopedStream::new(reader, u64::from(len) - 3);
// Multiple descriptors follow, but only the decoder configuration descriptor is useful.
while scoped.bytes_available() > MIN_DESCRIPTOR_SIZE {
let (desc, desc_len) = read_descriptor_header(&mut scoped)?;
match desc {
DECODER_CONFIG_DESCRIPTOR => {
dec_config = Some(DecoderConfigDescriptor::read(&mut scoped, desc_len)?);
}
SL_CONFIG_DESCRIPTOR => {
sl_config = Some(SLDescriptor::read(&mut scoped, desc_len)?);
}
_ => {
debug!("skipping {} object in es descriptor", desc);
scoped.ignore_bytes(u64::from(desc_len))?;
}
}
}
// Consume remaining bytes.
scoped.ignore()?;
// Decoder configuration descriptor is mandatory.
if dec_config.is_none() {
return decode_error("isomp4: missing decoder config descriptor");
}
// SL descriptor is mandatory.
if sl_config.is_none() {
return decode_error("isomp4: missing sl config descriptor");
}
Ok(ESDescriptor { es_id, dec_config: dec_config.unwrap(), sl_config: sl_config.unwrap() })
}
}
/*
class DecoderConfigDescriptor extends BaseDescriptor : bit(8) tag=DecoderConfigDescrTag {
bit(8) objectTypeIndication;
bit(6) streamType;
bit(1) upStream;
const bit(1) reserved=1;
bit(24) bufferSizeDB;
bit(32) maxBitrate;
bit(32) avgBitrate;
DecoderSpecificInfo decSpecificInfo[0 .. 1];
profileLevelIndicationIndexDescriptor profileLevelIndicationIndexDescr [0..255];
}
*/
#[allow(dead_code)]
#[derive(Debug)]
pub struct DecoderConfigDescriptor {
pub codec_type: CodecType,
pub object_type_indication: u8,
pub dec_specific_info: Option<DecoderSpecificInfo>,
}
impl ObjectDescriptor for DecoderConfigDescriptor {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
// AAC
const OBJECT_TYPE_ISO14496_3: u8 = 0x40;
const OBJECT_TYPE_ISO13818_7_MAIN: u8 = 0x66;
const OBJECT_TYPE_ISO13818_7_LC: u8 = 0x67;
// MP3
const OBJECT_TYPE_ISO13818_3: u8 = 0x69;
const OBJECT_TYPE_ISO11172_3: u8 = 0x6b;
let object_type_indication = reader.read_u8()?;
let (_stream_type, _upstream) = {
let val = reader.read_u8()?;
if val & 0x1 != 1 {
debug!("decoder config descriptor reserved bit is not 1");
}
((val & 0xfc) >> 2, (val & 0x2) >> 1)
};
let _buffer_size = reader.read_be_u24()?;
let _max_bitrate = reader.read_be_u32()?;
let _avg_bitrate = reader.read_be_u32()?;
let mut dec_specific_config = None;
let mut scoped = ScopedStream::new(reader, u64::from(len) - 13);
// Multiple descriptors follow, but only the decoder specific info descriptor is useful.
while scoped.bytes_available() > MIN_DESCRIPTOR_SIZE {
let (desc, desc_len) = read_descriptor_header(&mut scoped)?;
match desc {
DECODER_SPECIFIC_DESCRIPTOR => {
dec_specific_config = Some(DecoderSpecificInfo::read(&mut scoped, desc_len)?);
}
_ => {
debug!("skipping {} object in decoder config descriptor", desc);
scoped.ignore_bytes(u64::from(desc_len))?;
}
}
}
let codec_type = match object_type_indication {
OBJECT_TYPE_ISO14496_3 | OBJECT_TYPE_ISO13818_7_LC | OBJECT_TYPE_ISO13818_7_MAIN => {
CODEC_TYPE_AAC
}
OBJECT_TYPE_ISO13818_3 | OBJECT_TYPE_ISO11172_3 => CODEC_TYPE_MP3,
_ => {
debug!(
"unknown object type indication {:#x} for decoder config descriptor",
object_type_indication
);
CODEC_TYPE_NULL
}
};
// Consume remaining bytes.
scoped.ignore()?;
Ok(DecoderConfigDescriptor {
codec_type,
object_type_indication,
dec_specific_info: dec_specific_config,
})
}
}
#[derive(Debug)]
pub struct DecoderSpecificInfo {
pub extra_data: Box<[u8]>,
}
impl ObjectDescriptor for DecoderSpecificInfo {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
Ok(DecoderSpecificInfo { extra_data: reader.read_boxed_slice_exact(len as usize)? })
}
}
/*
class SLConfigDescriptor extends BaseDescriptor : bit(8) tag=SLConfigDescrTag {
bit(8) predefined;
if (predefined==0) {
bit(1) useAccessUnitStartFlag;
bit(1) useAccessUnitEndFlag;
bit(1) useRandomAccessPointFlag;
bit(1) hasRandomAccessUnitsOnlyFlag;
bit(1) usePaddingFlag;
bit(1) useTimeStampsFlag;
bit(1) useIdleFlag;
bit(1) durationFlag;
bit(32) timeStampResolution;
bit(32) OCRResolution;
bit(8) timeStampLength; // must be 64
bit(8) OCRLength; // must be 64
bit(8) AU_Length; // must be 32
bit(8) instantBitrateLength;
bit(4) degradationPriorityLength;
bit(5) AU_seqNumLength; // must be 16
bit(5) packetSeqNumLength; // must be 16
bit(2) reserved=0b11;
}
if (durationFlag) {
bit(32) timeScale;
bit(16) accessUnitDuration;
bit(16) compositionUnitDuration;
}
if (!useTimeStampsFlag) {
bit(timeStampLength) startDecodingTimeStamp;
bit(timeStampLength) startCompositionTimeStamp;
}
}
timeStampLength == 32, for predefined == 0x1
timeStampLength == 0, for predefined == 0x2
*/
#[derive(Debug)]
pub struct SLDescriptor;
impl ObjectDescriptor for SLDescriptor {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
// const SLCONFIG_PREDEFINED_CUSTOM: u8 = 0x0;
const SLCONFIG_PREDEFINED_NULL: u8 = 0x1; // older iTunes M4A
const SLCONFIG_PREDEFINED_MP4: u8 = 0x2;
let predefined = reader.read_u8()?;
match predefined {
SLCONFIG_PREDEFINED_MP4 => {
// Standard MP4: no extra fields. Nothing to read.
}
SLCONFIG_PREDEFINED_NULL => {
// Older iTunes files use predefined=0x1. The SL descriptor in
// this mode has no additional fields beyond the predefined byte,
// so we just ignore the remaining bytes.
if len > 1 {
reader.ignore_bytes(u64::from(len - 1))?;
}
}
_ => {
return unsupported_error("isomp4: sl descriptor predefined not mp4 or null");
}
}
Ok(SLDescriptor {})
}
}
@@ -0,0 +1,77 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::codecs::{CodecParameters, VerificationCheck, CODEC_TYPE_FLAC};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::{BufReader, ReadBytes};
use symphonia_utils_xiph::flac::metadata::{MetadataBlockHeader, MetadataBlockType, StreamInfo};
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct FlacAtom {
/// Atom header.
header: AtomHeader,
/// FLAC stream info block.
stream_info: StreamInfo,
/// FLAC extra data.
extra_data: Box<[u8]>,
}
impl Atom for FlacAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, flags) = AtomHeader::read_extra(reader)?;
if version != 0 {
return unsupported_error("isomp4 (flac): unsupported flac version");
}
if flags != 0 {
return decode_error("isomp4 (flac): flags not zero");
}
// The first block must be the stream information block.
let block_header = MetadataBlockHeader::read(reader)?;
if block_header.block_type != MetadataBlockType::StreamInfo {
return decode_error("isomp4 (flac): first block is not stream info");
}
// Ensure the block length is correct for a stream information block before allocating a
// buffer for it.
if !StreamInfo::is_valid_size(u64::from(block_header.block_len)) {
return decode_error("isomp4 (flac): invalid stream info block length");
}
let extra_data = reader.read_boxed_slice_exact(block_header.block_len as usize)?;
let stream_info = StreamInfo::read(&mut BufReader::new(&extra_data))?;
Ok(FlacAtom { header, stream_info, extra_data })
}
}
impl FlacAtom {
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
codec_params
.for_codec(CODEC_TYPE_FLAC)
.with_sample_rate(self.stream_info.sample_rate)
.with_bits_per_sample(self.stream_info.bits_per_sample)
.with_channels(self.stream_info.channels)
.with_packet_data_integrity(true)
.with_extra_data(self.extra_data.clone());
if let Some(md5) = self.stream_info.md5 {
codec_params.with_verification_code(VerificationCheck::Md5(md5));
}
}
}
@@ -0,0 +1,54 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
use crate::fourcc::FourCc;
/// File type atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct FtypAtom {
header: AtomHeader,
pub major: FourCc,
pub minor: [u8; 4],
pub compatible: Vec<FourCc>,
}
impl Atom for FtypAtom {
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
// The Ftyp atom must be have a data length that is known, and it must be a multiple of 4
// since it only stores FourCCs.
if header.data_len < 8 || header.data_len & 0x3 != 0 {
return decode_error("isomp4: invalid ftyp data length");
}
// Major
let major = FourCc::new(reader.read_quad_bytes()?);
// Minor
let minor = reader.read_quad_bytes()?;
// The remainder of the Ftyp atom contains the FourCCs of compatible brands.
let n_brands = (header.data_len - 8) / 4;
let mut compatible = Vec::new();
for _ in 0..n_brands {
let brand = reader.read_quad_bytes()?;
compatible.push(FourCc::new(brand));
}
Ok(FtypAtom { header, major, minor, compatible })
}
fn header(&self) -> AtomHeader {
self.header
}
}
@@ -0,0 +1,80 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::{
atoms::{Atom, AtomHeader},
fourcc::FourCc,
};
use log::warn;
/// Handler type.
#[derive(Debug, PartialEq, Eq)]
pub enum HandlerType {
/// Video handler.
Video,
/// Audio handler.
Sound,
/// Subtitle handler.
Subtitle,
/// Metadata handler.
Metadata,
/// Text handler.
Text,
/// Unknown handler type.
Other([u8; 4]),
}
/// Handler atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct HdlrAtom {
/// Atom header.
header: AtomHeader,
/// Handler type.
pub handler_type: HandlerType,
/// Human-readable handler name.
pub name: String,
}
impl Atom for HdlrAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
// Always 0 for MP4, but for Quicktime this contains the component type.
let _ = reader.read_quad_bytes()?;
let handler_type = match &reader.read_quad_bytes()? {
b"vide" => HandlerType::Video,
b"soun" => HandlerType::Sound,
b"meta" => HandlerType::Metadata,
b"subt" => HandlerType::Subtitle,
b"text" => HandlerType::Text,
&hdlr => {
warn!("unknown handler type {:?}", FourCc::new(hdlr));
HandlerType::Other(hdlr)
}
};
// These bytes are reserved for MP4, but for QuickTime they contain the component
// manufacturer, flags, and flags mask.
reader.ignore_bytes(4 * 3)?;
// Human readable UTF-8 string of the track type.
let buf = reader.read_boxed_slice_exact((header.data_len - 24) as usize)?;
let name = String::from_utf8_lossy(&buf).to_string();
Ok(HdlrAtom { header, handler_type, name })
}
}
@@ -0,0 +1,767 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::{BufReader, ReadBytes};
use symphonia_core::meta::{
MetadataBuilder, MetadataRevision, StandardTagKey, StandardVisualKey, Tag,
};
use symphonia_core::meta::{Value, Visual};
use symphonia_core::util::bits;
use symphonia_metadata::{id3v1, itunes};
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType};
use encoding_rs::{SHIFT_JIS, UTF_16BE};
use log::warn;
/// Data type enumeration for metadata value atoms as defined in the QuickTime File Format standard.
#[allow(dead_code)]
#[derive(Debug, Copy, Clone)]
pub enum DataType {
AffineTransformF64,
Bmp,
DimensionsF32,
Float32,
Float64,
Jpeg,
/// The data type is implicit to the atom.
NoType,
Png,
PointF32,
QuickTimeMetadata,
RectF32,
ShiftJis,
SignedInt16,
SignedInt32,
SignedInt64,
SignedInt8,
SignedIntVariable,
UnsignedInt16,
UnsignedInt32,
UnsignedInt64,
UnsignedInt8,
UnsignedIntVariable,
Utf16,
Utf16Sort,
Utf8,
Utf8Sort,
Unknown(u32),
}
impl From<u32> for DataType {
fn from(value: u32) -> Self {
match value {
0 => DataType::NoType,
1 => DataType::Utf8,
2 => DataType::Utf16,
3 => DataType::ShiftJis,
4 => DataType::Utf8Sort,
5 => DataType::Utf16Sort,
13 => DataType::Jpeg,
14 => DataType::Png,
21 => DataType::SignedIntVariable,
22 => DataType::UnsignedIntVariable,
23 => DataType::Float32,
24 => DataType::Float64,
27 => DataType::Bmp,
28 => DataType::QuickTimeMetadata,
65 => DataType::SignedInt8,
66 => DataType::SignedInt16,
67 => DataType::SignedInt32,
70 => DataType::PointF32,
71 => DataType::DimensionsF32,
72 => DataType::RectF32,
74 => DataType::SignedInt64,
75 => DataType::UnsignedInt8,
76 => DataType::UnsignedInt16,
77 => DataType::UnsignedInt32,
78 => DataType::UnsignedInt64,
79 => DataType::AffineTransformF64,
_ => DataType::Unknown(value),
}
}
}
fn parse_no_type(data: &[u8]) -> Option<Value> {
// Latin1, potentially null-terminated.
let end = data.iter().position(|&c| c == b'\0').unwrap_or(data.len());
let text = String::from_utf8_lossy(&data[..end]);
Some(Value::from(text))
}
fn parse_utf8(data: &[u8]) -> Option<Value> {
// UTF8, no null-terminator or count.
let text = String::from_utf8_lossy(data);
Some(Value::from(text))
}
fn parse_utf16(data: &[u8]) -> Option<Value> {
// UTF16 BE
let text = UTF_16BE.decode(data).0;
Some(Value::from(text))
}
fn parse_shift_jis(data: &[u8]) -> Option<Value> {
// Shift-JIS
let text = SHIFT_JIS.decode(data).0;
Some(Value::from(text))
}
fn parse_signed_int8(data: &[u8]) -> Option<Value> {
match data.len() {
1 => {
let s = bits::sign_extend_leq8_to_i8(data[0], 8);
Some(Value::from(s))
}
_ => None,
}
}
fn parse_signed_int16(data: &[u8]) -> Option<Value> {
match data.len() {
2 => {
let u = BufReader::new(data).read_be_u16().ok()?;
let s = bits::sign_extend_leq16_to_i16(u, 16);
Some(Value::from(s))
}
_ => None,
}
}
fn parse_signed_int32(data: &[u8]) -> Option<Value> {
match data.len() {
4 => {
let u = BufReader::new(data).read_be_u32().ok()?;
let s = bits::sign_extend_leq32_to_i32(u, 32);
Some(Value::from(s))
}
_ => None,
}
}
fn parse_signed_int64(data: &[u8]) -> Option<Value> {
match data.len() {
8 => {
let u = BufReader::new(data).read_be_u64().ok()?;
let s = bits::sign_extend_leq64_to_i64(u, 64);
Some(Value::from(s))
}
_ => None,
}
}
fn parse_var_signed_int(data: &[u8]) -> Option<Value> {
match data.len() {
1 => parse_signed_int8(data),
2 => parse_signed_int16(data),
4 => parse_signed_int32(data),
_ => None,
}
}
fn parse_unsigned_int8(data: &[u8]) -> Option<Value> {
match data.len() {
1 => Some(Value::from(data[0])),
_ => None,
}
}
fn parse_unsigned_int16(data: &[u8]) -> Option<Value> {
match data.len() {
2 => {
let u = BufReader::new(data).read_be_u16().ok()?;
Some(Value::from(u))
}
_ => None,
}
}
fn parse_unsigned_int32(data: &[u8]) -> Option<Value> {
match data.len() {
4 => {
let u = BufReader::new(data).read_be_u32().ok()?;
Some(Value::from(u))
}
_ => None,
}
}
fn parse_unsigned_int64(data: &[u8]) -> Option<Value> {
match data.len() {
8 => {
let u = BufReader::new(data).read_be_u64().ok()?;
Some(Value::from(u))
}
_ => None,
}
}
fn parse_var_unsigned_int(data: &[u8]) -> Option<Value> {
match data.len() {
1 => parse_unsigned_int8(data),
2 => parse_unsigned_int16(data),
4 => parse_unsigned_int32(data),
_ => None,
}
}
fn parse_float32(data: &[u8]) -> Option<Value> {
match data.len() {
4 => {
let f = BufReader::new(data).read_be_f32().ok()?;
Some(Value::Float(f64::from(f)))
}
_ => None,
}
}
fn parse_float64(data: &[u8]) -> Option<Value> {
match data.len() {
8 => {
let f = BufReader::new(data).read_be_f64().ok()?;
Some(Value::Float(f))
}
_ => None,
}
}
fn parse_tag_value(data_type: DataType, data: &[u8]) -> Option<Value> {
match data_type {
DataType::NoType => parse_no_type(data),
DataType::Utf8 | DataType::Utf8Sort => parse_utf8(data),
DataType::Utf16 | DataType::Utf16Sort => parse_utf16(data),
DataType::ShiftJis => parse_shift_jis(data),
DataType::UnsignedInt8 => parse_unsigned_int8(data),
DataType::UnsignedInt16 => parse_unsigned_int16(data),
DataType::UnsignedInt32 => parse_unsigned_int32(data),
DataType::UnsignedInt64 => parse_unsigned_int64(data),
DataType::UnsignedIntVariable => parse_var_unsigned_int(data),
DataType::SignedInt8 => parse_signed_int8(data),
DataType::SignedInt16 => parse_signed_int16(data),
DataType::SignedInt32 => parse_signed_int32(data),
DataType::SignedInt64 => parse_signed_int64(data),
DataType::SignedIntVariable => parse_var_signed_int(data),
DataType::Float32 => parse_float32(data),
DataType::Float64 => parse_float64(data),
_ => None,
}
}
/// Reads and parses a `MetaTagAtom` from the provided iterator and adds it to the `MetadataBuilder`
/// if there are no errors.
fn add_generic_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
std_key: Option<StandardTagKey>,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
for value_atom in tag.values.iter() {
// Parse the value atom data into a string, if possible.
if let Some(value) = parse_tag_value(value_atom.data_type, &value_atom.data) {
builder.add_tag(Tag::new(std_key, "", value));
}
else {
warn!("unsupported data type {:?} for {:?} tag", value_atom.data_type, std_key);
}
}
Ok(())
}
fn add_var_unsigned_int_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
std_key: StandardTagKey,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
if let Some(value_atom) = tag.values.first() {
if let Some(value) = parse_var_unsigned_int(&value_atom.data) {
builder.add_tag(Tag::new(Some(std_key), "", value));
}
else {
warn!("got unexpected data for {:?} tag", std_key);
}
}
Ok(())
}
fn add_var_signed_int_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
std_key: StandardTagKey,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
if let Some(value_atom) = tag.values.first() {
if let Some(value) = parse_var_signed_int(&value_atom.data) {
builder.add_tag(Tag::new(Some(std_key), "", value));
}
else {
warn!("got unexpected data for {:?} tag", std_key);
}
}
Ok(())
}
fn add_boolean_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
std_key: StandardTagKey,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There should only be 1 value.
if let Some(value) = tag.values.first() {
// Boolean tags are just "flags", only add a tag if the boolean is true (1).
if let Some(bool_value) = value.data.first() {
if *bool_value == 1 {
builder.add_tag(Tag::new(Some(std_key), "", Value::Flag));
}
}
}
Ok(())
}
fn add_m_of_n_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
m_key: StandardTagKey,
n_key: StandardTagKey,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There should only be 1 value.
if let Some(value) = tag.values.first() {
// The trkn and disk atoms contains an 8 byte value buffer, where the 4th and 6th bytes
// indicate the track/disk number and total number of tracks/disks, respectively. Odd.
if value.data.len() == 8 {
let m = value.data[3];
let n = value.data[5];
builder.add_tag(Tag::new(Some(m_key), "", Value::from(m)));
builder.add_tag(Tag::new(Some(n_key), "", Value::from(n)));
}
}
Ok(())
}
fn add_visual_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There could be more than one attached image.
for value in tag.values {
let media_type = match value.data_type {
DataType::Bmp => "image/bmp",
DataType::Jpeg => "image/jpeg",
DataType::Png => "image/png",
_ => "",
};
builder.add_visual(Visual {
media_type: media_type.into(),
dimensions: None,
bits_per_pixel: None,
color_mode: None,
usage: Some(StandardVisualKey::FrontCover),
tags: Default::default(),
data: value.data,
});
}
Ok(())
}
fn add_advisory_tag<B: ReadBytes>(
_iter: &mut AtomIterator<B>,
_builder: &mut MetadataBuilder,
) -> Result<()> {
Ok(())
}
fn add_media_type_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There should only be 1 value.
if let Some(value) = tag.values.first() {
if let Some(media_type_value) = value.data.first() {
let media_type = match media_type_value {
0 => "Movie",
1 => "Normal",
2 => "Audio Book",
5 => "Whacked Bookmark",
6 => "Music Video",
9 => "Short Film",
10 => "TV Show",
11 => "Booklet",
_ => "Unknown",
};
builder.add_tag(Tag::new(
Some(StandardTagKey::MediaFormat),
"",
Value::from(media_type),
));
}
}
Ok(())
}
fn add_id3v1_genre_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There should only be 1 value.
if let Some(value) = tag.values.first() {
// The ID3v1 genre is stored as a unsigned 16-bit big-endian integer.
let index = BufReader::new(&value.data).read_be_u16()?;
// The stored index uses 1-based indexing, but the ID3v1 genre list is 0-based.
if index > 0 {
if let Some(genre) = id3v1::util::genre_name((index - 1) as u8) {
builder.add_tag(Tag::new(Some(StandardTagKey::Genre), "", Value::from(*genre)));
}
}
}
Ok(())
}
fn add_freeform_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// A user-defined tag should only have 1 value.
for value_atom in tag.values.iter() {
// Parse the value atom data into a string, if possible.
if let Some(value) = parse_tag_value(value_atom.data_type, &value_atom.data) {
// Gets the fully qualified tag name.
let full_name = tag.full_name();
// Try to map iTunes freeform tags to standard tag keys.
let std_key = itunes::std_key_from_tag(&full_name);
builder.add_tag(Tag::new(std_key, &full_name, value));
}
else {
warn!("unsupported data type {:?} for free-form tag", value_atom.data_type);
}
}
Ok(())
}
/// Metadata tag data atom.
#[allow(dead_code)]
pub struct MetaTagDataAtom {
/// Atom header.
header: AtomHeader,
/// Tag data.
pub data: Box<[u8]>,
/// The data type contained in buf.
pub data_type: DataType,
}
impl Atom for MetaTagDataAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, flags) = AtomHeader::read_extra(reader)?;
// For the mov brand, this a data type indicator and must always be 0 (well-known type). It
// specifies the table in which the next 24-bit integer specifying the actual data type
// indexes. For iso/mp4, this is a version, and there is only one version, 0. Therefore,
// flags are interpreted as the actual data type index.
if version != 0 {
return decode_error("isomp4: invalid data atom version");
}
let data_type = DataType::from(flags);
// For the mov brand, the next four bytes are country and languages code. However, for
// iso/mp4 these codes should be ignored.
let _country = reader.read_be_u16()?;
let _language = reader.read_be_u16()?;
// The data payload is the remainder of the atom.
// TODO: Apply a limit.
let data = reader
.read_boxed_slice_exact((header.data_len - AtomHeader::EXTRA_DATA_SIZE - 4) as usize)?;
Ok(MetaTagDataAtom { header, data, data_type })
}
}
/// Metadata tag name and mean atom.
#[allow(dead_code)]
pub struct MetaTagNamespaceAtom {
/// Atom header.
header: AtomHeader,
/// For 'mean' atoms, this is the key namespace. For 'name' atom, this is the key name.
pub value: String,
}
impl Atom for MetaTagNamespaceAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let buf = reader
.read_boxed_slice_exact((header.data_len - AtomHeader::EXTRA_DATA_SIZE) as usize)?;
// Do a lossy conversion because metadata should not prevent the demuxer from working.
let value = String::from_utf8_lossy(&buf).to_string();
Ok(MetaTagNamespaceAtom { header, value })
}
}
/// A generic metadata tag atom.
#[allow(dead_code)]
pub struct MetaTagAtom {
/// Atom header.
header: AtomHeader,
/// Tag value(s).
pub values: Vec<MetaTagDataAtom>,
/// Optional, tag key namespace.
pub mean: Option<MetaTagNamespaceAtom>,
/// Optional, tag key name.
pub name: Option<MetaTagNamespaceAtom>,
}
impl MetaTagAtom {
pub fn full_name(&self) -> String {
let mut full_name = String::new();
if self.mean.is_some() || self.name.is_some() {
// full_name.push_str("----:");
if let Some(mean) = &self.mean {
full_name.push_str(&mean.value);
}
full_name.push(':');
if let Some(name) = &self.name {
full_name.push_str(&name.value);
}
}
full_name
}
}
impl Atom for MetaTagAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mean = None;
let mut name = None;
let mut values = Vec::new();
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MetaTagData => {
values.push(iter.read_atom::<MetaTagDataAtom>()?);
}
AtomType::MetaTagName => {
name = Some(iter.read_atom::<MetaTagNamespaceAtom>()?);
}
AtomType::MetaTagMeaning => {
mean = Some(iter.read_atom::<MetaTagNamespaceAtom>()?);
}
_ => (),
}
}
Ok(MetaTagAtom { header, values, mean, name })
}
}
/// User data atom.
#[allow(dead_code)]
pub struct IlstAtom {
/// Atom header.
header: AtomHeader,
/// Metadata revision.
pub metadata: MetadataRevision,
}
impl Atom for IlstAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mb = MetadataBuilder::new();
while let Some(header) = iter.next()? {
// Ignore standard atoms, check if other is a metadata atom.
match &header.atype {
AtomType::AdvisoryTag => add_advisory_tag(&mut iter, &mut mb)?,
AtomType::AlbumArtistTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::AlbumArtist))?
}
AtomType::AlbumTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Album))?
}
AtomType::ArtistLowerTag => (),
AtomType::ArtistTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Artist))?
}
AtomType::CategoryTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::PodcastCategory))?
}
AtomType::CommentTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Comment))?
}
AtomType::CompilationTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Compilation))?
}
AtomType::ComposerTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Composer))?
}
AtomType::CopyrightTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Copyright))?
}
AtomType::CoverTag => add_visual_tag(&mut iter, &mut mb)?,
AtomType::CustomGenreTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Genre))?
}
AtomType::DateTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Date))?
}
AtomType::DescriptionTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Description))?
}
AtomType::DiskNumberTag => add_m_of_n_tag(
&mut iter,
&mut mb,
StandardTagKey::DiscNumber,
StandardTagKey::DiscTotal,
)?,
AtomType::EncodedByTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::EncodedBy))?
}
AtomType::EncoderTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Encoder))?
}
AtomType::GaplessPlaybackTag => {
// TODO: Need standard tag key for gapless playback.
// add_boolean_tag(&mut iter, &mut mb, )?
}
AtomType::GenreTag => add_id3v1_genre_tag(&mut iter, &mut mb)?,
AtomType::GroupingTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::ContentGroup))?
}
AtomType::HdVideoTag => (),
AtomType::IdentPodcastTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::IdentPodcast))?
}
AtomType::KeywordTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::PodcastKeywords))?
}
AtomType::LongDescriptionTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Description))?
}
AtomType::LyricsTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Lyrics))?
}
AtomType::MediaTypeTag => add_media_type_tag(&mut iter, &mut mb)?,
AtomType::OwnerTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Owner))?
}
AtomType::PodcastTag => {
add_boolean_tag(&mut iter, &mut mb, StandardTagKey::Podcast)?
}
AtomType::PurchaseDateTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::PurchaseDate))?
}
AtomType::RatingTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Rating))?
}
AtomType::SortAlbumArtistTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortAlbumArtist))?
}
AtomType::SortAlbumTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortAlbum))?
}
AtomType::SortArtistTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortArtist))?
}
AtomType::SortComposerTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortComposer))?
}
AtomType::SortNameTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortTrackTitle))?
}
AtomType::TempoTag => {
add_var_signed_int_tag(&mut iter, &mut mb, StandardTagKey::Bpm)?
}
AtomType::TrackNumberTag => add_m_of_n_tag(
&mut iter,
&mut mb,
StandardTagKey::TrackNumber,
StandardTagKey::TrackTotal,
)?,
AtomType::TrackTitleTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TrackTitle))?
}
AtomType::TvEpisodeNameTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TvEpisodeTitle))?
}
AtomType::TvEpisodeNumberTag => {
add_var_unsigned_int_tag(&mut iter, &mut mb, StandardTagKey::TvEpisode)?
}
AtomType::TvNetworkNameTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TvNetwork))?
}
AtomType::TvSeasonNumberTag => {
add_var_unsigned_int_tag(&mut iter, &mut mb, StandardTagKey::TvSeason)?
}
AtomType::TvShowNameTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TvShowTitle))?
}
AtomType::UrlPodcastTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::UrlPodcast))?
}
AtomType::FreeFormTag => add_freeform_tag(&mut iter, &mut mb)?,
_ => (),
}
}
Ok(IlstAtom { header, metadata: mb.metadata() })
}
}
@@ -0,0 +1,93 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
fn parse_language(code: u16) -> String {
// An ISO language code outside of these bounds is not valid.
if code < 0x400 || code > 0x7fff {
String::new()
}
else {
let chars = [
((code >> 10) & 0x1f) as u8 + 0x60,
((code >> 5) & 0x1f) as u8 + 0x60,
((code >> 0) & 0x1f) as u8 + 0x60,
];
String::from_utf8_lossy(&chars).to_string()
}
}
/// Media header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MdhdAtom {
/// Atom header.
header: AtomHeader,
/// Creation time.
pub ctime: u64,
/// Modification time.
pub mtime: u64,
/// Timescale.
pub timescale: u32,
/// Duration of the media in timescale units.
pub duration: u64,
/// Language.
pub language: String,
}
impl Atom for MdhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, _) = AtomHeader::read_extra(reader)?;
let mut mdhd = MdhdAtom {
header,
ctime: 0,
mtime: 0,
timescale: 0,
duration: 0,
language: String::new(),
};
match version {
0 => {
mdhd.ctime = u64::from(reader.read_be_u32()?);
mdhd.mtime = u64::from(reader.read_be_u32()?);
mdhd.timescale = reader.read_be_u32()?;
// 0xffff_ffff is a special case.
mdhd.duration = match reader.read_be_u32()? {
u32::MAX => u64::MAX,
duration => u64::from(duration),
};
}
1 => {
mdhd.ctime = reader.read_be_u64()?;
mdhd.mtime = reader.read_be_u64()?;
mdhd.timescale = reader.read_be_u32()?;
mdhd.duration = reader.read_be_u64()?;
}
_ => {
return decode_error("isomp4: invalid mdhd version");
}
}
mdhd.language = parse_language(reader.read_be_u16()?);
// Quality
let _ = reader.read_be_u16()?;
Ok(mdhd)
}
}
@@ -0,0 +1,63 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, HdlrAtom, MdhdAtom, MinfAtom};
#[allow(dead_code)]
#[derive(Debug)]
pub struct MdiaAtom {
header: AtomHeader,
pub mdhd: MdhdAtom,
pub hdlr: HdlrAtom,
pub minf: MinfAtom,
}
impl Atom for MdiaAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mdhd = None;
let mut hdlr = None;
let mut minf = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MediaHeader => {
mdhd = Some(iter.read_atom::<MdhdAtom>()?);
}
AtomType::Handler => {
hdlr = Some(iter.read_atom::<HdlrAtom>()?);
}
AtomType::MediaInfo => {
minf = Some(iter.read_atom::<MinfAtom>()?);
}
_ => (),
}
}
if mdhd.is_none() {
return decode_error("isomp4: missing mdhd atom");
}
if hdlr.is_none() {
return decode_error("isomp4: missing hdlr atom");
}
if minf.is_none() {
return decode_error("isomp4: missing minf atom");
}
Ok(MdiaAtom { header, mdhd: mdhd.unwrap(), hdlr: hdlr.unwrap(), minf: minf.unwrap() })
}
}
@@ -0,0 +1,41 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Movie extends header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MehdAtom {
/// Atom header.
header: AtomHeader,
/// Fragment duration.
pub fragment_duration: u64,
}
impl Atom for MehdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, _) = AtomHeader::read_extra(reader)?;
let fragment_duration = match version {
0 => u64::from(reader.read_be_u32()?),
1 => reader.read_be_u64()?,
_ => {
return decode_error("isomp4: invalid mehd version");
}
};
Ok(MehdAtom { header, fragment_duration })
}
}
@@ -0,0 +1,66 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use std::fmt::Debug;
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use symphonia_core::meta::MetadataRevision;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, IlstAtom};
/// User data atom.
#[allow(dead_code)]
pub struct MetaAtom {
/// Atom header.
header: AtomHeader,
/// Metadata revision.
pub metadata: Option<MetadataRevision>,
}
impl Debug for MetaAtom {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "(redacted)")
}
}
impl MetaAtom {
/// If metadata was read, consumes the metadata and returns it.
pub fn take_metadata(&mut self) -> Option<MetadataRevision> {
self.metadata.take()
}
}
impl Atom for MetaAtom {
fn header(&self) -> AtomHeader {
self.header
}
#[allow(clippy::single_match)]
fn read<B: ReadBytes>(reader: &mut B, mut header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
// AtomIterator doesn't know the extra data was read already, so the extra data size must be
// subtrated from the atom's data length.
header.data_len -= AtomHeader::EXTRA_DATA_SIZE;
let mut iter = AtomIterator::new(reader, header);
let mut metadata = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MetaList => {
metadata = Some(iter.read_atom::<IlstAtom>()?.metadata);
}
_ => (),
}
}
Ok(MetaAtom { header, metadata })
}
}
@@ -0,0 +1,35 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Movie fragment header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MfhdAtom {
/// Atom header.
header: AtomHeader,
/// Sequence number associated with fragment.
pub sequence_number: u32,
}
impl Atom for MfhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let sequence_number = reader.read_be_u32()?;
Ok(MfhdAtom { header, sequence_number })
}
}
@@ -0,0 +1,54 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, SmhdAtom, StblAtom};
/// Media information atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MinfAtom {
/// Atom header.
header: AtomHeader,
/// Sound media header atom.
pub smhd: Option<SmhdAtom>,
/// Sample table atom.
pub stbl: StblAtom,
}
impl Atom for MinfAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut smhd = None;
let mut stbl = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::SoundMediaHeader => {
smhd = Some(iter.read_atom::<SmhdAtom>()?);
}
AtomType::SampleTable => {
stbl = Some(iter.read_atom::<StblAtom>()?);
}
_ => (),
}
}
if stbl.is_none() {
return decode_error("isomp4: missing stbl atom");
}
Ok(MinfAtom { header, smhd, stbl: stbl.unwrap() })
}
}
@@ -0,0 +1,482 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
pub(crate) mod alac;
pub(crate) mod co64;
pub(crate) mod ctts;
pub(crate) mod edts;
pub(crate) mod elst;
pub(crate) mod esds;
pub(crate) mod flac;
pub(crate) mod ftyp;
pub(crate) mod hdlr;
pub(crate) mod ilst;
pub(crate) mod mdhd;
pub(crate) mod mdia;
pub(crate) mod mehd;
pub(crate) mod meta;
pub(crate) mod mfhd;
pub(crate) mod minf;
pub(crate) mod moof;
pub(crate) mod moov;
pub(crate) mod mvex;
pub(crate) mod mvhd;
pub(crate) mod opus;
pub(crate) mod sidx;
pub(crate) mod smhd;
pub(crate) mod stbl;
pub(crate) mod stco;
pub(crate) mod stsc;
pub(crate) mod stsd;
pub(crate) mod stss;
pub(crate) mod stsz;
pub(crate) mod stts;
pub(crate) mod tfhd;
pub(crate) mod tkhd;
pub(crate) mod traf;
pub(crate) mod trak;
pub(crate) mod trex;
pub(crate) mod trun;
pub(crate) mod udta;
pub(crate) mod wave;
pub use self::meta::MetaAtom;
pub use alac::AlacAtom;
pub use co64::Co64Atom;
#[allow(unused_imports)]
pub use ctts::CttsAtom;
pub use edts::EdtsAtom;
pub use elst::ElstAtom;
pub use esds::EsdsAtom;
pub use flac::FlacAtom;
pub use ftyp::FtypAtom;
pub use hdlr::HdlrAtom;
pub use ilst::IlstAtom;
pub use mdhd::MdhdAtom;
pub use mdia::MdiaAtom;
pub use mehd::MehdAtom;
pub use mfhd::MfhdAtom;
pub use minf::MinfAtom;
pub use moof::MoofAtom;
pub use moov::MoovAtom;
pub use mvex::MvexAtom;
pub use mvhd::MvhdAtom;
pub use opus::OpusAtom;
pub use sidx::SidxAtom;
pub use smhd::SmhdAtom;
pub use stbl::StblAtom;
pub use stco::StcoAtom;
pub use stsc::StscAtom;
pub use stsd::StsdAtom;
#[allow(unused_imports)]
pub use stss::StssAtom;
pub use stsz::StszAtom;
pub use stts::SttsAtom;
pub use tfhd::TfhdAtom;
pub use tkhd::TkhdAtom;
pub use traf::TrafAtom;
pub use trak::TrakAtom;
pub use trex::TrexAtom;
pub use trun::TrunAtom;
pub use udta::UdtaAtom;
pub use wave::WaveAtom;
/// Atom types.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum AtomType {
Ac3,
AdvisoryTag,
Alac,
ALaw,
AlbumArtistTag,
AlbumTag,
ArtistLowerTag,
ArtistTag,
CategoryTag,
ChunkOffset,
ChunkOffset64,
CommentTag,
CompilationTag,
ComposerTag,
CompositionTimeToSample,
CopyrightTag,
CoverTag,
CustomGenreTag,
DateTag,
DescriptionTag,
DiskNumberTag,
Edit,
EditList,
EncodedByTag,
EncoderTag,
Esds,
F32SampleEntry,
F64SampleEntry,
FileType,
Flac,
FlacDsConfig,
Free,
FreeFormTag,
GaplessPlaybackTag,
GenreTag,
GroupingTag,
Handler,
HdVideoTag,
IdentPodcastTag,
KeywordTag,
LongDescriptionTag,
Lpcm,
LyricsTag,
Media,
MediaData,
MediaHeader,
MediaInfo,
MediaTypeTag,
Meta,
MetaList,
MetaTagData,
MetaTagMeaning,
MetaTagName,
Movie,
MovieExtends,
MovieExtendsHeader,
MovieFragment,
MovieFragmentHeader,
MovieHeader,
Mp3,
Mp4a,
MuLaw,
Opus,
OpusDsConfig,
OwnerTag,
PodcastTag,
PurchaseDateTag,
QtWave,
RatingTag,
S16BeSampleEntry,
S16LeSampleEntry,
S24SampleEntry,
S32SampleEntry,
SampleDescription,
SampleSize,
SampleTable,
SampleToChunk,
SegmentIndex,
Skip,
SortAlbumArtistTag,
SortAlbumTag,
SortArtistTag,
SortComposerTag,
SortNameTag,
SoundMediaHeader,
SyncSample,
TempoTag,
TimeToSample,
Track,
TrackExtends,
TrackFragment,
TrackFragmentHeader,
TrackFragmentRun,
TrackHeader,
TrackNumberTag,
TrackTitleTag,
TvEpisodeNameTag,
TvEpisodeNumberTag,
TvNetworkNameTag,
TvSeasonNumberTag,
TvShowNameTag,
U8SampleEntry,
UrlPodcastTag,
UserData,
Other([u8; 4]),
}
impl From<[u8; 4]> for AtomType {
fn from(val: [u8; 4]) -> Self {
match &val {
b".mp3" => AtomType::Mp3,
b"ac-3" => AtomType::Ac3,
b"alac" => AtomType::Alac,
b"alaw" => AtomType::ALaw,
b"co64" => AtomType::ChunkOffset64,
b"ctts" => AtomType::CompositionTimeToSample,
b"data" => AtomType::MetaTagData,
b"dfLa" => AtomType::FlacDsConfig,
b"dOps" => AtomType::OpusDsConfig,
b"edts" => AtomType::Edit,
b"elst" => AtomType::EditList,
b"esds" => AtomType::Esds,
b"fl32" => AtomType::F32SampleEntry,
b"fl64" => AtomType::F64SampleEntry,
b"fLaC" => AtomType::Flac,
b"free" => AtomType::Free,
b"ftyp" => AtomType::FileType,
b"hdlr" => AtomType::Handler,
b"ilst" => AtomType::MetaList,
b"in24" => AtomType::S24SampleEntry,
b"in32" => AtomType::S32SampleEntry,
b"lpcm" => AtomType::Lpcm,
b"mdat" => AtomType::MediaData,
b"mdhd" => AtomType::MediaHeader,
b"mdia" => AtomType::Media,
b"mean" => AtomType::MetaTagMeaning,
b"mehd" => AtomType::MovieExtendsHeader,
b"meta" => AtomType::Meta,
b"mfhd" => AtomType::MovieFragmentHeader,
b"minf" => AtomType::MediaInfo,
b"moof" => AtomType::MovieFragment,
b"moov" => AtomType::Movie,
b"mp4a" => AtomType::Mp4a,
b"mvex" => AtomType::MovieExtends,
b"mvhd" => AtomType::MovieHeader,
b"name" => AtomType::MetaTagName,
b"Opus" => AtomType::Opus,
b"raw " => AtomType::U8SampleEntry,
b"sidx" => AtomType::SegmentIndex,
b"skip" => AtomType::Skip,
b"smhd" => AtomType::SoundMediaHeader,
b"sowt" => AtomType::S16LeSampleEntry,
b"stbl" => AtomType::SampleTable,
b"stco" => AtomType::ChunkOffset,
b"stsc" => AtomType::SampleToChunk,
b"stsd" => AtomType::SampleDescription,
b"stss" => AtomType::SyncSample,
b"stsz" => AtomType::SampleSize,
b"stts" => AtomType::TimeToSample,
b"tfhd" => AtomType::TrackFragmentHeader,
b"tkhd" => AtomType::TrackHeader,
b"traf" => AtomType::TrackFragment,
b"trak" => AtomType::Track,
b"trex" => AtomType::TrackExtends,
b"trun" => AtomType::TrackFragmentRun,
b"twos" => AtomType::S16BeSampleEntry,
b"udta" => AtomType::UserData,
b"ulaw" => AtomType::MuLaw,
b"wave" => AtomType::QtWave,
// Metadata Boxes
b"----" => AtomType::FreeFormTag,
b"aART" => AtomType::AlbumArtistTag,
b"catg" => AtomType::CategoryTag,
b"covr" => AtomType::CoverTag,
b"cpil" => AtomType::CompilationTag,
b"cprt" => AtomType::CopyrightTag,
b"desc" => AtomType::DescriptionTag,
b"disk" => AtomType::DiskNumberTag,
b"egid" => AtomType::IdentPodcastTag,
b"gnre" => AtomType::GenreTag,
b"hdvd" => AtomType::HdVideoTag,
b"keyw" => AtomType::KeywordTag,
b"ldes" => AtomType::LongDescriptionTag,
b"ownr" => AtomType::OwnerTag,
b"pcst" => AtomType::PodcastTag,
b"pgap" => AtomType::GaplessPlaybackTag,
b"purd" => AtomType::PurchaseDateTag,
b"purl" => AtomType::UrlPodcastTag,
b"rate" => AtomType::RatingTag,
b"rtng" => AtomType::AdvisoryTag,
b"soaa" => AtomType::SortAlbumArtistTag,
b"soal" => AtomType::SortAlbumTag,
b"soar" => AtomType::SortArtistTag,
b"soco" => AtomType::SortComposerTag,
b"sonm" => AtomType::SortNameTag,
b"stik" => AtomType::MediaTypeTag,
b"tmpo" => AtomType::TempoTag,
b"trkn" => AtomType::TrackNumberTag,
b"tven" => AtomType::TvEpisodeNameTag,
b"tves" => AtomType::TvEpisodeNumberTag,
b"tvnn" => AtomType::TvNetworkNameTag,
b"tvsh" => AtomType::TvShowNameTag,
b"tvsn" => AtomType::TvSeasonNumberTag,
b"\xa9alb" => AtomType::AlbumTag,
b"\xa9art" => AtomType::ArtistLowerTag,
b"\xa9ART" => AtomType::ArtistTag,
b"\xa9cmt" => AtomType::CommentTag,
b"\xa9day" => AtomType::DateTag,
b"\xa9enc" => AtomType::EncodedByTag,
b"\xa9gen" => AtomType::CustomGenreTag,
b"\xa9grp" => AtomType::GroupingTag,
b"\xa9lyr" => AtomType::LyricsTag,
b"\xa9nam" => AtomType::TrackTitleTag,
b"\xa9too" => AtomType::EncoderTag,
b"\xa9wrt" => AtomType::ComposerTag,
_ => AtomType::Other(val),
}
}
}
/// Common atom header.
#[derive(Copy, Clone, Debug)]
pub struct AtomHeader {
/// The atom type.
pub atype: AtomType,
/// The total size of the atom including the header.
pub atom_len: u64,
/// The size of the payload data.
pub data_len: u64,
}
impl AtomHeader {
const HEADER_SIZE: u64 = 8;
const EXTENDED_HEADER_SIZE: u64 = AtomHeader::HEADER_SIZE + 8;
const EXTRA_DATA_SIZE: u64 = 4;
/// Reads an atom header from the provided `ByteStream`.
pub fn read<B: ReadBytes>(reader: &mut B) -> Result<AtomHeader> {
let mut atom_len = u64::from(reader.read_be_u32()?);
let atype = AtomType::from(reader.read_quad_bytes()?);
let data_len = match atom_len {
0 => 0,
1 => {
atom_len = reader.read_be_u64()?;
// The atom size should be atleast the length of the header.
if atom_len < AtomHeader::EXTENDED_HEADER_SIZE {
return decode_error("isomp4: atom size is invalid");
}
atom_len - AtomHeader::EXTENDED_HEADER_SIZE
}
_ => {
// The atom size should be atleast the length of the header.
if atom_len < AtomHeader::HEADER_SIZE {
return decode_error("isomp4: atom size is invalid");
}
atom_len - AtomHeader::HEADER_SIZE
}
};
Ok(AtomHeader { atype, atom_len, data_len })
}
#[allow(dead_code)]
pub fn base_header_len(&self) -> u64 {
match self.atom_len {
0 => AtomHeader::HEADER_SIZE,
_ => self.atom_len - self.data_len,
}
}
/// For applicable atoms, reads the atom header extra data: a tuple composed of a u8 version
/// number, and a u24 bitset of flags.
pub fn read_extra<B: ReadBytes>(reader: &mut B) -> Result<(u8, u32)> {
Ok((reader.read_u8()?, reader.read_be_u24()?))
}
}
pub trait Atom: Sized {
#[allow(dead_code)]
fn header(&self) -> AtomHeader;
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self>;
}
pub struct AtomIterator<B: ReadBytes> {
reader: B,
len: Option<u64>,
cur_atom: Option<AtomHeader>,
base_pos: u64,
next_atom_pos: u64,
}
impl<B: ReadBytes> AtomIterator<B> {
pub fn new_root(reader: B, len: Option<u64>) -> Self {
let base_pos = reader.pos();
AtomIterator { reader, len, cur_atom: None, base_pos, next_atom_pos: base_pos }
}
pub fn new(reader: B, container: AtomHeader) -> Self {
let base_pos = reader.pos();
AtomIterator {
reader,
len: Some(container.data_len),
cur_atom: None,
base_pos,
next_atom_pos: base_pos,
}
}
pub fn into_inner(self) -> B {
self.reader
}
pub fn inner_mut(&mut self) -> &mut B {
&mut self.reader
}
/// Exclusive end offset of the current atom in the stream.
pub fn current_atom_end(&self) -> u64 {
self.next_atom_pos
}
pub fn next(&mut self) -> Result<Option<AtomHeader>> {
// Ignore any remaining data in the current atom that was not read.
let cur_pos = self.reader.pos();
if cur_pos < self.next_atom_pos {
self.reader.ignore_bytes(self.next_atom_pos - cur_pos)?;
}
else if cur_pos > self.next_atom_pos {
// This is very bad, either the atom's length was incorrect or the demuxer erroroneously
// overread an atom.
return decode_error("isomp4: overread atom");
}
// If len is specified, then do not read more than len bytes.
if let Some(len) = self.len {
if self.next_atom_pos - self.base_pos >= len {
return Ok(None);
}
}
// Read the next atom header.
let atom = AtomHeader::read(&mut self.reader)?;
// Calculate the start position for the next atom (the exclusive end of the current atom).
self.next_atom_pos = match atom.atom_len {
0 => {
// An atom with a length of zero is defined to span to the end of the stream. If
// len is available, use it for the next atom start position, otherwise, use u64 max
// which will trip an end of stream error on the next iteration.
self.len.map(|l| self.base_pos + l).unwrap_or(u64::MAX)
}
len => self.next_atom_pos + len,
};
self.cur_atom = Some(atom);
Ok(self.cur_atom)
}
pub fn next_no_consume(&mut self) -> Result<Option<AtomHeader>> {
if self.cur_atom.is_some() {
Ok(self.cur_atom)
}
else {
self.next()
}
}
pub fn read_atom<A: Atom>(&mut self) -> Result<A> {
// It is not possible to read the current atom more than once because ByteStream is not
// seekable. Therefore, raise an assert if read_atom is called more than once between calls
// to next, or after next returns None.
assert!(self.cur_atom.is_some());
A::read(&mut self.reader, self.cur_atom.take().unwrap())
}
pub fn consume_atom(&mut self) {
assert!(self.cur_atom.take().is_some());
}
}
@@ -0,0 +1,60 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, MfhdAtom, TrafAtom};
/// Movie fragment atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MoofAtom {
/// Atom header.
header: AtomHeader,
/// The position of the first byte of this moof atom. This is used as the anchor point for the
/// subsequent track atoms.
pub moof_base_pos: u64,
/// Movie fragment header.
pub mfhd: MfhdAtom,
/// Track fragments.
pub trafs: Vec<TrafAtom>,
}
impl Atom for MoofAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let moof_base_pos = reader.pos() - AtomHeader::HEADER_SIZE;
let mut mfhd = None;
let mut trafs = Vec::new();
let mut iter = AtomIterator::new(reader, header);
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MovieFragmentHeader => {
mfhd = Some(iter.read_atom::<MfhdAtom>()?);
}
AtomType::TrackFragment => {
let traf = iter.read_atom::<TrafAtom>()?;
trafs.push(traf);
}
_ => (),
}
}
if mfhd.is_none() {
return decode_error("isomp4: missing mfhd atom");
}
Ok(MoofAtom { header, moof_base_pos, mfhd: mfhd.unwrap(), trafs })
}
}
@@ -0,0 +1,103 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use symphonia_core::meta::MetadataRevision;
use crate::atoms::{
Atom, AtomHeader, AtomIterator, AtomType, MvexAtom, MvhdAtom, TrakAtom, UdtaAtom,
};
use log::warn;
/// Movie atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MoovAtom {
/// Atom header.
header: AtomHeader,
/// Movie header atom.
pub mvhd: MvhdAtom,
/// Trak atoms.
pub traks: Vec<TrakAtom>,
/// Movie extends atom. The presence of this atom indicates a fragmented stream.
pub mvex: Option<MvexAtom>,
/// User data (usually metadata).
pub udta: Option<UdtaAtom>,
}
impl MoovAtom {
/// If metadata was read, consumes the metadata and returns it.
pub fn take_metadata(&mut self) -> Option<MetadataRevision> {
self.udta.as_mut().and_then(|udta| udta.take_metadata())
}
/// Is the movie segmented.
pub fn is_fragmented(&self) -> bool {
self.mvex.is_some()
}
}
impl Atom for MoovAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mvhd = None;
let mut traks = Vec::new();
let mut mvex = None;
let mut udta = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MovieHeader => {
mvhd = Some(iter.read_atom::<MvhdAtom>()?);
}
AtomType::Track => {
// Gracefully skip malformed tracks (e.g. MJPEG cover-art streams
// in older iTunes M4A files whose stbl or stsd may be missing
// required atoms). The AtomIterator's next_atom_pos was already
// set before read_atom was called, so a failure mid-parse still
// leaves the iterator positioned at the end of this atom.
match iter.read_atom::<TrakAtom>() {
Ok(trak) => traks.push(trak),
Err(e) => warn!("isomp4: skipping malformed trak atom: {}", e),
}
}
AtomType::MovieExtends => {
mvex = Some(iter.read_atom::<MvexAtom>()?);
}
AtomType::UserData => {
udta = Some(iter.read_atom::<UdtaAtom>()?);
}
_ => (),
}
}
if mvhd.is_none() {
return decode_error("isomp4: missing mvhd atom");
}
// If fragmented, the mvex atom should contain a trex atom for each trak atom in moov.
if let Some(mvex) = mvex.as_ref() {
// For each trak, find a matching trex atom using the track id.
for trak in traks.iter() {
let found = mvex.trexs.iter().any(|trex| trex.track_id == trak.tkhd.id);
if !found {
warn!("missing trex atom for trak with id={}", trak.tkhd.id);
}
}
}
Ok(MoovAtom { header, mvhd: mvhd.unwrap(), traks, mvex, udta })
}
}
@@ -0,0 +1,51 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, MehdAtom, TrexAtom};
/// Movie extends atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MvexAtom {
/// Atom header.
pub header: AtomHeader,
/// Movie extends header, optional.
pub mehd: Option<MehdAtom>,
/// Track extends box, one per track.
pub trexs: Vec<TrexAtom>,
}
impl Atom for MvexAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mehd = None;
let mut trexs = Vec::new();
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MovieExtendsHeader => {
mehd = Some(iter.read_atom::<MehdAtom>()?);
}
AtomType::TrackExtends => {
let trex = iter.read_atom::<TrexAtom>()?;
trexs.push(trex);
}
_ => (),
}
}
Ok(MvexAtom { header, mehd, trexs })
}
}
@@ -0,0 +1,80 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
use crate::fp::FpU8;
/// Movie header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MvhdAtom {
/// Atom header.
pub header: AtomHeader,
/// The creation time.
pub ctime: u64,
/// The modification time.
pub mtime: u64,
/// Timescale for the movie expressed as the number of units per second.
pub timescale: u32,
/// The duration of the movie in `timescale` units.
pub duration: u64,
/// The preferred volume to play the movie.
pub volume: FpU8,
}
impl Atom for MvhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, _) = AtomHeader::read_extra(reader)?;
let mut mvhd = MvhdAtom {
header,
ctime: 0,
mtime: 0,
timescale: 0,
duration: 0,
volume: Default::default(),
};
// Version 0 uses 32-bit time values, verion 1 used 64-bit values.
match version {
0 => {
mvhd.ctime = u64::from(reader.read_be_u32()?);
mvhd.mtime = u64::from(reader.read_be_u32()?);
mvhd.timescale = reader.read_be_u32()?;
// 0xffff_ffff is a special case.
mvhd.duration = match reader.read_be_u32()? {
u32::MAX => u64::MAX,
duration => u64::from(duration),
};
}
1 => {
mvhd.ctime = reader.read_be_u64()?;
mvhd.mtime = reader.read_be_u64()?;
mvhd.timescale = reader.read_be_u32()?;
mvhd.duration = reader.read_be_u64()?;
}
_ => return decode_error("isomp4: invalid mvhd version"),
}
// Ignore the preferred playback rate.
let _ = reader.read_be_u32()?;
// Preferred volume.
mvhd.volume = FpU8::parse_raw(reader.read_be_u16()?);
// Remaining fields are ignored.
Ok(mvhd)
}
}
@@ -0,0 +1,72 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::codecs::{CodecParameters, CODEC_TYPE_OPUS};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct OpusAtom {
/// Atom header.
header: AtomHeader,
/// Opus extra data (identification header).
extra_data: Box<[u8]>,
}
impl Atom for OpusAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
const OPUS_MAGIC: &[u8] = b"OpusHead";
const OPUS_MAGIC_LEN: usize = OPUS_MAGIC.len();
const MIN_OPUS_EXTRA_DATA_SIZE: usize = OPUS_MAGIC_LEN + 11;
const MAX_OPUS_EXTRA_DATA_SIZE: usize = MIN_OPUS_EXTRA_DATA_SIZE + 257;
// Offset of the Opus version number in the extra data.
const OPUS_EXTRADATA_VERSION_OFFSET: usize = OPUS_MAGIC_LEN;
// The dops atom contains an Opus identification header excluding the OpusHead magic
// signature. Therefore, the atom data length should be atleast as long as the shortest
// Opus identification header.
let data_len = header.data_len as usize;
if data_len < MIN_OPUS_EXTRA_DATA_SIZE - OPUS_MAGIC_LEN {
return decode_error("isomp4 (opus): opus identification header too short");
}
if data_len > MAX_OPUS_EXTRA_DATA_SIZE - OPUS_MAGIC_LEN {
return decode_error("isomp4 (opus): opus identification header too large");
}
let mut extra_data = vec![0; OPUS_MAGIC_LEN + data_len].into_boxed_slice();
// The Opus magic is excluded in the atom, but the extra data must start with it.
extra_data[..OPUS_MAGIC_LEN].copy_from_slice(OPUS_MAGIC);
// Read the extra data from the atom.
reader.read_buf_exact(&mut extra_data[OPUS_MAGIC_LEN..])?;
// Verify the version number is 0.
if extra_data[OPUS_EXTRADATA_VERSION_OFFSET] != 0 {
return unsupported_error("isomp4 (opus): unsupported opus version");
}
Ok(OpusAtom { header, extra_data })
}
}
impl OpusAtom {
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
codec_params.for_codec(CODEC_TYPE_OPUS).with_extra_data(self.extra_data.clone());
}
}
@@ -0,0 +1,89 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[derive(Debug)]
pub enum ReferenceType {
Segment,
Media,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct SidxReference {
pub reference_type: ReferenceType,
pub reference_size: u32,
pub subsegment_duration: u32,
// pub starts_with_sap: bool,
// pub sap_type: u8,
// pub sap_delta_time: u32,
}
/// Segment index atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct SidxAtom {
/// Atom header.
header: AtomHeader,
pub reference_id: u32,
pub timescale: u32,
pub earliest_pts: u64,
pub first_offset: u64,
pub references: Vec<SidxReference>,
}
impl Atom for SidxAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
// The anchor point for segment offsets is the first byte after this atom.
let anchor = reader.pos() + header.data_len;
let (version, _) = AtomHeader::read_extra(reader)?;
let reference_id = reader.read_be_u32()?;
let timescale = reader.read_be_u32()?;
let (earliest_pts, first_offset) = match version {
0 => (u64::from(reader.read_be_u32()?), anchor + u64::from(reader.read_be_u32()?)),
1 => (reader.read_be_u64()?, anchor + reader.read_be_u64()?),
_ => {
return decode_error("isomp4: invalid sidx version");
}
};
let _reserved = reader.read_be_u16()?;
let reference_count = reader.read_be_u16()?;
let mut references = Vec::new();
for _ in 0..reference_count {
let reference = reader.read_be_u32()?;
let subsegment_duration = reader.read_be_u32()?;
let reference_type = match (reference & 0x8000_0000) != 0 {
false => ReferenceType::Media,
true => ReferenceType::Segment,
};
let reference_size = reference & !0x8000_0000;
// Ignore SAP
let _ = reader.read_be_u32()?;
references.push(SidxReference { reference_type, reference_size, subsegment_duration });
}
Ok(SidxAtom { header, reference_id, timescale, earliest_pts, first_offset, references })
}
}
@@ -0,0 +1,40 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
use crate::fp::FpI8;
/// Sound header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct SmhdAtom {
/// Atom header.
header: AtomHeader,
/// Stereo balance.
pub balance: FpI8,
}
impl Atom for SmhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
// Stereo balance
let balance = FpI8::parse_raw(reader.read_be_u16()? as i16);
// Reserved.
let _ = reader.read_be_u16()?;
Ok(SmhdAtom { header, balance })
}
}
@@ -0,0 +1,108 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType};
use crate::atoms::{Co64Atom, StcoAtom, StscAtom, StsdAtom, StszAtom, SttsAtom};
use log::warn;
/// Sample table atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct StblAtom {
/// Atom header.
header: AtomHeader,
pub stsd: StsdAtom,
pub stts: SttsAtom,
pub stsc: StscAtom,
pub stsz: StszAtom,
pub stco: Option<StcoAtom>,
pub co64: Option<Co64Atom>,
}
impl Atom for StblAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut stsd = None;
let mut stts = None;
let mut stsc = None;
let mut stsz = None;
let mut stco = None;
let mut co64 = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::SampleDescription => {
stsd = Some(iter.read_atom::<StsdAtom>()?);
}
AtomType::TimeToSample => {
stts = Some(iter.read_atom::<SttsAtom>()?);
}
AtomType::CompositionTimeToSample => {
// Composition time to sample atom is only required for video.
warn!("ignoring ctts atom.");
}
AtomType::SyncSample => {
// Sync sample atom is only required for video.
warn!("ignoring stss atom.");
}
AtomType::SampleToChunk => {
stsc = Some(iter.read_atom::<StscAtom>()?);
}
AtomType::SampleSize => {
stsz = Some(iter.read_atom::<StszAtom>()?);
}
AtomType::ChunkOffset => {
stco = Some(iter.read_atom::<StcoAtom>()?);
}
AtomType::ChunkOffset64 => {
co64 = Some(iter.read_atom::<Co64Atom>()?);
}
_ => (),
}
}
if stsd.is_none() {
return decode_error("isomp4: missing stsd atom");
}
if stts.is_none() {
return decode_error("isomp4: missing stts atom");
}
if stsc.is_none() {
return decode_error("isomp4: missing stsc atom");
}
if stsz.is_none() {
return decode_error("isomp4: missing stsz atom");
}
if stco.is_none() && co64.is_none() {
// This is a spec. violation, but some m4a files appear to lack these atoms.
warn!("missing stco or co64 atom");
}
Ok(StblAtom {
header,
stsd: stsd.unwrap(),
stts: stts.unwrap(),
stsc: stsc.unwrap(),
stsz: stsz.unwrap(),
stco,
co64,
})
}
}
@@ -0,0 +1,41 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Chunk offset atom (32-bit version).
#[allow(dead_code)]
#[derive(Debug)]
pub struct StcoAtom {
/// Atom header.
header: AtomHeader,
pub chunk_offsets: Vec<u32>,
}
impl Atom for StcoAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let entry_count = reader.read_be_u32()?;
// TODO: Apply a limit.
let mut chunk_offsets = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
chunk_offsets.push(reader.read_be_u32()?);
}
Ok(StcoAtom { header, chunk_offsets })
}
}
@@ -0,0 +1,109 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct StscEntry {
pub first_chunk: u32,
pub first_sample: u32,
pub samples_per_chunk: u32,
pub sample_desc_index: u32,
}
/// Sample to Chunk Atom
#[allow(dead_code)]
#[derive(Debug)]
pub struct StscAtom {
/// Atom header.
header: AtomHeader,
/// Entries.
pub entries: Vec<StscEntry>,
}
impl StscAtom {
/// Finds the `StscEntry` for the sample indicated by `sample_num`. Note, `sample_num` is indexed
/// relative to the `StscAtom`. Complexity is O(log2 N).
pub fn find_entry_for_sample(&self, sample_num: u32) -> Option<&StscEntry> {
let mut left = 1;
let mut right = self.entries.len();
while left < right {
let mid = left + (right - left) / 2;
let entry = self.entries.get(mid).unwrap();
if entry.first_sample < sample_num {
left = mid + 1;
}
else {
right = mid;
}
}
// The index found above (left) is the exclusive upper bound of all entries where
// first_sample < sample_num. Therefore, the entry to return has an index of left-1. The
// index will never equal 0 so this is safe. If the table were empty, left == 1, thus calling
// get with an index of 0, and safely returning None.
self.entries.get(left - 1)
}
}
impl Atom for StscAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let entry_count = reader.read_be_u32()?;
// TODO: Apply a limit.
let mut entries = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
entries.push(StscEntry {
first_chunk: reader.read_be_u32()? - 1,
first_sample: 0,
samples_per_chunk: reader.read_be_u32()?,
sample_desc_index: reader.read_be_u32()?,
});
}
// Post-process entries to check for errors and calculate the file sample.
if entry_count > 0 {
for i in 0..entry_count as usize - 1 {
// Validate that first_chunk is monotonic across all entries.
if entries[i + 1].first_chunk < entries[i].first_chunk {
return decode_error("isomp4: stsc entry first chunk not monotonic");
}
// Validate that samples per chunk is > 0. Could the entry be ignored?
if entries[i].samples_per_chunk == 0 {
return decode_error("isomp4: stsc entry has 0 samples per chunk");
}
let n = entries[i + 1].first_chunk - entries[i].first_chunk;
entries[i + 1].first_sample =
entries[i].first_sample + (n * entries[i].samples_per_chunk);
}
// Validate that samples per chunk is > 0. Could the entry be ignored?
if entries[entry_count as usize - 1].samples_per_chunk == 0 {
return decode_error("isomp4: stsc entry has 0 samples per chunk");
}
}
Ok(StscAtom { header, entries })
}
}
@@ -0,0 +1,539 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::audio::Channels;
use symphonia_core::codecs::{CodecParameters, CodecType, CODEC_TYPE_MP3, CODEC_TYPE_NULL};
use symphonia_core::codecs::{CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_S16BE, CODEC_TYPE_PCM_S16LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_S24BE, CODEC_TYPE_PCM_S24LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_S32BE, CODEC_TYPE_PCM_S32LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_S8, CODEC_TYPE_PCM_U8};
use symphonia_core::codecs::{CODEC_TYPE_PCM_U16BE, CODEC_TYPE_PCM_U16LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_U24BE, CODEC_TYPE_PCM_U24LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_U32BE, CODEC_TYPE_PCM_U32LE};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{AlacAtom, Atom, AtomHeader, AtomType, EsdsAtom, FlacAtom, OpusAtom, WaveAtom};
use crate::fp::FpU16;
use super::AtomIterator;
/// Sample description atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct StsdAtom {
/// Atom header.
header: AtomHeader,
/// Sample entry.
sample_entry: SampleEntry,
}
impl Atom for StsdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let n_entries = reader.read_be_u32()?;
if n_entries == 0 {
return decode_error("isomp4: missing sample entry");
}
if n_entries > 1 {
return unsupported_error("isomp4: more than 1 sample entry");
}
let sample_entry_header = AtomHeader::read(reader)?;
let sample_entry = match sample_entry_header.atype {
AtomType::Mp4a
| AtomType::Alac
| AtomType::Flac
| AtomType::Opus
| AtomType::Mp3
| AtomType::Lpcm
| AtomType::QtWave
| AtomType::ALaw
| AtomType::MuLaw
| AtomType::U8SampleEntry
| AtomType::S16LeSampleEntry
| AtomType::S16BeSampleEntry
| AtomType::S24SampleEntry
| AtomType::S32SampleEntry
| AtomType::F32SampleEntry
| AtomType::F64SampleEntry => read_audio_sample_entry(reader, sample_entry_header)?,
_ => {
// Potentially video, subtitles, etc.
SampleEntry::Other
}
};
Ok(StsdAtom { header, sample_entry })
}
}
impl StsdAtom {
/// Fill the provided `CodecParameters` using the sample entry.
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
// Audio sample entry.
if let SampleEntry::Audio(ref entry) = self.sample_entry {
// General audio parameters.
codec_params.with_sample_rate(entry.sample_rate as u32);
// Codec-specific parameters.
match entry.codec_specific {
Some(AudioCodecSpecific::Esds(ref esds)) => {
esds.fill_codec_params(codec_params);
}
Some(AudioCodecSpecific::Alac(ref alac)) => {
alac.fill_codec_params(codec_params);
}
Some(AudioCodecSpecific::Flac(ref flac)) => {
flac.fill_codec_params(codec_params);
}
Some(AudioCodecSpecific::Opus(ref opus)) => {
opus.fill_codec_params(codec_params);
}
Some(AudioCodecSpecific::Mp3) => {
codec_params.for_codec(CODEC_TYPE_MP3);
}
Some(AudioCodecSpecific::Pcm(ref pcm)) => {
// PCM codecs.
codec_params
.for_codec(pcm.codec_type)
.with_bits_per_coded_sample(pcm.bits_per_coded_sample)
.with_bits_per_sample(pcm.bits_per_sample)
.with_max_frames_per_packet(pcm.frames_per_packet)
.with_channels(pcm.channels);
}
_ => (),
}
}
}
}
#[derive(Debug)]
pub struct Pcm {
pub codec_type: CodecType,
pub bits_per_sample: u32,
pub bits_per_coded_sample: u32,
pub frames_per_packet: u64,
pub channels: Channels,
}
#[derive(Debug)]
pub enum AudioCodecSpecific {
/// MPEG Elementary Stream descriptor.
Esds(EsdsAtom),
/// Apple Lossless Audio Codec (ALAC).
Alac(AlacAtom),
/// Free Lossless Audio Codec (FLAC).
Flac(FlacAtom),
/// Opus.
Opus(OpusAtom),
/// MP3.
Mp3,
/// PCM codecs.
Pcm(Pcm),
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct AudioSampleEntry {
pub num_channels: u32,
pub sample_size: u16,
pub sample_rate: f64,
pub codec_specific: Option<AudioCodecSpecific>,
}
#[derive(Debug)]
pub enum SampleEntry {
Audio(AudioSampleEntry),
// Video,
// Metadata,
Other,
}
/// Gets if the sample entry atom is for a PCM codec.
fn is_pcm_codec(atype: AtomType) -> bool {
// PCM data in version 0 and 1 is signalled by the sample entry atom type. In version 2, the
// atom type for PCM data is always LPCM.
atype == AtomType::Lpcm || pcm_codec_type(atype) != CODEC_TYPE_NULL
}
/// Gets the PCM codec from the sample entry atom type for version 0 and 1 sample entries.
fn pcm_codec_type(atype: AtomType) -> CodecType {
match atype {
AtomType::U8SampleEntry => CODEC_TYPE_PCM_U8,
AtomType::S16LeSampleEntry => CODEC_TYPE_PCM_S16LE,
AtomType::S16BeSampleEntry => CODEC_TYPE_PCM_S16BE,
AtomType::S24SampleEntry => CODEC_TYPE_PCM_S24LE,
AtomType::S32SampleEntry => CODEC_TYPE_PCM_S32LE,
AtomType::F32SampleEntry => CODEC_TYPE_PCM_F32LE,
AtomType::F64SampleEntry => CODEC_TYPE_PCM_F64LE,
_ => CODEC_TYPE_NULL,
}
}
/// Determines the number of bytes per PCM sample for a PCM codec type.
fn bytes_per_pcm_sample(pcm_codec_type: CodecType) -> u32 {
match pcm_codec_type {
CODEC_TYPE_PCM_S8 | CODEC_TYPE_PCM_U8 => 1,
CODEC_TYPE_PCM_S16BE | CODEC_TYPE_PCM_S16LE => 2,
CODEC_TYPE_PCM_U16BE | CODEC_TYPE_PCM_U16LE => 2,
CODEC_TYPE_PCM_S24BE | CODEC_TYPE_PCM_S24LE => 3,
CODEC_TYPE_PCM_U24BE | CODEC_TYPE_PCM_U24LE => 3,
CODEC_TYPE_PCM_S32BE | CODEC_TYPE_PCM_S32LE => 4,
CODEC_TYPE_PCM_U32BE | CODEC_TYPE_PCM_U32LE => 4,
CODEC_TYPE_PCM_F32BE | CODEC_TYPE_PCM_F32LE => 4,
CODEC_TYPE_PCM_F64BE | CODEC_TYPE_PCM_F64LE => 8,
_ => unreachable!(),
}
}
/// Gets the PCM codec from the LPCM parameters in the version 2 sample entry atom.
fn lpcm_codec_type(bits_per_sample: u32, lpcm_flags: u32) -> CodecType {
let is_floating_point = lpcm_flags & 0x1 != 0;
let is_big_endian = lpcm_flags & 0x2 != 0;
let is_signed = lpcm_flags & 0x4 != 0;
if is_floating_point {
// Floating-point sample format.
match bits_per_sample {
32 => {
if is_big_endian {
CODEC_TYPE_PCM_F32BE
}
else {
CODEC_TYPE_PCM_F32LE
}
}
64 => {
if is_big_endian {
CODEC_TYPE_PCM_F64BE
}
else {
CODEC_TYPE_PCM_F64LE
}
}
_ => CODEC_TYPE_NULL,
}
}
else {
// Integer sample format.
if is_signed {
// Signed-integer sample format.
match bits_per_sample {
8 => CODEC_TYPE_PCM_S8,
16 => {
if is_big_endian {
CODEC_TYPE_PCM_S16BE
}
else {
CODEC_TYPE_PCM_S16LE
}
}
24 => {
if is_big_endian {
CODEC_TYPE_PCM_S24BE
}
else {
CODEC_TYPE_PCM_S24LE
}
}
32 => {
if is_big_endian {
CODEC_TYPE_PCM_S32BE
}
else {
CODEC_TYPE_PCM_S32LE
}
}
_ => CODEC_TYPE_NULL,
}
}
else {
// Unsigned-integer sample format.
match bits_per_sample {
8 => CODEC_TYPE_PCM_U8,
16 => {
if is_big_endian {
CODEC_TYPE_PCM_U16BE
}
else {
CODEC_TYPE_PCM_U16LE
}
}
24 => {
if is_big_endian {
CODEC_TYPE_PCM_U24BE
}
else {
CODEC_TYPE_PCM_U24LE
}
}
32 => {
if is_big_endian {
CODEC_TYPE_PCM_U32BE
}
else {
CODEC_TYPE_PCM_U32LE
}
}
_ => CODEC_TYPE_NULL,
}
}
}
}
/// Gets the audio channels for a version 0 or 1 sample entry.
fn pcm_channels(num_channels: u32) -> Result<Channels> {
match num_channels {
1 => Ok(Channels::FRONT_LEFT),
2 => Ok(Channels::FRONT_LEFT | Channels::FRONT_RIGHT),
_ => decode_error("isomp4: invalid number of channels"),
}
}
/// Gets the audio channels for a version 2 LPCM sample entry.
fn lpcm_channels(num_channels: u32) -> Result<Channels> {
if num_channels < 1 {
return decode_error("isomp4: invalid number of channels");
}
if num_channels > 32 {
return unsupported_error("isomp4: maximum 32 channels");
}
// TODO: For LPCM, the channels are "auxilary". They do not have a speaker assignment. Symphonia
// does not have a way to represent this yet.
let channel_mask = !((!0 << 1) << (num_channels - 1));
match Channels::from_bits(channel_mask) {
Some(channels) => Ok(channels),
_ => unsupported_error("isomp4: unsupported number of channels"),
}
}
fn read_audio_sample_entry<B: ReadBytes>(
reader: &mut B,
mut header: AtomHeader,
) -> Result<SampleEntry> {
// An audio sample entry atom is derived from a base sample entry atom. The audio sample entry
// atom contains the fields of the base sample entry first, then the audio sample entry fields
// next. After those fields, a number of other atoms are nested, including the mandatory
// codec-specific atom. Though the codec-specific atom is nested within the (audio) sample entry
// atom, the (audio) sample entry atom uses the atom type of the codec-specific atom. This is
// odd in-that the final structure will appear to have the codec-specific atom nested within
// itself, which is not actually the case.
let data_start_pos = reader.pos();
// First 6 bytes of all sample entries should be all 0.
reader.ignore_bytes(6)?;
// Sample entry data reference.
let _ = reader.read_be_u16()?;
// The version of the audio sample entry.
let version = reader.read_be_u16()?;
// Skip revision and vendor.
reader.ignore_bytes(6)?;
let mut num_channels = u32::from(reader.read_be_u16()?);
let sample_size = reader.read_be_u16()?;
// Skip compression ID and packet size.
reader.ignore_bytes(4)?;
let mut sample_rate = f64::from(FpU16::parse_raw(reader.read_be_u32()?));
let is_pcm_codec = is_pcm_codec(header.atype);
let mut codec_specific = match version {
0 => {
// Version 0.
if is_pcm_codec {
let codec_type = pcm_codec_type(header.atype);
let bits_per_sample = 8 * bytes_per_pcm_sample(codec_type);
// Validate the codec-derived bytes-per-sample equals the declared bytes-per-sample.
if u32::from(sample_size) != bits_per_sample {
return decode_error("isomp4: invalid pcm sample size");
}
// The original fields describe the PCM sample format.
Some(AudioCodecSpecific::Pcm(Pcm {
codec_type: pcm_codec_type(header.atype),
bits_per_sample,
bits_per_coded_sample: bits_per_sample,
frames_per_packet: 1,
channels: pcm_channels(num_channels)?,
}))
}
else {
None
}
}
1 => {
// Version 1.
// The number of frames (ISO/MP4 samples) per packet. For PCM codecs, this is always 1.
let _frames_per_packet = reader.read_be_u32()?;
// The number of bytes per PCM audio sample. This value supersedes sample_size. For
// non-PCM codecs, this value is not useful.
let bytes_per_audio_sample = reader.read_be_u32()?;
// The number of bytes per PCM audio frame (ISO/MP4 sample). For non-PCM codecs, this
// value is not useful.
let _bytes_per_frame = reader.read_be_u32()?;
// The next value, as defined, is seemingly non-sensical.
let _ = reader.read_be_u32()?;
if is_pcm_codec {
let codec_type = pcm_codec_type(header.atype);
let codec_bytes_per_sample = bytes_per_pcm_sample(codec_type);
// Validate the codec-derived bytes-per-sample equals the declared bytes-per-sample.
if bytes_per_audio_sample != codec_bytes_per_sample {
return decode_error("isomp4: invalid pcm bytes per sample");
}
// The new fields describe the PCM sample format and supersede the original version
// 0 fields.
Some(AudioCodecSpecific::Pcm(Pcm {
codec_type,
bits_per_sample: 8 * codec_bytes_per_sample,
bits_per_coded_sample: 8 * codec_bytes_per_sample,
frames_per_packet: 1,
channels: pcm_channels(num_channels)?,
}))
}
else {
None
}
}
2 => {
// Version 2.
reader.ignore_bytes(4)?;
sample_rate = reader.read_be_f64()?;
num_channels = reader.read_be_u32()?;
if reader.read_be_u32()? != 0x7f00_0000 {
return decode_error("isomp4: audio sample entry v2 reserved must be 0x7f00_0000");
}
// The following fields are only useful for PCM codecs.
let bits_per_sample = reader.read_be_u32()?;
let lpcm_flags = reader.read_be_u32()?;
let _bytes_per_packet = reader.read_be_u32()?;
let lpcm_frames_per_packet = reader.read_be_u32()?;
// This is only valid if this is a PCM codec.
let codec_type = lpcm_codec_type(bits_per_sample, lpcm_flags);
if is_pcm_codec && codec_type != CODEC_TYPE_NULL {
// Like version 1, the new fields describe the PCM sample format and supersede the
// original version 0 fields.
Some(AudioCodecSpecific::Pcm(Pcm {
codec_type,
bits_per_sample,
bits_per_coded_sample: bits_per_sample,
frames_per_packet: u64::from(lpcm_frames_per_packet),
channels: lpcm_channels(num_channels)?,
}))
}
else {
None
}
}
_ => {
return unsupported_error("isomp4: unknown sample entry version");
}
};
// Need to account for the data already read from the atom.
header.data_len -= reader.pos() - data_start_pos;
let mut iter = AtomIterator::new(reader, header);
while let Some(entry_header) = iter.next()? {
match entry_header.atype {
AtomType::Esds => {
// MP4A/ESDS codec-specific atom.
if header.atype != AtomType::Mp4a || codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Esds(iter.read_atom::<EsdsAtom>()?));
}
AtomType::Alac => {
// ALAC codec-specific atom.
if header.atype != AtomType::Alac || codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Alac(iter.read_atom::<AlacAtom>()?));
}
AtomType::FlacDsConfig => {
// FLAC codec-specific atom.
if header.atype != AtomType::Flac || codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Flac(iter.read_atom::<FlacAtom>()?));
}
AtomType::OpusDsConfig => {
// Opus codec-specific atom.
if header.atype != AtomType::Opus || codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Opus(iter.read_atom::<OpusAtom>()?));
}
AtomType::QtWave => {
// The QuickTime WAVE (aka. siDecompressionParam) atom may contain many different
// types of sub-atoms to store decoder parameters.
let wave = iter.read_atom::<WaveAtom>()?;
if let Some(esds) = wave.esds {
if codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Esds(esds));
}
}
_ => (),
}
}
// A MP3 sample entry has no codec-specific atom.
if header.atype == AtomType::Mp3 {
if codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Mp3);
}
Ok(SampleEntry::Audio(AudioSampleEntry {
num_channels,
sample_size,
sample_rate,
codec_specific,
}))
}
@@ -0,0 +1,28 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct StssAtom {
/// Atom header.
header: AtomHeader,
}
impl Atom for StssAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(_reader: &mut B, _header: AtomHeader) -> Result<Self> {
todo!()
}
}
@@ -0,0 +1,58 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[derive(Debug)]
pub enum SampleSize {
Constant(u32),
Variable(Vec<u32>),
}
/// Sample Size Atom
#[allow(dead_code)]
#[derive(Debug)]
pub struct StszAtom {
/// Atom header.
header: AtomHeader,
/// The total number of samples.
pub sample_count: u32,
/// A vector of `sample_count` sample sizes, or a constant size for all samples.
pub sample_sizes: SampleSize,
}
impl Atom for StszAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let sample_size = reader.read_be_u32()?;
let sample_count = reader.read_be_u32()?;
let sample_sizes = if sample_size == 0 {
// TODO: Apply a limit.
let mut entries = Vec::with_capacity(sample_count as usize);
for _ in 0..sample_count {
entries.push(reader.read_be_u32()?);
}
SampleSize::Variable(entries)
}
else {
SampleSize::Constant(sample_size)
};
Ok(StszAtom { header, sample_count, sample_sizes })
}
}
@@ -0,0 +1,105 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[derive(Debug)]
pub struct SampleDurationEntry {
pub sample_count: u32,
pub sample_delta: u32,
}
/// Time-to-sample atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct SttsAtom {
/// Atom header.
header: AtomHeader,
pub entries: Vec<SampleDurationEntry>,
pub total_duration: u64,
}
impl SttsAtom {
/// Get the timestamp and duration for the sample indicated by `sample_num`. Note, `sample_num`
/// is indexed relative to the `SttsAtom`. Complexity of this function in O(N).
pub fn find_timing_for_sample(&self, sample_num: u32) -> Option<(u64, u32)> {
let mut ts = 0;
let mut next_entry_first_sample = 0;
// The Stts atom compactly encodes a mapping between number of samples and sample duration.
// Iterate through each entry until the entry containing the next sample is found. The next
// packet timestamp is then the sum of the product of sample count and sample duration for
// the n-1 iterated entries, plus the product of the number of consumed samples in the n-th
// iterated entry and sample duration.
for entry in &self.entries {
next_entry_first_sample += entry.sample_count;
if sample_num < next_entry_first_sample {
let entry_sample_offset = sample_num + entry.sample_count - next_entry_first_sample;
ts += u64::from(entry.sample_delta) * u64::from(entry_sample_offset);
return Some((ts, entry.sample_delta));
}
ts += u64::from(entry.sample_count) * u64::from(entry.sample_delta);
}
None
}
/// Get the sample that contains the timestamp indicated by `ts`. Note, the returned `sample_num`
/// is indexed relative to the `SttsAtom`. Complexity of this function in O(N).
pub fn find_sample_for_timestamp(&self, ts: u64) -> Option<u32> {
let mut ts_accum = 0;
let mut sample_num = 0;
for entry in &self.entries {
let delta = u64::from(entry.sample_delta) * u64::from(entry.sample_count);
if ts_accum + delta > ts {
sample_num += ((ts - ts_accum) / u64::from(entry.sample_delta)) as u32;
return Some(sample_num);
}
ts_accum += delta;
sample_num += entry.sample_count;
}
None
}
}
impl Atom for SttsAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let entry_count = reader.read_be_u32()?;
let mut total_duration = 0;
// TODO: Limit table length.
let mut entries = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
let sample_count = reader.read_be_u32()?;
let sample_delta = reader.read_be_u32()?;
total_duration += u64::from(sample_count) * u64::from(sample_delta);
entries.push(SampleDurationEntry { sample_count, sample_delta });
}
Ok(SttsAtom { header, entries, total_duration })
}
}
@@ -0,0 +1,84 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Track fragment header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TfhdAtom {
/// Atom header.
header: AtomHeader,
pub track_id: u32,
pub base_data_offset: Option<u64>,
pub sample_desc_idx: Option<u32>,
pub default_sample_duration: Option<u32>,
pub default_sample_size: Option<u32>,
pub default_sample_flags: Option<u32>,
/// If true, there are no samples for this time duration.
pub duration_is_empty: bool,
/// If true, the base data offset for this track is the first byte of the parent containing moof
/// atom.
pub default_base_is_moof: bool,
}
impl Atom for TfhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, flags) = AtomHeader::read_extra(reader)?;
let track_id = reader.read_be_u32()?;
let base_data_offset = match flags & 0x1 {
0 => None,
_ => Some(reader.read_be_u64()?),
};
let sample_desc_idx = match flags & 0x2 {
0 => None,
_ => Some(reader.read_be_u32()?),
};
let default_sample_duration = match flags & 0x8 {
0 => None,
_ => Some(reader.read_be_u32()?),
};
let default_sample_size = match flags & 0x10 {
0 => None,
_ => Some(reader.read_be_u32()?),
};
let default_sample_flags = match flags & 0x20 {
0 => None,
_ => Some(reader.read_be_u32()?),
};
let duration_is_empty = (flags & 0x1_0000) != 0;
// The default-base-is-moof flag is ignored if the base-data-offset flag is set.
let default_base_is_moof = (flags & 0x1 == 0) && (flags & 0x2_0000 != 0);
Ok(TfhdAtom {
header,
track_id,
base_data_offset,
sample_desc_idx,
default_sample_duration,
default_sample_size,
default_sample_flags,
duration_is_empty,
default_base_is_moof,
})
}
}
@@ -0,0 +1,89 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
use crate::fp::FpU8;
/// Track header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TkhdAtom {
/// Atom header.
header: AtomHeader,
/// Track header flags.
pub flags: u32,
/// Creation time.
pub ctime: u64,
/// Modification time.
pub mtime: u64,
/// Track identifier.
pub id: u32,
/// Track duration in the timescale units specified in the movie header. This value is equal to
/// the sum of the durations of all the track's edits.
pub duration: u64,
/// Layer.
pub layer: u16,
/// Grouping identifier.
pub alternate_group: u16,
/// Preferred volume for track playback.
pub volume: FpU8,
}
impl Atom for TkhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, flags) = AtomHeader::read_extra(reader)?;
let mut tkhd = TkhdAtom {
header,
flags,
ctime: 0,
mtime: 0,
id: 0,
duration: 0,
layer: 0,
alternate_group: 0,
volume: Default::default(),
};
// Version 0 uses 32-bit time values, verion 1 used 64-bit values.
match version {
0 => {
tkhd.ctime = u64::from(reader.read_be_u32()?);
tkhd.mtime = u64::from(reader.read_be_u32()?);
tkhd.id = reader.read_be_u32()?;
let _ = reader.read_be_u32()?; // Reserved
tkhd.duration = u64::from(reader.read_be_u32()?);
}
1 => {
tkhd.ctime = reader.read_be_u64()?;
tkhd.mtime = reader.read_be_u64()?;
tkhd.id = reader.read_be_u32()?;
let _ = reader.read_be_u32()?; // Reserved
tkhd.duration = reader.read_be_u64()?;
}
_ => return decode_error("isomp4: invalid tkhd version"),
}
// Reserved
let _ = reader.read_be_u64()?;
tkhd.layer = reader.read_be_u16()?;
tkhd.alternate_group = reader.read_be_u16()?;
tkhd.volume = FpU8::parse_raw(reader.read_be_u16()?);
// The remainder of the header is only useful for video tracks.
Ok(tkhd)
}
}
@@ -0,0 +1,64 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, TfhdAtom, TrunAtom};
/// Track fragment atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TrafAtom {
/// Atom header.
header: AtomHeader,
/// Track fragment header.
pub tfhd: TfhdAtom,
/// Track fragment sample runs.
pub truns: Vec<TrunAtom>,
/// The total number of samples in this track fragment.
pub total_sample_count: u32,
}
impl Atom for TrafAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut tfhd = None;
let mut truns = Vec::new();
let mut iter = AtomIterator::new(reader, header);
let mut total_sample_count = 0;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::TrackFragmentHeader => {
tfhd = Some(iter.read_atom::<TfhdAtom>()?);
}
AtomType::TrackFragmentRun => {
let trun = iter.read_atom::<TrunAtom>()?;
// Increment the total sample count.
total_sample_count += trun.sample_count;
truns.push(trun);
}
_ => (),
}
}
// Tfhd is mandatory.
if tfhd.is_none() {
return decode_error("isomp4: missing tfhd atom");
}
Ok(TrafAtom { header, tfhd: tfhd.unwrap(), truns, total_sample_count })
}
}
@@ -0,0 +1,64 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, EdtsAtom, MdiaAtom, TkhdAtom};
/// Track atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TrakAtom {
/// Atom header.
header: AtomHeader,
/// Track header atom.
pub tkhd: TkhdAtom,
/// Optional, edit list atom.
pub edts: Option<EdtsAtom>,
/// Media atom.
pub mdia: MdiaAtom,
}
impl Atom for TrakAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut tkhd = None;
let mut edts = None;
let mut mdia = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::TrackHeader => {
tkhd = Some(iter.read_atom::<TkhdAtom>()?);
}
AtomType::Edit => {
edts = Some(iter.read_atom::<EdtsAtom>()?);
}
AtomType::Media => {
mdia = Some(iter.read_atom::<MdiaAtom>()?);
}
_ => (),
}
}
if tkhd.is_none() {
return decode_error("isomp4: missing tkhd atom");
}
if mdia.is_none() {
return decode_error("isomp4: missing mdia atom");
}
Ok(TrakAtom { header, tkhd: tkhd.unwrap(), edts, mdia: mdia.unwrap() })
}
}
@@ -0,0 +1,48 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Track extends atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TrexAtom {
/// Atom header.
header: AtomHeader,
/// Track this atom describes.
pub track_id: u32,
/// Default sample description index.
pub default_sample_desc_idx: u32,
/// Default sample duration.
pub default_sample_duration: u32,
/// Default sample size.
pub default_sample_size: u32,
/// Default sample flags.
pub default_sample_flags: u32,
}
impl Atom for TrexAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
Ok(TrexAtom {
header,
track_id: reader.read_be_u32()?,
default_sample_desc_idx: reader.read_be_u32()?,
default_sample_duration: reader.read_be_u32()?,
default_sample_size: reader.read_be_u32()?,
default_sample_flags: reader.read_be_u32()?,
})
}
}
@@ -0,0 +1,321 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use symphonia_core::util::bits;
use crate::atoms::{Atom, AtomHeader};
/// Track fragment run atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TrunAtom {
/// Atom header.
header: AtomHeader,
/// Extended header flags.
flags: u32,
/// Data offset of this run.
pub data_offset: Option<i32>,
/// Number of samples in this run.
pub sample_count: u32,
/// Sample flags for the first sample only.
pub first_sample_flags: Option<u32>,
/// Sample duration for each sample in this run.
pub sample_duration: Vec<u32>,
/// Sample size for each sample in this run.
pub sample_size: Vec<u32>,
/// Sample flags for each sample in this run.
pub sample_flags: Vec<u32>,
/// The total size of all samples in this run. 0 if the sample size flag is not set.
total_sample_size: u64,
/// The total duration of all samples in this run. 0 if the sample duration flag is not set.
total_sample_duration: u64,
}
impl TrunAtom {
// Track fragment run atom flags.
const DATA_OFFSET_PRESENT: u32 = 0x1;
const FIRST_SAMPLE_FLAGS_PRESENT: u32 = 0x4;
const SAMPLE_DURATION_PRESENT: u32 = 0x100;
const SAMPLE_SIZE_PRESENT: u32 = 0x200;
const SAMPLE_FLAGS_PRESENT: u32 = 0x400;
const SAMPLE_COMPOSITION_TIME_OFFSETS_PRESENT: u32 = 0x800;
/// Indicates if sample durations are provided.
pub fn is_sample_duration_present(&self) -> bool {
self.flags & TrunAtom::SAMPLE_DURATION_PRESENT != 0
}
// Indicates if the duration of the first sample is provided.
pub fn is_first_sample_duration_present(&self) -> bool {
match self.first_sample_flags {
Some(flags) => flags & TrunAtom::FIRST_SAMPLE_FLAGS_PRESENT != 0,
None => false,
}
}
/// Indicates if sample sizes are provided.
pub fn is_sample_size_present(&self) -> bool {
self.flags & TrunAtom::SAMPLE_SIZE_PRESENT != 0
}
/// Indicates if the size for the first sample is provided.
pub fn is_first_sample_size_present(&self) -> bool {
match self.first_sample_flags {
Some(flags) => flags & TrunAtom::SAMPLE_SIZE_PRESENT != 0,
None => false,
}
}
/// Indicates if sample flags are provided.
#[allow(dead_code)]
pub fn are_sample_flags_present(&self) -> bool {
self.flags & TrunAtom::SAMPLE_FLAGS_PRESENT != 0
}
/// Indicates if sample composition time offsets are provided.
#[allow(dead_code)]
pub fn are_sample_composition_time_offsets_present(&self) -> bool {
self.flags & TrunAtom::SAMPLE_COMPOSITION_TIME_OFFSETS_PRESENT != 0
}
/// Gets the total duration of all samples.
pub fn total_duration(&self, default_dur: u32) -> u64 {
if self.is_sample_duration_present() {
self.total_sample_duration
}
else {
// The duration of all samples in the track fragment are not explictly known.
if self.sample_count > 0 && self.is_first_sample_duration_present() {
// The first sample has an explictly recorded duration.
u64::from(self.sample_duration[0])
+ u64::from(self.sample_count - 1) * u64::from(default_dur)
}
else {
// All samples have the default duration.
u64::from(self.sample_count) * u64::from(default_dur)
}
}
}
/// Gets the total size of all samples.
pub fn total_size(&self, default_size: u32) -> u64 {
if self.is_sample_size_present() {
self.total_sample_size
}
else if self.sample_count > 0 && self.is_first_sample_size_present() {
u64::from(self.sample_size[0])
+ u64::from(self.sample_count - 1) * u64::from(default_size)
}
else {
u64::from(self.sample_count) * u64::from(default_size)
}
}
/// Get the timestamp and duration of a sample. The desired sample is specified by the
/// trun-relative sample number, `sample_num_rel`.
pub fn sample_timing(&self, sample_num_rel: u32, default_dur: u32) -> (u64, u32) {
debug_assert!(sample_num_rel < self.sample_count);
if self.is_sample_duration_present() {
// All sample durations are unique.
let ts = if sample_num_rel > 0 {
self.sample_duration[..sample_num_rel as usize]
.iter()
.map(|&s| u64::from(s))
.sum::<u64>()
}
else {
0
};
let dur = self.sample_duration[sample_num_rel as usize];
(ts, dur)
}
else {
// The duration of all samples in the track fragment are not unique.
let ts = if sample_num_rel > 0 && self.is_first_sample_duration_present() {
// The first sample has a unique duration.
u64::from(self.sample_duration[0])
+ u64::from(sample_num_rel - 1) * u64::from(default_dur)
}
else {
// Zero or more samples with identical durations.
u64::from(sample_num_rel) * u64::from(default_dur)
};
(ts, default_dur)
}
}
/// Get the size of a sample. The desired sample is specified by the trun-relative sample
/// number, `sample_num_rel`.
pub fn sample_size(&self, sample_num_rel: u32, default_size: u32) -> u32 {
debug_assert!(sample_num_rel < self.sample_count);
if self.is_sample_size_present() {
self.sample_size[sample_num_rel as usize]
}
else if sample_num_rel == 0 && self.is_first_sample_size_present() {
self.sample_size[0]
}
else {
default_size
}
}
/// Get the byte offset and size of a sample. The desired sample is specified by the
/// trun-relative sample number, `sample_num_rel`.
pub fn sample_offset(&self, sample_num_rel: u32, default_size: u32) -> (u64, u32) {
debug_assert!(sample_num_rel < self.sample_count);
if self.is_sample_size_present() {
// All sample sizes are unique.
let offset = if sample_num_rel > 0 {
self.sample_size[..sample_num_rel as usize]
.iter()
.map(|&s| u64::from(s))
.sum::<u64>()
}
else {
0
};
(offset, self.sample_size[sample_num_rel as usize])
}
else {
// The size of all samples in the track are not unique.
let offset = if sample_num_rel > 0 && self.is_first_sample_size_present() {
// The first sample has a unique size.
u64::from(self.sample_size[0])
+ u64::from(sample_num_rel - 1) * u64::from(default_size)
}
else {
// Zero or more identically sized samples.
u64::from(sample_num_rel) * u64::from(default_size)
};
(offset, default_size)
}
}
/// Get the sample number (relative to the trun) of the sample that contains timestamp `ts`.
pub fn ts_sample(&self, ts_rel: u64, default_dur: u32) -> u32 {
let mut sample_num = 0;
let mut ts_delta = ts_rel;
if self.is_sample_duration_present() {
// If the sample durations are present, then each sample duration is independently
// stored. Sum sample durations until the delta is reached.
for &dur in &self.sample_duration {
if u64::from(dur) > ts_delta {
break;
}
ts_delta -= u64::from(dur);
sample_num += 1;
}
}
else {
if self.sample_count > 0 && self.is_first_sample_duration_present() {
// The first sample duration is unique.
let first_sample_dur = u64::from(self.sample_duration[0]);
if ts_delta >= first_sample_dur {
ts_delta -= first_sample_dur;
sample_num += 1;
}
else {
ts_delta -= ts_delta;
}
}
sample_num += ts_delta.checked_div(u64::from(default_dur)).unwrap_or(0) as u32;
}
sample_num
}
}
impl Atom for TrunAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, flags) = AtomHeader::read_extra(reader)?;
let sample_count = reader.read_be_u32()?;
let data_offset = match flags & TrunAtom::DATA_OFFSET_PRESENT {
0 => None,
_ => Some(bits::sign_extend_leq32_to_i32(reader.read_be_u32()?, 32)),
};
let first_sample_flags = match flags & TrunAtom::FIRST_SAMPLE_FLAGS_PRESENT {
0 => None,
_ => Some(reader.read_be_u32()?),
};
// If the first-sample-flags-present flag is set, then the sample-flags-present flag should
// not be set. The samples after the first shall use the default sample flags defined in the
// tfhd or mvex atoms.
if first_sample_flags.is_some() && (flags & TrunAtom::SAMPLE_FLAGS_PRESENT != 0) {
return decode_error(
"isomp4: sample-flag-present and first-sample-flags-present flags are set",
);
}
let mut sample_duration = Vec::new();
let mut sample_size = Vec::new();
let mut sample_flags = Vec::new();
let mut total_sample_size = 0;
let mut total_sample_duration = 0;
// TODO: Apply a limit.
for _ in 0..sample_count {
if (flags & TrunAtom::SAMPLE_DURATION_PRESENT) != 0 {
let duration = reader.read_be_u32()?;
total_sample_duration += u64::from(duration);
sample_duration.push(duration);
}
if (flags & TrunAtom::SAMPLE_SIZE_PRESENT) != 0 {
let size = reader.read_be_u32()?;
total_sample_size += u64::from(size);
sample_size.push(size);
}
if (flags & TrunAtom::SAMPLE_FLAGS_PRESENT) != 0 {
sample_flags.push(reader.read_be_u32()?);
}
// Ignoring composition time for now since it's a video thing...
if (flags & TrunAtom::SAMPLE_COMPOSITION_TIME_OFFSETS_PRESENT) != 0 {
// For version 0, this is a u32.
// For version 1, this is a i32.
let _ = reader.read_be_u32()?;
}
}
Ok(TrunAtom {
header,
flags,
data_offset,
sample_count,
first_sample_flags,
sample_duration,
sample_size,
sample_flags,
total_sample_size,
total_sample_duration,
})
}
}
@@ -0,0 +1,53 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use symphonia_core::meta::MetadataRevision;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, MetaAtom};
/// User data atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct UdtaAtom {
/// Atom header.
header: AtomHeader,
/// Metadata atom.
pub meta: Option<MetaAtom>,
}
impl UdtaAtom {
/// If metadata was read, consumes the metadata and returns it.
pub fn take_metadata(&mut self) -> Option<MetadataRevision> {
self.meta.as_mut().and_then(|meta| meta.take_metadata())
}
}
impl Atom for UdtaAtom {
fn header(&self) -> AtomHeader {
self.header
}
#[allow(clippy::single_match)]
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut meta = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::Meta => {
meta = Some(iter.read_atom::<MetaAtom>()?);
}
_ => (),
}
}
Ok(UdtaAtom { header, meta })
}
}
@@ -0,0 +1,41 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, EsdsAtom};
use super::{AtomIterator, AtomType};
#[allow(dead_code)]
#[derive(Debug)]
pub struct WaveAtom {
/// Atom header.
header: AtomHeader,
pub esds: Option<EsdsAtom>,
}
impl Atom for WaveAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut esds = None;
while let Some(header) = iter.next()? {
if header.atype == AtomType::Esds {
esds = Some(iter.read_atom::<EsdsAtom>()?);
}
}
Ok(WaveAtom { header, esds })
}
}
@@ -0,0 +1,667 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::{errors::end_of_stream_error, support_format};
use symphonia_core::codecs::CodecParameters;
use symphonia_core::errors::{
decode_error, seek_error, unsupported_error, Error, Result, SeekErrorKind,
};
use symphonia_core::formats::prelude::*;
use symphonia_core::io::{MediaSource, MediaSourceStream, ReadBytes, SeekBuffered};
use symphonia_core::meta::{Metadata, MetadataLog};
use symphonia_core::probe::{Descriptor, Instantiate, QueryDescriptor};
use symphonia_core::units::Time;
use std::io::{Seek, SeekFrom};
use std::sync::Arc;
use crate::atoms::{AtomIterator, AtomType};
use crate::atoms::{FtypAtom, MetaAtom, MoofAtom, MoovAtom, MvexAtom, SidxAtom, TrakAtom};
use crate::stream::*;
use log::{debug, info, trace, warn};
/// When `mdat` claims the rest of the file (common moov-at-end M4A), locate `moov` in the
/// tail instead of seeking to EOF (which yields end-of-stream during probe).
fn find_moov_atom_offset_in_tail(
mss: &mut MediaSourceStream,
total_len: u64,
) -> Result<Option<u64>> {
const MAX_SCAN: u64 = 8 * 1024 * 1024;
if total_len < 16 {
return Ok(None);
}
let scan_len = MAX_SCAN.min(total_len) as usize;
let scan_start = total_len - scan_len as u64;
mss.seek(SeekFrom::Start(scan_start))?;
let mut buf = vec![0u8; scan_len];
mss.read_buf_exact(&mut buf).map_err(Error::from)?;
for i in 0..buf.len().saturating_sub(8) {
if &buf[i + 4..i + 8] == b"moov" {
return Ok(Some(scan_start + i as u64));
}
}
Ok(None)
}
pub struct TrackState {
codec_params: CodecParameters,
/// The track number.
track_num: usize,
/// The current segment.
cur_seg: usize,
/// The current sample index relative to the track.
next_sample: u32,
/// The current sample byte position relative to the start of the track.
next_sample_pos: u64,
}
impl TrackState {
#[allow(clippy::single_match)]
pub fn new(track_num: usize, trak: &TrakAtom) -> Self {
let mut codec_params = CodecParameters::new();
codec_params
.with_time_base(TimeBase::new(1, trak.mdia.mdhd.timescale))
.with_n_frames(trak.mdia.mdhd.duration);
// Fill the codec parameters using the sample description atom.
trak.mdia.minf.stbl.stsd.fill_codec_params(&mut codec_params);
Self { codec_params, track_num, cur_seg: 0, next_sample: 0, next_sample_pos: 0 }
}
pub fn codec_params(&self) -> CodecParameters {
self.codec_params.clone()
}
}
/// Information regarding the next sample.
#[derive(Debug)]
struct NextSampleInfo {
/// The track number of the next sample.
track_num: usize,
/// The timestamp of the next sample.
ts: u64,
/// The timestamp expressed in seconds.
time: Time,
/// The duration of the next sample.
dur: u32,
/// The segment containing the next sample.
seg_idx: usize,
}
/// Information regarding a sample.
#[derive(Debug)]
struct SampleDataInfo {
/// The position of the sample in the track.
pos: u64,
/// The length of the sample.
len: u32,
}
/// ISO Base Media File Format (MP4, M4A, MOV, etc.) demultiplexer.
///
/// `IsoMp4Reader` implements a demuxer for the ISO Base Media File Format.
pub struct IsoMp4Reader {
iter: AtomIterator<MediaSourceStream>,
tracks: Vec<Track>,
cues: Vec<Cue>,
metadata: MetadataLog,
/// Segments of the movie. Sorted in ascending order by sequence number.
segs: Vec<Box<dyn StreamSegment>>,
/// State tracker for each track.
track_states: Vec<TrackState>,
/// Optional, movie extends atom used for fragmented streams.
mvex: Option<Arc<MvexAtom>>,
}
impl IsoMp4Reader {
/// Idempotently gets information regarding the next sample of the media stream. This function
/// selects the next sample with the lowest timestamp of all tracks.
fn next_sample_info(&self) -> Result<Option<NextSampleInfo>> {
let mut earliest = None;
// TODO: Consider returning samples based on lowest byte position in the track instead of
// timestamp. This may be important if video tracks are ever decoded (i.e., DTS vs. PTS).
for (state, track) in self.track_states.iter().zip(&self.tracks) {
// Get the timebase of the track used to calculate the presentation time.
let tb = track.codec_params.time_base.unwrap();
// Get the next timestamp for the next sample of the current track. The next sample may
// be in a future segment.
for (seg_idx_delta, seg) in self.segs[state.cur_seg..].iter().enumerate() {
// Try to get the timestamp for the next sample of the track from the segment.
if let Some(timing) = seg.sample_timing(state.track_num, state.next_sample)? {
// Calculate the presentation time using the timestamp.
let sample_time = tb.calc_time(timing.ts);
// Compare the presentation time of the sample from this track to other tracks,
// and select the track with the earliest presentation time.
match earliest {
Some(NextSampleInfo { track_num: _, ts: _, time, dur: _, seg_idx: _ })
if time <= sample_time =>
{
// Earliest is less than or equal to the track's next sample
// presentation time. No need to update earliest.
}
_ => {
// Earliest was either None, or greater than the track's next sample
// presentation time. Update earliest.
earliest = Some(NextSampleInfo {
track_num: state.track_num,
ts: timing.ts,
time: sample_time,
dur: timing.dur,
seg_idx: seg_idx_delta + state.cur_seg,
});
}
}
// Either the next sample of the track had the earliest presentation time seen
// thus far, or it was greater than those from other tracks, but there is no
// reason to check samples in future segments.
break;
}
}
}
Ok(earliest)
}
fn consume_next_sample(&mut self, info: &NextSampleInfo) -> Result<Option<SampleDataInfo>> {
// Get the track state.
let track = &mut self.track_states[info.track_num];
// Get the segment associated with the sample.
let seg = &self.segs[info.seg_idx];
// Get the sample data descriptor.
let sample_data_desc = seg.sample_data(track.track_num, track.next_sample, false)?;
// The sample base position in the sample data descriptor remains constant if the sample
// followed immediately after the previous sample. In this case, the track state's
// next_sample_pos is the position of the current sample. If the base position has jumped,
// then the base position is the position of current the sample.
let pos = if sample_data_desc.base_pos > track.next_sample_pos {
sample_data_desc.base_pos
}
else {
track.next_sample_pos
};
// Advance the track's current segment to the next sample's segment.
track.cur_seg = info.seg_idx;
// Advance the track's next sample number and position.
track.next_sample += 1;
track.next_sample_pos = pos + u64::from(sample_data_desc.size);
Ok(Some(SampleDataInfo { pos, len: sample_data_desc.size }))
}
fn try_read_more_segments(&mut self) -> Result<()> {
// Continue iterating over atoms until a segment (a moof + mdat atom pair) is found. All
// other atoms will be ignored.
while let Some(header) = self.iter.next_no_consume()? {
match header.atype {
AtomType::MediaData => {
// Consume the atom from the iterator so that on the next iteration a new atom
// will be read.
self.iter.consume_atom();
return Ok(());
}
AtomType::MovieFragment => {
let moof = self.iter.read_atom::<MoofAtom>()?;
// A moof segment can only be created if the mvex atom is present.
if let Some(mvex) = &self.mvex {
// Get the last segment. Note, there will always be one segment because the
// moov atom is converted into a segment when the reader is instantiated.
let last_seg = self.segs.last().unwrap();
// Create a new segment for the moof atom.
let seg = MoofSegment::new(moof, mvex.clone(), last_seg.as_ref());
// Segments should have a monotonic sequence number.
if seg.sequence_num() <= last_seg.sequence_num() {
warn!("moof fragment has a non-monotonic sequence number.");
}
// Push the segment.
self.segs.push(Box::new(seg));
}
else {
// TODO: This is a fatal error.
return decode_error("isomp4: moof atom present without mvex atom");
}
}
_ => {
trace!("skipping atom: {:?}.", header.atype);
self.iter.consume_atom();
}
}
}
// If no atoms were returned above, then the end-of-stream has been reached.
end_of_stream_error()
}
fn seek_track_by_time(&mut self, track_num: usize, time: Time) -> Result<SeekedTo> {
// Convert time to timestamp for the track.
if let Some(track) = self.tracks.get(track_num) {
let tb = track.codec_params.time_base.unwrap();
self.seek_track_by_ts(track_num, tb.calc_timestamp(time))
}
else {
seek_error(SeekErrorKind::Unseekable)
}
}
fn seek_track_by_ts(&mut self, track_num: usize, ts: u64) -> Result<SeekedTo> {
debug!("seeking track={} to frame_ts={}", track_num, ts);
struct SeekLocation {
seg_idx: usize,
sample_num: u32,
}
let mut seek_loc = None;
let mut seg_skip = 0;
loop {
// Iterate over all segments and attempt to find the segment and sample number that
// contains the desired timestamp. Skip segments already examined.
for (seg_idx, seg) in self.segs.iter().enumerate().skip(seg_skip) {
if let Some(sample_num) = seg.ts_sample(track_num, ts)? {
seek_loc = Some(SeekLocation { seg_idx, sample_num });
break;
}
// Mark the segment as examined.
seg_skip = seg_idx + 1;
}
// If a seek location is found, break.
if seek_loc.is_some() {
break;
}
// Otherwise, try to read more segments from the stream.
self.try_read_more_segments()?;
}
if let Some(seek_loc) = seek_loc {
let seg = &self.segs[seek_loc.seg_idx];
// Get the sample information.
let data_desc = seg.sample_data(track_num, seek_loc.sample_num, true)?;
// Update the track's next sample information to point to the seeked sample.
let track = &mut self.track_states[track_num];
track.cur_seg = seek_loc.seg_idx;
track.next_sample = seek_loc.sample_num;
track.next_sample_pos = data_desc.base_pos + data_desc.offset.unwrap();
// Get the actual timestamp for this sample.
let timing = seg.sample_timing(track_num, seek_loc.sample_num)?.unwrap();
debug!(
"seeked track={} to packet_ts={} (delta={})",
track_num,
timing.ts,
timing.ts as i64 - ts as i64
);
Ok(SeekedTo { track_id: track_num as u32, required_ts: ts, actual_ts: timing.ts })
}
else {
// Timestamp was not found.
seek_error(SeekErrorKind::OutOfRange)
}
}
}
impl QueryDescriptor for IsoMp4Reader {
fn query() -> &'static [Descriptor] {
&[support_format!(
"isomp4",
"ISO Base Media File Format",
&["mp4", "m4a", "m4p", "m4b", "m4r", "m4v", "mov"],
&["video/mp4", "audio/mp4"],
&[b"ftyp"] // Top-level atoms
)]
}
fn score(_context: &[u8]) -> u8 {
255
}
}
impl FormatReader for IsoMp4Reader {
fn try_new(mut mss: MediaSourceStream, _options: &FormatOptions) -> Result<Self> {
// To get to beginning of the atom.
mss.seek_buffered_rel(-4);
let is_seekable = mss.is_seekable();
let mut ftyp = None;
let mut moov = None;
let mut sidx = None;
// Get the total length of the stream, if possible.
let total_len = if is_seekable {
let pos = mss.pos();
let len = mss.byte_len().ok_or(Error::SeekError(SeekErrorKind::Unseekable))?;
mss.seek(SeekFrom::Start(pos))?;
info!("stream is seekable with len={} bytes.", len);
Some(len)
}
else {
None
};
let mut metadata = MetadataLog::default();
// Parse all atoms if the stream is seekable, otherwise parse all atoms up-to the mdat atom.
let mut iter = AtomIterator::new_root(mss, total_len);
while let Some(header) = iter.next()? {
// Top-level atoms.
match header.atype {
AtomType::FileType => {
ftyp = Some(iter.read_atom::<FtypAtom>()?);
}
AtomType::Movie => {
moov = Some(iter.read_atom::<MoovAtom>()?);
}
AtomType::SegmentIndex => {
// If the stream is not seekable, then it can only be assumed that the first
// segment index atom is indeed the first segment index because the format
// reader cannot practically skip past this point.
if !is_seekable {
sidx = Some(iter.read_atom::<SidxAtom>()?);
break;
}
else {
// If the stream is seekable, examine all segment indexes and select the
// index with the earliest presentation timestamp to be the first.
let new_sidx = iter.read_atom::<SidxAtom>()?;
let is_earlier = match &sidx {
Some(sidx) => new_sidx.earliest_pts < sidx.earliest_pts,
_ => true,
};
if is_earlier {
sidx = Some(new_sidx);
}
}
}
AtomType::MediaData | AtomType::MovieFragment => {
// The mdat atom contains the codec bitstream data. For segmented streams, a
// moof + mdat pair is required for playback. If the source is unseekable then
// the format reader cannot skip past these atoms without dropping samples.
if !is_seekable {
// If the moov atom hasn't been seen before the moof and/or mdat atom, and
// the stream is not seekable, then the mp4 is not streamable.
if moov.is_none() || ftyp.is_none() {
warn!("mp4 is not streamable.");
}
// The remainder of the stream will be read incrementally.
break;
}
let end = iter.current_atom_end();
let file_len = total_len.unwrap_or(end);
if moov.is_none() {
let mut mss = iter.into_inner();
let resume_at = if end < file_len.saturating_sub(8) {
// Bounded mdat — `moov` is the next top-level sibling.
end
} else if let Some(tl) = total_len {
match find_moov_atom_offset_in_tail(&mut mss, tl)? {
Some(off) => off,
None => return unsupported_error("isomp4: missing moov atom"),
}
} else {
end
};
mss.seek(SeekFrom::Start(resume_at))?;
// `AtomIterator::new_root` treats `len` as bytes available **from the
// current reader position**, not the absolute file length. Passing
// `total_len` here makes the iterator think the file is `total_len`
// bytes long after the seek, so reading the last atom's trailer
// (e.g. a tail `moov`) succeeds, but the next `iter.next()` then keeps
// reading past EOF and returns `end of stream`. Pass the **remaining**
// length instead so the iterator stops cleanly when the last atom ends.
let remaining = total_len.map(|tl| tl.saturating_sub(resume_at));
iter = AtomIterator::new_root(mss, remaining);
} else if moov.is_some() {
// `moov` was already parsed (fast-start layout). Skip the `mdat` body
// without linear-reading it (holes in RangedHttpSource). Never seek to
// `file_len` — that is one byte past the last valid offset and makes the
// next atom read return end-of-stream on in-memory sources.
if end >= file_len.saturating_sub(8) {
break;
}
let mut mss = iter.into_inner();
mss.seek(SeekFrom::Start(end))?;
let remaining = total_len.map(|tl| tl.saturating_sub(end));
iter = AtomIterator::new_root(mss, remaining);
}
}
AtomType::Meta => {
// Read the metadata atom and append it to the log.
let mut meta = iter.read_atom::<MetaAtom>()?;
if let Some(rev) = meta.take_metadata() {
metadata.push(rev);
}
}
AtomType::Free => (),
AtomType::Skip => (),
_ => {
info!("skipping top-level atom: {:?}.", header.atype);
}
}
}
if ftyp.is_none() {
return unsupported_error("isomp4: missing ftyp atom");
}
if moov.is_none() {
return unsupported_error("isomp4: missing moov atom");
}
// If the stream was seekable, then all atoms in the media source stream were scanned. Seek
// back to the first mdat atom for playback. If the stream is not seekable, then the atom
// iterator is currently positioned at the first mdat atom.
if is_seekable {
let mut mss = iter.into_inner();
mss.seek(SeekFrom::Start(0))?;
iter = AtomIterator::new_root(mss, total_len);
while let Some(header) = iter.next_no_consume()? {
match header.atype {
AtomType::MediaData | AtomType::MovieFragment => break,
_ => (),
}
iter.consume_atom();
}
}
let mut moov = moov.unwrap();
if moov.is_fragmented() {
// If a Segment Index (sidx) atom was found, add the segments contained within.
if sidx.is_some() {
info!("stream is segmented with a segment index.");
}
else {
info!("stream is segmented without a segment index.");
}
}
if let Some(rev) = moov.take_metadata() {
metadata.push(rev);
}
// Instantiate a TrackState for each track in the stream.
let track_states = moov
.traks
.iter()
.enumerate()
.map(|(t, trak)| TrackState::new(t, trak))
.collect::<Vec<TrackState>>();
// Instantiate a Tracks for all tracks above.
let tracks = track_states
.iter()
.map(|track| Track::new(track.track_num as u32, track.codec_params()))
.collect();
// A Movie Extends (mvex) atom is required to support segmented streams. If the mvex atom is
// present, wrap it in an Arc so it can be shared amongst all segments.
let mvex = moov.mvex.take().map(Arc::new);
// The number of tracks specified in the moov atom must match the number in the mvex atom.
if let Some(mvex) = &mvex {
if mvex.trexs.len() != moov.traks.len() {
return decode_error("isomp4: mvex and moov track number mismatch");
}
}
let segs: Vec<Box<dyn StreamSegment>> = vec![Box::new(MoovSegment::new(moov))];
Ok(IsoMp4Reader {
iter,
tracks,
cues: Default::default(),
metadata,
track_states,
segs,
mvex,
})
}
fn next_packet(&mut self) -> Result<Packet> {
// Get the index of the track with the next-nearest (minimum) timestamp.
let next_sample_info = loop {
// Using the current set of segments, try to get the next sample info.
if let Some(info) = self.next_sample_info()? {
break info;
}
else {
// No more segments. If the stream is unseekable, it may be the case that there are
// more segments coming. Iterate atoms until a new segment is found or the
// end-of-stream is reached.
self.try_read_more_segments()?;
}
};
// Get the position and length information of the next sample.
let sample_info = self.consume_next_sample(&next_sample_info)?.unwrap();
let reader = self.iter.inner_mut();
// Attempt a fast seek within the buffer cache.
if reader.seek_buffered(sample_info.pos) != sample_info.pos {
if reader.is_seekable() {
// Fallback to a slow seek if the stream is seekable.
reader.seek(SeekFrom::Start(sample_info.pos))?;
}
else if sample_info.pos > reader.pos() {
// The stream is not seekable but the desired seek position is ahead of the reader's
// current position, thus the seek can be emulated by ignoring the bytes up to the
// the desired seek position.
reader.ignore_bytes(sample_info.pos - reader.pos())?;
}
else {
// The stream is not seekable and the desired seek position falls outside the lower
// bound of the buffer cache. This sample cannot be read.
return decode_error("isomp4: packet out-of-bounds for a non-seekable stream");
}
}
Ok(Packet::new_from_boxed_slice(
next_sample_info.track_num as u32,
next_sample_info.ts,
u64::from(next_sample_info.dur),
reader.read_boxed_slice_exact(sample_info.len as usize)?,
))
}
fn metadata(&mut self) -> Metadata<'_> {
self.metadata.metadata()
}
fn cues(&self) -> &[Cue] {
&self.cues
}
fn tracks(&self) -> &[Track] {
&self.tracks
}
fn seek(&mut self, _mode: SeekMode, to: SeekTo) -> Result<SeekedTo> {
if self.tracks.is_empty() {
return seek_error(SeekErrorKind::Unseekable);
}
match to {
SeekTo::TimeStamp { ts, track_id } => {
let selected_track_id = track_id as usize;
// The seek timestamp is in timebase units specific to the selected track. Get the
// selected track and use the timebase to convert the timestamp into time units so
// that the other tracks can be seeked.
if let Some(selected_track) = self.tracks().get(selected_track_id) {
// Convert to time units.
let time = selected_track.codec_params.time_base.unwrap().calc_time(ts);
// Seek all tracks excluding the primary track to the desired time.
for t in 0..self.track_states.len() {
if t != selected_track_id {
self.seek_track_by_time(t, time)?;
}
}
// Seek the primary track and return the result.
self.seek_track_by_ts(selected_track_id, ts)
}
else {
seek_error(SeekErrorKind::Unseekable)
}
}
SeekTo::Time { time, track_id } => {
// Select the first track if a selected track was not provided.
let selected_track_id = track_id.unwrap_or(0) as usize;
// Seek all tracks excluding the selected track and discard the result.
for t in 0..self.track_states.len() {
if t != selected_track_id {
self.seek_track_by_time(t, time)?;
}
}
// Seek the primary track and return the result.
self.seek_track_by_time(selected_track_id, time)
}
}
}
fn into_inner(self: Box<Self>) -> MediaSourceStream {
self.iter.into_inner()
}
}
@@ -0,0 +1,31 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use std::fmt;
/// Four character codes for typical Ftyps (reference: http://ftyps.com/).
#[derive(PartialEq, Eq, Clone, Copy)]
#[repr(transparent)]
pub struct FourCc {
val: [u8; 4],
}
impl FourCc {
/// Construct a new FourCC code from the given byte array.
pub fn new(val: [u8; 4]) -> Self {
Self { val }
}
}
impl fmt::Debug for FourCc {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match std::str::from_utf8(&self.val) {
Ok(name) => f.write_str(name),
_ => write!(f, "{:x?}", self.val),
}
}
}
@@ -0,0 +1,78 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/// An unsigned 16.16-bit fixed point value.
#[derive(Copy, Clone, Debug, Default)]
pub struct FpU16(u32);
impl FpU16 {
pub fn new(val: u16) -> Self {
Self(u32::from(val) << 16)
}
pub fn parse_raw(val: u32) -> Self {
Self(val)
}
}
impl From<FpU16> for f64 {
fn from(fp: FpU16) -> Self {
f64::from(fp.0) / f64::from(1u32 << 16)
}
}
/// An unsigned 8.8-bit fixed point value.
#[derive(Copy, Clone, Debug, Default)]
pub struct FpU8(u16);
impl FpU8 {
pub fn new(val: u8) -> Self {
Self(u16::from(val) << 8)
}
pub fn parse_raw(val: u16) -> Self {
Self(val)
}
}
impl From<FpU8> for f64 {
fn from(fp: FpU8) -> Self {
f64::from(fp.0) / f64::from(1u16 << 8)
}
}
impl From<FpU8> for f32 {
fn from(fp: FpU8) -> Self {
f32::from(fp.0) / f32::from(1u16 << 8)
}
}
/// An unsigned 8.8-bit fixed point value.
#[derive(Copy, Clone, Debug, Default)]
pub struct FpI8(i16);
impl FpI8 {
pub fn new(val: i8) -> Self {
Self(i16::from(val) * 0x100)
}
pub fn parse_raw(val: i16) -> Self {
Self(val)
}
}
impl From<FpI8> for f64 {
fn from(fp: FpI8) -> Self {
f64::from(fp.0) / f64::from(1u16 << 8)
}
}
impl From<FpI8> for f32 {
fn from(fp: FpI8) -> Self {
f32::from(fp.0) / f32::from(1u16 << 8)
}
}
@@ -0,0 +1,22 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
#![warn(rust_2018_idioms)]
// The following lints are allowed in all Symphonia crates. Please see clippy.toml for their
// justification.
#![allow(clippy::comparison_chain)]
#![allow(clippy::excessive_precision)]
#![allow(clippy::identity_op)]
#![allow(clippy::manual_range_contains)]
mod atoms;
mod demuxer;
mod fourcc;
mod fp;
mod stream;
pub use demuxer::IsoMp4Reader;
@@ -0,0 +1,444 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Error, Result};
use crate::atoms::{stsz::SampleSize, Co64Atom, MoofAtom, MoovAtom, MvexAtom, StcoAtom, TrafAtom};
use std::ops::Range;
use std::sync::Arc;
/// Sample data information.
pub struct SampleDataDesc {
/// The starting byte position within the media data of the group of samples that contains the
/// sample described.
pub base_pos: u64,
/// The offset relative to the base position of the sample described.
pub offset: Option<u64>,
/// The size of the sample.
pub size: u32,
}
/// Timing information for one sample.
pub struct SampleTiming {
/// The timestamp of the sample.
pub ts: u64,
/// The duration of the sample.
pub dur: u32,
}
pub trait StreamSegment: Send + Sync {
/// Gets the sequence number of this segment.
fn sequence_num(&self) -> u32;
/// Gets the first and last sample numbers for the track `track_num`.
fn track_sample_range(&self, track_num: usize) -> Range<u32>;
/// Gets the first and last sample timestamps for the track `track_num`.
fn track_ts_range(&self, track_num: usize) -> Range<u64>;
/// Get the timestamp and duration for the sample indicated by `sample_num` for the track
/// `track_num`.
fn sample_timing(&self, track_num: usize, sample_num: u32) -> Result<Option<SampleTiming>>;
/// Get the sample number of the sample containing the timestamp indicated by `ts` for track
// `track_num`.
fn ts_sample(&self, track_num: usize, ts: u64) -> Result<Option<u32>>;
/// Get the byte position of the group of samples containing the sample indicated by
/// `sample_num` for track `track_num`, and it's size.
///
/// Optionally, the offset of the sample relative to the aforementioned byte position can be
/// returned.
fn sample_data(
&self,
track_num: usize,
sample_num: u32,
get_offset: bool,
) -> Result<SampleDataDesc>;
}
/// Track-to-stream sequencing information.
#[derive(Copy, Clone, Debug, Default)]
struct SequenceInfo {
/// The sample number of the first sample of a track in a fragment.
first_sample: u32,
/// The timestamp of the first sample of a track in a fragment.
first_ts: u64,
/// The total duration of all samples of a track in a fragment.
total_sample_duration: u64,
/// The total sample count of a track in a fragment.
total_sample_count: u32,
/// If present in the moof segment, this is the index of the track fragment atom for the track
/// this sequence information is associated with.
traf_idx: Option<usize>,
}
pub struct MoofSegment {
moof: MoofAtom,
mvex: Arc<MvexAtom>,
seq: Vec<SequenceInfo>,
}
impl MoofSegment {
/// Instantiate a new segment from a `MoofAtom`.
pub fn new(moof: MoofAtom, mvex: Arc<MvexAtom>, prev: &dyn StreamSegment) -> MoofSegment {
let mut seq = Vec::with_capacity(mvex.trexs.len());
// Calculate the sequence information for each track, even if not present in the fragment.
for (track_num, trex) in mvex.trexs.iter().enumerate() {
let mut info = SequenceInfo {
first_sample: prev.track_sample_range(track_num).end,
first_ts: prev.track_ts_range(track_num).end,
..Default::default()
};
// Find the track fragment for the track.
for (traf_idx, traf) in moof.trafs.iter().enumerate() {
if trex.track_id != traf.tfhd.track_id {
continue;
}
// Calculate the total duration of all runs in the fragment for the track.
let default_dur =
traf.tfhd.default_sample_duration.unwrap_or(trex.default_sample_duration);
for trun in traf.truns.iter() {
info.total_sample_duration += trun.total_duration(default_dur);
}
info.total_sample_count = traf.total_sample_count;
info.traf_idx = Some(traf_idx);
}
seq.push(info);
}
MoofSegment { moof, mvex, seq }
}
/// Try to get the Track Fragment atom associated with the track identified by `track_num`.
fn try_get_traf(&self, track_num: usize) -> Option<&TrafAtom> {
debug_assert!(track_num < self.seq.len());
self.seq[track_num].traf_idx.map(|idx| &self.moof.trafs[idx])
}
}
impl StreamSegment for MoofSegment {
fn sequence_num(&self) -> u32 {
self.moof.mfhd.sequence_number
}
fn sample_timing(&self, track_num: usize, sample_num: u32) -> Result<Option<SampleTiming>> {
// Get the track fragment associated with track_num.
let traf = match self.try_get_traf(track_num) {
Some(traf) => traf,
None => return Ok(None),
};
let mut sample_num_rel = sample_num - self.seq[track_num].first_sample;
let mut trun_ts_offset = self.seq[track_num].first_ts;
let default_dur = traf
.tfhd
.default_sample_duration
.unwrap_or(self.mvex.trexs[track_num].default_sample_duration);
for trun in traf.truns.iter() {
// If the sample is contained within the this track run, get the timing of of the
// sample.
if sample_num_rel < trun.sample_count {
let (ts, dur) = trun.sample_timing(sample_num_rel, default_dur);
return Ok(Some(SampleTiming { ts: trun_ts_offset + ts, dur }));
}
let trun_dur = trun.total_duration(default_dur);
sample_num_rel -= trun.sample_count;
trun_ts_offset += trun_dur;
}
Ok(None)
}
fn ts_sample(&self, track_num: usize, ts: u64) -> Result<Option<u32>> {
// Get the track fragment associated with track_num.
let traf = match self.try_get_traf(track_num) {
Some(traf) => traf,
None => return Ok(None),
};
let mut sample_num = self.seq[track_num].first_sample;
let mut ts_accum = self.seq[track_num].first_ts;
let default_dur = traf
.tfhd
.default_sample_duration
.unwrap_or(self.mvex.trexs[track_num].default_sample_duration);
for trun in traf.truns.iter() {
// Get the total duration of this track run.
let trun_dur = trun.total_duration(default_dur);
// If the timestamp after the track run is greater than the desired timestamp, then the
// desired sample must be in this run of samples.
if ts_accum + trun_dur > ts {
sample_num += trun.ts_sample(ts - ts_accum, default_dur);
return Ok(Some(sample_num));
}
sample_num += trun.sample_count;
ts_accum += trun_dur;
}
Ok(None)
}
fn sample_data(
&self,
track_num: usize,
sample_num: u32,
get_offset: bool,
) -> Result<SampleDataDesc> {
// Get the track fragment associated with track_num.
let traf = self.try_get_traf(track_num).unwrap();
// If an explicit anchor-point is set, then use that for the position, otherwise use the
// first-byte of the enclosing moof atom.
let traf_base_pos = match traf.tfhd.base_data_offset {
Some(pos) => pos,
_ => self.moof.moof_base_pos,
};
let mut sample_num_rel = sample_num - self.seq[track_num].first_sample;
let mut trun_offset = traf_base_pos;
let default_size =
traf.tfhd.default_sample_size.unwrap_or(self.mvex.trexs[track_num].default_sample_size);
for trun in traf.truns.iter() {
// If a data offset is present for this track fragment run, then calculate the new base
// position for the run. When a data offset is not present, do nothing because this run
// follows the previous run.
if let Some(offset) = trun.data_offset {
// The offset for the run is relative to the anchor-point defined in the track
// fragment header.
trun_offset = if offset.is_negative() {
traf_base_pos - u64::from(offset.wrapping_abs() as u32)
}
else {
traf_base_pos + offset as u64
};
}
if sample_num_rel < trun.sample_count {
let (offset, size) = if get_offset {
// Get the size and offset of the sample.
let (offset, size) = trun.sample_offset(sample_num_rel, default_size);
(Some(offset), size)
}
else {
// Just get the size of the sample.
let size = trun.sample_size(sample_num_rel, default_size);
(None, size)
};
return Ok(SampleDataDesc { base_pos: trun_offset, size, offset });
}
// Get the total size of the track fragment run.
let trun_size = trun.total_size(default_size);
sample_num_rel -= trun.sample_count;
trun_offset += trun_size;
}
decode_error("isomp4: invalid sample index")
}
fn track_sample_range(&self, track_num: usize) -> Range<u32> {
debug_assert!(track_num < self.seq.len());
let track = &self.seq[track_num];
track.first_sample..track.first_sample + track.total_sample_count
}
fn track_ts_range(&self, track_num: usize) -> Range<u64> {
debug_assert!(track_num < self.seq.len());
let track = &self.seq[track_num];
track.first_ts..track.first_ts + track.total_sample_duration
}
}
fn get_chunk_offset(
stco: &Option<StcoAtom>,
co64: &Option<Co64Atom>,
chunk: usize,
) -> Result<Option<u64>> {
// Get the offset from either the stco or co64 atoms.
if let Some(stco) = stco.as_ref() {
// 32-bit offset
if let Some(offset) = stco.chunk_offsets.get(chunk) {
Ok(Some(u64::from(*offset)))
}
else {
decode_error("isomp4: missing stco entry")
}
}
else if let Some(co64) = co64.as_ref() {
// 64-bit offset
if let Some(offset) = co64.chunk_offsets.get(chunk) {
Ok(Some(*offset))
}
else {
decode_error("isomp4: missing co64 entry")
}
}
else {
// This should never happen because it is mandatory to have either a stco or co64 atom.
decode_error("isomp4: missing stco or co64 atom")
}
}
pub struct MoovSegment {
moov: MoovAtom,
}
impl MoovSegment {
/// Instantiate a segment from the provide moov atom.
pub fn new(moov: MoovAtom) -> MoovSegment {
MoovSegment { moov }
}
}
impl StreamSegment for MoovSegment {
fn sequence_num(&self) -> u32 {
// The segment defined by the moov atom is always 0.
0
}
fn sample_timing(&self, track_num: usize, sample_num: u32) -> Result<Option<SampleTiming>> {
// Get the trak atom associated with track_num.
debug_assert!(track_num < self.moov.traks.len());
let trak = &self.moov.traks[track_num];
// Find the sample timing. Note, complexity of O(N).
let timing = trak.mdia.minf.stbl.stts.find_timing_for_sample(sample_num);
if let Some((ts, dur)) = timing {
Ok(Some(SampleTiming { ts, dur }))
}
else {
Ok(None)
}
}
fn ts_sample(&self, track_num: usize, ts: u64) -> Result<Option<u32>> {
// Get the trak atom associated with track_num.
debug_assert!(track_num < self.moov.traks.len());
let trak = &self.moov.traks[track_num];
// Find the sample timestamp. Note, complexity of O(N).
Ok(trak.mdia.minf.stbl.stts.find_sample_for_timestamp(ts))
}
fn sample_data(
&self,
track_num: usize,
sample_num: u32,
get_offset: bool,
) -> Result<SampleDataDesc> {
// Get the trak atom associated with track_num.
debug_assert!(track_num < self.moov.traks.len());
let trak = &self.moov.traks[track_num];
// Get the constituent tables.
let stsz = &trak.mdia.minf.stbl.stsz;
let stsc = &trak.mdia.minf.stbl.stsc;
let stco = &trak.mdia.minf.stbl.stco;
let co64 = &trak.mdia.minf.stbl.co64;
// Find the sample-to-chunk mapping. Note, complexity of O(log N).
let group = stsc
.find_entry_for_sample(sample_num)
.ok_or(Error::DecodeError("invalid sample index"))?;
// Index of the sample relative to the chunk group.
let sample_in_group = sample_num - group.first_sample;
// Index of the chunk containing the sample relative to the chunk group.
let chunk_in_group = sample_in_group / group.samples_per_chunk;
// Index of the chunk containing the sample relative to the entire stream.
let chunk_in_stream = group.first_chunk + chunk_in_group;
// Get the byte position of the first sample of the chunk containing the sample.
let base_pos = get_chunk_offset(stco, co64, chunk_in_stream as usize)?.unwrap();
// Determine the absolute sample byte position if requested by calculating the offset of
// the sample from the base position of the chunk.
let offset = if get_offset {
// Index of the sample relative to the chunk containing the sample.
let sample_in_chunk = sample_in_group - (chunk_in_group * group.samples_per_chunk);
// Calculat the byte offset of the sample relative to the chunk containing it.
let offset = match stsz.sample_sizes {
SampleSize::Constant(size) => {
// Constant size samples can be calculated directly.
u64::from(sample_in_chunk) * u64::from(size)
}
SampleSize::Variable(ref entries) => {
// For variable size samples, sum the sizes of all the samples preceeding the
// desired sample in the chunk.
let chunk_first_sample = (sample_num - sample_in_chunk) as usize;
if let Some(samples) = entries.get(chunk_first_sample..sample_num as usize) {
samples.iter().map(|&size| u64::from(size)).sum()
}
else {
return decode_error("isomp4: missing one or more stsz entries");
}
}
};
Some(offset)
}
else {
None
};
// Get the size in bytes of the sample.
let size = match stsz.sample_sizes {
SampleSize::Constant(size) => size,
SampleSize::Variable(ref entries) => {
if let Some(size) = entries.get(sample_num as usize) {
*size
}
else {
return decode_error("isomp4: missing stsz entry");
}
}
};
Ok(SampleDataDesc { base_pos, size, offset })
}
fn track_sample_range(&self, track_num: usize) -> Range<u32> {
debug_assert!(track_num < self.moov.traks.len());
0..self.moov.traks[track_num].mdia.minf.stbl.stsz.sample_count
}
fn track_ts_range(&self, track_num: usize) -> Range<u64> {
debug_assert!(track_num < self.moov.traks.len());
0..self.moov.traks[track_num].mdia.minf.stbl.stts.total_duration
}
}
-17
View File
@@ -708,27 +708,10 @@ pub fn run() {
psysonic_library::commands::library_search,
psysonic_library::commands::library_live_search,
psysonic_library::commands::library_advanced_search,
psysonic_library::commands::library_cluster_advanced_search,
psysonic_library::commands::library_list_lossless_albums,
psysonic_library::commands::library_list_albums,
psysonic_library::commands::library_list_albums_by_genre,
psysonic_library::commands::library_get_artist_lossless_browse,
psysonic_library::commands::library_search_cross_server,
psysonic_library::commands::library_cluster_list_tracks,
psysonic_library::commands::library_cluster_list_albums,
psysonic_library::commands::library_cluster_list_artists,
psysonic_library::commands::library_cluster_list_favorites,
psysonic_library::commands::library_cluster_list_favorite_albums,
psysonic_library::commands::library_cluster_list_favorite_artists,
psysonic_library::commands::library_cluster_player_stats_year_summary,
psysonic_library::commands::library_cluster_player_stats_heatmap,
psysonic_library::commands::library_cluster_player_stats_day_detail,
psysonic_library::commands::library_cluster_player_stats_recent_days,
psysonic_library::commands::library_cluster_player_stats_most_played,
psysonic_library::commands::library_cluster_resolve_candidates,
psysonic_library::commands::library_cluster_album_detail,
psysonic_library::commands::library_cluster_artist_detail,
psysonic_library::commands::library_search_cluster,
psysonic_library::commands::library_get_track,
psysonic_library::commands::library_get_tracks_batch,
psysonic_library::commands::library_get_tracks_by_album,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.48.0-dev",
"version": "1.47.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
-427
View File
@@ -202,8 +202,6 @@ export interface LibrarySortClause {
export interface LibraryAdvancedSearchRequest {
serverId: string;
libraryScope?: string | null;
/** Multiple music-folder ids (OR). Preferred over `libraryScope` when length > 1. */
libraryScopeIds?: string[] | null;
query?: string | null; // shorthand → fts clause on text fields
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
@@ -381,52 +379,9 @@ export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<Li
}));
}
export interface LibraryAlbumBrowseRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
export interface LibraryAlbumBrowseResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
source: 'local';
}
/** Paginated plain All Albums from the local track index. */
export function libraryListAlbums(
request: LibraryAlbumBrowseRequest,
): Promise<LibraryAlbumBrowseResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryAlbumBrowseResponse>('library_list_albums', {
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
}).then(response => ({
...response,
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
}));
}
export interface LibraryLosslessAlbumsRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
@@ -446,9 +401,6 @@ export function libraryListLosslessAlbums(
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
@@ -512,327 +464,6 @@ export function librarySearchCrossServer(args: {
}));
}
export interface LibraryClusterCandidateDto {
serverId: string;
trackId: string;
durationSec: number;
priorityRank: number;
isWinner: boolean;
}
export interface LibraryClusterResolveResponse {
candidates: LibraryClusterCandidateDto[];
clusterKey?: string | null;
}
function mapServersOrderedToIndexKeys(serverIds: string[]): string[] {
return serverIds.map(serverIndexKeyForId);
}
function mapClusterLibraryScopesToIndexKeys(
scopes: Record<string, string[]> | undefined,
): Record<string, string[]> | undefined {
if (!scopes) return undefined;
const out: Record<string, string[]> = {};
for (const [sid, scopeIds] of Object.entries(scopes)) {
if (scopeIds.length > 0) out[serverIndexKeyForId(sid)] = scopeIds;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/** Merged track list for cluster scope (ordered members = priority). */
export function libraryClusterListTracks(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_tracks', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(env => ({
...env,
tracks: mapTracksServerId(env.tracks),
}));
}
export interface LibraryClusterAlbumsResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
}
export interface LibraryClusterArtistsResponse {
artists: LibraryArtistDto[];
hasMore: boolean;
}
export function libraryClusterListAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_albums', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(resp => ({
...resp,
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_artists', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(resp => ({
...resp,
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListFavorites(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_favorites', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(env => ({
...env,
tracks: mapTracksServerId(env.tracks),
}));
}
export function libraryClusterPlayerStatsYearSummary(args: {
serversOrdered: string[];
year: number;
}): Promise<PlaySessionYearSummary> {
return invoke<PlaySessionYearSummary>('library_cluster_player_stats_year_summary', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
year: args.year,
},
});
}
export function libraryClusterPlayerStatsHeatmap(args: {
serversOrdered: string[];
year: number;
}): Promise<PlaySessionHeatmapDay[]> {
return invoke<PlaySessionHeatmapDay[]>('library_cluster_player_stats_heatmap', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
year: args.year,
},
});
}
export function libraryClusterResolveCandidates(args: {
serversOrdered: string[];
clusterKey?: string;
serverId?: string;
trackId?: string;
}): Promise<LibraryClusterResolveResponse> {
return invoke<LibraryClusterResolveResponse>('library_cluster_resolve_candidates', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
clusterKey: args.clusterKey,
serverId: args.serverId ? serverIndexKeyForId(args.serverId) : undefined,
trackId: args.trackId,
},
}).then(response => ({
...response,
candidates: response.candidates.map(c => ({
...c,
serverId: mapServerIdFromIndexKey(c.serverId),
})),
}));
}
export interface LibraryClusterAlbumDetailResponse {
album: LibraryAlbumDto;
tracks: LibraryTrackDto[];
ownerServerId: string;
relatedAlbums: LibraryAlbumDto[];
}
export interface LibraryClusterArtistDetailResponse {
artist: LibraryArtistDto;
albums: LibraryAlbumDto[];
topTracks: LibraryTrackDto[];
ownerServerId: string;
artistKey?: string | null;
}
export function libraryClusterAlbumDetail(args: {
serversOrdered: string[];
serverId: string;
entityId: string;
}): Promise<LibraryClusterAlbumDetailResponse> {
return invoke<LibraryClusterAlbumDetailResponse>('library_cluster_album_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
serverId: serverIndexKeyForId(args.serverId),
entityId: args.entityId,
},
}).then(resp => ({
...resp,
album: { ...resp.album, serverId: mapServerIdFromIndexKey(resp.album.serverId) },
tracks: mapTracksServerId(resp.tracks),
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
relatedAlbums: resp.relatedAlbums.map(a => ({
...a,
serverId: mapServerIdFromIndexKey(a.serverId),
})),
}));
}
export function libraryClusterArtistDetail(args: {
serversOrdered: string[];
serverId: string;
entityId: string;
}): Promise<LibraryClusterArtistDetailResponse> {
return invoke<LibraryClusterArtistDetailResponse>('library_cluster_artist_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
serverId: serverIndexKeyForId(args.serverId),
entityId: args.entityId,
},
}).then(resp => ({
...resp,
artist: { ...resp.artist, serverId: mapServerIdFromIndexKey(resp.artist.serverId) },
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
topTracks: mapTracksServerId(resp.topTracks),
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
}));
}
/** Cluster-mode search — dedup by cluster_key + priority. */
export function librarySearchCluster(args: {
query: string;
limit?: number;
offset?: number;
serversOrdered: string[];
}): Promise<LibraryCrossServerSearchResponse> {
return invoke<LibraryCrossServerSearchResponse>('library_search_cluster', {
query: args.query,
limit: args.limit,
offset: args.offset,
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
}).then(response => ({
...response,
hits: mapTracksServerId(response.hits),
fuzzy: mapTracksServerId(response.fuzzy),
serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)),
}));
}
export interface LibraryClusterAdvancedSearchRequest {
serversOrdered: string[];
query?: string | null;
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
starredOnly?: boolean | null;
restrictAlbumIds?: string[] | null;
/** Per-member getAlbumList2 allowlists (`serverId` → album ids). */
restrictAlbumScopes?: Record<string, string[]>;
queryAlbumTitleOnly?: boolean | null;
sort?: LibrarySortClause[];
limit: number;
offset?: number;
skipTotals?: boolean;
libraryScopes?: Record<string, string[]>;
}
export function libraryClusterAdvancedSearch(
request: LibraryClusterAdvancedSearchRequest,
): Promise<LibraryAdvancedSearchResponse> {
return invoke<LibraryAdvancedSearchResponse>('library_cluster_advanced_search', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(request.serversOrdered),
query: request.query ?? undefined,
entityTypes: request.entityTypes,
filters: request.filters ?? [],
starredOnly: request.starredOnly ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
restrictAlbumScopes: mapClusterLibraryScopesToIndexKeys(request.restrictAlbumScopes) ?? {},
queryAlbumTitleOnly: request.queryAlbumTitleOnly ?? undefined,
sort: request.sort ?? [],
limit: request.limit,
offset: request.offset ?? 0,
skipTotals: request.skipTotals ?? false,
libraryScopes: mapClusterLibraryScopesToIndexKeys(request.libraryScopes) ?? {},
},
}).then(response => ({
...response,
artists: response.artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId),
})),
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId),
})),
tracks: mapTracksServerId(response.tracks),
}));
}
export function libraryClusterListFavoriteAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_favorite_albums', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListFavoriteArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_favorite_artists', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryGetTrack(
serverId: string,
trackId: string,
@@ -1093,13 +724,11 @@ export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise
export function libraryGetGenreAlbumCounts(args: {
serverId: string;
libraryScope?: string;
libraryScopeIds?: string[];
}): Promise<GenreAlbumCountRow[]> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<GenreAlbumCountRow[]>('library_get_genre_album_counts', {
serverId: indexKey,
libraryScope: args.libraryScope,
libraryScopeIds: args.libraryScopeIds,
});
}
@@ -1107,7 +736,6 @@ export type LibraryGenreAlbumsRequest = {
serverId: string;
genre: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
@@ -1131,7 +759,6 @@ export function libraryListAlbumsByGenre(
serverId: indexKey,
genre: request.genre,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
sort: request.sort ?? [],
limit: request.limit ?? 50,
offset: request.offset ?? 0,
@@ -1187,60 +814,6 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySession
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
}
export interface PlaySessionMostPlayed {
track: LibraryTrackDto;
trackPlayCount: number;
totalListenedSec: number;
}
export function libraryClusterPlayerStatsDayDetail(args: {
serversOrdered: string[];
dateIso: string;
}): Promise<PlaySessionDayDetail> {
return invoke<PlaySessionDayDetail>('library_cluster_player_stats_day_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
dateIso: args.dateIso,
},
}).then(detail => ({
...detail,
tracks: detail.tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId),
})),
}));
}
export function libraryClusterPlayerStatsRecentDays(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionRecentDay[]> {
return invoke<PlaySessionRecentDay[]>('library_cluster_player_stats_recent_days', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
});
}
export function libraryClusterPlayerStatsMostPlayed(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionMostPlayed[]> {
return invoke<PlaySessionMostPlayed[]>('library_cluster_player_stats_most_played', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
}).then(rows => rows.map(row => ({
...row,
track: {
...row.track,
serverId: mapServerIdFromIndexKey(row.track.serverId),
},
})));
}
// ── Event subscriptions ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload {
+3 -3
View File
@@ -1,7 +1,6 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { libraryScopeForServer } from './subsonicClient';
import { ndLogin } from './navidromeAdmin';
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
let cachedToken: { serverUrl: string; token: string } | null = null;
@@ -25,9 +24,10 @@ function asString(v: unknown, fallback = ''): string {
* Mirrors the Subsonic `musicFolderId` we pipe through `libraryFilterParams()` Navidrome
* uses the same id space, so the same value is valid for the native API's `library_id` filter. */
function currentLibraryId(): string | null {
const { activeServerId } = useAuthStore.getState();
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
return libraryScopeForServer(activeServerId) ?? null;
const f = musicLibraryFilterByServer[activeServerId];
return !f || f === 'all' ? null : f;
}
function asNumber(v: unknown): number | undefined {
+2 -2
View File
@@ -72,7 +72,7 @@ describe('libraryFilterParams', () => {
it('returns { musicFolderId } when the active server has a specific filter', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
});
expect(libraryFilterParams()).toEqual({ musicFolderId: 'mf-7' });
});
@@ -91,7 +91,7 @@ describe('libraryScopeForServer', () => {
it('returns the folder id when scoped', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
});
expect(libraryScopeForServer(serverId)).toBe('mf-7');
});

Some files were not shown because too many files have changed in this diff Show More