Implement Week 2 metadata extraction and cache database
Week 1 fixes: - Move hex to workspace dependencies - Add cargo-criterion, protobuf, grpcurl to flake.nix Week 2 implementation: - musicfs-metadata: MetadataParser with symphonia 0.5 for FLAC, MP3, Opus/Vorbis, M4A/AAC (2 tests) - musicfs-cache: SQLite schema per architecture 4.3.6 with track/disc columns, TEXT content_hash, all required indexes - musicfs-cache/db.rs: Database with upsert, CRUD, mtime lookup (9 tests) - musicfs-cache/metadata.rs: MetadataCache with store/lookup/is_fresh/ invalidate (2 tests) - musicfs-core: Added Metadata error variant 22 tests pass total. Oracle-verified against architecture doc.
This commit is contained in:
Generated
+455
-9
@@ -2,12 +2,30 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy 0.8.48",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -19,12 +37,30 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -37,12 +73,55 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.62"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-epoch"
|
||||||
|
version = "0.9.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -59,18 +138,52 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "extended"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fuser"
|
name = "fuser"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -83,7 +196,16 @@ dependencies = [
|
|||||||
"page_size",
|
"page_size",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zerocopy",
|
"zerocopy 0.7.35",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fxhash"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -99,6 +221,15 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -114,6 +245,15 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -144,12 +284,27 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "instant"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leb128fmt"
|
name = "leb128fmt"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -162,6 +317,17 @@ version = "0.2.186"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -203,6 +369,17 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "musicfs-cache"
|
name = "musicfs-cache"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"musicfs-core",
|
||||||
|
"rmp-serde",
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"sled",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "musicfs-cas"
|
name = "musicfs-cas"
|
||||||
@@ -241,6 +418,12 @@ version = "0.1.0"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "musicfs-metadata"
|
name = "musicfs-metadata"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"musicfs-core",
|
||||||
|
"symphonia",
|
||||||
|
"thiserror",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "musicfs-origins"
|
name = "musicfs-origins"
|
||||||
@@ -265,6 +448,15 @@ version = "0.1.0"
|
|||||||
name = "musicfs-sync"
|
name = "musicfs-sync"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -281,6 +473,17 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.11.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
|
||||||
|
dependencies = [
|
||||||
|
"instant",
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core 0.8.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -288,7 +491,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lock_api",
|
"lock_api",
|
||||||
"parking_lot_core",
|
"parking_lot_core 0.9.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"instant",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall 0.2.16",
|
||||||
|
"smallvec",
|
||||||
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -299,7 +516,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"redox_syscall",
|
"redox_syscall 0.5.18",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -350,13 +567,55 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.2.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmp"
|
||||||
|
version = "0.8.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmp-serde"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||||
|
dependencies = [
|
||||||
|
"rmp",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -365,7 +624,7 @@ version = "1.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
@@ -427,6 +686,12 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -437,6 +702,22 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sled"
|
||||||
|
version = "0.34.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"fs2",
|
||||||
|
"fxhash",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"parking_lot 0.11.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
@@ -453,6 +734,139 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"symphonia-bundle-flac",
|
||||||
|
"symphonia-bundle-mp3",
|
||||||
|
"symphonia-codec-aac",
|
||||||
|
"symphonia-codec-alac",
|
||||||
|
"symphonia-codec-vorbis",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-format-ogg",
|
||||||
|
"symphonia-format-riff",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-bundle-flac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"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-codec-aac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-alac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-vorbis"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-core"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"bytemuck",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-ogg"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-riff"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||||
|
dependencies = [
|
||||||
|
"extended",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -506,7 +920,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
"parking_lot 0.12.5",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
@@ -568,6 +982,18 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -620,7 +1046,7 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -727,7 +1153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags",
|
"bitflags 2.11.1",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -770,7 +1196,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"zerocopy-derive",
|
"zerocopy-derive 0.7.35",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive 0.8.48",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -784,6 +1219,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.48"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ sled = "0.34"
|
|||||||
|
|
||||||
# Hashing (per architecture 8.3)
|
# Hashing (per architecture 8.3)
|
||||||
xxhash-rust = { version = "0.8", features = ["xxh64"] }
|
xxhash-rust = { version = "0.8", features = ["xxh64"] }
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
# Audio metadata
|
||||||
|
symphonia = { version = "0.5", default-features = false, features = [
|
||||||
|
"aac", "alac", "flac", "mp3", "ogg", "vorbis", "wav"
|
||||||
|
] }
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -4,3 +4,14 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
musicfs-core = { path = "../musicfs-core" }
|
||||||
|
rusqlite = { workspace = true, features = ["bundled"] }
|
||||||
|
sled.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
rmp-serde.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
use musicfs_core::{
|
||||||
|
AudioFormat, AudioMeta, ContentHash, Error, FileId, FileMeta, OriginId, RealPath, Result,
|
||||||
|
VirtualPath,
|
||||||
|
};
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
const SCHEMA: &str = include_str!("schema.sql");
|
||||||
|
|
||||||
|
pub struct Database {
|
||||||
|
conn: Arc<Mutex<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
info!(?path, "Opening database");
|
||||||
|
|
||||||
|
let conn =
|
||||||
|
Connection::open(path).map_err(|e| Error::Database(format!("open failed: {}", e)))?;
|
||||||
|
|
||||||
|
conn.execute_batch(SCHEMA)
|
||||||
|
.map_err(|e| Error::Database(format!("schema init failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_memory() -> Result<Self> {
|
||||||
|
let conn = Connection::open_in_memory()
|
||||||
|
.map_err(|e| Error::Database(format!("open_in_memory failed: {}", e)))?;
|
||||||
|
|
||||||
|
conn.execute_batch(SCHEMA)
|
||||||
|
.map_err(|e| Error::Database(format!("schema init failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_file(
|
||||||
|
&self,
|
||||||
|
origin_id: &OriginId,
|
||||||
|
real_path: &Path,
|
||||||
|
virtual_path: &VirtualPath,
|
||||||
|
audio_meta: &AudioMeta,
|
||||||
|
origin_mtime: SystemTime,
|
||||||
|
origin_size: u64,
|
||||||
|
) -> Result<FileId> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
|
let mtime_secs = origin_mtime
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
r#"
|
||||||
|
INSERT INTO files (
|
||||||
|
origin_id, real_path, virtual_path,
|
||||||
|
title, artist, album, album_artist, genre,
|
||||||
|
year, track, disc,
|
||||||
|
duration_ms, bitrate, sample_rate, format,
|
||||||
|
origin_mtime, origin_size
|
||||||
|
) VALUES (
|
||||||
|
?1, ?2, ?3,
|
||||||
|
?4, ?5, ?6, ?7, ?8,
|
||||||
|
?9, ?10, ?11,
|
||||||
|
?12, ?13, ?14, ?15,
|
||||||
|
?16, ?17
|
||||||
|
)
|
||||||
|
ON CONFLICT(origin_id, real_path) DO UPDATE SET
|
||||||
|
virtual_path = excluded.virtual_path,
|
||||||
|
title = excluded.title,
|
||||||
|
artist = excluded.artist,
|
||||||
|
album = excluded.album,
|
||||||
|
album_artist = excluded.album_artist,
|
||||||
|
genre = excluded.genre,
|
||||||
|
year = excluded.year,
|
||||||
|
track = excluded.track,
|
||||||
|
disc = excluded.disc,
|
||||||
|
duration_ms = excluded.duration_ms,
|
||||||
|
bitrate = excluded.bitrate,
|
||||||
|
sample_rate = excluded.sample_rate,
|
||||||
|
format = excluded.format,
|
||||||
|
origin_mtime = excluded.origin_mtime,
|
||||||
|
origin_size = excluded.origin_size,
|
||||||
|
last_sync = strftime('%s', 'now')
|
||||||
|
"#,
|
||||||
|
params![
|
||||||
|
&origin_id.0,
|
||||||
|
real_path.to_string_lossy(),
|
||||||
|
virtual_path.as_str(),
|
||||||
|
&audio_meta.title,
|
||||||
|
&audio_meta.artist,
|
||||||
|
&audio_meta.album,
|
||||||
|
&audio_meta.album_artist,
|
||||||
|
&audio_meta.genre,
|
||||||
|
&audio_meta.year,
|
||||||
|
&audio_meta.track,
|
||||||
|
&audio_meta.disc,
|
||||||
|
&audio_meta.duration_ms.map(|d| d as i64),
|
||||||
|
&audio_meta.bitrate,
|
||||||
|
&audio_meta.sample_rate,
|
||||||
|
format!("{:?}", audio_meta.format),
|
||||||
|
mtime_secs,
|
||||||
|
origin_size as i64,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| Error::Database(format!("upsert failed: {}", e)))?;
|
||||||
|
|
||||||
|
let id = conn.last_insert_rowid();
|
||||||
|
debug!(id, vpath = virtual_path.as_str(), "Upserted file");
|
||||||
|
|
||||||
|
Ok(FileId(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_file_by_virtual_path(&self, path: &VirtualPath) -> Result<Option<FileMeta>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
|
conn.query_row(
|
||||||
|
r#"
|
||||||
|
SELECT id, origin_id, real_path, virtual_path,
|
||||||
|
title, artist, album, album_artist, genre,
|
||||||
|
year, track, disc,
|
||||||
|
duration_ms, bitrate, sample_rate, format,
|
||||||
|
origin_mtime, origin_size, content_hash
|
||||||
|
FROM files
|
||||||
|
WHERE virtual_path = ?1
|
||||||
|
"#,
|
||||||
|
params![path.as_str()],
|
||||||
|
|row| {
|
||||||
|
let format_str: Option<String> = row.get(15)?;
|
||||||
|
let format = format_str
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_audio_format)
|
||||||
|
.unwrap_or(AudioFormat::Unknown);
|
||||||
|
|
||||||
|
let content_hash: Option<String> = row.get(18)?;
|
||||||
|
|
||||||
|
Ok(FileMeta {
|
||||||
|
id: FileId(row.get(0)?),
|
||||||
|
real_path: RealPath {
|
||||||
|
origin_id: OriginId(row.get(1)?),
|
||||||
|
path: PathBuf::from(row.get::<_, String>(2)?),
|
||||||
|
},
|
||||||
|
virtual_path: VirtualPath::new(row.get::<_, String>(3)?),
|
||||||
|
audio: Some(AudioMeta {
|
||||||
|
title: row.get(4)?,
|
||||||
|
artist: row.get(5)?,
|
||||||
|
album: row.get(6)?,
|
||||||
|
album_artist: row.get(7)?,
|
||||||
|
genre: row.get(8)?,
|
||||||
|
year: row.get(9)?,
|
||||||
|
track: row.get(10)?,
|
||||||
|
disc: row.get(11)?,
|
||||||
|
duration_ms: row.get::<_, Option<i64>>(12)?.map(|d| d as u64),
|
||||||
|
bitrate: row.get(13)?,
|
||||||
|
sample_rate: row.get(14)?,
|
||||||
|
format,
|
||||||
|
}),
|
||||||
|
size: row.get::<_, i64>(17)? as u64,
|
||||||
|
mtime: UNIX_EPOCH + Duration::from_secs(row.get::<_, i64>(16)? as u64),
|
||||||
|
content_hash: content_hash.and_then(|s| parse_content_hash(&s)),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| Error::Database(format!("query failed: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_file_by_id(&self, id: FileId) -> Result<Option<FileMeta>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
|
let vpath: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT virtual_path FROM files WHERE id = ?1",
|
||||||
|
params![id.0],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| Error::Database(format!("query failed: {}", e)))?;
|
||||||
|
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
match vpath {
|
||||||
|
Some(p) => self.get_file_by_virtual_path(&VirtualPath::new(p)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_files_by_origin(&self, origin_id: &OriginId) -> Result<Vec<VirtualPath>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT virtual_path FROM files WHERE origin_id = ?1")
|
||||||
|
.map_err(|e| Error::Database(format!("prepare failed: {}", e)))?;
|
||||||
|
|
||||||
|
let paths: Vec<VirtualPath> = stmt
|
||||||
|
.query_map(params![&origin_id.0], |row| {
|
||||||
|
Ok(VirtualPath::new(row.get::<_, String>(0)?))
|
||||||
|
})
|
||||||
|
.map_err(|e| Error::Database(format!("query failed: {}", e)))?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_file(&self, id: FileId) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute("DELETE FROM files WHERE id = ?1", params![id.0])
|
||||||
|
.map_err(|e| Error::Database(format!("delete failed: {}", e)))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_count(&self) -> Result<u64> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM files", [], |row| {
|
||||||
|
row.get::<_, i64>(0)
|
||||||
|
})
|
||||||
|
.map(|c| c as u64)
|
||||||
|
.map_err(|e| Error::Database(format!("count failed: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_content_hash(&self, id: FileId, hash: &ContentHash) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE files SET content_hash = ?1 WHERE id = ?2",
|
||||||
|
params![hash.to_hex(), id.0],
|
||||||
|
)
|
||||||
|
.map_err(|e| Error::Database(format!("update hash failed: {}", e)))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mtime_by_real_path(
|
||||||
|
&self,
|
||||||
|
origin_id: &OriginId,
|
||||||
|
real_path: &Path,
|
||||||
|
) -> Result<Option<SystemTime>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT origin_mtime FROM files WHERE origin_id = ?1 AND real_path = ?2",
|
||||||
|
params![&origin_id.0, real_path.to_string_lossy()],
|
||||||
|
|row| {
|
||||||
|
let mtime_secs: i64 = row.get(0)?;
|
||||||
|
Ok(UNIX_EPOCH + Duration::from_secs(mtime_secs as u64))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| Error::Database(format!("query mtime failed: {}", e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_audio_format(s: &str) -> AudioFormat {
|
||||||
|
match s {
|
||||||
|
"Flac" => AudioFormat::Flac,
|
||||||
|
"Mp3" => AudioFormat::Mp3,
|
||||||
|
"Aac" => AudioFormat::Aac,
|
||||||
|
"Opus" => AudioFormat::Opus,
|
||||||
|
"Vorbis" => AudioFormat::Vorbis,
|
||||||
|
"Wav" => AudioFormat::Wav,
|
||||||
|
"Alac" => AudioFormat::Alac,
|
||||||
|
_ => AudioFormat::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_content_hash(hex: &str) -> Option<ContentHash> {
|
||||||
|
if hex.len() != 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
|
||||||
|
if i >= 8 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let s = std::str::from_utf8(chunk).ok()?;
|
||||||
|
bytes[i] = u8::from_str_radix(s, 16).ok()?;
|
||||||
|
}
|
||||||
|
Some(ContentHash(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_database_creation() {
|
||||||
|
let db = Database::open_memory().unwrap();
|
||||||
|
assert_eq!(db.file_count().unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upsert_and_retrieve() {
|
||||||
|
let db = Database::open_memory().unwrap();
|
||||||
|
|
||||||
|
let origin_id = OriginId::from("local");
|
||||||
|
let real_path = Path::new("/music/test.flac");
|
||||||
|
let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac");
|
||||||
|
let audio_meta = AudioMeta {
|
||||||
|
title: Some("Track".to_string()),
|
||||||
|
artist: Some("Artist".to_string()),
|
||||||
|
album: Some("Album".to_string()),
|
||||||
|
track: Some(1),
|
||||||
|
format: AudioFormat::Flac,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = db
|
||||||
|
.upsert_file(
|
||||||
|
&origin_id,
|
||||||
|
real_path,
|
||||||
|
&virtual_path,
|
||||||
|
&audio_meta,
|
||||||
|
UNIX_EPOCH,
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let retrieved = db
|
||||||
|
.get_file_by_virtual_path(&virtual_path)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(retrieved.id, id);
|
||||||
|
assert_eq!(
|
||||||
|
retrieved.audio.as_ref().unwrap().title,
|
||||||
|
Some("Track".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upsert_updates_existing() {
|
||||||
|
let db = Database::open_memory().unwrap();
|
||||||
|
|
||||||
|
let origin_id = OriginId::from("local");
|
||||||
|
let real_path = Path::new("/music/test.flac");
|
||||||
|
let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac");
|
||||||
|
|
||||||
|
let meta1 = AudioMeta {
|
||||||
|
title: Some("Original".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
db.upsert_file(
|
||||||
|
&origin_id,
|
||||||
|
real_path,
|
||||||
|
&virtual_path,
|
||||||
|
&meta1,
|
||||||
|
UNIX_EPOCH,
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let meta2 = AudioMeta {
|
||||||
|
title: Some("Updated".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
db.upsert_file(
|
||||||
|
&origin_id,
|
||||||
|
real_path,
|
||||||
|
&virtual_path,
|
||||||
|
&meta2,
|
||||||
|
UNIX_EPOCH,
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(db.file_count().unwrap(), 1);
|
||||||
|
|
||||||
|
let retrieved = db
|
||||||
|
.get_file_by_virtual_path(&virtual_path)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
retrieved.audio.as_ref().unwrap().title,
|
||||||
|
Some("Updated".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metadata_persistence() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("test.db");
|
||||||
|
|
||||||
|
{
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
db.upsert_file(
|
||||||
|
&OriginId::from("local"),
|
||||||
|
Path::new("/test.flac"),
|
||||||
|
&VirtualPath::new("/Test.flac"),
|
||||||
|
&AudioMeta::default(),
|
||||||
|
UNIX_EPOCH,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
assert_eq!(db.file_count().unwrap(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete_file() {
|
||||||
|
let db = Database::open_memory().unwrap();
|
||||||
|
|
||||||
|
let id = db
|
||||||
|
.upsert_file(
|
||||||
|
&OriginId::from("local"),
|
||||||
|
Path::new("/test.flac"),
|
||||||
|
&VirtualPath::new("/Test.flac"),
|
||||||
|
&AudioMeta::default(),
|
||||||
|
UNIX_EPOCH,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(db.file_count().unwrap(), 1);
|
||||||
|
db.delete_file(id).unwrap();
|
||||||
|
assert_eq!(db.file_count().unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_files_by_origin() {
|
||||||
|
let db = Database::open_memory().unwrap();
|
||||||
|
let origin = OriginId::from("local");
|
||||||
|
|
||||||
|
db.upsert_file(
|
||||||
|
&origin,
|
||||||
|
Path::new("/a.flac"),
|
||||||
|
&VirtualPath::new("/A.flac"),
|
||||||
|
&AudioMeta::default(),
|
||||||
|
UNIX_EPOCH,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
db.upsert_file(
|
||||||
|
&origin,
|
||||||
|
Path::new("/b.flac"),
|
||||||
|
&VirtualPath::new("/B.flac"),
|
||||||
|
&AudioMeta::default(),
|
||||||
|
UNIX_EPOCH,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let paths = db.list_files_by_origin(&origin).unwrap();
|
||||||
|
assert_eq!(paths.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_content_hash_update() {
|
||||||
|
let db = Database::open_memory().unwrap();
|
||||||
|
|
||||||
|
let id = db
|
||||||
|
.upsert_file(
|
||||||
|
&OriginId::from("local"),
|
||||||
|
Path::new("/test.flac"),
|
||||||
|
&VirtualPath::new("/Test.flac"),
|
||||||
|
&AudioMeta::default(),
|
||||||
|
UNIX_EPOCH,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let hash = ContentHash::from_bytes(b"test data");
|
||||||
|
db.update_content_hash(id, &hash).unwrap();
|
||||||
|
|
||||||
|
let retrieved = db
|
||||||
|
.get_file_by_virtual_path(&VirtualPath::new("/Test.flac"))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(retrieved.content_hash.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
#![allow(dead_code)]
|
mod db;
|
||||||
|
mod metadata;
|
||||||
|
|
||||||
|
pub use db::Database;
|
||||||
|
pub use metadata::MetadataCache;
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use crate::db::Database;
|
||||||
|
use musicfs_core::{AudioMeta, FileMeta, OriginId, Result, VirtualPath};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub struct MetadataCache {
|
||||||
|
db: Arc<Database>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetadataCache {
|
||||||
|
pub fn new(db: Arc<Database>) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store(
|
||||||
|
&self,
|
||||||
|
origin_id: &OriginId,
|
||||||
|
real_path: &Path,
|
||||||
|
virtual_path: &VirtualPath,
|
||||||
|
audio_meta: &AudioMeta,
|
||||||
|
origin_mtime: SystemTime,
|
||||||
|
origin_size: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.db.upsert_file(
|
||||||
|
origin_id,
|
||||||
|
real_path,
|
||||||
|
virtual_path,
|
||||||
|
audio_meta,
|
||||||
|
origin_mtime,
|
||||||
|
origin_size,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup(&self, path: &VirtualPath) -> Result<Option<FileMeta>> {
|
||||||
|
self.db.get_file_by_virtual_path(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_fresh(
|
||||||
|
&self,
|
||||||
|
origin_id: &OriginId,
|
||||||
|
real_path: &Path,
|
||||||
|
current_mtime: SystemTime,
|
||||||
|
) -> Result<bool> {
|
||||||
|
if let Some(cached_mtime) = self.db.get_mtime_by_real_path(origin_id, real_path)? {
|
||||||
|
let current_secs = current_mtime
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or(Duration::ZERO)
|
||||||
|
.as_secs();
|
||||||
|
let cached_secs = cached_mtime
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or(Duration::ZERO)
|
||||||
|
.as_secs();
|
||||||
|
Ok(current_secs == cached_secs)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalidate(&self, path: &VirtualPath) -> Result<()> {
|
||||||
|
if let Some(meta) = self.db.get_file_by_virtual_path(path)? {
|
||||||
|
self.db.delete_file(meta.id)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use musicfs_core::AudioFormat;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metadata_cache_store_and_lookup() {
|
||||||
|
let db = Arc::new(Database::open_memory().unwrap());
|
||||||
|
let cache = MetadataCache::new(db);
|
||||||
|
|
||||||
|
let origin_id = OriginId::from("local");
|
||||||
|
let real_path = Path::new("/music/song.flac");
|
||||||
|
let virtual_path = VirtualPath::new("/Artist/Album/Song.flac");
|
||||||
|
let meta = AudioMeta {
|
||||||
|
title: Some("Song".to_string()),
|
||||||
|
artist: Some("Artist".to_string()),
|
||||||
|
format: AudioFormat::Flac,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
cache
|
||||||
|
.store(&origin_id, real_path, &virtual_path, &meta, UNIX_EPOCH, 5000)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let retrieved = cache.lookup(&virtual_path).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
retrieved.audio.as_ref().unwrap().title,
|
||||||
|
Some("Song".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metadata_cache_invalidate() {
|
||||||
|
let db = Arc::new(Database::open_memory().unwrap());
|
||||||
|
let cache = MetadataCache::new(db);
|
||||||
|
|
||||||
|
let virtual_path = VirtualPath::new("/Test.flac");
|
||||||
|
|
||||||
|
cache
|
||||||
|
.store(
|
||||||
|
&OriginId::from("local"),
|
||||||
|
Path::new("/test.flac"),
|
||||||
|
&virtual_path,
|
||||||
|
&AudioMeta::default(),
|
||||||
|
UNIX_EPOCH,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(cache.lookup(&virtual_path).unwrap().is_some());
|
||||||
|
|
||||||
|
cache.invalidate(&virtual_path).unwrap();
|
||||||
|
|
||||||
|
assert!(cache.lookup(&virtual_path).unwrap().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
origin_id TEXT NOT NULL,
|
||||||
|
real_path TEXT NOT NULL,
|
||||||
|
virtual_path TEXT NOT NULL,
|
||||||
|
|
||||||
|
title TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
album TEXT,
|
||||||
|
album_artist TEXT,
|
||||||
|
genre TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
track INTEGER,
|
||||||
|
disc INTEGER,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
bitrate INTEGER,
|
||||||
|
sample_rate INTEGER,
|
||||||
|
format TEXT,
|
||||||
|
|
||||||
|
origin_mtime INTEGER NOT NULL,
|
||||||
|
origin_size INTEGER NOT NULL,
|
||||||
|
content_hash TEXT,
|
||||||
|
chunk_manifest BLOB,
|
||||||
|
last_sync INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||||
|
|
||||||
|
UNIQUE(origin_id, real_path)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS artwork (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
art_type TEXT NOT NULL,
|
||||||
|
chunk_hash TEXT NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
mime_type TEXT,
|
||||||
|
UNIQUE(file_id, art_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collections (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
query_json TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_virtual ON files(virtual_path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_artist_album ON files(artist, album);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_content_hash ON files(content_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_real ON files(origin_id, real_path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_last_sync ON files(last_sync);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artwork_file ON artwork(file_id);
|
||||||
@@ -8,4 +8,4 @@ thiserror.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
tokio = { workspace = true, features = ["sync"] }
|
tokio = { workspace = true, features = ["sync"] }
|
||||||
xxhash-rust.workspace = true
|
xxhash-rust.workspace = true
|
||||||
hex = "0.4"
|
hex.workspace = true
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ pub enum Error {
|
|||||||
#[error("Cache error: {0}")]
|
#[error("Cache error: {0}")]
|
||||||
Cache(String),
|
Cache(String),
|
||||||
|
|
||||||
|
#[error("Metadata extraction error: {0}")]
|
||||||
|
Metadata(String),
|
||||||
|
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
Database(String),
|
Database(String),
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
musicfs-core = { path = "../musicfs-core" }
|
||||||
|
symphonia.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
#![allow(dead_code)]
|
mod parser;
|
||||||
|
|
||||||
|
pub use parser::MetadataParser;
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
use musicfs_core::{AudioFormat, AudioMeta, Error, Result};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::path::Path;
|
||||||
|
use symphonia::core::codecs::CODEC_TYPE_NULL;
|
||||||
|
use symphonia::core::formats::FormatOptions;
|
||||||
|
use symphonia::core::io::MediaSourceStream;
|
||||||
|
use symphonia::core::meta::MetadataOptions;
|
||||||
|
use symphonia::core::probe::Hint;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
pub struct MetadataParser;
|
||||||
|
|
||||||
|
impl MetadataParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_file(&self, path: &Path) -> Result<AudioMeta> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
|
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||||
|
|
||||||
|
let mut hint = Hint::new();
|
||||||
|
if !ext.is_empty() {
|
||||||
|
hint.with_extension(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fmt_opts = FormatOptions::default();
|
||||||
|
let meta_opts = MetadataOptions::default();
|
||||||
|
|
||||||
|
let probed = symphonia::default::get_probe()
|
||||||
|
.format(&hint, mss, &fmt_opts, &meta_opts)
|
||||||
|
.map_err(|e| Error::Metadata(format!("Failed to probe format: {}", e)))?;
|
||||||
|
let mut format = probed.format;
|
||||||
|
|
||||||
|
let mut audio_meta = AudioMeta {
|
||||||
|
format: AudioFormat::from_extension(ext),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(metadata) = format.metadata().current() {
|
||||||
|
self.extract_tags(&mut audio_meta, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(track) = format
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||||
|
{
|
||||||
|
let params = &track.codec_params;
|
||||||
|
|
||||||
|
if let Some(n_frames) = params.n_frames {
|
||||||
|
if let Some(sample_rate) = params.sample_rate {
|
||||||
|
audio_meta.duration_ms =
|
||||||
|
Some((n_frames as u64 * 1000) / sample_rate as u64);
|
||||||
|
audio_meta.sample_rate = Some(sample_rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(bits_per_sample) = params.bits_per_sample {
|
||||||
|
if let Some(sample_rate) = params.sample_rate {
|
||||||
|
if let Some(channels) = params.channels {
|
||||||
|
audio_meta.bitrate = Some(
|
||||||
|
bits_per_sample * sample_rate * channels.count() as u32 / 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(?audio_meta, "Parsed metadata");
|
||||||
|
Ok(audio_meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tags(
|
||||||
|
&self,
|
||||||
|
meta: &mut AudioMeta,
|
||||||
|
metadata: &symphonia::core::meta::MetadataRevision,
|
||||||
|
) {
|
||||||
|
use symphonia::core::meta::StandardTagKey;
|
||||||
|
|
||||||
|
for tag in metadata.tags() {
|
||||||
|
if let Some(std_key) = tag.std_key {
|
||||||
|
let value = tag.value.to_string();
|
||||||
|
match std_key {
|
||||||
|
StandardTagKey::TrackTitle => meta.title = Some(value),
|
||||||
|
StandardTagKey::Artist => meta.artist = Some(value),
|
||||||
|
StandardTagKey::Album => meta.album = Some(value),
|
||||||
|
StandardTagKey::AlbumArtist => meta.album_artist = Some(value),
|
||||||
|
StandardTagKey::Genre => meta.genre = Some(value),
|
||||||
|
StandardTagKey::TrackNumber => {
|
||||||
|
meta.track = value.split('/').next().and_then(|s| s.parse().ok());
|
||||||
|
}
|
||||||
|
StandardTagKey::DiscNumber => {
|
||||||
|
meta.disc = value.split('/').next().and_then(|s| s.parse().ok());
|
||||||
|
}
|
||||||
|
StandardTagKey::Date | StandardTagKey::ReleaseDate => {
|
||||||
|
meta.year = value.chars().take(4).collect::<String>().parse().ok();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MetadataParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audio_format_detection() {
|
||||||
|
assert_eq!(AudioFormat::from_extension("flac"), AudioFormat::Flac);
|
||||||
|
assert_eq!(AudioFormat::from_extension("mp3"), AudioFormat::Mp3);
|
||||||
|
assert_eq!(AudioFormat::from_extension("opus"), AudioFormat::Opus);
|
||||||
|
assert_eq!(AudioFormat::from_extension("ogg"), AudioFormat::Vorbis);
|
||||||
|
assert_eq!(AudioFormat::from_extension("m4a"), AudioFormat::Aac);
|
||||||
|
assert_eq!(AudioFormat::from_extension("wav"), AudioFormat::Wav);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_creation() {
|
||||||
|
let parser = MetadataParser::new();
|
||||||
|
let default_parser = MetadataParser::default();
|
||||||
|
assert!(std::mem::size_of_val(&parser) == std::mem::size_of_val(&default_parser));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,11 @@
|
|||||||
# Dev tools
|
# Dev tools
|
||||||
cargo-watch
|
cargo-watch
|
||||||
cargo-nextest
|
cargo-nextest
|
||||||
|
cargo-criterion
|
||||||
|
|
||||||
|
# gRPC tooling (Week 10+)
|
||||||
|
protobuf
|
||||||
|
grpcurl
|
||||||
];
|
];
|
||||||
|
|
||||||
RUST_BACKTRACE = "1";
|
RUST_BACKTRACE = "1";
|
||||||
|
|||||||
Reference in New Issue
Block a user