From d664439746fb52eea1828be9e0f1bca992d9dd02 Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 12 May 2026 18:15:44 +0200 Subject: [PATCH] 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. --- musicfs/Cargo.lock | 464 ++++++++++++++++- musicfs/Cargo.toml | 6 + musicfs/crates/musicfs-cache/Cargo.toml | 11 + musicfs/crates/musicfs-cache/src/db.rs | 480 ++++++++++++++++++ musicfs/crates/musicfs-cache/src/lib.rs | 6 +- musicfs/crates/musicfs-cache/src/metadata.rs | 124 +++++ musicfs/crates/musicfs-cache/src/schema.sql | 58 +++ musicfs/crates/musicfs-core/Cargo.toml | 2 +- musicfs/crates/musicfs-core/src/error.rs | 3 + musicfs/crates/musicfs-metadata/Cargo.toml | 4 + musicfs/crates/musicfs-metadata/src/lib.rs | 4 +- musicfs/crates/musicfs-metadata/src/parser.rs | 134 +++++ musicfs/flake.nix | 5 + 13 files changed, 1289 insertions(+), 12 deletions(-) create mode 100644 musicfs/crates/musicfs-cache/src/db.rs create mode 100644 musicfs/crates/musicfs-cache/src/metadata.rs create mode 100644 musicfs/crates/musicfs-cache/src/schema.sql create mode 100644 musicfs/crates/musicfs-metadata/src/parser.rs diff --git a/musicfs/Cargo.lock b/musicfs/Cargo.lock index 4c7779a..4e1fac1 100644 --- a/musicfs/Cargo.lock +++ b/musicfs/Cargo.lock @@ -2,12 +2,30 @@ # It is not intended for manual editing. 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]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-trait" version = "0.1.89" @@ -19,12 +37,30 @@ dependencies = [ "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]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -37,12 +73,55 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "equivalent" version = "1.0.2" @@ -59,18 +138,52 @@ dependencies = [ "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]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "fuser" version = "0.14.0" @@ -83,7 +196,16 @@ dependencies = [ "page_size", "pkg-config", "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]] @@ -99,6 +221,15 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -114,6 +245,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "heck" version = "0.5.0" @@ -144,12 +284,27 @@ dependencies = [ "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]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -162,6 +317,17 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "linux-raw-sys" version = "0.12.1" @@ -203,6 +369,17 @@ dependencies = [ [[package]] name = "musicfs-cache" version = "0.1.0" +dependencies = [ + "musicfs-core", + "rmp-serde", + "rusqlite", + "serde", + "sled", + "tempfile", + "thiserror", + "tokio", + "tracing", +] [[package]] name = "musicfs-cas" @@ -241,6 +418,12 @@ version = "0.1.0" [[package]] name = "musicfs-metadata" version = "0.1.0" +dependencies = [ + "musicfs-core", + "symphonia", + "thiserror", + "tracing", +] [[package]] name = "musicfs-origins" @@ -265,6 +448,15 @@ version = "0.1.0" name = "musicfs-sync" 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]] name = "once_cell" version = "1.21.4" @@ -281,6 +473,17 @@ dependencies = [ "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]] name = "parking_lot" version = "0.12.5" @@ -288,7 +491,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "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]] @@ -299,7 +516,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -350,13 +567,55 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 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]] @@ -365,7 +624,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -427,6 +686,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -437,6 +702,22 @@ dependencies = [ "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]] name = "smallvec" version = "1.15.1" @@ -453,6 +734,139 @@ dependencies = [ "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]] name = "syn" version = "2.0.117" @@ -506,7 +920,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", "socket2", @@ -568,6 +982,18 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "wasi" 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" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -727,7 +1153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -770,7 +1196,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "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]] @@ -784,6 +1219,17 @@ dependencies = [ "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]] name = "zmij" version = "1.0.21" diff --git a/musicfs/Cargo.toml b/musicfs/Cargo.toml index 9b9d4f8..ac880dc 100644 --- a/musicfs/Cargo.toml +++ b/musicfs/Cargo.toml @@ -37,6 +37,12 @@ sled = "0.34" # Hashing (per architecture 8.3) 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 tempfile = "3" diff --git a/musicfs/crates/musicfs-cache/Cargo.toml b/musicfs/crates/musicfs-cache/Cargo.toml index 1df036d..bf945bf 100644 --- a/musicfs/crates/musicfs-cache/Cargo.toml +++ b/musicfs/crates/musicfs-cache/Cargo.toml @@ -4,3 +4,14 @@ version.workspace = true edition.workspace = true [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 diff --git a/musicfs/crates/musicfs-cache/src/db.rs b/musicfs/crates/musicfs-cache/src/db.rs new file mode 100644 index 0000000..4126383 --- /dev/null +++ b/musicfs/crates/musicfs-cache/src/db.rs @@ -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>, +} + +impl Database { + pub fn open(path: &Path) -> Result { + 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 { + 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 { + 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> { + 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 = row.get(15)?; + let format = format_str + .as_deref() + .map(parse_audio_format) + .unwrap_or(AudioFormat::Unknown); + + let content_hash: Option = 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>(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> { + let conn = self.conn.lock().unwrap(); + + let vpath: Option = 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> { + 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 = 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 { + 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> { + 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 { + 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()); + } +} diff --git a/musicfs/crates/musicfs-cache/src/lib.rs b/musicfs/crates/musicfs-cache/src/lib.rs index f9da2c4..ae4da5f 100644 --- a/musicfs/crates/musicfs-cache/src/lib.rs +++ b/musicfs/crates/musicfs-cache/src/lib.rs @@ -1 +1,5 @@ -#![allow(dead_code)] +mod db; +mod metadata; + +pub use db::Database; +pub use metadata::MetadataCache; diff --git a/musicfs/crates/musicfs-cache/src/metadata.rs b/musicfs/crates/musicfs-cache/src/metadata.rs new file mode 100644 index 0000000..9570872 --- /dev/null +++ b/musicfs/crates/musicfs-cache/src/metadata.rs @@ -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, +} + +impl MetadataCache { + pub fn new(db: Arc) -> 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> { + self.db.get_file_by_virtual_path(path) + } + + pub fn is_fresh( + &self, + origin_id: &OriginId, + real_path: &Path, + current_mtime: SystemTime, + ) -> Result { + 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()); + } +} diff --git a/musicfs/crates/musicfs-cache/src/schema.sql b/musicfs/crates/musicfs-cache/src/schema.sql new file mode 100644 index 0000000..510b32e --- /dev/null +++ b/musicfs/crates/musicfs-cache/src/schema.sql @@ -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); diff --git a/musicfs/crates/musicfs-core/Cargo.toml b/musicfs/crates/musicfs-core/Cargo.toml index 6535876..8c7df9a 100644 --- a/musicfs/crates/musicfs-core/Cargo.toml +++ b/musicfs/crates/musicfs-core/Cargo.toml @@ -8,4 +8,4 @@ thiserror.workspace = true serde.workspace = true tokio = { workspace = true, features = ["sync"] } xxhash-rust.workspace = true -hex = "0.4" +hex.workspace = true diff --git a/musicfs/crates/musicfs-core/src/error.rs b/musicfs/crates/musicfs-core/src/error.rs index 6d32ad1..a767e1e 100644 --- a/musicfs/crates/musicfs-core/src/error.rs +++ b/musicfs/crates/musicfs-core/src/error.rs @@ -17,6 +17,9 @@ pub enum Error { #[error("Cache error: {0}")] Cache(String), + #[error("Metadata extraction error: {0}")] + Metadata(String), + #[error("Database error: {0}")] Database(String), diff --git a/musicfs/crates/musicfs-metadata/Cargo.toml b/musicfs/crates/musicfs-metadata/Cargo.toml index aa5107f..dab6512 100644 --- a/musicfs/crates/musicfs-metadata/Cargo.toml +++ b/musicfs/crates/musicfs-metadata/Cargo.toml @@ -4,3 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] +musicfs-core = { path = "../musicfs-core" } +symphonia.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/musicfs/crates/musicfs-metadata/src/lib.rs b/musicfs/crates/musicfs-metadata/src/lib.rs index f9da2c4..8650c6f 100644 --- a/musicfs/crates/musicfs-metadata/src/lib.rs +++ b/musicfs/crates/musicfs-metadata/src/lib.rs @@ -1 +1,3 @@ -#![allow(dead_code)] +mod parser; + +pub use parser::MetadataParser; diff --git a/musicfs/crates/musicfs-metadata/src/parser.rs b/musicfs/crates/musicfs-metadata/src/parser.rs new file mode 100644 index 0000000..4c0d8e1 --- /dev/null +++ b/musicfs/crates/musicfs-metadata/src/parser.rs @@ -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 { + 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::().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)); + } +} diff --git a/musicfs/flake.nix b/musicfs/flake.nix index f0b2b42..20e9454 100644 --- a/musicfs/flake.nix +++ b/musicfs/flake.nix @@ -33,6 +33,11 @@ # Dev tools cargo-watch cargo-nextest + cargo-criterion + + # gRPC tooling (Week 10+) + protobuf + grpcurl ]; RUST_BACKTRACE = "1";