mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-24 16:25:46 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f59f2315b | |||
| 4658314619 | |||
| 65b7f2cc9d | |||
| 0792ee6297 |
+6
-54
@@ -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
@@ -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,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-7BvKeTkZzAQoBVm2vw2oZRsRKWN/Du1pn89U1rtc47k="
|
||||
"npmDepsHash": "sha256-r+dan77izwx/QjbT/JXJNfIxUn6YV0FWso+F3y/TLEc="
|
||||
}
|
||||
|
||||
Generated
+426
-480
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+194
-274
@@ -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
@@ -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/tauri’s 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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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", ¶ms, 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", ¶ms, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.1–2.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 §5–6).
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
[](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
|
||||
}
|
||||
}
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user