diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 6f2da73..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,295 +0,0 @@ -# AGENTS.md — beetfs/musicfs - -> Everything an AI agent needs to get work done on this project. - ---- - -## Quick Start - -```bash -cd beetfs/musicfs -nix develop # Enter dev shell (ALL tooling provided) -cargo check # Verify compilation -cargo test # Run all tests (162 tests, ~10s) -cargo build --release # Build release binary -``` - -**No `rustup`, no `apt install`, no manual dependency management.** The Nix flake provides everything: Rust stable + rust-analyzer + clippy + rustfmt, FUSE3, SQLite, OpenSSL, protobuf, grpcurl, cargo-nextest, cargo-criterion, lld linker. - ---- - -## Project Overview - -MusicFS is a **read-only FUSE filesystem** that presents music libraries organized by metadata (artist/album/track) instead of physical file paths. It supports multiple storage backends (local, NFS, S3, SFTP), content-addressable caching with delta sync, and full-text search. - -**Key constraint**: Read-only. Never modifies origin files. Never pushes changes to the origin server. - ---- - -## Repository Layout - -``` -beetfs/ -├── musicfs/ # Rust implementation (active) -│ ├── Cargo.toml # Workspace root -│ ├── flake.nix # Nix dev shell -│ ├── .cargo/config.toml # LLD linker, aliases (t/c/b) -│ ├── crates/ # 11 workspace crates -│ │ ├── musicfs-cli/ # Binary entry point (clap) -│ │ ├── musicfs-core/ # Types, errors, config, events -│ │ ├── musicfs-fuse/ # FUSE ops (fuser) -│ │ ├── musicfs-metadata/ # Audio parsing (symphonia) -│ │ ├── musicfs-cache/ # Cache: tree, metadata, patterns, eviction -│ │ ├── musicfs-cas/ # Content-addressable store (sled + chunks) -│ │ ├── musicfs-origins/ # Origin backends: local, NFS, SMB, S3, SFTP -│ │ ├── musicfs-sync/ # Delta sync, CDC chunking (fastcdc), watcher -│ │ ├── musicfs-search/ # Full-text search (tantivy) -│ │ ├── musicfs-grpc/ # gRPC control API (tonic + prost) -│ │ └── musicfs-plugins/ # Plugin system (native .so + WASM) -│ ├── tests/ -│ │ ├── e2e/e2e_players.rs # E2E: mpv/VLC playback (manual, #[ignore]) -│ │ └── integration/ # (placeholder) -│ └── dist/ # Deployment -│ ├── musicfs.service # systemd unit -│ ├── config.example.toml # Example config -│ ├── logrotate.d/musicfs # Log rotation -│ ├── PKGBUILD # Arch package -│ └── musicfs.spec # RPM spec -├── docs/ -│ ├── templates/ -│ │ ├── bluedoc.md # Full design doc (5-20+ pages) -│ │ └── greendoc.md # One-pager (1-2 pages) -│ ├── v2/ -│ │ ├── architecture.md # System design (GOLDEN — source of truth) -│ │ ├── requirements.md # Functional + non-functional requirements -│ │ ├── development-plan.md # Implementation roadmap (weeks 1-14) -│ │ └── plans/ # Weekly plans, feature plans, research -│ └── v1/ # Original Python beetfs docs (reference only) -└── beetsplug/beetFs.py # Original Python implementation (archived) -``` - ---- - -## Build & Test Commands - -```bash -# Cargo aliases (.cargo/config.toml) -cargo t # cargo test -cargo c # cargo check -cargo b # cargo build - -# Common workflows -cargo check # Fast compile check -cargo test # All tests -cargo test -p musicfs-core # Single crate -cargo clippy # Lint -cargo fmt # Format -cargo nextest run # Parallel test runner (faster) - -# gRPC -cargo build -p musicfs-grpc # Triggers proto codegen via build.rs -grpcurl -unix /run/musicfs.sock musicfs.v1.MusicFS/GetStatus - -# Watch mode -cargo watch -x 'check' -x 'test' - -# Release -cargo build --release -``` - -**Proto file location**: `crates/musicfs-grpc/proto/musicfs.proto` (codegen via `tonic-build` in `build.rs`) - ---- - -## Architecture - -**Read the full design**: [`docs/v2/architecture.md`](docs/v2/architecture.md) — this is the source of truth for all architectural decisions, component design, data schemas, API definitions, and diagrams. - -**Quick orientation** — MusicFS is a workspace of 11 crates. The dependency flow is: - -`musicfs-cli` → `musicfs-fuse` / `musicfs-grpc` / `musicfs-search` → `musicfs-core` → `musicfs-cache` / `musicfs-origins` / `musicfs-metadata` → `musicfs-cas` → `musicfs-sync` - -Key concepts: Virtual Tree (in-memory directory structure from metadata), CAS (content-addressable chunk storage with sled index), Origin Federation (multi-backend with failover and health monitoring), CDC (content-defined chunking for delta sync), Event Bus (tokio broadcast for cross-component notifications). - -For performance targets, scalability requirements, and quantitative constraints, see [`docs/v2/requirements.md`](docs/v2/requirements.md). - ---- - -## Code Conventions - -### Rust - -- **Edition**: 2021, **MSRV**: 1.75+ -- **Linker**: LLD via clang (configured in `.cargo/config.toml`) -- **Error handling**: `thiserror` for library errors, `anyhow` for CLI -- **Async**: `tokio` runtime, `async-trait` for trait objects -- **Concurrency**: `parking_lot` for hot-path locks, `dashmap` for concurrent maps, `std::sync::RwLock` elsewhere -- **Logging**: `tracing` with structured fields (`#[instrument]`, `info!`, `debug!`, etc.) -- **Serialization**: `serde` + `toml` for config, `rmp-serde` (msgpack) for binary data, `prost` for protobuf - -### Never Do - -- `as any`, `@ts-ignore` equivalents — no `unsafe` without justification -- Empty `catch` / `let _ = result` on operations that can fail meaningfully -- Suppress type errors -- Commit secrets or credentials - -### Testing Patterns - -- **Fixtures**: `TempDir::new().unwrap()` for isolated storage (used in 29 files) -- **In-memory DB**: `Database::open_memory()` for fast SQLite tests -- **No mocking framework** — tests use real implementations with temp directories -- **Async tests**: `#[tokio::test]` -- **Helper functions**: `make_file_meta()`, `mock_health()` — currently duplicated per module - ---- - -## Golden Documents - -These are the authoritative references. All implementations must match them. - -| Document | Path | Role | -|----------|------|------| -| **Architecture** | `docs/v2/architecture.md` | System design — THE source of truth | -| **Requirements** | `docs/v2/requirements.md` | What to build (FR-*, NFR-*) | -| **Development Plan** | `docs/v2/development-plan.md` | How to build it (week-by-week) | -| **Proto Definition** | `crates/musicfs-grpc/proto/musicfs.proto` | API contract | - -If code contradicts architecture.md, the architecture doc wins (unless explicitly superseded by a newer plan document). - ---- - -## Documentation Rules - -### Templates - -Two templates exist in `docs/templates/`: - -| Template | When to Use | Length | Review Level | -|----------|-------------|--------|-------------| -| **BlueDoc** | New systems, major architecture changes, new services | 5-20+ pages | Cross-functional | -| **GreenDoc** | Bug fixes, small features, optimizations, config changes | 1-2 pages | Peer review | - -**Decision rule**: If any GreenDoc section needs more than 3 paragraphs, upgrade to a BlueDoc. - -### When Neither Template Fits - -If the work doesn't fit BlueDoc or GreenDoc (e.g., research summaries, audit reports, testing strategies, runbooks): -1. **Stop** — do not force-fit content into wrong template -2. **Propose** a new template format to the user with: name, intended use case, suggested structure -3. **Get approval** before writing the document -4. Save approved template to `docs/templates/{name}.md` for future use - -### Document Metadata - -Every document MUST have at the top: - -```markdown -**Date**: YYYY-MM-DD -**Status**: [Draft / In-Review / Approved / Shipped / Obsolete] -**Prerequisites**: [links to dependent docs] -``` - -BlueDoc additionally requires: Authors, Reviewers, Approvers. - -### Writing Conventions - -- **Tables**: Use for requirements mapping, deliverables tracking, comparisons -- **Code blocks**: Include for implementation examples, config samples, commands -- **Checklists**: `[ ]` for exit criteria and success metrics -- **Section numbering**: Hierarchical (1., 1.1, 1.2) -- **Cross-references**: Relative markdown links (`[architecture](../architecture.md)`) -- **Requirement tracing**: Reference FR-X.Y / NFR-X.Y from requirements.md -- **Diagrams**: PlantUML or Mermaid (architecture.md uses PlantUML) - -### Post-Task Documentation Check (MANDATORY) - -After completing any non-trivial task, check whether documentation needs updating: - -1. **Did the architecture change?** (new component, changed data flow, new dependency) → Update `docs/v2/architecture.md` -2. **Did requirements change?** (new constraint, relaxed target, dropped feature) → Update `docs/v2/requirements.md` -3. **Did the API change?** (new RPC, changed message, removed endpoint) → Update `proto/musicfs.proto` + `docs/api/` -4. **Did build/tooling change?** (new dependency, changed Nix flake, new cargo feature) → Update this `AGENTS.md` -5. **Did a plan get completed or invalidated?** → Update the plan's **Status** field (Draft → Shipped / Obsolete) -6. **Did the config format change?** → Update `dist/config.example.toml` - -If documentation is out of date, **fix it in the same commit** as the code change. Do not leave it for later. - -### File Naming - -``` -docs/v2/plans/week-NN-{feature}.md # Weekly implementation plans -docs/v2/plans/{feature}-{type}.md # Feature plans, research, proposals -docs/v2/{topic}.md # Top-level docs (architecture, requirements) -docs/templates/{name}.md # Document templates -``` - ---- - -## Current State & Known Issues - -### What's Implemented (Weeks 1-11) - -- FUSE filesystem with local origin -- Metadata extraction (symphonia: FLAC, MP3, AAC, OGG, Opus) -- Virtual tree with configurable path templates -- CAS with CDC chunking and deduplication -- Multi-origin federation with failover and health monitoring -- NFS/SMB origin wrappers with retry logic -- Full-text search (tantivy) with `.search/` virtual directory -- Smart collections, artwork caching, predictive prefetch -- Plugin system (native + WASM) -- gRPC control API with event streaming -- Comprehensive tracing/logging with journald integration - -### Critical Open Issues - -Detailed in `docs/v2/plans/resilience-fault-tolerance.md` and `docs/v2/plans/persistent-state.md`: - -1. **No persistent state on mount** — every restart does full origin scan (O(N) instead of O(1)). SQLite, tantivy, and manifests persist on disk but are never loaded. -2. **No signal handling** — SIGTERM kills the daemon instantly, no graceful shutdown -3. **No crash recovery** — corrupted cache = crash on startup, no repair -4. **FUSE↔tokio deadlock risk** — `block_on()` in sync FUSE callback can hang under load -5. **Fire-and-forget tasks** — background tasks (health monitor, watcher, indexer) not supervised -6. **RwLock poison** — single panic in a writer kills all FUSE operations - -### S3/SFTP Origins - -`s3.rs` and `sftp.rs` are **feature-gated stubs** (not implemented). The `Origin` trait and failover infrastructure work, but only `local`, `nfs`, and `smb` origins have real implementations. - ---- - -## Running the Filesystem - -```bash -# Development -nix develop -cargo build -./target/debug/musicfs mount /mnt/music --origin /path/to/music - -# Production (systemd) -sudo cp dist/musicfs.service /etc/systemd/system/ -sudo systemctl enable --now musicfs - -# E2E tests (requires mounted filesystem) -MUSICFS_TEST_MOUNT=/mnt/music cargo test --test e2e_players -- --ignored -``` - ---- - -## Key Dependencies - -| Crate | Version | Purpose | -|-------|---------|---------| -| `fuser` | 0.14 | FUSE interface | -| `tokio` | 1.x | Async runtime (full features) | -| `rusqlite` | 0.31 | SQLite (bundled) | -| `sled` | 0.34 | Embedded KV (CAS chunk index) | -| `tantivy` | 0.22 | Full-text search | -| `symphonia` | 0.5 | Audio metadata extraction | -| `fastcdc` | 3.x | Content-defined chunking | -| `tonic` | 0.11 | gRPC server | -| `tracing` | 0.1 | Structured logging | -| `clap` | 4.x | CLI argument parsing | -| `parking_lot` | 0.12 | Fast locks | -| `dashmap` | 5.x | Concurrent HashMap | diff --git a/COPYING b/COPYING deleted file mode 100644 index 94a9ed0..0000000 --- a/COPYING +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 6dae04f..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,5205 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[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 = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "ar_archive_writer" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" -dependencies = [ - "object 0.37.3", -] - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.6.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" -dependencies = [ - "async-trait", - "axum-core", - "bitflags 1.3.2", - "bytes", - "futures-util", - "http", - "http-body", - "hyper", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "mime", - "rustversion", - "tower-layer", - "tower-service", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[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 = "bitpacking" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" -dependencies = [ - "crunchy", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bmrng" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9758e48498ae13d49b51a979d553d254e67021b203d9597e82a04ebd81025b2" -dependencies = [ - "futures", - "loom", - "tokio", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -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", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "census" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpp_demangle" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cranelift-bforest" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b57d4f3ffc28bbd6ef1ca7b50b20126717232f97487efe027d135d9d87eb29c" -dependencies = [ - "cranelift-entity", -] - -[[package]] -name = "cranelift-codegen" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1f7d0ac7fd53f2c29db3ff9a063f6ff5a8be2abaa8f6942aceb6e1521e70df7" -dependencies = [ - "bumpalo", - "cranelift-bforest", - "cranelift-codegen-meta", - "cranelift-codegen-shared", - "cranelift-control", - "cranelift-entity", - "cranelift-isle", - "gimli", - "hashbrown 0.14.5", - "log", - "regalloc2", - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cranelift-codegen-meta" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40bf21460a600178956cb7fd900a7408c6587fbb988a8063f7215361801a1da" -dependencies = [ - "cranelift-codegen-shared", -] - -[[package]] -name = "cranelift-codegen-shared" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d792ecc1243b7ebec4a7f77d9ed428ef27456eeb1f8c780587a6f5c38841be19" - -[[package]] -name = "cranelift-control" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea2808043df964b73ad7582e09afbbe06a31f3fb9db834d53e74b4e16facaeb" -dependencies = [ - "arbitrary", -] - -[[package]] -name = "cranelift-entity" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1930946836da6f514da87625cd1a0331f3908e0de454628c24a0b97b130c4d4" -dependencies = [ - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-frontend" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5482a5fcdf98f2f31b21093643bdcfe9030866b8be6481117022e7f52baa0f2b" -dependencies = [ - "cranelift-codegen", - "log", - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cranelift-isle" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f6e1869b6053383bdb356900e42e33555b4c9ebee05699469b7c53cdafc82ea" - -[[package]] -name = "cranelift-native" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91446e8045f1c4bc164b7bba68e2419c623904580d4b730877a663c6da38964" -dependencies = [ - "cranelift-codegen", - "libc", - "target-lexicon", -] - -[[package]] -name = "cranelift-wasm" -version = "0.106.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b17979b862d3b0d52de6ae3294ffe4d86c36027b56ad0443a7c8c8f921d14f" -dependencies = [ - "cranelift-codegen", - "cranelift-entity", - "cranelift-frontend", - "itertools", - "log", - "smallvec", - "wasmparser 0.201.0", - "wasmtime-types", -] - -[[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-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[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 = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core 0.9.12", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "debugid" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" -dependencies = [ - "uuid", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "directories-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "extended" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" - -[[package]] -name = "fail" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5e43d0f78a42ad591453aedb1d7ae631ce7ee445c7643691055a9ed8d3b01c" -dependencies = [ - "log", - "once_cell", - "rand", -] - -[[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 = "fastcdc" -version = "3.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf51ceb43e96afbfe4dd5c6f6082af5dfd60e220820b8123792d61963f2ce6bc" - -[[package]] -name = "fastdivide" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "filetime" -version = "0.2.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "fs4" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" -dependencies = [ - "rustix 0.38.44", - "windows-sys 0.52.0", -] - -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - -[[package]] -name = "fuser" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e697f6f62c20b6fad1ba0f84ae909f25971cf16e735273524e3977c94604cf8" -dependencies = [ - "libc", - "log", - "memchr", - "page_size", - "pkg-config", - "smallvec", - "zerocopy 0.7.35", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "fxprof-processed-profile" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" -dependencies = [ - "bitflags 2.11.1", - "debugid", - "fxhash", - "serde", - "serde_json", -] - -[[package]] -name = "generator" -version = "0.6.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061d3be1afec479d56fa3bd182bf966c7999ec175fcfdb87ac14d417241366c6" -dependencies = [ - "cc", - "libc", - "log", - "rustversion", - "winapi", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gimli" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" -dependencies = [ - "fallible-iterator", - "indexmap 2.14.0", - "stable_deref_trait", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util 0.7.18", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -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.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "htmlescape" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http", - "hyper", - "rustls", - "tokio", - "tokio-rustls", -] - -[[package]] -name = "hyper-timeout" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" -dependencies = [ - "hyper", - "pin-project-lite", - "tokio", - "tokio-io-timeout", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.24.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" -dependencies = [ - "bytemuck", - "byteorder", - "color_quant", - "jpeg-decoder", - "num-traits", - "png", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "inotify" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" -dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "ittapi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" -dependencies = [ - "anyhow", - "ittapi-sys", - "log", -] - -[[package]] -name = "ittapi-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" -dependencies = [ - "cc", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "jpeg-decoder" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "285efcf12ef41bec907b3000d5ffaeb54191d4d9d83c0d6157e6cbc2db255e64" -dependencies = [ - "bitflags 2.11.1", - "libc", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "levenshtein_automata" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "libc", -] - -[[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.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "lofty" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca" -dependencies = [ - "byteorder", - "data-encoding", - "flate2", - "lofty_attr", - "log", - "ogg_pager", - "paste", -] - -[[package]] -name = "lofty_attr" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "loom" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a6650b2f722ae8c0e2ebc46d07f80c9923464fc206d962332f1eff83143530" -dependencies = [ - "cfg-if", - "futures-util", - "generator", - "scoped-tls", - "serde", - "serde_json", -] - -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "lz4_flex" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" - -[[package]] -name = "mach" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa" -dependencies = [ - "libc", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "measure_time" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbefd235b0aadd181626f281e1d684e116972988c14c264e42069d5e8a5775cc" -dependencies = [ - "instant", - "log", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memfd" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix 1.1.4", -] - -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", -] - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "mockall_double" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dffc15b97456ecc84d2bde8c1df79145e154f45225828c4361f676e1b82acd6" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot 0.12.5", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - -[[package]] -name = "murmurhash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" - -[[package]] -name = "musicfs-cache" -version = "0.1.0" -dependencies = [ - "bytes", - "chrono", - "image", - "lofty", - "musicfs-cas", - "musicfs-core", - "musicfs-metadata", - "parking_lot 0.12.5", - "rmp-serde", - "rusqlite", - "serde", - "serde_json", - "sled", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", -] - -[[package]] -name = "musicfs-cas" -version = "0.1.0" -dependencies = [ - "bytes", - "dirs", - "fail", - "hex", - "musicfs-cache", - "musicfs-core", - "musicfs-origins", - "musicfs-sync", - "parking_lot 0.12.5", - "rmp-serde", - "serde", - "sled", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", - "xxhash-rust", -] - -[[package]] -name = "musicfs-cli" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "dirs", - "libc", - "musicfs-cache", - "musicfs-cas", - "musicfs-core", - "musicfs-fuse", - "musicfs-grpc", - "musicfs-metadata", - "musicfs-origins", - "parking_lot 0.12.5", - "sd-notify", - "serde", - "serde_json", - "tokio", - "tokio-stream", - "tokio-util 0.7.18", - "toml", - "tonic", - "tracing", - "tracing-appender", - "tracing-journald", - "tracing-subscriber", -] - -[[package]] -name = "musicfs-core" -version = "0.1.0" -dependencies = [ - "hex", - "parking_lot 0.12.5", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", - "tokio", - "toml", - "tracing", - "xxhash-rust", -] - -[[package]] -name = "musicfs-fuse" -version = "0.1.0" -dependencies = [ - "fuser", - "libc", - "moka", - "musicfs-cache", - "musicfs-cas", - "musicfs-core", - "musicfs-search", - "parking_lot 0.12.5", - "tempfile", - "tokio", - "tracing", -] - -[[package]] -name = "musicfs-grpc" -version = "0.1.0" -dependencies = [ - "chrono", - "csv", - "hex", - "hmac", - "musicfs-cache", - "musicfs-cas", - "musicfs-core", - "musicfs-metadata", - "musicfs-search", - "parking_lot 0.12.5", - "prost", - "reqwest", - "serde", - "serde_json", - "sha2", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tokio-stream", - "tonic", - "tonic-build", - "tracing", -] - -[[package]] -name = "musicfs-metadata" -version = "0.1.0" -dependencies = [ - "image", - "musicfs-core", - "symphonia", - "thiserror 1.0.69", - "tracing", -] - -[[package]] -name = "musicfs-origins" -version = "0.1.0" -dependencies = [ - "async-trait", - "dashmap", - "futures", - "libc", - "musicfs-core", - "parking_lot 0.12.5", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", -] - -[[package]] -name = "musicfs-plugins" -version = "0.1.0" -dependencies = [ - "async-trait", - "libloading", - "musicfs-core", - "semver", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", - "wasmtime", -] - -[[package]] -name = "musicfs-search" -version = "0.1.0" -dependencies = [ - "moka", - "musicfs-core", - "parking_lot 0.12.5", - "rusqlite", - "serde", - "serde_json", - "tantivy", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", -] - -[[package]] -name = "musicfs-sync" -version = "0.1.0" -dependencies = [ - "async-trait", - "fastcdc", - "musicfs-core", - "musicfs-origins", - "notify", - "rmp-serde", - "serde", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", - "xxhash-rust", -] - -[[package]] -name = "musicfs-test-utils" -version = "0.1.0" -dependencies = [ - "async-trait", - "bytes", - "fail", - "libc", - "musicfs-cache", - "musicfs-cas", - "musicfs-core", - "musicfs-origins", - "musicfs-search", - "nix", - "noxious-client", - "parking_lot 0.12.5", - "reqwest", - "rlimit", - "sd-notify", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tokio-test", - "tokio-util 0.7.18", - "tracing", -] - -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "notify" -version = "6.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" -dependencies = [ - "bitflags 2.11.1", - "crossbeam-channel", - "filetime", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio 0.8.11", - "walkdir", - "windows-sys 0.48.0", -] - -[[package]] -name = "noxious" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e68998924150ba54dbf1adf4c3f7f7c10bb5d3c6789ab71af11e34fe4c667970" -dependencies = [ - "async-trait", - "bmrng", - "bytes", - "futures", - "mockall_double", - "pin-project-lite", - "rand", - "serde", - "thiserror 1.0.69", - "tokio", - "tokio-util 0.6.10", - "tracing", -] - -[[package]] -name = "noxious-client" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b7ab7a9efb5768cd07e2b2455f80b3998d7397be76398c2ac03a52a42b652e7" -dependencies = [ - "noxious", - "reqwest", - "serde", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "object" -version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" -dependencies = [ - "crc32fast", - "hashbrown 0.14.5", - "indexmap 2.14.0", - "memchr", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "ogg_pager" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" -dependencies = [ - "byteorder", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "oneshot" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" - -[[package]] -name = "openssl" -version = "0.10.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ownedbytes" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "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]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset", - "indexmap 2.14.0", -] - -[[package]] -name = "pin-project" -version = "1.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy 0.8.48", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" -dependencies = [ - "bytes", - "heck 0.5.0", - "itertools", - "log", - "multimap", - "once_cell", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn 2.0.117", - "tempfile", -] - -[[package]] -name = "prost-derive" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "prost-types" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" -dependencies = [ - "prost", -] - -[[package]] -name = "psm" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" -dependencies = [ - "ar_archive_writer", - "cc", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[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 2.11.1", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "regalloc2" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad156d539c879b7a24a363a2016d77961786e71f48f2e2fc8302a92abd2429a6" -dependencies = [ - "hashbrown 0.13.2", - "log", - "rustc-hash", - "slice-group-by", - "smallvec", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "hyper", - "hyper-rustls", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration", - "tokio", - "tokio-native-tls", - "tokio-rustls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", - "winreg", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rlimit" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7043b63bd0cd1aaa628e476b80e6d4023a3b50eb32789f2728908107bd0c793a" -dependencies = [ - "libc", -] - -[[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]] -name = "rust-stemmers" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" -dependencies = [ - "serde", - "serde_derive", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki", - "sct", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "sd-notify" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4" -dependencies = [ - "libc", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "sketches-ddsketch" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" -dependencies = [ - "serde", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[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 = "slice-group-by" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "sptr" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - -[[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 = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "tantivy" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96599ea6fccd844fc833fed21d2eecac2e6a7c1afd9e044057391d78b1feb141" -dependencies = [ - "aho-corasick", - "arc-swap", - "base64 0.22.1", - "bitpacking", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "downcast-rs", - "fastdivide", - "fnv", - "fs4", - "htmlescape", - "itertools", - "levenshtein_automata", - "log", - "lru", - "lz4_flex", - "measure_time", - "memmap2", - "num_cpus", - "once_cell", - "oneshot", - "rayon", - "regex", - "rust-stemmers", - "rustc-hash", - "serde", - "serde_json", - "sketches-ddsketch", - "smallvec", - "tantivy-bitpacker", - "tantivy-columnar", - "tantivy-common", - "tantivy-fst", - "tantivy-query-grammar", - "tantivy-stacker", - "tantivy-tokenizer-api", - "tempfile", - "thiserror 1.0.69", - "time", - "uuid", - "winapi", -] - -[[package]] -name = "tantivy-bitpacker" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df" -dependencies = [ - "bitpacking", -] - -[[package]] -name = "tantivy-columnar" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" -dependencies = [ - "downcast-rs", - "fastdivide", - "itertools", - "serde", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-sstable", - "tantivy-stacker", -] - -[[package]] -name = "tantivy-common" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes", - "serde", - "time", -] - -[[package]] -name = "tantivy-fst" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" -dependencies = [ - "byteorder", - "regex-syntax", - "utf8-ranges", -] - -[[package]] -name = "tantivy-query-grammar" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" -dependencies = [ - "nom", -] - -[[package]] -name = "tantivy-sstable" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e" -dependencies = [ - "tantivy-bitpacker", - "tantivy-common", - "tantivy-fst", - "zstd", -] - -[[package]] -name = "tantivy-stacker" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8" -dependencies = [ - "murmurhash32", - "rand_distr", - "tantivy-common", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04" -dependencies = [ - "serde", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio 1.2.0", - "parking_lot 0.12.5", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.3", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-io-timeout" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" -dependencies = [ - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-test" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" -dependencies = [ - "futures-core", - "tokio", - "tokio-stream", -] - -[[package]] -name = "tokio-util" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36943ee01a6d67977dd3f84a5a1d2efeb4ada3a1ae771cadfaa535d9d9fc6507" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "log", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tonic" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" -dependencies = [ - "async-stream", - "async-trait", - "axum", - "base64 0.21.7", - "bytes", - "h2", - "http", - "http-body", - "hyper", - "hyper-timeout", - "percent-encoding", - "pin-project", - "prost", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-build" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4ef6dd70a610078cb4e338a0f79d06bc759ff1b22d2120c2ff02ae264ba9c2" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand", - "slab", - "tokio", - "tokio-util 0.7.18", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-appender" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" -dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-journald" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3a81ed245bfb62592b1e2bc153e77656d94ee6a0497683a65a12ccaf2438d0" -dependencies = [ - "libc", - "tracing-core", - "tracing-subscriber", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8-ranges" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[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 = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.201.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9c7d2731df60006819b013f64ccc2019691deccf6e11a1804bc850cd6748f1a" -dependencies = [ - "leb128", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser 0.244.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.248.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" -dependencies = [ - "leb128fmt", - "wasmparser 0.248.0", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", -] - -[[package]] -name = "wasmparser" -version = "0.201.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" -dependencies = [ - "bitflags 2.11.1", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "wasmparser" -version = "0.248.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" -dependencies = [ - "bitflags 2.11.1", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "wasmprinter" -version = "0.201.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a67e66da702706ba08729a78e3c0079085f6bfcb1a62e4799e97bbf728c2c265" -dependencies = [ - "anyhow", - "wasmparser 0.201.0", -] - -[[package]] -name = "wasmtime" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e300c0e3f19dc9064e3b17ce661088646c70dbdde36aab46470ed68ba58db7d" -dependencies = [ - "addr2line", - "anyhow", - "async-trait", - "bincode", - "bumpalo", - "cfg-if", - "encoding_rs", - "fxprof-processed-profile", - "gimli", - "indexmap 2.14.0", - "ittapi", - "libc", - "log", - "object 0.32.2", - "once_cell", - "paste", - "rayon", - "rustix 0.38.44", - "semver", - "serde", - "serde_derive", - "serde_json", - "target-lexicon", - "wasm-encoder 0.201.0", - "wasmparser 0.201.0", - "wasmtime-cache", - "wasmtime-component-macro", - "wasmtime-component-util", - "wasmtime-cranelift", - "wasmtime-environ", - "wasmtime-fiber", - "wasmtime-jit-debug", - "wasmtime-jit-icache-coherence", - "wasmtime-runtime", - "wasmtime-slab", - "wasmtime-winch", - "wat", - "windows-sys 0.52.0", -] - -[[package]] -name = "wasmtime-asm-macros" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110aa598e02a136fb095ca70fa96367fc16bab55256a131e66f9b58f16c73daf" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "wasmtime-cache" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e660537b0ac2fc76917fb0cc9d403d2448b6983a84e59c51f7fea7b7dae024" -dependencies = [ - "anyhow", - "base64 0.21.7", - "bincode", - "directories-next", - "log", - "rustix 0.38.44", - "serde", - "serde_derive", - "sha2", - "toml", - "windows-sys 0.52.0", - "zstd", -] - -[[package]] -name = "wasmtime-component-macro" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091f32ce586251ac4d07019388fb665b010d9518ffe47be1ddbabb162eed6007" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasmtime-component-util", - "wasmtime-wit-bindgen", - "wit-parser 0.201.0", -] - -[[package]] -name = "wasmtime-component-util" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd17dc1ebc0b28fd24b6b9d07638f55b82ae908918ff08fd221f8b0fefa9125" - -[[package]] -name = "wasmtime-cranelift" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e923262451a4b5b39fe02f69f1338d56356db470e289ea1887346b9c7f592738" -dependencies = [ - "anyhow", - "cfg-if", - "cranelift-codegen", - "cranelift-control", - "cranelift-entity", - "cranelift-frontend", - "cranelift-native", - "cranelift-wasm", - "gimli", - "log", - "object 0.32.2", - "target-lexicon", - "thiserror 1.0.69", - "wasmparser 0.201.0", - "wasmtime-cranelift-shared", - "wasmtime-environ", - "wasmtime-versioned-export-macros", -] - -[[package]] -name = "wasmtime-cranelift-shared" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "508898cbbea0df81a5d29cfc1c7c72431a1bc4c9e89fd9514b4c868474c05c7a" -dependencies = [ - "anyhow", - "cranelift-codegen", - "cranelift-control", - "cranelift-native", - "gimli", - "object 0.32.2", - "target-lexicon", - "wasmtime-environ", -] - -[[package]] -name = "wasmtime-environ" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7e3f2aa72dbb64c19708646e1ff97650f34e254598b82bad5578ea9c80edd30" -dependencies = [ - "anyhow", - "bincode", - "cpp_demangle", - "cranelift-entity", - "gimli", - "indexmap 2.14.0", - "log", - "object 0.32.2", - "rustc-demangle", - "serde", - "serde_derive", - "target-lexicon", - "thiserror 1.0.69", - "wasm-encoder 0.201.0", - "wasmparser 0.201.0", - "wasmprinter", - "wasmtime-component-util", - "wasmtime-types", -] - -[[package]] -name = "wasmtime-fiber" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9235b643527bcbac808216ed342e1fba324c95f14a62762acfa6f2e6ca5edbd6" -dependencies = [ - "anyhow", - "cc", - "cfg-if", - "rustix 0.38.44", - "wasmtime-asm-macros", - "wasmtime-versioned-export-macros", - "windows-sys 0.52.0", -] - -[[package]] -name = "wasmtime-jit-debug" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92de34217bf7f0464262adf391a9950eba440f9dfc7d3b0e3209302875c6f65f" -dependencies = [ - "object 0.32.2", - "once_cell", - "rustix 0.38.44", - "wasmtime-versioned-export-macros", -] - -[[package]] -name = "wasmtime-jit-icache-coherence" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c22ca2ef4d87b23d400660373453e274b2251bc2d674e3102497f690135e04b0" -dependencies = [ - "cfg-if", - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "wasmtime-runtime" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1806ee242ca4fd183309b7406e4e83ae7739b7569f395d56700de7c7ef9f5eb8" -dependencies = [ - "anyhow", - "cc", - "cfg-if", - "encoding_rs", - "indexmap 2.14.0", - "libc", - "log", - "mach", - "memfd", - "memoffset", - "paste", - "psm", - "rustix 0.38.44", - "sptr", - "wasm-encoder 0.201.0", - "wasmtime-asm-macros", - "wasmtime-environ", - "wasmtime-fiber", - "wasmtime-jit-debug", - "wasmtime-versioned-export-macros", - "wasmtime-wmemcheck", - "windows-sys 0.52.0", -] - -[[package]] -name = "wasmtime-slab" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c58bef9ce877fd06acb58f08d003af17cb05cc51225b455e999fbad8e584c0" - -[[package]] -name = "wasmtime-types" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cebe297aa063136d9d2e5b347c1528868aa43c2c8d0e1eb0eec144567e38fe0f" -dependencies = [ - "cranelift-entity", - "serde", - "serde_derive", - "thiserror 1.0.69", - "wasmparser 0.201.0", -] - -[[package]] -name = "wasmtime-versioned-export-macros" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffaafa5c12355b1a9ee068e9295d50c4ca0a400c721950cdae4f5b54391a2da5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "wasmtime-winch" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d618b4e90d3f259b1b77411ce573c9f74aade561957102132e169918aabdc863" -dependencies = [ - "anyhow", - "cranelift-codegen", - "gimli", - "object 0.32.2", - "target-lexicon", - "wasmparser 0.201.0", - "wasmtime-cranelift-shared", - "wasmtime-environ", - "winch-codegen", -] - -[[package]] -name = "wasmtime-wit-bindgen" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c7a253c8505edd7493603e548bff3af937b0b7dbf2b498bd5ff2131b651af72" -dependencies = [ - "anyhow", - "heck 0.4.1", - "indexmap 2.14.0", - "wit-parser 0.201.0", -] - -[[package]] -name = "wasmtime-wmemcheck" -version = "19.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9a8c62e9df8322b2166d2a6f096fbec195ddb093748fd74170dcf25ef596769" - -[[package]] -name = "wast" -version = "248.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder 0.248.0", -] - -[[package]] -name = "wat" -version = "1.248.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" -dependencies = [ - "wast", -] - -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "winch-codegen" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15869abc9e3bb29c017c003dbe007a08e9910e8ff9023a962aa13c1b2ee6af" -dependencies = [ - "anyhow", - "cranelift-codegen", - "gimli", - "regalloc2", - "smallvec", - "target-lexicon", - "wasmparser 0.201.0", - "wasmtime-environ", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser 0.244.0", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder 0.244.0", - "wasm-metadata", - "wasmparser 0.244.0", - "wit-parser 0.244.0", -] - -[[package]] -name = "wit-parser" -version = "0.201.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.201.0", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.244.0", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "byteorder", - "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]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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 2.0.117", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 922b7ed..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,95 +0,0 @@ -[workspace] -resolver = "2" -members = ["crates/*"] - -[workspace.package] -version = "0.1.0" -edition = "2021" -license = "MIT OR Apache-2.0" -rust-version = "1.75" -authors = ["MusicFS Contributors"] -repository = "https://github.com/user/musicfs" - -[workspace.dependencies] -# Async runtime -tokio = { version = "1", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } -async-trait = "0.1" -futures = "0.3" - -# Error handling -thiserror = "1" -anyhow = "1" - -# Serialization -serde = { version = "1", features = ["derive"] } -serde_json = "1" -rmp-serde = "1" -toml = "0.8" - -# Concurrent collections -dashmap = "5" - -# Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -tracing-appender = "0.2" -tracing-journald = "0.3" - -# FUSE -fuser = "0.14" - -# Database -rusqlite = { version = "0.31", features = ["bundled"] } -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" -] } - -# Bytes handling -bytes = "1" - -# Platform directories -dirs = "5" - -# CLI -clap = { version = "4", features = ["derive"] } - -# Testing -tempfile = "3" -fail = "0.5" -rlimit = "0.10" -nix = { version = "0.29", features = ["signal", "process"] } -wiremock = "0.6" -assert_cmd = "2.0" -noxious-client = "1.0" - -# Platform-specific -libc = "0.2" - -# Search (Week 8) -tantivy = "0.22" -moka = { version = "0.12", features = ["sync"] } - -# Concurrency -parking_lot = "0.12" - -# gRPC (Week 8) -tonic = "0.11" -prost = "0.12" -tokio-stream = "0.1" - -# Smart Features (Week 9) -image = { version = "0.24", default-features = false, features = ["jpeg", "png"] } -chrono = "0.4" - -sd-notify = "0.4" - -[workspace.dependencies.tonic-build] -version = "0.11" diff --git a/README.md b/README.md deleted file mode 100644 index 3e5e61f..0000000 --- a/README.md +++ /dev/null @@ -1,879 +0,0 @@ -# MusicFS - -> A read-only FUSE filesystem that presents your music library organized by metadata — artist, album, track — regardless of how files are stored on disk. - -Browse `/Artist/Album/Track.flac` in any media player or file manager. Original files are never touched. - ---- - -## What It Does - -MusicFS mounts as a virtual filesystem. Point it at your music storage (local drive, NFS share, S3 bucket, SFTP server) and it exposes a clean metadata-based directory tree: - -``` -/mnt/music/ -├── Metallica/ -│ └── 72 Seasons (2023) [FLAC]/ -│ ├── 01 - 72 Seasons.flac -│ ├── 02 - Shadows Follow.flac -│ └── cover.jpg -├── Pink Floyd/ -│ └── The Wall (1979) [FLAC]/ -│ ├── 01 - In the Flesh?.flac -│ └── ... -└── .search/ - └── (full-text search — see Search section) -``` - -Files are read directly from origin storage with local chunk caching. Once cached, playback works entirely offline. Write operations return `EROFS` — origin files are always safe. - ---- - -## Features - -| Feature | Details | -|---------|---------| -| **Instant mount** | O(1) regardless of library size (<500ms) | -| **Metadata-organized paths** | Configurable path templates via `$artist`, `$album`, `$year`, etc. | -| **Multi-origin federation** | Local, NFS, SMB, S3, SFTP — automatic failover by priority | -| **Content-addressable cache** | Chunk-level deduplication, LRU eviction, delta sync (>90% bandwidth savings) | -| **Full-text search** | `/.search/metallica/` returns instant results across 1M+ tracks | -| **Metadata overlay** | Set/override tags in the virtual layer without modifying originals | -| **Album art** | Virtual `cover.jpg` per album, extracted from embedded tags | -| **Plugin system** | Native `.so` and WASM plugins for custom origins, formats, metadata sources | -| **gRPC control API** | Cache stats, origin health, live event streaming, metadata management | -| **systemd integration** | `sd_notify` ready, journald logging, clean SIGTERM handling | - -**Supported formats:** FLAC, MP3, OGG, WAV, M4A, AAC, Opus - ---- - -## Quick Start - -```bash -# 1. Enter dev environment (provides Rust, FUSE3, SQLite, everything) -nix develop - -# 2. Build -cargo build - -# 3. Mount your music library -./target/debug/musicfs mount /mnt/music --origin /path/to/your/music - -# 4. Browse -ls /mnt/music -mpv /mnt/music/Artist/Album/01\ -\ Track.flac - -# 5. Unmount -fusermount -u /mnt/music -``` - -No `rustup`, no `apt install`. The Nix flake provides the full toolchain. - ---- - -## Installation - -### From Nix (recommended) - -```bash -# Development shell — everything you need -nix develop - -# Or install the binary into your profile -nix profile install .#musicfs -``` - -### From Source - -**Prerequisites (non-Nix):** -- Rust 1.75+ -- `libfuse3-dev` / `fuse3` (package name varies by distro) -- `libsqlite3-dev` -- `libssl-dev` -- `protobuf-compiler` (for gRPC) -- `clang` + `lld` - -```bash -git clone https://github.com/user/musicfs -cd musicfs/musicfs -cargo build --release -sudo cp target/release/musicfs /usr/local/bin/ -``` - -### System Requirements - -| Resource | Minimum | Recommended | -|----------|---------|-------------| -| CPU | 1 core | 4 cores | -| RAM | 256 MB | 2 GB | -| Disk (cache) | 1 GB | 50 GB | -| Linux kernel | 4.x+ | 5.x+ | -| FUSE module | required | — | - ---- - -## Configuration - -MusicFS can be configured via file (`--config`), CLI flags, or environment variables (`RUST_LOG` for log level). - -### Minimal Config - -```toml -mount_point = "/mnt/music" -cache_dir = "/home/user/.cache/musicfs" - -[[origins]] -id = "local" -origin_type = "local" -priority = 1 -path = "/mnt/nas/music" -``` - -```bash -musicfs mount --config /etc/musicfs/config.toml -``` - -### Full Config Reference - - -```toml -# MusicFS Configuration -# Copy to /etc/musicfs/config.toml or ~/.config/musicfs/config.toml - -# Required: where to mount the virtual filesystem -mount_point = "/mnt/music" - -# Required: directory for cache data (CAS chunks, metadata, search index) -cache_dir = "/var/cache/musicfs" - -# ------------------------------------------------------------------------------ -# Origins - music sources (at least one required) -# Supported types: local, nfs, smb, s3, sftp -# Lower priority number = preferred source for failover -# ------------------------------------------------------------------------------ - -[[origins]] -id = "local-music" -origin_type = "local" -priority = 1 -enabled = true -path = "/home/user/Music" - -[[origins]] -id = "nas-nfs" -origin_type = "nfs" -priority = 2 -enabled = true -path = "/mnt/nas/music" - -[[origins]] -id = "nas-smb" -origin_type = "smb" -priority = 3 -enabled = false -path = "/mnt/smb/music" - -[[origins]] -id = "cloud-backup" -origin_type = "s3" -priority = 10 -enabled = false -bucket = "my-music-backup" -region = "us-east-1" - -[[origins]] -id = "remote-server" -origin_type = "sftp" -priority = 10 -enabled = false -host = "music.example.com" -port = 22 -user = "musicfs" -path = "/srv/music" - -# ------------------------------------------------------------------------------ -# Cache settings -# ------------------------------------------------------------------------------ - -[cache] -# In-memory metadata cache size (artist/album/track info) -metadata_cache_mb = 100 - -# On-disk content cache size (audio chunks) -content_cache_gb = 10 - -# ------------------------------------------------------------------------------ -# Health monitoring for origin failover -# ------------------------------------------------------------------------------ - -[health] -# How often to check origin health -check_interval_secs = 30 - -# Timeout for health check probes -timeout_ms = 5000 - -# Consecutive failures before marking origin unhealthy -unhealthy_threshold = 3 - -# Per-origin type thresholds (overrides unhealthy_threshold) -[health.per_origin_thresholds] -local = 1 -nfs = 3 -smb = 3 -s3 = 3 -sftp = 3 - -# ------------------------------------------------------------------------------ -# Logging -# ------------------------------------------------------------------------------ - -[logging] -# Directory for log files -log_dir = "/var/log/musicfs" - -# Output logs as JSON (for log aggregators) -json_output = false - -# Send logs to systemd journal -journald = true - -# Log level filter (tracing format) -# Examples: "info", "debug", "musicfs=debug,warn", "musicfs_fuse=trace" -level = "musicfs=info,warn" - -# Trace sampling rate for performance tracing (0.0 to 1.0) -trace_sample_rate = 1.0 -``` - -### Cache Layout on Disk - -``` -~/.cache/musicfs/ -├── musicfs.db # SQLite: file metadata, virtual tree, overlay data -├── musicfs.lock # Single-instance lock -├── musicfs.pid # Daemon PID -├── chunks/ # Content-addressable chunk files -│ ├── aa/ # 256 subdirs (first 2 hex chars of hash) -│ │ └── aa1b2c… # 64 KB average chunk -│ └── ... -├── search.idx/ # Tantivy full-text search index -└── chunks.sled/ # Sled KV: content hash → chunk location -``` - ---- - -## CLI Reference - -``` -musicfs [OPTIONS] - -OPTIONS: - -l, --log-level Log verbosity [default: info] -``` - -### `mount` — Start the filesystem - -```bash -# From CLI flags (quick start) -musicfs mount /mnt/music --origin /path/to/music - -# From config file -musicfs mount --config /etc/musicfs/config.toml - -# All flags -musicfs mount [MOUNTPOINT] \ - --config # Config file (overrides flags) - --origin # Source music directory - --cache-dir # Cache location [default: ~/.cache/musicfs] - --grpc-port # gRPC server port [default: 50052] -``` - -### `status` — Daemon status - -```bash -musicfs status -``` - -### `cache` — Cache management - -```bash -musicfs cache stats # Hit rate, size, dedup ratio -musicfs cache clear # Clear all caches -musicfs cache clear # Clear cache for one origin -musicfs cache prefetch [path…] # Pre-warm cache for paths -``` - -### `search` — Full-text search - -```bash -musicfs search "metallica" # Search across all metadata -musicfs search "dark side" --limit 20 # Limit results [default: 100] -``` - -Search results are also browsable as a virtual directory (see [Search](#search)). - -### `origin` — Origin management - -```bash -musicfs origin list # List all configured origins -musicfs origin health # Check health of one origin -musicfs origin rescan # Force re-scan and re-index -``` - -### `metadata` — Metadata overlay - -```bash -# Requires running daemon -musicfs metadata get "/Artist/Album/01 - Track.flac" -musicfs metadata get "/Artist/Album/01 - Track.flac" --field artist - -musicfs metadata set "/Artist/Album/01 - Track.flac" \ - --title "New Title" \ - --artist "New Artist" \ - --album "New Album" \ - --track 1 \ - --genre "Rock" \ - --date "2023" - -# Set from JSON -musicfs metadata set "/path/to/file.flac" --json '{"title":"foo","year":2023}' - -# Show current (overlaid) metadata -musicfs metadata diff "/path/to/file.flac" - -# Revert overlay — restore original metadata -musicfs metadata clear "/path/to/file.flac" - -# Bulk import/export -musicfs metadata import library.csv -musicfs metadata import library.json -musicfs metadata export --output library.json -musicfs metadata export --output library.csv --query "artist:Metallica" -``` - -> **Note:** `--endpoint` flag (default `http://[::1]:50051`) selects the gRPC server. - -### `trash` — Deleted file recovery - -When files disappear from the origin, MusicFS moves them to a virtual trash rather than removing them immediately. - -```bash -musicfs trash list --config /etc/musicfs/config.toml -musicfs trash list --since 7d # Deleted in last 7 days -musicfs trash list --origin local # Filter by origin -musicfs trash list --path "/Metallica" # Filter by path prefix - -musicfs trash restore "/Metallica/72 Seasons" # Restore folder -musicfs trash restore --all # Restore everything - -musicfs trash empty --older-than 30d # Permanently delete old entries -musicfs trash empty --pattern "/Unknown*" # Delete by pattern -``` - -### `events` — Live event stream - -```bash -musicfs events # All events -musicfs events --type file_added # Filter by type -# Event types: file_added, file_removed, file_modified, -# origin_connected, origin_disconnected, -# sync_started, sync_completed, cache_eviction -``` - -### `shutdown` — Stop the daemon - -```bash -musicfs shutdown # Graceful (drain in-flight ops) -musicfs shutdown --graceful false # Immediate -musicfs shutdown --timeout 60 # Max drain timeout seconds -``` - ---- - -## Storage Origins - -### Local Filesystem - -```toml -[[origins]] -id = "local" -origin_type = "local" -priority = 1 -path = "/mnt/nas/music" -``` - -Changes detected via `inotify`. Zero-latency access. - -### NFS - -```toml -[[origins]] -id = "nfs" -origin_type = "nfs" -priority = 2 -host = "nas.local" -export = "/exports/music" -``` - -### SMB / CIFS - -```toml -[[origins]] -id = "smb" -origin_type = "smb" -priority = 3 -host = "nas.local" -share = "music" -``` - -### S3 (stub — not yet functional) - -```toml -[[origins]] -id = "s3" -origin_type = "s3" -priority = 4 -bucket = "my-music" -region = "us-east-1" -# Credentials via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars -``` - -### SFTP (stub — not yet functional) - -```toml -[[origins]] -id = "sftp" -origin_type = "sftp" -priority = 4 -host = "server.example.com" -port = 22 -username = "alice" -# Auth via SSH agent or key file — never store passwords in config -``` - -### Multi-Origin Failover - -Multiple origins are federates into a single virtual tree. MusicFS selects origins by priority, falling back automatically when one becomes unhealthy. Health is polled every `check_interval_secs` (default: 30s). When all origins for a file are unavailable, cached data is served seamlessly. - ---- - -## Virtual Filesystem Layout - -### Path Templates - -The virtual path for each file is built from its audio metadata using a configurable template. Variables are sanitized (no `/`, `\`, `:`). - -**Default template:** -``` -$artist/$album ($year) [$format_upper]/$track - $title.$format -``` - -**Template variables:** - -| Variable | Description | Example | -|----------|-------------|---------| -| `$artist` | Track artist | `Metallica` | -| `$album` | Album name | `72 Seasons` | -| `$title` | Track title | `Lux Æterna` | -| `$track` | Track number (zero-padded) | `03` | -| `$disc` | Disc number | `1` | -| `$year` | Release year | `2023` | -| `$genre` | Genre | `Metal` | -| `$format` | File extension (lowercase) | `flac` | -| `$format_upper` | File extension (uppercase) | `FLAC` | - -Files with missing metadata fall back to `Unknown Artist/Unknown Album/filename`. - -### Album Art - -Each album directory includes a virtual `cover.jpg` extracted from the embedded tags of the first track. No files are written to disk by MusicFS — the image is synthesized on read. - -### Search - -The `/.search/` virtual directory exposes full-text search as filesystem paths: - -```bash -# Search via filesystem — use the query as a directory name -ls "/mnt/music/.search/dark side of the moon/" -# → Returns matching tracks as symlinks to their virtual paths - -# Or use the CLI -musicfs search "dark side of the moon" -musicfs search "artist:Metallica" --limit 50 -``` - -**Query syntax** (powered by [tantivy](https://github.com/quickwit-oss/tantivy)): - -| Syntax | Example | Matches | -|--------|---------|---------| -| Simple terms | `metallica sandman` | All fields contain both words | -| Field-specific | `artist:Metallica` | Artist field only | -| Phrase | `album:"Master of Puppets"` | Exact phrase in album | -| Fuzzy | `metalica~1` | Within Levenshtein distance 1 | -| Range | `year:[1980 TO 1989]` | Numeric range | -| Boolean | `genre:Metal AND year:[1980 TO 1989]` | Combined conditions | - -Indexed fields: `title`, `artist`, `album`, `album_artist`, `genre`, `composer`, `year`. -Results cached for 5 minutes. Max 1000 results per query. Queries capped at 256 characters. - -### Smart Collections - -Built-in and custom query-based virtual folders appear alongside regular directories: - -- **Recently Added** — tracks added in the last 30 days -- **80s Music** — year 1980–1989 -- **90s Music** — year 1990–1999 - -Custom collections can be defined via the gRPC API with compound boolean queries over any indexed field. - ---- - -## Metadata Overlay - -MusicFS lets you override metadata in the virtual layer **without touching origin files**. Overlaid metadata is synthesized into the audio file header on read — players see your corrected tags, the origin file is unchanged. - -```bash -# Fix a misnamed artist -musicfs metadata set "/Unknown/Best Of/01 - Track.flac" \ - --artist "The Beatles" \ - --album "Past Masters" - -# Verify -musicfs metadata get "/The Beatles/Past Masters/01 - Track.flac" - -# See what's been overlaid vs. original -musicfs metadata diff "/The Beatles/Past Masters/01 - Track.flac" - -# Revert -musicfs metadata clear "/The Beatles/Past Masters/01 - Track.flac" -``` - -Supported fields: `title`, `artist`, `album`, `album-artist`, `track`, `disc`, `genre`, `date`, `composer`, `comment`, `lyrics`, `copyright`, `compilation`, sort fields (`artist-sort`, etc.), MusicBrainz IDs, ReplayGain values, and arbitrary custom tags. - ---- - -## Plugin Development - -Plugins extend MusicFS without modifying core code. Three plugin types: - -| Type | Purpose | Examples | -|------|---------|---------| -| **Origin** | Custom storage backends | Google Drive, Dropbox, custom NAS protocol | -| **Metadata** | External tag enrichment | MusicBrainz, Discogs, Last.fm | -| **Format** | Custom audio formats | Game audio, proprietary codecs | - -### Native Plugin (`.so`) - -```rust -// Cargo.toml -[lib] -crate-type = ["cdylib"] - -[dependencies] -musicfs-plugins = { path = "..." } -semver = "1" -serde_json = "1" -``` - -```rust -use musicfs_plugins::{declare_plugin, Plugin, PluginType, FormatPlugin}; -use musicfs_core::AudioMeta; -use semver::Version; -use serde_json::Value; - -struct MyFormatPlugin; - -impl Plugin for MyFormatPlugin { - fn name(&self) -> &str { "my-format" } - fn version(&self) -> Version { Version::new(1, 0, 0) } - fn plugin_type(&self) -> PluginType { PluginType::Format } - fn init(&mut self, _config: Value) -> musicfs_plugins::Result<()> { Ok(()) } - fn shutdown(&mut self) -> musicfs_plugins::Result<()> { Ok(()) } -} - -impl FormatPlugin for MyFormatPlugin { - fn extensions(&self) -> &[&str] { &["xyz"] } - - fn parse(&self, reader: &mut dyn std::io::Read) -> musicfs_plugins::Result { - // Parse your format and return metadata - todo!() - } - - fn synthesize_header(&self, metadata: &AudioMeta) -> musicfs_plugins::Result> { - // Build a new file header with updated metadata - todo!() - } -} - -// Required export — MusicFS calls this to instantiate the plugin -declare_plugin!(MyFormatPlugin, MyFormatPlugin); -``` - -```bash -cargo build --release -# produces target/release/libmy_format_plugin.so -``` - -### Loading Plugins - -```toml -[plugins] -enabled = true -search_paths = ["/usr/lib/musicfs/plugins"] # Auto-discover .so files here - -[plugins.plugins.my-format] -path = "/path/to/libmy_format_plugin.so" -enabled = true -config = { key = "value" } # Passed to Plugin::init() -``` - -### WASM Plugins (experimental) - -```toml -[plugins.wasm] -enabled = true -max_memory_mb = 64 -max_cpu_time_ms = 5000 -``` - -Load a `.wasm` binary at runtime via the gRPC API or by placing it in a search path. WASM plugins run sandboxed inside [wasmtime](https://wasmtime.dev/). - -### Plugin API Version - -Current: `0.1.0`. Breaking changes will increment the major version. MusicFS checks `musicfs_plugin_api_version()` before loading any native plugin. - ---- - -## Control API (gRPC) - -MusicFS exposes a gRPC API for programmatic control. The server starts automatically with the daemon. - -**Default port:** `50052` (override with `--grpc-port`) -**Proto definition:** `crates/musicfs-grpc/proto/musicfs.proto` - -### Available RPCs - -``` -MusicFS service: - GetStatus → daemon version, uptime, mount state, open handles - Shutdown → graceful or forced stop - GetCacheStats → hit rate, chunk count, dedup ratio, per-tier breakdown - ClearCache → clear all or per-origin, per-tier, dry-run supported - Prefetch → pre-warm cache for paths or search queries - ListOrigins → all configured origins with file count and health - GetOriginHealth → health status and latency for one origin - RescanOrigin → force re-scan with streaming progress - Search → full-text search (paginated or streaming) - SubscribeEvents → server-streaming live event feed - -MetadataService: - GetMetadata → all tags for a virtual path - UpdateMetadata → set overlay tags for a file - ClearOverlay → revert to original metadata - ImportMetadata → bulk import from CSV/JSON (streaming progress) -``` - -### Query with `grpcurl` - -```bash -# Daemon status -grpcurl -plaintext localhost:50052 musicfs.v1.MusicFS/GetStatus - -# Search -grpcurl -plaintext -d '{"query": "metallica", "limit": 10}' \ - localhost:50052 musicfs.v1.MusicFS/Search - -# Cache stats -grpcurl -plaintext localhost:50052 musicfs.v1.MusicFS/GetCacheStats - -# List origins -grpcurl -plaintext localhost:50052 musicfs.v1.MusicFS/ListOrigins - -# Trigger rescan with live progress -grpcurl -plaintext -d '{"origin_id": "local"}' \ - localhost:50052 musicfs.v1.MusicFS/RescanOrigin - -# Live event stream -grpcurl -plaintext localhost:50052 musicfs.v1.MusicFS/SubscribeEvents -``` - ---- - -## Production Deployment - -### systemd - -```bash -sudo cp dist/musicfs.service /etc/systemd/system/ - -# Edit the service to match your paths: -# ExecStart=/usr/bin/musicfs mount --config /etc/musicfs/config.toml - -sudo systemctl enable --now musicfs -sudo systemctl status musicfs -``` - - -```ini -[Unit] -Description=MusicFS - Virtual FUSE Filesystem for Music -After=network.target - -[Service] -ExecStart=/usr/bin/musicfs mount /mnt/music --origin /path/to/music -ExecStopPost=/usr/bin/fusermount -u /mnt/music -Restart=on-failure - -[Install] -WantedBy=multi-user.target -``` - -MusicFS sends `sd_notify(READY)` when the mount is live and `sd_notify(STOPPING)` during shutdown. Use `Type=notify` for precise readiness tracking. - -### Signals - -| Signal | Behavior | -|--------|---------| -| `SIGTERM` | Graceful shutdown — drains in-flight ops, unmounts | -| `SIGINT` | Graceful shutdown (same) | -| `SIGHUP` | Process pending file restores from trash | - -### Security Notes - -- Run as an **unprivileged user** — no root required. -- Store remote credentials in the **system keyring** or environment variables. Never put them in the config file. -- Credentials are redacted from logs and `RUST_LOG` output. -- WASM plugins run sandboxed. Native `.so` plugins have full process access — only load plugins you trust. - ---- - -## Observability - -### Logs - -```bash -# Set level at startup -musicfs mount ... --log-level debug -# or via env -RUST_LOG=musicfs=debug,warn musicfs mount ... -``` - -| Level | Content | -|-------|---------| -| `error` | Unrecoverable failures, data corruption | -| `warn` | Recoverable failures, origin timeouts, skipped files | -| `info` | Mount/unmount, sync completion, config reload | -| `debug` | Cache hits/misses, origin selection, file scans | -| `trace` | Individual FUSE operations, chunk I/O | - -Log files rotate daily in `log_dir` (default: `/var/log/musicfs/`). Structured JSON available with `json_output = true`. On Linux, logs forward to journald by default (`journald = true`). - -### Prometheus Metrics - -Metrics are exposed in Prometheus format via the gRPC API: - -``` -musicfs_fuse_ops_total{op="read"} 152341 -musicfs_fuse_ops_total{op="readdir"} 8234 -musicfs_fuse_latency_seconds{op="read",quantile="0.99"} 0.004 -musicfs_cache_hits_total 142107 -musicfs_cache_misses_total 10234 -musicfs_cache_size_bytes 5368709120 -musicfs_origin_health{origin="local"} 1 -musicfs_origin_health{origin="s3"} 0 -musicfs_sync_files_changed{origin="local"} 15 -``` - ---- - -## Performance - -| Operation | Target | Maximum | -|-----------|--------|---------| -| Mount (any library size) | <100ms | 500ms | -| `stat()` cached | <1ms | 5ms | -| `readdir()` cached | <10ms | 50ms | -| `open()` cached | <5ms | 20ms | -| `read()` cached | <1ms | 5ms | -| `read()` cache miss, local | <50ms | 200ms | -| `read()` cache miss, remote | <200ms | 1000ms | -| Search (1M tracks) | <500ms | 1000ms | -| Sequential read (cached) | >500 MB/s | — | -| Metadata ops | >1000 ops/s | — | - -Memory: <50 MB idle, <200 MB with 1K files active, <500 MB peak. -Scales to 10M+ files with O(1) mount and O(log n) lookups. - ---- - -## Known Limitations - -These are tracked issues — see `docs/v2/plans/` for details. - -| Issue | Impact | Workaround | -|-------|--------|-----------| -| **No persistent state on mount** | Every restart does a full origin scan (O(N)). SQLite/search index persist but are not loaded on startup. | — | -| **S3 and SFTP origins are stubs** | Only `local`, `nfs`, and `smb` have real implementations. | Use NFS/SMB mount as proxy for remote storage. | -| **No write-through for metadata** | Overlaid metadata exists only in MusicFS's database, not in the actual audio files. | Use a tagger (beets, mp3tag) to write back if needed. | -| **FUSE↔tokio deadlock risk** | `block_on()` in sync FUSE callbacks can stall under heavy concurrent load. | Keep concurrent open handles below ~500. | -| **No background task supervision** | Health monitor, watcher, and indexer are fire-and-forget. A crash silently stops background work. | Restart the daemon periodically in critical deployments. | - ---- - -## Architecture - -MusicFS is a workspace of 11 Rust crates: - -``` -musicfs-cli → binary, CLI parsing, startup wiring -musicfs-fuse → FUSE operations (fuser), virtual tree serving -musicfs-core → shared types, config, events, errors -musicfs-cache → SQLite metadata DB, virtual tree, format handlers -musicfs-cas → content-addressable chunk store (sled + xxHash64) -musicfs-origins → origin backends (local, NFS, SMB, S3 stub, SFTP stub) -musicfs-metadata → audio tag extraction (symphonia) -musicfs-sync → delta sync, CDC chunking (FastCDC), inotify watcher -musicfs-search → full-text index (tantivy), .search/ virtual dir -musicfs-grpc → gRPC server (tonic + prost), proto codegen -musicfs-plugins → plugin host, native .so loader, WASM sandbox -``` - -Data flow on a cache miss: `FUSE read()` → `VirtualPathResolver` → `CAS` (chunk lookup) → `OriginFederation` (fetch missing range) → CDC chunk → store → return. - -Full design: [`docs/v2/architecture.md`](docs/v2/architecture.md) -Requirements: [`docs/v2/requirements.md`](docs/v2/requirements.md) -Roadmap: [`docs/v2/development-plan.md`](docs/v2/development-plan.md) - ---- - -## Development - -```bash -nix develop # Enter dev shell - -cargo check # Fast compile check -cargo test # All 162 tests -cargo test -p musicfs-core # Single crate -cargo clippy # Lint -cargo fmt # Format -cargo nextest run # Parallel test runner (faster) -cargo watch -x check -x test # Watch mode - -# Cargo aliases -cargo t # test -cargo c # check -cargo b # build - -# gRPC codegen (runs via build.rs automatically) -cargo build -p musicfs-grpc -``` - -Pre-commit hooks (rustfmt + clippy) are installed automatically in the Nix dev shell. - ---- - -## License - -MIT OR Apache-2.0 — see [LICENSE-MIT](LICENSE-MIT) and [LICENSE-APACHE](LICENSE-APACHE). diff --git a/benchmarks/results/benchmark_results.json b/benchmarks/results/benchmark_results.json deleted file mode 100644 index ffe7fdd..0000000 --- a/benchmarks/results/benchmark_results.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "timestamp": "2026-05-12T14:42:37.343765", - "results": [ - { - "mean_ms": null, - "runs": 0, - "name": "mount_time", - "memory_kb": null, - "error": "Mount process died: Traceback (most recent call last):\n File \"/tmp/nix-shell.VlFHpy/nix-shell.rhvctI/beetfs_bench_JfIl36/mount.py\", line 40, in \n beetFs.directory_structure.adddir(sub_elements, level_subbed[level])\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 414, in adddir\n node = self.getnode(elements, root=root)\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 403, in getnode\n return self.getnode(elements, root=root.dirs[topdir])\nKeyError: u'Bench Artist'\n", - "min_ms": null, - "metadata": {}, - "max_ms": null - }, - { - "mean_ms": null, - "runs": 0, - "name": "readdir", - "memory_kb": null, - "error": "Mount process died: Traceback (most recent call last):\n File \"/tmp/nix-shell.VlFHpy/nix-shell.rhvctI/beetfs_bench_JfIl36/mount.py\", line 40, in \n beetFs.directory_structure.adddir(sub_elements, level_subbed[level])\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 414, in adddir\n node = self.getnode(elements, root=root)\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 403, in getnode\n return self.getnode(elements, root=root.dirs[topdir])\nKeyError: u'Bench Artist'\n", - "min_ms": null, - "metadata": {}, - "max_ms": null - }, - { - "mean_ms": null, - "runs": 0, - "name": "stat_latency", - "memory_kb": null, - "error": "Mount process died: Traceback (most recent call last):\n File \"/tmp/nix-shell.VlFHpy/nix-shell.rhvctI/beetfs_bench_JfIl36/mount.py\", line 40, in \n beetFs.directory_structure.adddir(sub_elements, level_subbed[level])\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 414, in adddir\n node = self.getnode(elements, root=root)\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 403, in getnode\n return self.getnode(elements, root=root.dirs[topdir])\nKeyError: u'Bench Artist'\n", - "min_ms": null, - "metadata": {}, - "max_ms": null - }, - { - "mean_ms": null, - "runs": 0, - "name": "enoent_lookup", - "memory_kb": null, - "error": "Mount process died: Traceback (most recent call last):\n File \"/tmp/nix-shell.VlFHpy/nix-shell.rhvctI/beetfs_bench_JfIl36/mount.py\", line 40, in \n beetFs.directory_structure.adddir(sub_elements, level_subbed[level])\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 414, in adddir\n node = self.getnode(elements, root=root)\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 403, in getnode\n return self.getnode(elements, root=root.dirs[topdir])\nKeyError: u'Bench Artist'\n", - "min_ms": null, - "metadata": {}, - "max_ms": null - }, - { - "mean_ms": null, - "runs": 0, - "name": "file_open", - "memory_kb": null, - "error": "Mount process died: Traceback (most recent call last):\n File \"/tmp/nix-shell.VlFHpy/nix-shell.rhvctI/beetfs_bench_JfIl36/mount.py\", line 40, in \n beetFs.directory_structure.adddir(sub_elements, level_subbed[level])\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 414, in adddir\n node = self.getnode(elements, root=root)\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 403, in getnode\n return self.getnode(elements, root=root.dirs[topdir])\nKeyError: u'Bench Artist'\n", - "min_ms": null, - "metadata": {}, - "max_ms": null - }, - { - "mean_ms": null, - "runs": 0, - "name": "read_throughput", - "memory_kb": null, - "error": "Mount process died: Traceback (most recent call last):\n File \"/tmp/nix-shell.VlFHpy/nix-shell.rhvctI/beetfs_bench_JfIl36/mount.py\", line 40, in \n beetFs.directory_structure.adddir(sub_elements, level_subbed[level])\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 414, in adddir\n node = self.getnode(elements, root=root)\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 403, in getnode\n return self.getnode(elements, root=root.dirs[topdir])\nKeyError: u'Bench Artist'\n", - "min_ms": null, - "metadata": {}, - "max_ms": null - }, - { - "mean_ms": null, - "runs": 0, - "name": "memory_usage", - "memory_kb": null, - "error": "Mount process died: Traceback (most recent call last):\n File \"/tmp/nix-shell.VlFHpy/nix-shell.rhvctI/beetfs_bench_JfIl36/mount.py\", line 40, in \n beetFs.directory_structure.adddir(sub_elements, level_subbed[level])\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 414, in adddir\n node = self.getnode(elements, root=root)\n File \"/home/fujin/Code/agregators/music-agregator/beetfs/beetsplug/beetFs.py\", line 403, in getnode\n return self.getnode(elements, root=root.dirs[topdir])\nKeyError: u'Bench Artist'\n", - "min_ms": null, - "metadata": {}, - "max_ms": null - } - ] -} \ No newline at end of file diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py deleted file mode 100644 index 463bc78..0000000 --- a/benchmarks/run_benchmarks.py +++ /dev/null @@ -1,624 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -beetfs Benchmark Suite -Measures mount time, metadata ops, file I/O, and memory usage. -""" -from __future__ import print_function -import os -import sys -import time -import json -import tempfile -import shutil -import subprocess -import signal -import resource -import datetime - -# Add project paths -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'beetsplug')) -sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'tests')) - -from conftest import create_synthetic_flac - - -class BenchmarkResult(object): - """Stores benchmark results.""" - def __init__(self, name): - self.name = name - self.timings = [] - self.memory_kb = None - self.error = None - self.metadata = {} - - def add_timing(self, seconds): - self.timings.append(seconds) - - @property - def mean(self): - if not self.timings: - return None - return sum(self.timings) / len(self.timings) - - @property - def min_time(self): - return min(self.timings) if self.timings else None - - @property - def max_time(self): - return max(self.timings) if self.timings else None - - def to_dict(self): - return { - 'name': self.name, - 'mean_ms': self.mean * 1000 if self.mean else None, - 'min_ms': self.min_time * 1000 if self.min_time else None, - 'max_ms': self.max_time * 1000 if self.max_time else None, - 'runs': len(self.timings), - 'memory_kb': self.memory_kb, - 'error': self.error, - 'metadata': self.metadata - } - - -class BeetFSBenchmark(object): - """Benchmark harness for beetfs.""" - - def __init__(self, output_dir): - self.output_dir = output_dir - self.results = [] - self.temp_dir = None - self.mount_dir = None - self.music_dir = None - self.db_path = None - self.mount_process = None - - def setup(self, num_tracks=10, track_size_mb=5): - """Create test environment with synthetic tracks.""" - self.temp_dir = tempfile.mkdtemp(prefix='beetfs_bench_') - self.mount_dir = os.path.join(self.temp_dir, 'mount') - self.music_dir = os.path.join(self.temp_dir, 'music') - self.db_path = os.path.join(self.temp_dir, 'library.db') - self.config_dir = os.path.join(self.temp_dir, 'config') - - os.makedirs(self.mount_dir) - os.makedirs(self.music_dir) - os.makedirs(self.config_dir) - - # Create beets config - config_path = os.path.join(self.config_dir, 'config.yaml') - with open(config_path, 'w') as f: - f.write('directory: {}\n'.format(self.music_dir)) - f.write('library: {}\n'.format(self.db_path)) - f.write('plugins: []\n') - - os.environ['BEETSDIR'] = self.config_dir - - # Create synthetic FLAC files - print("Creating {} synthetic tracks ({} MB each)...".format(num_tracks, track_size_mb)) - track_paths = [] - for i in range(num_tracks): - artist = 'Bench Artist' - album = 'Bench Album' - title = 'Track {:03d}'.format(i + 1) - filename = '{:02d} - {} - {}.flac'.format(i + 1, artist, title) - track_path = os.path.join(self.music_dir, artist, album, filename) - self._makedirs(os.path.dirname(track_path)) - create_synthetic_flac(track_path, duration_sec=track_size_mb * 10, - artist=artist, title=title, album=album, track=str(i + 1)) - track_paths.append(track_path) - - # Import into beets library - print("Importing tracks into beets library...") - from beets import config - from beets.library import Library - config.read(user=False) - config['directory'].set(self.music_dir) - config['library'].set(self.db_path) - - lib = Library(self.db_path) - from beets.library import Item - for i, path in enumerate(track_paths): - item = Item( - path=path, - artist=u'Bench Artist', - album=u'Bench Album', - title=u'Track {:03d}'.format(i + 1), - track=i + 1, - year=2024, - genre=u'Benchmark', - format='flac' - ) - lib.add(item) - lib._close() - - return len(track_paths) - - def _makedirs(self, path): - """Python 2 compatible makedirs.""" - if not os.path.exists(path): - os.makedirs(path) - - def teardown(self): - """Clean up test environment.""" - self.unmount() - if self.temp_dir and os.path.exists(self.temp_dir): - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def mount(self): - """Mount beetfs and return time taken.""" - # Create mount script - beetfs_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - beetsplug = os.path.join(beetfs_root, 'beetsplug') - - mount_script = ''' -import sys -sys.path.insert(0, "{beetfs_root}") -sys.path.insert(0, "{beetsplug}") - -import os -import re -os.environ["BEETSDIR"] = "{config_dir}" - -from beets import config -from beets.library import Library - -config.read(user=False) -config["directory"] = "{music_dir}" -config["library"] = "{db_path}" - -lib = Library("{db_path}") - -import beetFs -import fuse - -fuse.fuse_python_api = (0, 2) - -beetFs.library = lib -beetFs.structure_depth = 4 -beetFs.structure_split = [0, 1, 2, 3] -beetFs.directory_structure = beetFs.FSNode({{}}, {{}}) - -for item in lib.items(): - mapping = beetFs.template_mapping(lib, item) - path_str = beetFs.PATH_FORMAT - for key, val in mapping.items(): - if val is not None: - clean_val = re.sub(r"[\\\\/:]|^\\.", "_", unicode(val)) - path_str = path_str.replace("$" + key, clean_val) - elements = path_str.split("/") - sub_elements = elements[0:beetFs.structure_depth-1] - for level in range(len(sub_elements)): - level_subbed = sub_elements[0:level+1] - beetFs.directory_structure.adddir(sub_elements, level_subbed[level]) - beetFs.directory_structure.addfile( - sub_elements, - elements[beetFs.structure_depth-1], - item.id - ) - -fs = beetFs.beetFileSystem( - version="%prog " + fuse.__version__, - usage="beetfs benchmark", - dash_s_do="setsingle" -) - -fs.parser.add_option(mountopt="root", metavar="PATH", default="{music_dir}", - help="music library root path") -fs.parse(args=["{mount_dir}"], errex=1) -fs.flags = 0 -fs.multithreaded = False -fs.fuse_args.setmod("foreground") -fs.fuse_args.add("fsname=beetfs") -fs.fuse_args.add("nonempty") -fs.lib = lib - -fs.main() -'''.format( - beetfs_root=beetfs_root, - beetsplug=beetsplug, - config_dir=self.config_dir, - music_dir=self.music_dir, - db_path=self.db_path, - mount_dir=self.mount_dir - ) - - script_path = os.path.join(self.temp_dir, 'mount.py') - with open(script_path, 'w') as f: - f.write(mount_script) - - start_time = time.time() - self.mount_process = subprocess.Popen( - [sys.executable, script_path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - # Wait for mount - timeout = 30 - poll_interval = 0.05 - elapsed = 0 - while elapsed < timeout: - if os.path.ismount(self.mount_dir): - mount_time = time.time() - start_time - return mount_time - time.sleep(poll_interval) - elapsed += poll_interval - - # Check if process died - if self.mount_process.poll() is not None: - stdout, stderr = self.mount_process.communicate() - raise RuntimeError("Mount process died: {}".format(stderr.decode('utf-8', errors='replace'))) - - raise RuntimeError("Mount timeout after {} seconds".format(timeout)) - - def unmount(self): - """Unmount beetfs.""" - if os.path.ismount(self.mount_dir): - subprocess.call(['fusermount', '-u', self.mount_dir]) - time.sleep(0.5) - - if self.mount_process and self.mount_process.poll() is None: - self.mount_process.terminate() - try: - self.mount_process.wait(timeout=5) - except: - self.mount_process.kill() - - def get_memory_usage(self): - """Get current process memory usage in KB.""" - if self.mount_process and self.mount_process.poll() is None: - try: - with open('/proc/{}/status'.format(self.mount_process.pid)) as f: - for line in f: - if line.startswith('VmRSS:'): - return int(line.split()[1]) - except: - pass - return None - - # ======================== - # BENCHMARK METHODS - # ======================== - - def bench_mount_time(self, runs=5): - """Benchmark mount time.""" - result = BenchmarkResult('mount_time') - print("\n=== Mount Time Benchmark ({} runs) ===".format(runs)) - - for i in range(runs): - try: - mount_time = self.mount() - result.add_timing(mount_time) - print(" Run {}: {:.3f}s".format(i + 1, mount_time)) - result.memory_kb = self.get_memory_usage() - self.unmount() - time.sleep(0.5) - except Exception as e: - result.error = str(e) - print(" Run {}: ERROR - {}".format(i + 1, e)) - break - - self.results.append(result) - return result - - def bench_stat_latency(self, runs=50): - """Benchmark single stat() call latency.""" - result = BenchmarkResult('stat_latency') - print("\n=== Stat Latency Benchmark ({} runs) ===".format(runs)) - - try: - self.mount() - time.sleep(1) # Let mount settle - - # Find a file to stat - test_path = None - for root, dirs, files in os.walk(self.mount_dir): - if files: - test_path = os.path.join(root, files[0]) - break - - if not test_path: - result.error = "No files found in mount" - self.results.append(result) - return result - - result.metadata['test_path'] = test_path - - for i in range(runs): - start = time.time() - try: - os.stat(test_path) - elapsed = time.time() - start - result.add_timing(elapsed) - except OSError as e: - result.error = "stat failed: {} (errno {})".format(e.strerror, e.errno) - print(" ERROR: {}".format(result.error)) - break - - if result.timings: - print(" Mean: {:.3f}ms, Min: {:.3f}ms, Max: {:.3f}ms".format( - result.mean * 1000, result.min_time * 1000, result.max_time * 1000)) - - self.unmount() - except Exception as e: - result.error = str(e) - print(" ERROR: {}".format(e)) - - self.results.append(result) - return result - - def bench_readdir(self, runs=20): - """Benchmark directory listing.""" - result = BenchmarkResult('readdir') - print("\n=== Readdir Benchmark ({} runs) ===".format(runs)) - - try: - self.mount() - time.sleep(1) - - for i in range(runs): - start = time.time() - try: - entries = os.listdir(self.mount_dir) - elapsed = time.time() - start - result.add_timing(elapsed) - if i == 0: - result.metadata['entry_count'] = len(entries) - except OSError as e: - result.error = "listdir failed: {} (errno {})".format(e.strerror, e.errno) - print(" ERROR: {}".format(result.error)) - break - - if result.timings: - print(" Mean: {:.3f}ms, Entries: {}".format( - result.mean * 1000, result.metadata.get('entry_count', 'N/A'))) - - self.unmount() - except Exception as e: - result.error = str(e) - print(" ERROR: {}".format(e)) - - self.results.append(result) - return result - - def bench_file_open(self, runs=10): - """Benchmark file open latency.""" - result = BenchmarkResult('file_open') - print("\n=== File Open Benchmark ({} runs) ===".format(runs)) - - try: - self.mount() - time.sleep(1) - - # Find a file to open - test_path = None - for root, dirs, files in os.walk(self.mount_dir): - if files: - test_path = os.path.join(root, files[0]) - break - - if not test_path: - result.error = "No files found in mount" - self.results.append(result) - return result - - result.metadata['test_path'] = test_path - - for i in range(runs): - # Clear page cache between runs (requires sudo, skip if not available) - try: - subprocess.call(['sync']) - except: - pass - - start = time.time() - try: - f = open(test_path, 'rb') - f.read(1) # Trigger actual open - f.close() - elapsed = time.time() - start - result.add_timing(elapsed) - except (IOError, OSError) as e: - result.error = "open failed: {}".format(e) - print(" ERROR: {}".format(result.error)) - break - - if result.timings: - print(" Mean: {:.3f}ms, Min: {:.3f}ms, Max: {:.3f}ms".format( - result.mean * 1000, result.min_time * 1000, result.max_time * 1000)) - - self.unmount() - except Exception as e: - result.error = str(e) - print(" ERROR: {}".format(e)) - - self.results.append(result) - return result - - def bench_read_throughput(self): - """Benchmark read throughput.""" - result = BenchmarkResult('read_throughput') - print("\n=== Read Throughput Benchmark ===") - - try: - self.mount() - time.sleep(1) - - # Find a file to read - test_path = None - for root, dirs, files in os.walk(self.mount_dir): - if files: - test_path = os.path.join(root, files[0]) - break - - if not test_path: - result.error = "No files found in mount" - self.results.append(result) - return result - - result.metadata['test_path'] = test_path - - # Read entire file and measure throughput - start = time.time() - try: - with open(test_path, 'rb') as f: - data = f.read() - elapsed = time.time() - start - - file_size = len(data) - throughput_mbps = (file_size / (1024 * 1024)) / elapsed if elapsed > 0 else 0 - - result.add_timing(elapsed) - result.metadata['file_size_bytes'] = file_size - result.metadata['throughput_mbps'] = throughput_mbps - - print(" File size: {:.2f} MB, Time: {:.3f}s, Throughput: {:.2f} MB/s".format( - file_size / (1024 * 1024), elapsed, throughput_mbps)) - - except (IOError, OSError) as e: - result.error = "read failed: {}".format(e) - print(" ERROR: {}".format(result.error)) - - self.unmount() - except Exception as e: - result.error = str(e) - print(" ERROR: {}".format(e)) - - self.results.append(result) - return result - - def bench_memory_usage(self): - """Benchmark memory usage.""" - result = BenchmarkResult('memory_usage') - print("\n=== Memory Usage Benchmark ===") - - try: - self.mount() - time.sleep(2) - - # Measure idle memory - idle_mem = self.get_memory_usage() - result.metadata['idle_memory_kb'] = idle_mem - print(" Idle memory: {} KB".format(idle_mem)) - - # Open a file and measure - test_path = None - for root, dirs, files in os.walk(self.mount_dir): - if files: - test_path = os.path.join(root, files[0]) - break - - if test_path: - try: - with open(test_path, 'rb') as f: - f.read() - after_read_mem = self.get_memory_usage() - result.metadata['after_read_memory_kb'] = after_read_mem - print(" After file read: {} KB".format(after_read_mem)) - if idle_mem and after_read_mem: - print(" Memory increase: {} KB".format(after_read_mem - idle_mem)) - except (IOError, OSError) as e: - result.error = "read failed: {}".format(e) - - result.memory_kb = self.get_memory_usage() - self.unmount() - except Exception as e: - result.error = str(e) - print(" ERROR: {}".format(e)) - - self.results.append(result) - return result - - def bench_enoent_lookup(self, runs=50): - """Benchmark ENOENT lookup (missing file) latency.""" - result = BenchmarkResult('enoent_lookup') - print("\n=== ENOENT Lookup Benchmark ({} runs) ===".format(runs)) - - try: - self.mount() - time.sleep(1) - - # Non-existent file path - missing_path = os.path.join(self.mount_dir, 'nonexistent', 'cover.jpg') - - for i in range(runs): - start = time.time() - try: - os.stat(missing_path) - except OSError: - pass # Expected - elapsed = time.time() - start - result.add_timing(elapsed) - - if result.timings: - print(" Mean: {:.3f}ms, Min: {:.3f}ms, Max: {:.3f}ms".format( - result.mean * 1000, result.min_time * 1000, result.max_time * 1000)) - - self.unmount() - except Exception as e: - result.error = str(e) - print(" ERROR: {}".format(e)) - - self.results.append(result) - return result - - def save_results(self, filename='benchmark_results.json'): - """Save results to JSON file.""" - output_path = os.path.join(self.output_dir, filename) - data = { - 'timestamp': datetime.datetime.now().isoformat(), - 'results': [r.to_dict() for r in self.results] - } - with open(output_path, 'w') as f: - json.dump(data, f, indent=2) - print("\nResults saved to: {}".format(output_path)) - return output_path - - -def main(): - print("=" * 60) - print("beetfs Benchmark Suite") - print("=" * 60) - - output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results') - if not os.path.exists(output_dir): - os.makedirs(output_dir) - - bench = BeetFSBenchmark(output_dir) - - try: - # Setup with 10 tracks, 5MB each - num_tracks = bench.setup(num_tracks=10, track_size_mb=5) - print("Setup complete: {} tracks".format(num_tracks)) - - # Run benchmarks - bench.bench_mount_time(runs=3) - bench.bench_readdir(runs=10) - bench.bench_stat_latency(runs=20) - bench.bench_enoent_lookup(runs=20) - bench.bench_file_open(runs=5) - bench.bench_read_throughput() - bench.bench_memory_usage() - - # Save results - bench.save_results() - - finally: - bench.teardown() - - # Print summary - print("\n" + "=" * 60) - print("SUMMARY") - print("=" * 60) - for r in bench.results: - status = "OK" if not r.error else "FAIL" - mean_str = "{:.3f}ms".format(r.mean * 1000) if r.mean else "N/A" - print("{:20} {:6} Mean: {:>12} Error: {}".format( - r.name, status, mean_str, r.error or "None")) - - -if __name__ == '__main__': - main() diff --git a/config.example.toml b/config.example.toml deleted file mode 100644 index 29e4660..0000000 --- a/config.example.toml +++ /dev/null @@ -1,107 +0,0 @@ -# MusicFS Configuration -# Copy to /etc/musicfs/config.toml or ~/.config/musicfs/config.toml - -# Required: where to mount the virtual filesystem -mount_point = "/mnt/music" - -# Required: directory for cache data (CAS chunks, metadata, search index) -cache_dir = "/var/cache/musicfs" - -# ------------------------------------------------------------------------------ -# Origins - music sources (at least one required) -# Supported types: local, nfs, smb, s3, sftp -# Lower priority number = preferred source for failover -# ------------------------------------------------------------------------------ - -[[origins]] -id = "local-music" -origin_type = "local" -priority = 1 -enabled = true -path = "/home/user/Music" - -[[origins]] -id = "nas-nfs" -origin_type = "nfs" -priority = 2 -enabled = true -path = "/mnt/nas/music" - -[[origins]] -id = "nas-smb" -origin_type = "smb" -priority = 3 -enabled = false -path = "/mnt/smb/music" - -[[origins]] -id = "cloud-backup" -origin_type = "s3" -priority = 10 -enabled = false -bucket = "my-music-backup" -region = "us-east-1" - -[[origins]] -id = "remote-server" -origin_type = "sftp" -priority = 10 -enabled = false -host = "music.example.com" -port = 22 -user = "musicfs" -path = "/srv/music" - -# ------------------------------------------------------------------------------ -# Cache settings -# ------------------------------------------------------------------------------ - -[cache] -# In-memory metadata cache size (artist/album/track info) -metadata_cache_mb = 100 - -# On-disk content cache size (audio chunks) -content_cache_gb = 10 - -# ------------------------------------------------------------------------------ -# Health monitoring for origin failover -# ------------------------------------------------------------------------------ - -[health] -# How often to check origin health -check_interval_secs = 30 - -# Timeout for health check probes -timeout_ms = 5000 - -# Consecutive failures before marking origin unhealthy -unhealthy_threshold = 3 - -# Per-origin type thresholds (overrides unhealthy_threshold) -[health.per_origin_thresholds] -local = 1 -nfs = 3 -smb = 3 -s3 = 3 -sftp = 3 - -# ------------------------------------------------------------------------------ -# Logging -# ------------------------------------------------------------------------------ - -[logging] -# Directory for log files -log_dir = "/var/log/musicfs" - -# Output logs as JSON (for log aggregators) -json_output = false - -# Send logs to systemd journal -journald = true - -# Log level filter (tracing format) -# Examples: "info", "debug", "musicfs=debug,warn", "musicfs_fuse=trace" -level = "musicfs=info,warn" - -# Trace sampling rate for performance tracing (0.0 to 1.0) -trace_sample_rate = 1.0 diff --git a/config.toml b/config.toml deleted file mode 100644 index b6d99d5..0000000 --- a/config.toml +++ /dev/null @@ -1,25 +0,0 @@ -mount_point = "./dev/music" -cache_dir = "./dev/cache/musicfs" - -[[origins]] -id = "local-storage" -origin_type = "local" -priority = 1 -enabled = true -path = "/home/fujin/.local/share/docker/volumes/containers_downloads/_data" - -[cache] -metadata_cache_mb = 100 -content_cache_gb = 10 - -[health] -check_interval_secs = 30 -timeout_ms = 5000 -unhealthy_threshold = 3 - -[logging] -log_dir = "./dev/log" -json_output = false -journald = true -level = "musicfs=info,warn" -trace_sample_rate = 1.0 diff --git a/crates/musicfs-cache/Cargo.toml b/crates/musicfs-cache/Cargo.toml deleted file mode 100644 index 575750f..0000000 --- a/crates/musicfs-cache/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "musicfs-cache" -version.workspace = true -edition.workspace = true - -[dependencies] -musicfs-core = { path = "../musicfs-core" } -musicfs-cas = { path = "../musicfs-cas" } -musicfs-metadata = { path = "../musicfs-metadata" } -bytes.workspace = true -rusqlite = { workspace = true, features = ["bundled"] } -sled.workspace = true -tokio.workspace = true -tracing.workspace = true -thiserror.workspace = true -serde.workspace = true -rmp-serde.workspace = true -serde_json.workspace = true -image.workspace = true -lofty = "0.24" -parking_lot.workspace = true -chrono.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/musicfs-cache/src/artwork.rs b/crates/musicfs-cache/src/artwork.rs deleted file mode 100644 index e684448..0000000 --- a/crates/musicfs-cache/src/artwork.rs +++ /dev/null @@ -1,213 +0,0 @@ -use image::ImageFormat; -use musicfs_cas::CasStore; -use musicfs_core::ChunkHash; -use musicfs_metadata::artwork::{ArtSize, ArtType, Artwork}; -use std::io::Cursor; -use std::path::Path; -use std::sync::Arc; -use tracing::{debug, info, trace, warn}; - -const MAX_ARTWORK_INPUT_SIZE: usize = 10 * 1024 * 1024; - -pub struct ArtworkCache { - store: Arc, - db_path: std::path::PathBuf, -} - -#[derive(Debug)] -pub struct CachedArtwork { - pub file_id: i64, - pub art_type: String, - pub chunk_hash: ChunkHash, - pub width: u32, - pub height: u32, -} - -impl ArtworkCache { - pub fn new(store: Arc, db_path: &Path) -> Result { - let db = rusqlite::Connection::open(db_path)?; - - db.execute( - "CREATE TABLE IF NOT EXISTS artwork ( - id INTEGER PRIMARY KEY, - file_id INTEGER NOT NULL, - art_type TEXT NOT NULL, - chunk_hash TEXT NOT NULL, - width INTEGER NOT NULL, - height INTEGER NOT NULL, - UNIQUE(file_id, art_type) - )", - [], - )?; - - info!(path = ?db_path, "Artwork cache opened"); - Ok(Self { - store, - db_path: db_path.to_path_buf(), - }) - } - - pub async fn store(&self, file_id: i64, artwork: &Artwork) -> Result { - trace!( - file_id = file_id, - size_bytes = artwork.data.len(), - "Storing artwork" - ); - if artwork.data.len() > MAX_ARTWORK_INPUT_SIZE { - warn!( - file_id = file_id, - size = artwork.data.len(), - max = MAX_ARTWORK_INPUT_SIZE, - "Artwork too large" - ); - return Err(ArtworkError::ImageTooLarge(artwork.data.len())); - } - - let hash = self.store.put(&artwork.data).await?; - - let art_type_str = match artwork.art_type { - ArtType::Front => "front", - ArtType::Back => "back", - ArtType::Other => "other", - }; - - let db_path = self.db_path.clone(); - let art_type_clone = art_type_str.to_string(); - let hash_hex = hash.to_hex(); - let width = artwork.width; - let height = artwork.height; - - tokio::task::spawn_blocking(move || { - let db = rusqlite::Connection::open(&db_path)?; - db.execute( - "INSERT OR REPLACE INTO artwork - (file_id, art_type, chunk_hash, width, height) - VALUES (?1, ?2, ?3, ?4, ?5)", - rusqlite::params![file_id, art_type_clone, hash_hex, width, height], - )?; - Ok::<_, ArtworkError>(()) - }) - .await - .map_err(|e| ArtworkError::SpawnBlocking(e.to_string()))??; - - debug!("Cached artwork for file {}", file_id); - Ok(hash) - } - - pub async fn get( - &self, - file_id: i64, - art_type: &str, - size: ArtSize, - ) -> Result>, ArtworkError> { - trace!(file_id = file_id, art_type = %art_type, "Getting artwork"); - let db_path = self.db_path.clone(); - let art_type_clone = art_type.to_string(); - - let hash_hex: Option = tokio::task::spawn_blocking(move || { - let db = rusqlite::Connection::open(&db_path)?; - db.query_row( - "SELECT chunk_hash FROM artwork WHERE file_id = ?1 AND art_type = ?2", - rusqlite::params![file_id, art_type_clone], - |row| row.get(0), - ) - .ok() - .ok_or(ArtworkError::NotFound) - }) - .await - .map_err(|e| ArtworkError::SpawnBlocking(e.to_string()))? - .ok(); - - match hash_hex { - Some(hex) => { - trace!(file_id = file_id, "Artwork cache hit"); - let hash = ChunkHash::from_hex(&hex).ok_or(ArtworkError::InvalidHash)?; - let data = self.store.get(&hash).await?; - - match size { - ArtSize::Full => Ok(Some(data.to_vec())), - ArtSize::Thumbnail | ArtSize::Medium => { - let resized = self.resize_on_demand(&data, size)?; - Ok(Some(resized)) - } - } - } - None => { - trace!(file_id = file_id, "Artwork cache miss"); - Ok(None) - } - } - } - - pub async fn has(&self, file_id: i64, art_type: &str) -> Result { - let db_path = self.db_path.clone(); - let art_type_clone = art_type.to_string(); - - tokio::task::spawn_blocking(move || { - let db = rusqlite::Connection::open(&db_path)?; - let count: i64 = db.query_row( - "SELECT COUNT(*) FROM artwork WHERE file_id = ?1 AND art_type = ?2", - rusqlite::params![file_id, art_type_clone], - |row| row.get(0), - )?; - Ok(count > 0) - }) - .await - .map_err(|e| ArtworkError::SpawnBlocking(e.to_string()))? - } - - fn resize_on_demand(&self, data: &[u8], size: ArtSize) -> Result, ArtworkError> { - let max_dim = size.max_dimension().unwrap_or(300); - let img = image::load_from_memory(data).map_err(|_| ArtworkError::InvalidImage)?; - - if img.width() <= max_dim && img.height() <= max_dim { - return Ok(data.to_vec()); - } - - let resized = img.thumbnail(max_dim, max_dim); - let mut output = Vec::new(); - let mut cursor = Cursor::new(&mut output); - resized - .write_to(&mut cursor, ImageFormat::Jpeg) - .map_err(|_| ArtworkError::ResizeFailed)?; - - Ok(output) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum ArtworkError { - #[error("database error: {0}")] - Database(#[from] rusqlite::Error), - - #[error("CAS error: {0}")] - Cas(#[from] musicfs_cas::CasError), - - #[error("invalid hash")] - InvalidHash, - - #[error("artwork not found")] - NotFound, - - #[error("image too large: {0} bytes (max 10MB)")] - ImageTooLarge(usize), - - #[error("invalid image data")] - InvalidImage, - - #[error("resize failed")] - ResizeFailed, - - #[error("spawn_blocking error: {0}")] - SpawnBlocking(String), -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_max_artwork_size() { - assert_eq!(MAX_ARTWORK_INPUT_SIZE, 10 * 1024 * 1024); - } -} diff --git a/crates/musicfs-cache/src/db.rs b/crates/musicfs-cache/src/db.rs deleted file mode 100644 index 75e1be2..0000000 --- a/crates/musicfs-cache/src/db.rs +++ /dev/null @@ -1,1792 +0,0 @@ -use crate::FormatLayout; -use musicfs_core::{ - AudioFormat, AudioMeta, ContentHash, Error, FileId, FileMeta, OriginId, RealPath, Result, - VirtualPath, -}; -use rusqlite::{params, Connection, OptionalExtension}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tracing::{debug, info, warn}; - -const SCHEMA: &str = include_str!("schema.sql"); - -pub struct Database { - conn: Arc>, -} - -impl Database { - pub fn open(path: &Path) -> Result { - debug!(?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)))?; - - let db = Self { - conn: Arc::new(Mutex::new(conn)), - }; - let count = db.file_count().unwrap_or(0); - info!(path = ?path, file_count = count, "Database opened"); - Ok(db) - } - - pub fn open_with_integrity_check(path: &Path) -> Result { - debug!(?path, "Opening database with integrity check"); - - let conn = - Connection::open(path).map_err(|e| Error::Database(format!("open failed: {}", e)))?; - - let integrity: String = conn - .query_row("PRAGMA integrity_check(1)", [], |row| row.get(0)) - .map_err(|e| Error::Database(format!("integrity check failed: {}", e)))?; - - if integrity != "ok" { - warn!(path = ?path, result = %integrity, "Database integrity check failed"); - return Err(Error::DatabaseCorrupted(format!( - "integrity check failed: {}", - integrity - ))); - } - - conn.execute_batch(SCHEMA) - .map_err(|e| Error::Database(format!("schema init failed: {}", e)))?; - - let db = Self { - conn: Arc::new(Mutex::new(conn)), - }; - let count = db.file_count().unwrap_or(0); - info!(path = ?path, file_count = count, "Database opened (integrity verified)"); - Ok(db) - } - - 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 { - self.upsert_file_with_layout( - origin_id, - real_path, - virtual_path, - audio_meta, - origin_mtime, - origin_size, - None, - None, - ) - } - - pub fn upsert_file_with_layout( - &self, - origin_id: &OriginId, - real_path: &Path, - virtual_path: &VirtualPath, - audio_meta: &AudioMeta, - origin_mtime: SystemTime, - origin_size: u64, - format_layout: Option<&FormatLayout>, - custom_tags: Option<&HashMap>, - ) -> Result { - let conn = self.conn.lock().unwrap(); - - let mtime_secs = origin_mtime - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - - // Serialize format_layout as msgpack BLOB - let format_layout_blob: Option> = format_layout - .map(|fl| rmp_serde::to_vec(fl)) - .transpose() - .map_err(|e| Error::Database(format!("format_layout serialization failed: {}", e)))?; - - // Serialize custom_tags as JSON TEXT - let custom_tags_json: Option = custom_tags - .map(|ct| serde_json::to_string(ct)) - .transpose() - .map_err(|e| Error::Database(format!("custom_tags serialization failed: {}", e)))?; - - 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, - track_total, disc_total, date, composer, comment, - lyrics, copyright, compilation, - artist_sort, album_artist_sort, album_sort, title_sort, - mb_recording_id, mb_album_id, mb_artist_id, mb_album_artist_id, mb_release_group_id, - replaygain_track_gain, replaygain_track_peak, replaygain_album_gain, replaygain_album_peak, - channels, bits_per_sample, encoder, - custom_tags, format_layout, - origin_mtime, origin_size - ) VALUES ( - ?1, ?2, ?3, - ?4, ?5, ?6, ?7, ?8, - ?9, ?10, ?11, - ?12, ?13, ?14, ?15, - ?16, ?17, ?18, ?19, ?20, - ?21, ?22, ?23, - ?24, ?25, ?26, ?27, - ?28, ?29, ?30, ?31, ?32, - ?33, ?34, ?35, ?36, - ?37, ?38, ?39, - ?40, ?41, - ?42, ?43 - ) - 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, - track_total = excluded.track_total, - disc_total = excluded.disc_total, - date = excluded.date, - composer = excluded.composer, - comment = excluded.comment, - lyrics = excluded.lyrics, - copyright = excluded.copyright, - compilation = excluded.compilation, - artist_sort = excluded.artist_sort, - album_artist_sort = excluded.album_artist_sort, - album_sort = excluded.album_sort, - title_sort = excluded.title_sort, - mb_recording_id = excluded.mb_recording_id, - mb_album_id = excluded.mb_album_id, - mb_artist_id = excluded.mb_artist_id, - mb_album_artist_id = excluded.mb_album_artist_id, - mb_release_group_id = excluded.mb_release_group_id, - replaygain_track_gain = excluded.replaygain_track_gain, - replaygain_track_peak = excluded.replaygain_track_peak, - replaygain_album_gain = excluded.replaygain_album_gain, - replaygain_album_peak = excluded.replaygain_album_peak, - channels = excluded.channels, - bits_per_sample = excluded.bits_per_sample, - encoder = excluded.encoder, - custom_tags = excluded.custom_tags, - format_layout = excluded.format_layout, - 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), - &audio_meta.track_total, - &audio_meta.disc_total, - &audio_meta.date, - &audio_meta.composer, - &audio_meta.comment, - &audio_meta.lyrics, - &audio_meta.copyright, - &audio_meta.compilation.map(|b| if b { 1i32 } else { 0i32 }), - &audio_meta.artist_sort, - &audio_meta.album_artist_sort, - &audio_meta.album_sort, - &audio_meta.title_sort, - &audio_meta.mb_recording_id, - &audio_meta.mb_album_id, - &audio_meta.mb_artist_id, - &audio_meta.mb_album_artist_id, - &audio_meta.mb_release_group_id, - &audio_meta.replaygain_track_gain, - &audio_meta.replaygain_track_peak, - &audio_meta.replaygain_album_gain, - &audio_meta.replaygain_album_peak, - &audio_meta.channels, - &audio_meta.bits_per_sample, - &audio_meta.encoder, - &custom_tags_json, - &format_layout_blob, - mtime_secs, - origin_size as i64, - ], - ) - .map_err(|e| Error::Database(format!("upsert failed: {}", e)))?; - - let id = conn.last_insert_rowid(); - let file_id = if id == 0 { - conn.query_row( - "SELECT id FROM files WHERE origin_id = ?1 AND real_path = ?2", - params![&origin_id.0, real_path.to_string_lossy()], - |row| row.get::<_, i64>(0), - ) - .map_err(|e| Error::Database(format!("failed to get file id after upsert: {}", e)))? - } else { - id - }; - debug!(id = file_id, vpath = virtual_path.as_str(), "Upserted file"); - - Ok(FileId(file_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, - track_total, disc_total, date, composer, comment, - lyrics, copyright, compilation, - artist_sort, album_artist_sort, album_sort, title_sort, - mb_recording_id, mb_album_id, mb_artist_id, mb_album_artist_id, mb_release_group_id, - replaygain_track_gain, replaygain_track_peak, replaygain_album_gain, replaygain_album_peak, - channels, bits_per_sample, encoder, - 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 compilation_int: Option = row.get(23)?; - let content_hash: Option = row.get(42)?; - - 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, - track_total: row.get(16)?, - disc_total: row.get(17)?, - date: row.get(18)?, - composer: row.get(19)?, - comment: row.get(20)?, - lyrics: row.get(21)?, - copyright: row.get(22)?, - compilation: compilation_int.map(|i| i != 0), - artist_sort: row.get(24)?, - album_artist_sort: row.get(25)?, - album_sort: row.get(26)?, - title_sort: row.get(27)?, - mb_recording_id: row.get(28)?, - mb_album_id: row.get(29)?, - mb_artist_id: row.get(30)?, - mb_album_artist_id: row.get(31)?, - mb_release_group_id: row.get(32)?, - replaygain_track_gain: row.get(33)?, - replaygain_track_peak: row.get(34)?, - replaygain_album_gain: row.get(35)?, - replaygain_album_peak: row.get(36)?, - channels: row.get(37)?, - bits_per_sample: row.get(38)?, - encoder: row.get(39)?, - }), - size: row.get::<_, i64>(41)? as u64, - mtime: UNIX_EPOCH + Duration::from_secs(row.get::<_, i64>(40)? 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))) - } - - pub fn path_exists(&self, path: &VirtualPath) -> Result { - let conn = self.conn.lock().unwrap(); - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM files WHERE virtual_path = ?1", - params![path.as_str()], - |row| row.get(0), - ) - .map_err(|e| Error::Database(format!("path_exists query failed: {}", e)))?; - Ok(count > 0) - } - - pub fn update_virtual_path(&self, id: FileId, new_path: &VirtualPath) -> Result<()> { - let conn = self.conn.lock().unwrap(); - let rows = conn - .execute( - "UPDATE files SET virtual_path = ?1 WHERE id = ?2", - params![new_path.as_str(), id.0], - ) - .map_err(|e| Error::Database(format!("update_virtual_path failed: {}", e)))?; - - if rows == 0 { - return Err(Error::FileNotFound(format!("file id {} not found", id.0))); - } - debug!( - id = id.0, - new_path = new_path.as_str(), - "updated virtual path" - ); - Ok(()) - } - - pub fn rename_directory(&self, old_prefix: &str, new_prefix: &str) -> Result { - let conn = self.conn.lock().unwrap(); - - let pattern = format!("{}%", old_prefix); - let old_len = old_prefix.len(); - - let rows = conn - .execute( - "UPDATE files SET virtual_path = ?1 || substr(virtual_path, ?2) WHERE virtual_path LIKE ?3", - params![new_prefix, old_len as i64 + 1, pattern], - ) - .map_err(|e| Error::Database(format!("rename_directory failed: {}", e)))?; - - debug!(old_prefix, new_prefix, rows, "renamed directory paths"); - Ok(rows as u64) - } - - pub fn get_files_by_prefix(&self, prefix: &str) -> Result> { - let conn = self.conn.lock().unwrap(); - let pattern = format!("{}%", prefix); - - let mut stmt = conn - .prepare("SELECT id, virtual_path FROM files WHERE virtual_path LIKE ?1") - .map_err(|e| Error::Database(format!("prepare failed: {}", e)))?; - - let files: Vec<(FileId, VirtualPath)> = stmt - .query_map(params![pattern], |row| { - Ok(( - FileId(row.get(0)?), - VirtualPath::new(row.get::<_, String>(1)?), - )) - }) - .map_err(|e| Error::Database(format!("query failed: {}", e)))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(files) - } - - pub fn insert_directory(&self, path: &VirtualPath) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "INSERT OR IGNORE INTO directories (path) VALUES (?1)", - params![path.as_str()], - ) - .map_err(|e| Error::Database(format!("insert_directory failed: {}", e)))?; - debug!(path = path.as_str(), "inserted directory"); - Ok(()) - } - - pub fn delete_directory(&self, path: &VirtualPath) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "DELETE FROM directories WHERE path = ?1", - params![path.as_str()], - ) - .map_err(|e| Error::Database(format!("delete_directory failed: {}", e)))?; - Ok(()) - } - - pub fn rename_directories(&self, old_prefix: &str, new_prefix: &str) -> Result { - let conn = self.conn.lock().unwrap(); - let pattern = format!("{}%", old_prefix); - let old_len = old_prefix.len(); - - let rows = conn - .execute( - "UPDATE directories SET path = ?1 || substr(path, ?2) WHERE path LIKE ?3", - params![new_prefix, old_len as i64 + 1, pattern], - ) - .map_err(|e| Error::Database(format!("rename_directories failed: {}", e)))?; - - debug!(old_prefix, new_prefix, rows, "renamed directory paths"); - Ok(rows as u64) - } - - pub fn list_directories(&self) -> Result> { - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn - .prepare("SELECT path FROM directories ORDER BY path") - .map_err(|e| Error::Database(format!("prepare failed: {}", e)))?; - - let dirs: Vec = stmt - .query_map([], |row| Ok(VirtualPath::new(row.get::<_, String>(0)?))) - .map_err(|e| Error::Database(format!("query failed: {}", e)))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(dirs) - } - - pub fn get_file_by_real_path( - &self, - origin_id: &OriginId, - real_path: &Path, - ) -> Result> { - let conn = self.conn.lock().unwrap(); - - conn.query_row( - "SELECT virtual_path FROM files WHERE origin_id = ?1 AND real_path = ?2", - params![&origin_id.0, real_path.to_string_lossy()], - |row| Ok(VirtualPath::new(row.get::<_, String>(0)?)), - ) - .optional() - .map_err(|e| Error::Database(format!("query failed: {}", e))) - } - - pub fn get_file_metadata_row(&self, file_id: FileId) -> Result { - let conn = self.conn.lock().unwrap(); - - conn.query_row( - r#" - SELECT title, artist, album, album_artist, genre, - year, track, disc, - duration_ms, bitrate, sample_rate, format, - track_total, disc_total, date, composer, comment, - lyrics, copyright, compilation, - artist_sort, album_artist_sort, album_sort, title_sort, - mb_recording_id, mb_album_id, mb_artist_id, mb_album_artist_id, mb_release_group_id, - replaygain_track_gain, replaygain_track_peak, replaygain_album_gain, replaygain_album_peak, - channels, bits_per_sample, encoder - FROM files - WHERE id = ?1 - "#, - params![file_id.0], - |row| { - let format_str: Option = row.get(11)?; - let format = format_str - .as_deref() - .map(parse_audio_format) - .unwrap_or(AudioFormat::Unknown); - - let compilation_int: Option = row.get(19)?; - - Ok(AudioMeta { - title: row.get(0)?, - artist: row.get(1)?, - album: row.get(2)?, - album_artist: row.get(3)?, - genre: row.get(4)?, - year: row.get(5)?, - track: row.get(6)?, - disc: row.get(7)?, - duration_ms: row.get::<_, Option>(8)?.map(|d| d as u64), - bitrate: row.get(9)?, - sample_rate: row.get(10)?, - format, - track_total: row.get(12)?, - disc_total: row.get(13)?, - date: row.get(14)?, - composer: row.get(15)?, - comment: row.get(16)?, - lyrics: row.get(17)?, - copyright: row.get(18)?, - compilation: compilation_int.map(|i| i != 0), - artist_sort: row.get(20)?, - album_artist_sort: row.get(21)?, - album_sort: row.get(22)?, - title_sort: row.get(23)?, - mb_recording_id: row.get(24)?, - mb_album_id: row.get(25)?, - mb_artist_id: row.get(26)?, - mb_album_artist_id: row.get(27)?, - mb_release_group_id: row.get(28)?, - replaygain_track_gain: row.get(29)?, - replaygain_track_peak: row.get(30)?, - replaygain_album_gain: row.get(31)?, - replaygain_album_peak: row.get(32)?, - channels: row.get(33)?, - bits_per_sample: row.get(34)?, - encoder: row.get(35)?, - }) - }, - ) - .map_err(|e| Error::Database(format!("get_file_metadata_row failed: {}", e))) - } - - pub fn get_format_layout(&self, file_id: FileId) -> Result> { - let conn = self.conn.lock().unwrap(); - - let blob: Option> = conn - .query_row( - "SELECT format_layout FROM files WHERE id = ?1", - params![file_id.0], - |row| row.get(0), - ) - .optional() - .map_err(|e| Error::Database(format!("get_format_layout query failed: {}", e)))? - .flatten(); - - match blob { - Some(data) => { - let layout: FormatLayout = rmp_serde::from_slice(&data).map_err(|e| { - Error::Database(format!("format_layout deserialization failed: {}", e)) - })?; - Ok(Some(layout)) - } - None => Ok(None), - } - } - - pub fn get_custom_tags(&self, file_id: FileId) -> Result>> { - let conn = self.conn.lock().unwrap(); - - let json: Option = conn - .query_row( - "SELECT custom_tags FROM files WHERE id = ?1", - params![file_id.0], - |row| row.get(0), - ) - .optional() - .map_err(|e| Error::Database(format!("get_custom_tags query failed: {}", e)))? - .flatten(); - - match json { - Some(data) => { - let tags: HashMap = serde_json::from_str(&data).map_err(|e| { - Error::Database(format!("custom_tags deserialization failed: {}", e)) - })?; - Ok(Some(tags)) - } - None => Ok(None), - } - } - - pub fn update_metadata(&self, file_id: FileId, metadata: &AudioMeta) -> Result<()> { - let mut updates = Vec::new(); - let mut params_vec: Vec> = Vec::new(); - - macro_rules! add_field { - ($field:ident, $col:literal) => { - if let Some(ref val) = metadata.$field { - updates.push(concat!($col, " = ?")); - params_vec.push(Box::new(val.clone())); - } - }; - ($field:ident, $col:literal, u32) => { - if let Some(val) = metadata.$field { - updates.push(concat!($col, " = ?")); - params_vec.push(Box::new(val as i64)); - } - }; - ($field:ident, $col:literal, u64) => { - if let Some(val) = metadata.$field { - updates.push(concat!($col, " = ?")); - params_vec.push(Box::new(val as i64)); - } - }; - ($field:ident, $col:literal, f32) => { - if let Some(val) = metadata.$field { - updates.push(concat!($col, " = ?")); - params_vec.push(Box::new(val as f64)); - } - }; - ($field:ident, $col:literal, bool) => { - if let Some(val) = metadata.$field { - updates.push(concat!($col, " = ?")); - params_vec.push(Box::new(if val { 1i32 } else { 0i32 })); - } - }; - } - - add_field!(title, "title"); - add_field!(artist, "artist"); - add_field!(album, "album"); - add_field!(album_artist, "album_artist"); - add_field!(genre, "genre"); - add_field!(year, "year", u32); - add_field!(track, "track", u32); - add_field!(disc, "disc", u32); - add_field!(duration_ms, "duration_ms", u64); - add_field!(bitrate, "bitrate", u32); - add_field!(sample_rate, "sample_rate", u32); - add_field!(track_total, "track_total", u32); - add_field!(disc_total, "disc_total", u32); - add_field!(date, "date"); - add_field!(composer, "composer"); - add_field!(comment, "comment"); - add_field!(lyrics, "lyrics"); - add_field!(copyright, "copyright"); - add_field!(compilation, "compilation", bool); - add_field!(artist_sort, "artist_sort"); - add_field!(album_artist_sort, "album_artist_sort"); - add_field!(album_sort, "album_sort"); - add_field!(title_sort, "title_sort"); - add_field!(mb_recording_id, "mb_recording_id"); - add_field!(mb_album_id, "mb_album_id"); - add_field!(mb_artist_id, "mb_artist_id"); - add_field!(mb_album_artist_id, "mb_album_artist_id"); - add_field!(mb_release_group_id, "mb_release_group_id"); - add_field!(replaygain_track_gain, "replaygain_track_gain", f32); - add_field!(replaygain_track_peak, "replaygain_track_peak", f32); - add_field!(replaygain_album_gain, "replaygain_album_gain", f32); - add_field!(replaygain_album_peak, "replaygain_album_peak", f32); - add_field!(channels, "channels", u32); - add_field!(bits_per_sample, "bits_per_sample", u32); - add_field!(encoder, "encoder"); - - if updates.is_empty() { - return Ok(()); - } - - let sql = format!("UPDATE files SET {} WHERE id = ?", updates.join(", ")); - params_vec.push(Box::new(file_id.0)); - - let conn = self.conn.lock().unwrap(); - let params_refs: Vec<&dyn rusqlite::ToSql> = - params_vec.iter().map(|p| p.as_ref()).collect(); - - let rows = conn - .execute(&sql, params_refs.as_slice()) - .map_err(|e| Error::Database(format!("update_metadata failed: {}", e)))?; - - if rows == 0 { - return Err(Error::FileNotFound(format!( - "file id {} not found", - file_id.0 - ))); - } - - debug!(id = file_id.0, fields = updates.len(), "updated metadata"); - Ok(()) - } - - pub fn update_enrichment(&self, file_id: FileId, enrichment: &EnrichmentUpdate) -> Result<()> { - let conn = self.conn.lock().unwrap(); - - let mut set_clauses = vec![ - "label = ?1".to_string(), - "album_type = ?2".to_string(), - "cover_url = ?3".to_string(), - "enrichment_source = ?4".to_string(), - "enriched_at = strftime('%s', 'now')".to_string(), - "enrichment_attempts = 0".to_string(), - "last_enrichment_error = NULL".to_string(), - ]; - - let mut params_vec: Vec> = vec![ - Box::new(enrichment.label.clone()), - Box::new(enrichment.album_type.clone()), - Box::new(enrichment.cover_url.clone()), - Box::new(enrichment.source.clone()), - ]; - - if let Some(ref genres) = enrichment.genres_json { - params_vec.push(Box::new(genres.clone())); - set_clauses.push(format!("genres_json = ?{}", params_vec.len())); - } - if let Some(ref genre) = enrichment.primary_genre { - params_vec.push(Box::new(genre.clone())); - set_clauses.push(format!("genre = ?{}", params_vec.len())); - } - - params_vec.push(Box::new(file_id.0)); - let id_param = params_vec.len(); - - let sql = format!( - "UPDATE files SET {} WHERE id = ?{}", - set_clauses.join(", "), - id_param - ); - - let params_refs: Vec<&dyn rusqlite::ToSql> = - params_vec.iter().map(|p| p.as_ref()).collect(); - - let rows = conn - .execute(&sql, params_refs.as_slice()) - .map_err(|e| Error::Database(format!("update_enrichment failed: {}", e)))?; - - if rows == 0 { - return Err(Error::FileNotFound(format!( - "file id {} not found", - file_id.0 - ))); - } - - debug!( - id = file_id.0, - source = &enrichment.source, - "updated enrichment metadata" - ); - Ok(()) - } - - pub fn clear_overlay(&self, file_id: FileId) -> Result<()> { - let conn = self.conn.lock().unwrap(); - - let rows = conn - .execute( - r#" - UPDATE files SET - title = NULL, artist = NULL, album = NULL, album_artist = NULL, genre = NULL, - year = NULL, track = NULL, disc = NULL, - duration_ms = NULL, bitrate = NULL, sample_rate = NULL, format = NULL, - track_total = NULL, disc_total = NULL, date = NULL, composer = NULL, comment = NULL, - lyrics = NULL, copyright = NULL, compilation = NULL, - artist_sort = NULL, album_artist_sort = NULL, album_sort = NULL, title_sort = NULL, - mb_recording_id = NULL, mb_album_id = NULL, mb_artist_id = NULL, mb_album_artist_id = NULL, mb_release_group_id = NULL, - replaygain_track_gain = NULL, replaygain_track_peak = NULL, replaygain_album_gain = NULL, replaygain_album_peak = NULL, - channels = NULL, bits_per_sample = NULL, encoder = NULL, - custom_tags = NULL, format_layout = NULL, - label = NULL, album_type = NULL, cover_url = NULL, genres_json = NULL, - enrichment_source = NULL, enriched_at = NULL, - enrichment_attempts = 0, last_enrichment_error = NULL - WHERE id = ?1 - "#, - params![file_id.0], - ) - .map_err(|e| Error::Database(format!("clear_overlay failed: {}", e)))?; - - if rows == 0 { - return Err(Error::FileNotFound(format!( - "file id {} not found", - file_id.0 - ))); - } - - debug!(id = file_id.0, "cleared overlay metadata"); - Ok(()) - } - - pub fn mark_trashed(&self, id: FileId, original_path: &VirtualPath) -> Result<()> { - let conn = self.conn.lock().unwrap(); - let rows = conn - .execute( - "UPDATE files SET trashed = 1, original_path = ?1, trashed_at = strftime('%s', 'now') WHERE id = ?2", - params![original_path.as_str(), id.0], - ) - .map_err(|e| Error::Database(format!("mark_trashed failed: {}", e)))?; - - if rows == 0 { - return Err(Error::FileNotFound(format!("file id {} not found", id.0))); - } - debug!( - id = id.0, - original_path = original_path.as_str(), - "marked file as trashed" - ); - Ok(()) - } - - pub fn unmark_trashed(&self, id: FileId) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "UPDATE files SET trashed = 0, original_path = NULL, trashed_at = NULL WHERE id = ?1", - params![id.0], - ) - .map_err(|e| Error::Database(format!("unmark_trashed failed: {}", e)))?; - debug!(id = id.0, "unmarked file as trashed"); - Ok(()) - } - - pub fn list_trashed(&self, filter: &TrashedFilter) -> Result> { - let conn = self.conn.lock().unwrap(); - - let mut sql = String::from( - "SELECT id, virtual_path, original_path, trashed_at, origin_id FROM files WHERE trashed = 1", - ); - let mut params_vec: Vec> = Vec::new(); - - if let Some(ref origin) = filter.origin_id { - sql.push_str(" AND origin_id = ?"); - params_vec.push(Box::new(origin.0.clone())); - } - - if let Some(ref prefix) = filter.path_prefix { - sql.push_str(" AND original_path LIKE ?"); - params_vec.push(Box::new(format!("{}%", prefix))); - } - - if let Some(since) = filter.since { - let cutoff = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64 - - since.as_secs() as i64; - sql.push_str(" AND trashed_at >= ?"); - params_vec.push(Box::new(cutoff)); - } - - sql.push_str(" ORDER BY trashed_at DESC"); - - let mut stmt = conn - .prepare(&sql) - .map_err(|e| Error::Database(format!("prepare failed: {}", e)))?; - - let params_refs: Vec<&dyn rusqlite::ToSql> = - params_vec.iter().map(|p| p.as_ref()).collect(); - - let files: Vec = stmt - .query_map(params_refs.as_slice(), |row| { - Ok(TrashedFile { - file_id: FileId(row.get(0)?), - current_path: VirtualPath::new(row.get::<_, String>(1)?), - original_path: VirtualPath::new(row.get::<_, String>(2)?), - trashed_at: row.get(3)?, - origin_id: OriginId(row.get(4)?), - }) - }) - .map_err(|e| Error::Database(format!("query failed: {}", e)))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(files) - } - - pub fn get_trashed_by_prefix(&self, prefix: &str) -> Result> { - self.list_trashed(&TrashedFilter { - path_prefix: Some(prefix.to_string()), - ..Default::default() - }) - } - - pub fn is_trashed(&self, path: &VirtualPath) -> Result { - let conn = self.conn.lock().unwrap(); - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM files WHERE virtual_path = ?1 AND trashed = 1", - params![path.as_str()], - |row| row.get(0), - ) - .map_err(|e| Error::Database(format!("is_trashed query failed: {}", e)))?; - Ok(count > 0) - } - - pub fn purge_trashed(&self, filter: &TrashedFilter) -> Result { - let trashed = self.list_trashed(filter)?; - let count = trashed.len() as u64; - - let conn = self.conn.lock().unwrap(); - for file in trashed { - conn.execute("DELETE FROM files WHERE id = ?1", params![file.file_id.0]) - .map_err(|e| Error::Database(format!("purge delete failed: {}", e)))?; - } - - debug!(count, "purged trashed files"); - Ok(count) - } -} - -#[derive(Debug, Clone)] -pub struct TrashedFile { - pub file_id: FileId, - pub current_path: VirtualPath, - pub original_path: VirtualPath, - pub trashed_at: i64, - pub origin_id: OriginId, -} - -#[derive(Debug, Clone, Default)] -pub struct EnrichmentUpdate { - pub label: Option, - pub album_type: Option, - pub cover_url: Option, - pub genres_json: Option, - pub primary_genre: Option, - pub source: String, -} - -#[derive(Debug, Clone, Default)] -pub struct TrashedFilter { - pub origin_id: Option, - pub path_prefix: Option, - pub since: Option, -} - -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()); - } - - #[test] - fn test_path_exists() { - let db = Database::open_memory().unwrap(); - - let path = VirtualPath::new("/Artist/Album/Track.flac"); - assert!(!db.path_exists(&path).unwrap()); - - db.upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &path, - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - assert!(db.path_exists(&path).unwrap()); - assert!(!db - .path_exists(&VirtualPath::new("/Other/Path.flac")) - .unwrap()); - } - - #[test] - fn test_update_virtual_path() { - let db = Database::open_memory().unwrap(); - - let old_path = VirtualPath::new("/Old/Path/Track.flac"); - let new_path = VirtualPath::new("/New/Path/Track.flac"); - - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &old_path, - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - db.update_virtual_path(id, &new_path).unwrap(); - - assert!(db.get_file_by_virtual_path(&old_path).unwrap().is_none()); - assert!(db.get_file_by_virtual_path(&new_path).unwrap().is_some()); - } - - #[test] - fn test_rename_directory() { - let db = Database::open_memory().unwrap(); - let origin = OriginId::from("local"); - - db.upsert_file( - &origin, - Path::new("/a.flac"), - &VirtualPath::new("/Artist/Album/Track1.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - db.upsert_file( - &origin, - Path::new("/b.flac"), - &VirtualPath::new("/Artist/Album/Track2.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - db.upsert_file( - &origin, - Path::new("/c.flac"), - &VirtualPath::new("/Other/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - let count = db.rename_directory("/Artist/", "/Renamed Artist/").unwrap(); - assert_eq!(count, 2); - - assert!(db - .path_exists(&VirtualPath::new("/Renamed Artist/Album/Track1.flac")) - .unwrap()); - assert!(db - .path_exists(&VirtualPath::new("/Renamed Artist/Album/Track2.flac")) - .unwrap()); - assert!(db - .path_exists(&VirtualPath::new("/Other/Track.flac")) - .unwrap()); - assert!(!db - .path_exists(&VirtualPath::new("/Artist/Album/Track1.flac")) - .unwrap()); - } - - #[test] - fn test_get_files_by_prefix() { - let db = Database::open_memory().unwrap(); - let origin = OriginId::from("local"); - - db.upsert_file( - &origin, - Path::new("/a.flac"), - &VirtualPath::new("/Artist/Album/Track1.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - db.upsert_file( - &origin, - Path::new("/b.flac"), - &VirtualPath::new("/Artist/Album/Track2.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - db.upsert_file( - &origin, - Path::new("/c.flac"), - &VirtualPath::new("/Other/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - let files = db.get_files_by_prefix("/Artist/").unwrap(); - assert_eq!(files.len(), 2); - - let files = db.get_files_by_prefix("/Other/").unwrap(); - assert_eq!(files.len(), 1); - } - - #[test] - fn test_mark_trashed() { - let db = Database::open_memory().unwrap(); - - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &VirtualPath::new("/Artist/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac")) - .unwrap(); - - let trashed = db.list_trashed(&TrashedFilter::default()).unwrap(); - assert_eq!(trashed.len(), 1); - assert_eq!(trashed[0].original_path.as_str(), "/Artist/Track.flac"); - } - - #[test] - fn test_unmark_trashed() { - let db = Database::open_memory().unwrap(); - - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &VirtualPath::new("/Artist/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac")) - .unwrap(); - assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1); - - db.unmark_trashed(id).unwrap(); - assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0); - } - - #[test] - fn test_list_trashed_with_filter() { - let db = Database::open_memory().unwrap(); - let origin1 = OriginId::from("local1"); - let origin2 = OriginId::from("local2"); - - let id1 = db - .upsert_file( - &origin1, - Path::new("/a.flac"), - &VirtualPath::new("/Artist1/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - let id2 = db - .upsert_file( - &origin2, - Path::new("/b.flac"), - &VirtualPath::new("/Artist2/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - db.mark_trashed(id1, &VirtualPath::new("/Artist1/Track.flac")) - .unwrap(); - db.mark_trashed(id2, &VirtualPath::new("/Artist2/Track.flac")) - .unwrap(); - - let all = db.list_trashed(&TrashedFilter::default()).unwrap(); - assert_eq!(all.len(), 2); - - let filtered = db - .list_trashed(&TrashedFilter { - origin_id: Some(origin1.clone()), - ..Default::default() - }) - .unwrap(); - assert_eq!(filtered.len(), 1); - assert_eq!(filtered[0].origin_id, origin1); - - let by_path = db.get_trashed_by_prefix("/Artist1").unwrap(); - assert_eq!(by_path.len(), 1); - } - - #[test] - fn test_purge_trashed() { - let db = Database::open_memory().unwrap(); - - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &VirtualPath::new("/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 100, - ) - .unwrap(); - - db.mark_trashed(id, &VirtualPath::new("/Track.flac")) - .unwrap(); - assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1); - - let count = db.purge_trashed(&TrashedFilter::default()).unwrap(); - assert_eq!(count, 1); - assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0); - assert_eq!(db.file_count().unwrap(), 0); - } - - #[test] - fn test_new_metadata_fields_roundtrip() { - let db = Database::open_memory().unwrap(); - - let audio_meta = AudioMeta { - title: Some("Test Track".to_string()), - artist: Some("Test Artist".to_string()), - album: Some("Test Album".to_string()), - album_artist: Some("Test Album Artist".to_string()), - genre: Some("Rock".to_string()), - year: Some(2024), - track: Some(5), - disc: Some(1), - duration_ms: Some(180000), - bitrate: Some(320), - sample_rate: Some(44100), - format: AudioFormat::Flac, - track_total: Some(12), - disc_total: Some(2), - date: Some("2024-03-15".to_string()), - composer: Some("Test Composer".to_string()), - comment: Some("Test comment".to_string()), - lyrics: Some("La la la".to_string()), - copyright: Some("2024 Test Records".to_string()), - compilation: Some(true), - artist_sort: Some("Artist, Test".to_string()), - album_artist_sort: Some("Album Artist, Test".to_string()), - album_sort: Some("Album, Test".to_string()), - title_sort: Some("Track, Test".to_string()), - mb_recording_id: Some("rec-123".to_string()), - mb_album_id: Some("alb-456".to_string()), - mb_artist_id: Some("art-789".to_string()), - mb_album_artist_id: Some("aa-012".to_string()), - mb_release_group_id: Some("rg-345".to_string()), - replaygain_track_gain: Some(-6.5), - replaygain_track_peak: Some(0.95), - replaygain_album_gain: Some(-7.2), - replaygain_album_peak: Some(0.98), - channels: Some(2), - bits_per_sample: Some(24), - encoder: Some("FLAC 1.4.0".to_string()), - }; - - let vpath = VirtualPath::new("/Artist/Album/05 - Track.flac"); - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/music/test.flac"), - &vpath, - &audio_meta, - UNIX_EPOCH, - 5000000, - ) - .unwrap(); - - let retrieved = db.get_file_by_virtual_path(&vpath).unwrap().unwrap(); - let audio = retrieved.audio.unwrap(); - - assert_eq!(audio.title, Some("Test Track".to_string())); - assert_eq!(audio.track_total, Some(12)); - assert_eq!(audio.disc_total, Some(2)); - assert_eq!(audio.date, Some("2024-03-15".to_string())); - assert_eq!(audio.composer, Some("Test Composer".to_string())); - assert_eq!(audio.comment, Some("Test comment".to_string())); - assert_eq!(audio.lyrics, Some("La la la".to_string())); - assert_eq!(audio.copyright, Some("2024 Test Records".to_string())); - assert_eq!(audio.compilation, Some(true)); - assert_eq!(audio.artist_sort, Some("Artist, Test".to_string())); - assert_eq!(audio.mb_recording_id, Some("rec-123".to_string())); - assert_eq!(audio.mb_album_id, Some("alb-456".to_string())); - assert!(audio.replaygain_track_gain.is_some()); - assert!((audio.replaygain_track_gain.unwrap() - (-6.5)).abs() < 0.01); - assert_eq!(audio.channels, Some(2)); - assert_eq!(audio.bits_per_sample, Some(24)); - assert_eq!(audio.encoder, Some("FLAC 1.4.0".to_string())); - - let meta_row = db.get_file_metadata_row(id).unwrap(); - assert_eq!(meta_row.title, Some("Test Track".to_string())); - assert_eq!(meta_row.track_total, Some(12)); - assert_eq!(meta_row.mb_album_id, Some("alb-456".to_string())); - } - - #[test] - fn test_update_metadata_partial() { - let db = Database::open_memory().unwrap(); - - let audio_meta = AudioMeta { - title: Some("Original Title".to_string()), - artist: Some("Original Artist".to_string()), - album: Some("Original Album".to_string()), - track: Some(1), - ..Default::default() - }; - - let vpath = VirtualPath::new("/Artist/Album/Track.flac"); - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &vpath, - &audio_meta, - UNIX_EPOCH, - 1000, - ) - .unwrap(); - - let update = AudioMeta { - title: Some("Updated Title".to_string()), - composer: Some("New Composer".to_string()), - ..Default::default() - }; - db.update_metadata(id, &update).unwrap(); - - let retrieved = db.get_file_metadata_row(id).unwrap(); - assert_eq!(retrieved.title, Some("Updated Title".to_string())); - assert_eq!(retrieved.artist, Some("Original Artist".to_string())); - assert_eq!(retrieved.album, Some("Original Album".to_string())); - assert_eq!(retrieved.composer, Some("New Composer".to_string())); - assert_eq!(retrieved.track, Some(1)); - } - - #[test] - fn test_update_metadata_empty_noop() { - let db = Database::open_memory().unwrap(); - - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &VirtualPath::new("/Track.flac"), - &AudioMeta { - title: Some("Title".to_string()), - ..Default::default() - }, - UNIX_EPOCH, - 1000, - ) - .unwrap(); - - let empty_update = AudioMeta::default(); - db.update_metadata(id, &empty_update).unwrap(); - - let retrieved = db.get_file_metadata_row(id).unwrap(); - assert_eq!(retrieved.title, Some("Title".to_string())); - } - - #[test] - fn test_clear_overlay() { - let db = Database::open_memory().unwrap(); - - let audio_meta = AudioMeta { - title: Some("Title".to_string()), - artist: Some("Artist".to_string()), - album: Some("Album".to_string()), - composer: Some("Composer".to_string()), - mb_album_id: Some("mb-123".to_string()), - replaygain_track_gain: Some(-5.0), - ..Default::default() - }; - - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &VirtualPath::new("/Track.flac"), - &audio_meta, - UNIX_EPOCH, - 1000, - ) - .unwrap(); - - db.clear_overlay(id).unwrap(); - - let retrieved = db.get_file_metadata_row(id).unwrap(); - assert!(retrieved.title.is_none()); - assert!(retrieved.artist.is_none()); - assert!(retrieved.album.is_none()); - assert!(retrieved.composer.is_none()); - assert!(retrieved.mb_album_id.is_none()); - assert!(retrieved.replaygain_track_gain.is_none()); - } - - #[test] - fn test_format_layout_roundtrip() { - use crate::FormatLayout; - - let db = Database::open_memory().unwrap(); - - let layout = FormatLayout { - audio_start: 1024, - audio_end: 5000000, - format: AudioFormat::Flac, - format_data: Some(vec![0x66, 0x4c, 0x61, 0x43]), - }; - - let id = db - .upsert_file_with_layout( - &OriginId::from("local"), - Path::new("/test.flac"), - &VirtualPath::new("/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 5000000, - Some(&layout), - None, - ) - .unwrap(); - - let retrieved = db.get_format_layout(id).unwrap().unwrap(); - assert_eq!(retrieved.audio_start, 1024); - assert_eq!(retrieved.audio_end, 5000000); - assert_eq!(retrieved.format, AudioFormat::Flac); - assert_eq!(retrieved.format_data, Some(vec![0x66, 0x4c, 0x61, 0x43])); - } - - #[test] - fn test_custom_tags_roundtrip() { - let db = Database::open_memory().unwrap(); - - let mut custom_tags = HashMap::new(); - custom_tags.insert("CUSTOM_FIELD".to_string(), "custom value".to_string()); - custom_tags.insert("ANOTHER_TAG".to_string(), "another value".to_string()); - - let id = db - .upsert_file_with_layout( - &OriginId::from("local"), - Path::new("/test.flac"), - &VirtualPath::new("/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 1000, - None, - Some(&custom_tags), - ) - .unwrap(); - - let retrieved = db.get_custom_tags(id).unwrap().unwrap(); - assert_eq!( - retrieved.get("CUSTOM_FIELD"), - Some(&"custom value".to_string()) - ); - assert_eq!( - retrieved.get("ANOTHER_TAG"), - Some(&"another value".to_string()) - ); - } - - #[test] - fn test_format_layout_none() { - let db = Database::open_memory().unwrap(); - - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &VirtualPath::new("/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 1000, - ) - .unwrap(); - - let layout = db.get_format_layout(id).unwrap(); - assert!(layout.is_none()); - } - - #[test] - fn test_custom_tags_none() { - let db = Database::open_memory().unwrap(); - - let id = db - .upsert_file( - &OriginId::from("local"), - Path::new("/test.flac"), - &VirtualPath::new("/Track.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 1000, - ) - .unwrap(); - - let tags = db.get_custom_tags(id).unwrap(); - assert!(tags.is_none()); - } -} diff --git a/crates/musicfs-cache/src/eviction.rs b/crates/musicfs-cache/src/eviction.rs deleted file mode 100644 index f39a716..0000000 --- a/crates/musicfs-cache/src/eviction.rs +++ /dev/null @@ -1,155 +0,0 @@ -use musicfs_cas::CasStore; -use musicfs_core::ChunkHash; -use parking_lot::RwLock; -use std::collections::BTreeMap; -use std::time::Instant; -use tracing::info; - -pub trait EvictionPolicy: Send + Sync { - fn record_access(&self, hash: ChunkHash); - fn select_victims(&self, count: usize) -> Vec; - fn remove(&self, hash: &ChunkHash); -} - -pub struct LruEviction { - access_times: RwLock>, - hash_to_time: RwLock>, -} - -impl LruEviction { - pub fn new() -> Self { - Self { - access_times: RwLock::new(BTreeMap::new()), - hash_to_time: RwLock::new(std::collections::HashMap::new()), - } - } - - pub async fn evict_to_target( - &self, - store: &CasStore, - target_size: u64, - ) -> Result { - let mut bytes_freed = 0u64; - - while store.current_size() > target_size { - let victims = self.select_victims(10); - - if victims.is_empty() { - break; - } - - for hash in victims { - if let Ok(data) = store.get(&hash).await { - bytes_freed += data.len() as u64; - store.delete(&hash).await?; - self.remove(&hash); - } - } - } - - if bytes_freed > 0 { - info!("Evicted {} bytes from cache", bytes_freed); - } - - Ok(bytes_freed) - } -} - -impl Default for LruEviction { - fn default() -> Self { - Self::new() - } -} - -impl EvictionPolicy for LruEviction { - fn record_access(&self, hash: ChunkHash) { - let now = Instant::now(); - let mut times = self.access_times.write(); - let mut h2t = self.hash_to_time.write(); - - if let Some(old_time) = h2t.remove(&hash) { - times.remove(&old_time); - } - - times.insert(now, hash); - h2t.insert(hash, now); - } - - fn select_victims(&self, count: usize) -> Vec { - let times = self.access_times.read(); - times.values().take(count).copied().collect() - } - - fn remove(&self, hash: &ChunkHash) { - let mut times = self.access_times.write(); - let mut h2t = self.hash_to_time.write(); - - if let Some(time) = h2t.remove(hash) { - times.remove(&time); - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum EvictionError { - #[error("CAS error: {0}")] - Cas(#[from] musicfs_cas::CasError), -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_lru_access_order() { - let lru = LruEviction::new(); - - let h1 = ChunkHash::from_bytes(b"chunk1"); - let h2 = ChunkHash::from_bytes(b"chunk2"); - let h3 = ChunkHash::from_bytes(b"chunk3"); - - lru.record_access(h1); - std::thread::sleep(std::time::Duration::from_millis(1)); - lru.record_access(h2); - std::thread::sleep(std::time::Duration::from_millis(1)); - lru.record_access(h3); - - let victims = lru.select_victims(2); - assert_eq!(victims.len(), 2); - assert_eq!(victims[0], h1); - assert_eq!(victims[1], h2); - } - - #[test] - fn test_lru_reaccess_updates_order() { - let lru = LruEviction::new(); - - let h1 = ChunkHash::from_bytes(b"chunk1"); - let h2 = ChunkHash::from_bytes(b"chunk2"); - - lru.record_access(h1); - std::thread::sleep(std::time::Duration::from_millis(1)); - lru.record_access(h2); - std::thread::sleep(std::time::Duration::from_millis(1)); - lru.record_access(h1); - - let victims = lru.select_victims(1); - assert_eq!(victims[0], h2); - } - - #[test] - fn test_lru_remove() { - let lru = LruEviction::new(); - - let h1 = ChunkHash::from_bytes(b"chunk1"); - let h2 = ChunkHash::from_bytes(b"chunk2"); - - lru.record_access(h1); - lru.record_access(h2); - lru.remove(&h1); - - let victims = lru.select_victims(10); - assert_eq!(victims.len(), 1); - assert_eq!(victims[0], h2); - } -} diff --git a/crates/musicfs-cache/src/format_handler.rs b/crates/musicfs-cache/src/format_handler.rs deleted file mode 100644 index 9e8806f..0000000 --- a/crates/musicfs-cache/src/format_handler.rs +++ /dev/null @@ -1,103 +0,0 @@ -use crate::FormatLayout; -use musicfs_core::AudioMeta; -use std::collections::HashMap; -use std::sync::Arc; - -/// Error types for format handling operations -#[derive(Debug, thiserror::Error)] -pub enum FormatError { - #[error("Unsupported format")] - UnsupportedFormat, - - #[error("Invalid data: {0}")] - InvalidData(String), - - #[error("Synthesis failed: {0}")] - SynthesisFailed(String), -} - -/// Trait for format-specific metadata handling. -/// -/// Implementations handle: -/// 1. Analyzing original files to find audio boundaries -/// 2. Synthesizing new headers from database metadata -pub trait FormatHandler: Send + Sync + 'static { - /// Unique identifier for this handler - fn id(&self) -> &'static str; - - /// Human-readable name - fn name(&self) -> &'static str; - - /// File extensions this handler supports - fn extensions(&self) -> &[&'static str]; - - /// MIME types this handler supports - fn mime_types(&self) -> &[&'static str]; - - /// Analyze file bytes to determine audio layout - fn analyze( - &self, - data: &[u8], - file_size: u64, - ) -> std::result::Result; - - /// Synthesize header bytes from metadata. Called on every read(). - fn synthesize( - &self, - metadata: &AudioMeta, - layout: &FormatLayout, - ) -> std::result::Result, FormatError>; - - /// Extract metadata from header bytes (for initial ingest) - fn extract(&self, data: &[u8]) -> std::result::Result; - - /// Estimate header size without full synthesis (for getattr) - fn estimate_header_size(&self, _metadata: &AudioMeta) -> usize { - 10 * 1024 // 10KB default - } -} - -/// Registry for format handlers -pub struct FormatHandlerRegistry { - handlers: HashMap>, - extension_map: HashMap, -} - -impl FormatHandlerRegistry { - /// Create empty registry - pub fn new() -> Self { - Self { - handlers: HashMap::new(), - extension_map: HashMap::new(), - } - } - - /// Register a format handler - pub fn register(&mut self, handler: Arc) { - let id = handler.id().to_string(); - - // Map extensions to handler ID - for ext in handler.extensions() { - self.extension_map.insert(ext.to_string(), id.clone()); - } - - self.handlers.insert(id, handler); - } - - /// Get handler by file extension - pub fn get_by_extension(&self, ext: &str) -> Option> { - let id = self.extension_map.get(ext)?; - self.handlers.get(id).cloned() - } - - /// Get handler by format ID - pub fn get_by_format(&self, format: &str) -> Option> { - self.handlers.get(format).cloned() - } -} - -impl Default for FormatHandlerRegistry { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/musicfs-cache/src/format_layout.rs b/crates/musicfs-cache/src/format_layout.rs deleted file mode 100644 index c21cfb6..0000000 --- a/crates/musicfs-cache/src/format_layout.rs +++ /dev/null @@ -1,22 +0,0 @@ -use musicfs_core::AudioFormat; -use serde::{Deserialize, Serialize}; - -/// Describes the byte layout of an audio file for overlay splicing. -/// -/// This struct tracks where the audio data begins and ends in the origin file, -/// allowing the OverlayReader to splice synthetic headers with original audio. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FormatLayout { - /// Byte offset where audio data begins in the origin file - pub audio_start: u64, - - /// Byte offset where audio data ends in the origin file - pub audio_end: u64, - - /// Audio format (from musicfs-core) - pub format: AudioFormat, - - /// Format-specific data (e.g., FLAC STREAMINFO block, MP4 stco offsets) - /// Stored as raw bytes, interpreted by format handlers - pub format_data: Option>, -} diff --git a/crates/musicfs-cache/src/handlers/flac.rs b/crates/musicfs-cache/src/handlers/flac.rs deleted file mode 100644 index c2d31ef..0000000 --- a/crates/musicfs-cache/src/handlers/flac.rs +++ /dev/null @@ -1,886 +0,0 @@ -//! FLAC format handler for metadata synthesis. -//! -//! FLAC files use Vorbis comments for metadata. The file structure is: -//! - "fLaC" marker (4 bytes) -//! - STREAMINFO block (mandatory, 38 bytes total: 4 header + 34 data) -//! - Optional metadata blocks (VORBIS_COMMENT, PICTURE, PADDING, etc.) -//! - Audio frames -//! -//! CRITICAL: STREAMINFO must be preserved from the original file as it contains -//! MD5 checksum, sample count, and audio properties that must match the audio data. - -use crate::{FormatError, FormatHandler, FormatLayout}; -use lofty::config::ParseOptions; -use lofty::file::AudioFile; -use lofty::flac::FlacFile; -use lofty::ogg::VorbisComments; -use lofty::tag::Accessor; -use musicfs_core::{AudioFormat, AudioMeta}; -use std::borrow::Cow; -use std::io::Cursor; - -/// FLAC stream marker: "fLaC" in ASCII -const FLAC_MARKER: &[u8; 4] = b"fLaC"; - -/// FLAC metadata block types -const BLOCK_TYPE_STREAMINFO: u8 = 0; -const BLOCK_TYPE_VORBIS_COMMENT: u8 = 4; - -/// STREAMINFO block data size (always 34 bytes) -const STREAMINFO_DATA_SIZE: usize = 34; - -/// Metadata block header size (1 byte type/flags + 3 bytes length) -const BLOCK_HEADER_SIZE: usize = 4; - -/// Full STREAMINFO block size (header + data) -const STREAMINFO_BLOCK_SIZE: usize = BLOCK_HEADER_SIZE + STREAMINFO_DATA_SIZE; - -pub struct FlacHandler; - -impl FlacHandler { - pub fn new() -> Self { - Self - } - - /// Parse FLAC metadata block header. - /// Returns (is_last, block_type, block_size). - fn parse_block_header(data: &[u8]) -> Option<(bool, u8, usize)> { - if data.len() < BLOCK_HEADER_SIZE { - return None; - } - let is_last = (data[0] & 0x80) != 0; - let block_type = data[0] & 0x7F; - let block_size = - ((data[1] as usize) << 16) | ((data[2] as usize) << 8) | (data[3] as usize); - Some((is_last, block_type, block_size)) - } - - /// Write a metadata block header. - fn write_block_header(is_last: bool, block_type: u8, size: usize) -> [u8; 4] { - let type_byte = if is_last { - block_type | 0x80 - } else { - block_type - }; - [ - type_byte, - ((size >> 16) & 0xFF) as u8, - ((size >> 8) & 0xFF) as u8, - (size & 0xFF) as u8, - ] - } - - /// Build Vorbis comments from AudioMeta. - fn build_vorbis_comments(metadata: &AudioMeta) -> VorbisComments { - let mut tag = VorbisComments::default(); - - // Basic fields (using Accessor trait) - if let Some(ref title) = metadata.title { - tag.set_title(title.clone()); - } - if let Some(ref artist) = metadata.artist { - tag.set_artist(artist.clone()); - } - if let Some(ref album) = metadata.album { - tag.set_album(album.clone()); - } - if let Some(ref genre) = metadata.genre { - tag.set_genre(genre.clone()); - } - - // Album artist - if let Some(ref album_artist) = metadata.album_artist { - tag.insert("ALBUMARTIST".to_string(), album_artist.clone()); - } - - // Year/Date - if let Some(ref date) = metadata.date { - tag.insert("DATE".to_string(), date.clone()); - } else if let Some(year) = metadata.year { - tag.insert("DATE".to_string(), year.to_string()); - } - - // Track/Disc numbers - if let Some(track) = metadata.track { - tag.insert("TRACKNUMBER".to_string(), track.to_string()); - } - if let Some(track_total) = metadata.track_total { - tag.insert("TRACKTOTAL".to_string(), track_total.to_string()); - } - if let Some(disc) = metadata.disc { - tag.insert("DISCNUMBER".to_string(), disc.to_string()); - } - if let Some(disc_total) = metadata.disc_total { - tag.insert("DISCTOTAL".to_string(), disc_total.to_string()); - } - - // Extended metadata - if let Some(ref composer) = metadata.composer { - tag.insert("COMPOSER".to_string(), composer.clone()); - } - if let Some(ref comment) = metadata.comment { - tag.insert("COMMENT".to_string(), comment.clone()); - } - if let Some(ref lyrics) = metadata.lyrics { - tag.insert("LYRICS".to_string(), lyrics.clone()); - } - if let Some(ref copyright) = metadata.copyright { - tag.insert("COPYRIGHT".to_string(), copyright.clone()); - } - if let Some(compilation) = metadata.compilation { - tag.insert( - "COMPILATION".to_string(), - if compilation { "1" } else { "0" }.to_string(), - ); - } - - // Sort fields - if let Some(ref title_sort) = metadata.title_sort { - tag.insert("TITLESORT".to_string(), title_sort.clone()); - } - if let Some(ref artist_sort) = metadata.artist_sort { - tag.insert("ARTISTSORT".to_string(), artist_sort.clone()); - } - if let Some(ref album_sort) = metadata.album_sort { - tag.insert("ALBUMSORT".to_string(), album_sort.clone()); - } - if let Some(ref album_artist_sort) = metadata.album_artist_sort { - tag.insert("ALBUMARTISTSORT".to_string(), album_artist_sort.clone()); - } - - // MusicBrainz IDs - if let Some(ref mb_recording_id) = metadata.mb_recording_id { - tag.insert("MUSICBRAINZ_TRACKID".to_string(), mb_recording_id.clone()); - } - if let Some(ref mb_album_id) = metadata.mb_album_id { - tag.insert("MUSICBRAINZ_ALBUMID".to_string(), mb_album_id.clone()); - } - if let Some(ref mb_artist_id) = metadata.mb_artist_id { - tag.insert("MUSICBRAINZ_ARTISTID".to_string(), mb_artist_id.clone()); - } - if let Some(ref mb_album_artist_id) = metadata.mb_album_artist_id { - tag.insert( - "MUSICBRAINZ_ALBUMARTISTID".to_string(), - mb_album_artist_id.clone(), - ); - } - if let Some(ref mb_release_group_id) = metadata.mb_release_group_id { - tag.insert( - "MUSICBRAINZ_RELEASEGROUPID".to_string(), - mb_release_group_id.clone(), - ); - } - - // ReplayGain - if let Some(gain) = metadata.replaygain_track_gain { - tag.insert( - "REPLAYGAIN_TRACK_GAIN".to_string(), - format!("{:.2} dB", gain), - ); - } - if let Some(peak) = metadata.replaygain_track_peak { - tag.insert("REPLAYGAIN_TRACK_PEAK".to_string(), format!("{:.6}", peak)); - } - if let Some(gain) = metadata.replaygain_album_gain { - tag.insert( - "REPLAYGAIN_ALBUM_GAIN".to_string(), - format!("{:.2} dB", gain), - ); - } - if let Some(peak) = metadata.replaygain_album_peak { - tag.insert("REPLAYGAIN_ALBUM_PEAK".to_string(), format!("{:.6}", peak)); - } - - // Encoder - if let Some(ref encoder) = metadata.encoder { - tag.insert("ENCODER".to_string(), encoder.clone()); - } - - tag - } - - /// Serialize Vorbis comments to bytes (without block header). - /// Format: vendor_length (4 LE) + vendor + comment_count (4 LE) + comments - fn serialize_vorbis_comments(tag: &VorbisComments) -> Vec { - let vendor = tag.vendor(); - let vendor = if vendor.is_empty() { "musicfs" } else { vendor }; - let mut data = Vec::new(); - - // Vendor string (little-endian length + UTF-8 string) - let vendor_bytes = vendor.as_bytes(); - data.extend_from_slice(&(vendor_bytes.len() as u32).to_le_bytes()); - data.extend_from_slice(vendor_bytes); - - // Collect all comments - let comments: Vec<_> = tag.items().collect(); - data.extend_from_slice(&(comments.len() as u32).to_le_bytes()); - - for (key, value) in comments { - let comment = format!("{}={}", key, value); - let comment_bytes = comment.as_bytes(); - data.extend_from_slice(&(comment_bytes.len() as u32).to_le_bytes()); - data.extend_from_slice(comment_bytes); - } - - data - } - - /// Extract metadata from Vorbis comments tag. - fn extract_from_vorbis_comments(tag: &VorbisComments) -> AudioMeta { - let mut meta = AudioMeta::default(); - meta.format = AudioFormat::Flac; - - // Basic fields (using Accessor trait) - meta.title = tag.title().map(|c: Cow<'_, str>| c.into_owned()); - meta.artist = tag.artist().map(|c: Cow<'_, str>| c.into_owned()); - meta.album = tag.album().map(|c: Cow<'_, str>| c.into_owned()); - meta.genre = tag.genre().map(|c: Cow<'_, str>| c.into_owned()); - - // Album artist - meta.album_artist = tag.get("ALBUMARTIST").map(String::from); - - // Date/Year - meta.date = tag.get("DATE").map(String::from); - if let Some(ref date) = meta.date { - if let Some(year_str) = date.split('-').next() { - meta.year = year_str.parse().ok(); - } - } - - // Track/Disc numbers - meta.track = tag.get("TRACKNUMBER").and_then(|s| s.parse().ok()); - meta.track_total = tag.get("TRACKTOTAL").and_then(|s| s.parse().ok()); - meta.disc = tag.get("DISCNUMBER").and_then(|s| s.parse().ok()); - meta.disc_total = tag.get("DISCTOTAL").and_then(|s| s.parse().ok()); - - // Extended metadata - meta.composer = tag.get("COMPOSER").map(String::from); - meta.comment = tag.get("COMMENT").map(String::from); - meta.lyrics = tag.get("LYRICS").map(String::from); - meta.copyright = tag.get("COPYRIGHT").map(String::from); - meta.compilation = tag - .get("COMPILATION") - .map(|s| s == "1" || s.eq_ignore_ascii_case("true")); - - // Sort fields - meta.title_sort = tag.get("TITLESORT").map(String::from); - meta.artist_sort = tag.get("ARTISTSORT").map(String::from); - meta.album_sort = tag.get("ALBUMSORT").map(String::from); - meta.album_artist_sort = tag.get("ALBUMARTISTSORT").map(String::from); - - // MusicBrainz IDs - meta.mb_recording_id = tag.get("MUSICBRAINZ_TRACKID").map(String::from); - meta.mb_album_id = tag.get("MUSICBRAINZ_ALBUMID").map(String::from); - meta.mb_artist_id = tag.get("MUSICBRAINZ_ARTISTID").map(String::from); - meta.mb_album_artist_id = tag.get("MUSICBRAINZ_ALBUMARTISTID").map(String::from); - meta.mb_release_group_id = tag.get("MUSICBRAINZ_RELEASEGROUPID").map(String::from); - - // ReplayGain - meta.replaygain_track_gain = tag - .get("REPLAYGAIN_TRACK_GAIN") - .and_then(|s| Self::parse_replaygain_value(s)); - meta.replaygain_track_peak = tag - .get("REPLAYGAIN_TRACK_PEAK") - .and_then(|s| s.parse().ok()); - meta.replaygain_album_gain = tag - .get("REPLAYGAIN_ALBUM_GAIN") - .and_then(|s| Self::parse_replaygain_value(s)); - meta.replaygain_album_peak = tag - .get("REPLAYGAIN_ALBUM_PEAK") - .and_then(|s| s.parse().ok()); - - // Encoder - meta.encoder = tag.get("ENCODER").map(String::from); - - meta - } - - /// Parse ReplayGain value, stripping optional "dB" suffix. - fn parse_replaygain_value(value: &str) -> Option { - value - .trim() - .trim_end_matches(" dB") - .trim_end_matches("dB") - .parse() - .ok() - } -} - -impl Default for FlacHandler { - fn default() -> Self { - Self::new() - } -} - -impl FormatHandler for FlacHandler { - fn id(&self) -> &'static str { - "flac" - } - - fn name(&self) -> &'static str { - "FLAC" - } - - fn extensions(&self) -> &[&'static str] { - &["flac"] - } - - fn mime_types(&self) -> &[&'static str] { - &["audio/flac", "audio/x-flac"] - } - - fn analyze(&self, data: &[u8], file_size: u64) -> Result { - // Verify FLAC marker - if data.len() < FLAC_MARKER.len() || &data[0..4] != FLAC_MARKER { - return Err(FormatError::InvalidData("Not a FLAC file".to_string())); - } - - let mut offset = FLAC_MARKER.len(); - let mut streaminfo_data: Option> = None; - - // Parse metadata blocks to find audio_start and extract STREAMINFO - loop { - if offset + BLOCK_HEADER_SIZE > data.len() { - return Err(FormatError::InvalidData( - "Truncated FLAC metadata".to_string(), - )); - } - - let (is_last, block_type, block_size) = Self::parse_block_header(&data[offset..]) - .ok_or_else(|| FormatError::InvalidData("Invalid block header".to_string()))?; - - // Extract STREAMINFO block data (without header) - if block_type == BLOCK_TYPE_STREAMINFO { - if block_size != STREAMINFO_DATA_SIZE { - return Err(FormatError::InvalidData(format!( - "Invalid STREAMINFO size: {} (expected {})", - block_size, STREAMINFO_DATA_SIZE - ))); - } - let data_start = offset + BLOCK_HEADER_SIZE; - let data_end = data_start + block_size; - if data_end > data.len() { - return Err(FormatError::InvalidData( - "Truncated STREAMINFO block".to_string(), - )); - } - streaminfo_data = Some(data[data_start..data_end].to_vec()); - } - - offset += BLOCK_HEADER_SIZE + block_size; - - if is_last { - break; - } - } - - let streaminfo = streaminfo_data - .ok_or_else(|| FormatError::InvalidData("Missing STREAMINFO block".to_string()))?; - - Ok(FormatLayout { - audio_start: offset as u64, - audio_end: file_size, - format: AudioFormat::Flac, - format_data: Some(streaminfo), - }) - } - - fn synthesize( - &self, - metadata: &AudioMeta, - layout: &FormatLayout, - ) -> Result, FormatError> { - // STREAMINFO must be preserved from original - let streaminfo_data = layout.format_data.as_ref().ok_or_else(|| { - FormatError::SynthesisFailed("Missing STREAMINFO data in layout".to_string()) - })?; - - if streaminfo_data.len() != STREAMINFO_DATA_SIZE { - return Err(FormatError::SynthesisFailed(format!( - "Invalid STREAMINFO size: {} (expected {})", - streaminfo_data.len(), - STREAMINFO_DATA_SIZE - ))); - } - - // Build Vorbis comments - let vorbis_tag = Self::build_vorbis_comments(metadata); - let vorbis_data = Self::serialize_vorbis_comments(&vorbis_tag); - - // Calculate total header size - let total_size = - FLAC_MARKER.len() + STREAMINFO_BLOCK_SIZE + BLOCK_HEADER_SIZE + vorbis_data.len(); - let mut buffer = Vec::with_capacity(total_size); - - // Write FLAC marker - buffer.extend_from_slice(FLAC_MARKER); - - // Write STREAMINFO block (not last) - let streaminfo_header = - Self::write_block_header(false, BLOCK_TYPE_STREAMINFO, STREAMINFO_DATA_SIZE); - buffer.extend_from_slice(&streaminfo_header); - buffer.extend_from_slice(streaminfo_data); - - // Write VORBIS_COMMENT block (last) - let vorbis_header = - Self::write_block_header(true, BLOCK_TYPE_VORBIS_COMMENT, vorbis_data.len()); - buffer.extend_from_slice(&vorbis_header); - buffer.extend_from_slice(&vorbis_data); - - Ok(buffer) - } - - fn extract(&self, data: &[u8]) -> Result { - let mut cursor = Cursor::new(data); - - let flac_file = FlacFile::read_from(&mut cursor, ParseOptions::new()) - .map_err(|e| FormatError::InvalidData(e.to_string()))?; - - let tag = flac_file - .vorbis_comments() - .ok_or_else(|| FormatError::InvalidData("No Vorbis comments found".to_string()))?; - - Ok(Self::extract_from_vorbis_comments(tag)) - } - - fn estimate_header_size(&self, _metadata: &AudioMeta) -> usize { - // fLaC (4) + STREAMINFO (38) + VORBIS_COMMENT header (4) + typical comments (~4KB) - 8192 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_test_meta() -> AudioMeta { - AudioMeta { - title: Some("Test Title".to_string()), - artist: Some("Test Artist".to_string()), - album: Some("Test Album".to_string()), - album_artist: Some("Test Album Artist".to_string()), - genre: Some("Rock".to_string()), - year: Some(2024), - track: Some(5), - track_total: Some(12), - disc: Some(1), - disc_total: Some(2), - format: AudioFormat::Flac, - date: Some("2024-03-15".to_string()), - composer: Some("Test Composer".to_string()), - comment: Some("Test Comment".to_string()), - lyrics: Some("Test Lyrics\nLine 2".to_string()), - copyright: Some("2024 Test Copyright".to_string()), - compilation: Some(false), - title_sort: Some("Title, Test".to_string()), - artist_sort: Some("Artist, Test".to_string()), - album_sort: Some("Album, Test".to_string()), - album_artist_sort: Some("Album Artist, Test".to_string()), - mb_recording_id: Some("rec-12345".to_string()), - mb_album_id: Some("alb-12345".to_string()), - mb_artist_id: Some("art-12345".to_string()), - mb_album_artist_id: Some("albart-12345".to_string()), - mb_release_group_id: Some("rg-12345".to_string()), - replaygain_track_gain: Some(-6.5), - replaygain_track_peak: Some(0.987654), - replaygain_album_gain: Some(-5.2), - replaygain_album_peak: Some(0.999999), - encoder: Some("FLAC 1.4.0".to_string()), - ..Default::default() - } - } - - /// Create a minimal valid FLAC file header for testing. - fn make_minimal_flac_header() -> Vec { - let mut data = Vec::new(); - - // FLAC marker - data.extend_from_slice(b"fLaC"); - - // STREAMINFO block (last=true for minimal file) - // Header: type=0 (STREAMINFO), last=1, size=34 - data.push(0x80); // 0x80 = last flag set, type 0 - data.push(0x00); - data.push(0x00); - data.push(0x22); // 34 bytes - - // STREAMINFO data (34 bytes) - minimal valid values - // min_block_size (16 bits) = 4096 - data.push(0x10); - data.push(0x00); - // max_block_size (16 bits) = 4096 - data.push(0x10); - data.push(0x00); - // min_frame_size (24 bits) = 0 (unknown) - data.push(0x00); - data.push(0x00); - data.push(0x00); - // max_frame_size (24 bits) = 0 (unknown) - data.push(0x00); - data.push(0x00); - data.push(0x00); - // sample_rate (20 bits) = 44100, channels-1 (3 bits) = 1, bits-1 (5 bits) = 15 - // 44100 = 0xAC44, channels=2 (1), bits=16 (15) - // Packed: SSSS SSSS SSSS SSSS SSSS CCCC CBBB BB - // 0xAC44 << 12 | (1 << 9) | (15 << 4) = ... - // Let's use simpler encoding: - // Byte 0-1: sample_rate high 16 bits of 20 - // Byte 2: sample_rate low 4 bits | channels 3 bits | bits high 1 bit - // Byte 3: bits low 4 bits | total_samples high 4 bits - // Actually the format is: - // 20 bits sample rate, 3 bits channels-1, 5 bits bits-1, 36 bits total samples - // 44100 = 0x0AC44 - data.push(0x0A); // sample_rate bits 19-12 - data.push(0xC4); // sample_rate bits 11-4 - data.push(0x42); // sample_rate bits 3-0 (0x4), channels-1 (0x1=stereo), bits-1 high bit (0) - data.push(0xF0); // bits-1 low 4 bits (0xF=15, so 16 bits), total_samples high 4 bits (0) - // total_samples (remaining 32 bits) = 0 - data.push(0x00); - data.push(0x00); - data.push(0x00); - data.push(0x00); - // MD5 signature (128 bits = 16 bytes) - data.extend_from_slice(&[0u8; 16]); - - data - } - - /// Create a FLAC header with Vorbis comments for testing extract(). - fn make_flac_with_vorbis_comments() -> Vec { - let mut data = Vec::new(); - - // FLAC marker - data.extend_from_slice(b"fLaC"); - - // STREAMINFO block (not last) - data.push(0x00); // type=0, last=0 - data.push(0x00); - data.push(0x00); - data.push(0x22); // 34 bytes - - // STREAMINFO data (34 bytes) - data.push(0x10); - data.push(0x00); - data.push(0x10); - data.push(0x00); - data.extend_from_slice(&[0u8; 6]); // frame sizes - data.push(0x0A); - data.push(0xC4); - data.push(0x42); - data.push(0xF0); - data.extend_from_slice(&[0u8; 4]); // total samples - data.extend_from_slice(&[0u8; 16]); // MD5 - - // VORBIS_COMMENT block (last) - // Vendor: "test" - // Comments: TITLE=Test Song, ARTIST=Test Artist - let vendor = b"test"; - let comments = [ - b"TITLE=Test Song".as_slice(), - b"ARTIST=Test Artist".as_slice(), - b"ALBUM=Test Album".as_slice(), - b"TRACKNUMBER=3".as_slice(), - b"REPLAYGAIN_TRACK_GAIN=-5.50 dB".as_slice(), - ]; - - let mut vorbis_data = Vec::new(); - // Vendor length (LE) - vorbis_data.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); - vorbis_data.extend_from_slice(vendor); - // Comment count (LE) - vorbis_data.extend_from_slice(&(comments.len() as u32).to_le_bytes()); - for comment in &comments { - vorbis_data.extend_from_slice(&(comment.len() as u32).to_le_bytes()); - vorbis_data.extend_from_slice(*comment); - } - - // VORBIS_COMMENT header - data.push(0x84); // type=4, last=1 - data.push(((vorbis_data.len() >> 16) & 0xFF) as u8); - data.push(((vorbis_data.len() >> 8) & 0xFF) as u8); - data.push((vorbis_data.len() & 0xFF) as u8); - data.extend_from_slice(&vorbis_data); - - data - } - - #[test] - fn test_id_and_name() { - let handler = FlacHandler::new(); - assert_eq!(handler.id(), "flac"); - assert_eq!(handler.name(), "FLAC"); - } - - #[test] - fn test_extensions_and_mime_types() { - let handler = FlacHandler::new(); - assert_eq!(handler.extensions(), &["flac"]); - assert_eq!(handler.mime_types(), &["audio/flac", "audio/x-flac"]); - } - - #[test] - fn test_estimate_header_size() { - let handler = FlacHandler::new(); - let meta = AudioMeta::default(); - assert_eq!(handler.estimate_header_size(&meta), 8192); - } - - #[test] - fn test_analyze_valid_flac() { - let handler = FlacHandler::new(); - let data = make_minimal_flac_header(); - let file_size = data.len() as u64 + 1000; // Pretend there's audio data - - let result = handler.analyze(&data, file_size); - assert!(result.is_ok(), "analyze failed: {:?}", result.err()); - - let layout = result.unwrap(); - assert_eq!(layout.audio_start, 42); // 4 (marker) + 38 (STREAMINFO) - assert_eq!(layout.audio_end, file_size); - assert_eq!(layout.format, AudioFormat::Flac); - assert!(layout.format_data.is_some()); - assert_eq!( - layout.format_data.as_ref().unwrap().len(), - STREAMINFO_DATA_SIZE - ); - } - - #[test] - fn test_analyze_invalid_marker() { - let handler = FlacHandler::new(); - let data = b"ID3\x04\x00\x00"; // MP3 header, not FLAC - - let result = handler.analyze(data, 1000); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), FormatError::InvalidData(_))); - } - - #[test] - fn test_analyze_truncated() { - let handler = FlacHandler::new(); - let data = b"fLaC"; // Just the marker, no blocks - - let result = handler.analyze(data, 4); - assert!(result.is_err()); - } - - #[test] - fn test_synthesize_creates_valid_flac_header() { - let handler = FlacHandler::new(); - let meta = make_test_meta(); - - // Create layout with STREAMINFO - let original_data = make_minimal_flac_header(); - let layout = handler - .analyze(&original_data, original_data.len() as u64) - .unwrap(); - - let result = handler.synthesize(&meta, &layout); - assert!(result.is_ok(), "synthesize failed: {:?}", result.err()); - - let bytes = result.unwrap(); - - // Verify FLAC marker - assert!(bytes.len() >= 4); - assert_eq!(&bytes[0..4], b"fLaC"); - - // Verify STREAMINFO block header - assert_eq!(bytes[4] & 0x7F, BLOCK_TYPE_STREAMINFO); // Type 0 - assert_eq!(bytes[4] & 0x80, 0); // Not last - - // Verify STREAMINFO size - let streaminfo_size = - ((bytes[5] as usize) << 16) | ((bytes[6] as usize) << 8) | (bytes[7] as usize); - assert_eq!(streaminfo_size, STREAMINFO_DATA_SIZE); - - // Verify VORBIS_COMMENT block follows - let vorbis_offset = 4 + 4 + STREAMINFO_DATA_SIZE; - assert_eq!(bytes[vorbis_offset] & 0x7F, BLOCK_TYPE_VORBIS_COMMENT); - assert_eq!(bytes[vorbis_offset] & 0x80, 0x80); // Is last - } - - #[test] - fn test_synthesize_preserves_streaminfo() { - let handler = FlacHandler::new(); - let meta = AudioMeta::default(); - - // Create layout with specific STREAMINFO - let original_data = make_minimal_flac_header(); - let layout = handler - .analyze(&original_data, original_data.len() as u64) - .unwrap(); - let original_streaminfo = layout.format_data.as_ref().unwrap().clone(); - - let synthesized = handler.synthesize(&meta, &layout).unwrap(); - - // Extract STREAMINFO from synthesized header - let streaminfo_start = 4 + 4; // After marker and header - let streaminfo_end = streaminfo_start + STREAMINFO_DATA_SIZE; - let synthesized_streaminfo = &synthesized[streaminfo_start..streaminfo_end]; - - assert_eq!(synthesized_streaminfo, original_streaminfo.as_slice()); - } - - #[test] - fn test_synthesize_missing_streaminfo() { - let handler = FlacHandler::new(); - let meta = AudioMeta::default(); - let layout = FormatLayout { - audio_start: 42, - audio_end: 1000, - format: AudioFormat::Flac, - format_data: None, // Missing STREAMINFO - }; - - let result = handler.synthesize(&meta, &layout); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - FormatError::SynthesisFailed(_) - )); - } - - #[test] - fn test_extract_from_flac() { - let handler = FlacHandler::new(); - let data = make_flac_with_vorbis_comments(); - - let result = handler.extract(&data); - assert!(result.is_ok(), "extract failed: {:?}", result.err()); - - let meta = result.unwrap(); - assert_eq!(meta.title, Some("Test Song".to_string())); - assert_eq!(meta.artist, Some("Test Artist".to_string())); - assert_eq!(meta.album, Some("Test Album".to_string())); - assert_eq!(meta.track, Some(3)); - assert_eq!(meta.format, AudioFormat::Flac); - - // Check ReplayGain parsing - let gain = meta.replaygain_track_gain.unwrap(); - assert!((gain - (-5.5)).abs() < 0.01); - } - - #[test] - fn test_build_and_extract_vorbis_comments() { - let original_meta = make_test_meta(); - let tag = FlacHandler::build_vorbis_comments(&original_meta); - let extracted = FlacHandler::extract_from_vorbis_comments(&tag); - - assert_eq!(extracted.title, original_meta.title); - assert_eq!(extracted.artist, original_meta.artist); - assert_eq!(extracted.album, original_meta.album); - assert_eq!(extracted.album_artist, original_meta.album_artist); - assert_eq!(extracted.genre, original_meta.genre); - assert_eq!(extracted.track, original_meta.track); - assert_eq!(extracted.track_total, original_meta.track_total); - assert_eq!(extracted.disc, original_meta.disc); - assert_eq!(extracted.disc_total, original_meta.disc_total); - assert_eq!(extracted.composer, original_meta.composer); - assert_eq!(extracted.comment, original_meta.comment); - assert_eq!(extracted.lyrics, original_meta.lyrics); - assert_eq!(extracted.copyright, original_meta.copyright); - assert_eq!(extracted.compilation, original_meta.compilation); - assert_eq!(extracted.title_sort, original_meta.title_sort); - assert_eq!(extracted.artist_sort, original_meta.artist_sort); - assert_eq!(extracted.album_sort, original_meta.album_sort); - assert_eq!(extracted.album_artist_sort, original_meta.album_artist_sort); - assert_eq!(extracted.mb_recording_id, original_meta.mb_recording_id); - assert_eq!(extracted.mb_album_id, original_meta.mb_album_id); - assert_eq!(extracted.mb_artist_id, original_meta.mb_artist_id); - assert_eq!( - extracted.mb_album_artist_id, - original_meta.mb_album_artist_id - ); - assert_eq!( - extracted.mb_release_group_id, - original_meta.mb_release_group_id - ); - assert_eq!(extracted.encoder, original_meta.encoder); - - // ReplayGain values (with tolerance for formatting) - let orig_track_gain = original_meta.replaygain_track_gain.unwrap(); - let ext_track_gain = extracted.replaygain_track_gain.unwrap(); - assert!((orig_track_gain - ext_track_gain).abs() < 0.01); - - let orig_track_peak = original_meta.replaygain_track_peak.unwrap(); - let ext_track_peak = extracted.replaygain_track_peak.unwrap(); - assert!((orig_track_peak - ext_track_peak).abs() < 0.0001); - } - - #[test] - fn test_parse_replaygain_value() { - assert_eq!(FlacHandler::parse_replaygain_value("-6.50 dB"), Some(-6.50)); - assert_eq!(FlacHandler::parse_replaygain_value("-6.50dB"), Some(-6.50)); - assert_eq!(FlacHandler::parse_replaygain_value("-6.50"), Some(-6.50)); - assert_eq!(FlacHandler::parse_replaygain_value(" 3.2 dB "), Some(3.2)); - assert_eq!(FlacHandler::parse_replaygain_value("invalid"), None); - } - - #[test] - fn test_parse_block_header() { - // Not last, type 0, size 34 - let header = [0x00, 0x00, 0x00, 0x22]; - let (is_last, block_type, size) = FlacHandler::parse_block_header(&header).unwrap(); - assert!(!is_last); - assert_eq!(block_type, 0); - assert_eq!(size, 34); - - // Last, type 4, size 256 - let header = [0x84, 0x00, 0x01, 0x00]; - let (is_last, block_type, size) = FlacHandler::parse_block_header(&header).unwrap(); - assert!(is_last); - assert_eq!(block_type, 4); - assert_eq!(size, 256); - } - - #[test] - fn test_write_block_header() { - let header = FlacHandler::write_block_header(false, 0, 34); - assert_eq!(header, [0x00, 0x00, 0x00, 0x22]); - - let header = FlacHandler::write_block_header(true, 4, 256); - assert_eq!(header, [0x84, 0x00, 0x01, 0x00]); - } - - #[test] - fn test_empty_metadata_produces_minimal_vorbis() { - let handler = FlacHandler::new(); - let meta = AudioMeta::default(); - - let original_data = make_minimal_flac_header(); - let layout = handler - .analyze(&original_data, original_data.len() as u64) - .unwrap(); - - let result = handler.synthesize(&meta, &layout); - assert!(result.is_ok()); - - let bytes = result.unwrap(); - // Should have: fLaC (4) + STREAMINFO (38) + VORBIS_COMMENT (header + minimal data) - assert!(bytes.len() >= 42 + 4 + 8); // At least vendor string overhead - } - - #[test] - fn test_round_trip_synthesize_analyze() { - let handler = FlacHandler::new(); - let meta = make_test_meta(); - - // Create initial layout - let original_data = make_minimal_flac_header(); - let layout = handler - .analyze(&original_data, original_data.len() as u64) - .unwrap(); - - // Synthesize new header - let synthesized = handler.synthesize(&meta, &layout).unwrap(); - - // Analyze synthesized header - let new_layout = handler - .analyze(&synthesized, synthesized.len() as u64) - .unwrap(); - - // STREAMINFO should be preserved - assert_eq!(new_layout.format_data, layout.format_data); - assert_eq!(new_layout.format, AudioFormat::Flac); - } -} diff --git a/crates/musicfs-cache/src/handlers/id3v2.rs b/crates/musicfs-cache/src/handlers/id3v2.rs deleted file mode 100644 index 01ab3d9..0000000 --- a/crates/musicfs-cache/src/handlers/id3v2.rs +++ /dev/null @@ -1,631 +0,0 @@ -use crate::{FormatError, FormatHandler, FormatLayout}; -use lofty::config::{ParseOptions, WriteOptions}; -use lofty::file::AudioFile; -use lofty::id3::v2::{ - CommentFrame, Frame, FrameId, Id3v2Tag, TextInformationFrame, UnsynchronizedTextFrame, -}; -use lofty::mpeg::MpegFile; -use lofty::tag::{Accessor, TagExt}; -use lofty::TextEncoding; -use musicfs_core::{AudioFormat, AudioMeta}; -use std::borrow::Cow; -use std::io::Cursor; - -const ID3V2_HEADER_SIZE: usize = 10; -const ID3V1_TAG_SIZE: usize = 128; - -pub struct Id3v2Handler; - -impl Id3v2Handler { - pub fn new() -> Self { - Self - } - - fn parse_id3v2_header(data: &[u8]) -> Option { - if data.len() < ID3V2_HEADER_SIZE { - return None; - } - - if &data[0..3] != b"ID3" { - return None; - } - - let size = syncsafe_decode(&data[6..10]); - Some(ID3V2_HEADER_SIZE + size) - } - - fn has_id3v1_tag(data: &[u8], file_size: u64) -> bool { - if file_size < ID3V1_TAG_SIZE as u64 { - return false; - } - - let tag_start = (file_size as usize).saturating_sub(ID3V1_TAG_SIZE); - if tag_start >= data.len() { - return false; - } - - &data[tag_start..tag_start + 3] == b"TAG" - } - - fn set_text_frame(tag: &mut Id3v2Tag, frame_id: &'static str, value: &str) { - let id = FrameId::Valid(Cow::Borrowed(frame_id)); - let frame = Frame::Text(TextInformationFrame::new( - id, - TextEncoding::UTF8, - value.to_string(), - )); - tag.insert(frame); - } - - fn set_track_disc_frame( - tag: &mut Id3v2Tag, - frame_id: &'static str, - num: u32, - total: Option, - ) { - let value = match total { - Some(t) => format!("{}/{}", num, t), - None => num.to_string(), - }; - Self::set_text_frame(tag, frame_id, &value); - } - - fn set_comment_frame(tag: &mut Id3v2Tag, value: &str) { - let frame = Frame::Comment(CommentFrame::new( - TextEncoding::UTF8, - *b"eng", - String::new(), - value.to_string(), - )); - tag.insert(frame); - } - - fn set_lyrics_frame(tag: &mut Id3v2Tag, value: &str) { - let frame = Frame::UnsynchronizedText(UnsynchronizedTextFrame::new( - TextEncoding::UTF8, - *b"eng", - String::new(), - value.to_string(), - )); - tag.insert(frame); - } - - fn build_tag_from_meta(metadata: &AudioMeta) -> Id3v2Tag { - let mut tag = Id3v2Tag::new(); - - if let Some(ref title) = metadata.title { - tag.set_title(title.clone()); - } - if let Some(ref artist) = metadata.artist { - tag.set_artist(artist.clone()); - } - if let Some(ref album) = metadata.album { - tag.set_album(album.clone()); - } - if let Some(ref album_artist) = metadata.album_artist { - Self::set_text_frame(&mut tag, "TPE2", album_artist); - } - if let Some(year) = metadata.year { - Self::set_text_frame(&mut tag, "TDRC", &year.to_string()); - } - if let Some(ref genre) = metadata.genre { - tag.set_genre(genre.clone()); - } - - if let Some(track) = metadata.track { - Self::set_track_disc_frame(&mut tag, "TRCK", track, metadata.track_total); - } - if let Some(disc) = metadata.disc { - Self::set_track_disc_frame(&mut tag, "TPOS", disc, metadata.disc_total); - } - - if let Some(ref date) = metadata.date { - Self::set_text_frame(&mut tag, "TDRC", date); - } - if let Some(ref composer) = metadata.composer { - Self::set_text_frame(&mut tag, "TCOM", composer); - } - if let Some(ref comment) = metadata.comment { - Self::set_comment_frame(&mut tag, comment); - } - if let Some(ref lyrics) = metadata.lyrics { - Self::set_lyrics_frame(&mut tag, lyrics); - } - if let Some(ref copyright) = metadata.copyright { - Self::set_text_frame(&mut tag, "TCOP", copyright); - } - if let Some(compilation) = metadata.compilation { - Self::set_text_frame(&mut tag, "TCMP", if compilation { "1" } else { "0" }); - } - - if let Some(ref title_sort) = metadata.title_sort { - Self::set_text_frame(&mut tag, "TSOT", title_sort); - } - if let Some(ref artist_sort) = metadata.artist_sort { - Self::set_text_frame(&mut tag, "TSOP", artist_sort); - } - if let Some(ref album_sort) = metadata.album_sort { - Self::set_text_frame(&mut tag, "TSOA", album_sort); - } - if let Some(ref album_artist_sort) = metadata.album_artist_sort { - Self::set_text_frame(&mut tag, "TSO2", album_artist_sort); - } - - if let Some(ref mb_recording_id) = metadata.mb_recording_id { - tag.insert_user_text( - "MusicBrainz Recording Id".to_string(), - mb_recording_id.clone(), - ); - } - if let Some(ref mb_album_id) = metadata.mb_album_id { - tag.insert_user_text("MusicBrainz Album Id".to_string(), mb_album_id.clone()); - } - if let Some(ref mb_artist_id) = metadata.mb_artist_id { - tag.insert_user_text("MusicBrainz Artist Id".to_string(), mb_artist_id.clone()); - } - if let Some(ref mb_album_artist_id) = metadata.mb_album_artist_id { - tag.insert_user_text( - "MusicBrainz Album Artist Id".to_string(), - mb_album_artist_id.clone(), - ); - } - if let Some(ref mb_release_group_id) = metadata.mb_release_group_id { - tag.insert_user_text( - "MusicBrainz Release Group Id".to_string(), - mb_release_group_id.clone(), - ); - } - - if let Some(gain) = metadata.replaygain_track_gain { - tag.insert_user_text( - "REPLAYGAIN_TRACK_GAIN".to_string(), - format!("{:.2} dB", gain), - ); - } - if let Some(peak) = metadata.replaygain_track_peak { - tag.insert_user_text("REPLAYGAIN_TRACK_PEAK".to_string(), format!("{:.6}", peak)); - } - if let Some(gain) = metadata.replaygain_album_gain { - tag.insert_user_text( - "REPLAYGAIN_ALBUM_GAIN".to_string(), - format!("{:.2} dB", gain), - ); - } - if let Some(peak) = metadata.replaygain_album_peak { - tag.insert_user_text("REPLAYGAIN_ALBUM_PEAK".to_string(), format!("{:.6}", peak)); - } - - if let Some(ref encoder) = metadata.encoder { - Self::set_text_frame(&mut tag, "TSSE", encoder); - } - - tag - } - - fn extract_text_frame(tag: &Id3v2Tag, frame_id: &str) -> Option { - let id = FrameId::new(frame_id).ok()?; - tag.get_text(&id).map(|s| s.to_string()) - } - - fn parse_track_disc(value: &str) -> (Option, Option) { - let parts: Vec<&str> = value.split('/').collect(); - let num = parts.first().and_then(|s| s.parse().ok()); - let total = parts.get(1).and_then(|s| s.parse().ok()); - (num, total) - } - - fn parse_replaygain_value(value: &str) -> Option { - value - .trim() - .trim_end_matches(" dB") - .trim_end_matches("dB") - .parse() - .ok() - } - - fn extract_from_tag(tag: &Id3v2Tag) -> AudioMeta { - let mut meta = AudioMeta::default(); - meta.format = AudioFormat::Mp3; - - meta.title = tag.title().map(|c: Cow<'_, str>| c.into_owned()); - meta.artist = tag.artist().map(|c: Cow<'_, str>| c.into_owned()); - meta.album = tag.album().map(|c: Cow<'_, str>| c.into_owned()); - meta.album_artist = Self::extract_text_frame(tag, "TPE2"); - meta.genre = tag.genre().map(|c: Cow<'_, str>| c.into_owned()); - - if let Some(track_str) = Self::extract_text_frame(tag, "TRCK") { - let (track, track_total) = Self::parse_track_disc(&track_str); - meta.track = track; - meta.track_total = track_total; - } else { - meta.track = tag.track(); - meta.track_total = tag.track_total(); - } - - if let Some(disc_str) = Self::extract_text_frame(tag, "TPOS") { - let (disc, disc_total) = Self::parse_track_disc(&disc_str); - meta.disc = disc; - meta.disc_total = disc_total; - } else { - meta.disc = tag.disk(); - meta.disc_total = tag.disk_total(); - } - - meta.date = Self::extract_text_frame(tag, "TDRC"); - if let Some(ref date) = meta.date { - if let Some(year_str) = date.split('-').next() { - meta.year = year_str.parse().ok(); - } - } - - meta.composer = Self::extract_text_frame(tag, "TCOM"); - meta.comment = tag.comment().map(|c: Cow<'_, str>| c.into_owned()); - - if let Some(uslt) = tag.unsync_text().next() { - meta.lyrics = Some(uslt.content.to_string()); - } - - meta.copyright = Self::extract_text_frame(tag, "TCOP"); - - if let Some(tcmp) = Self::extract_text_frame(tag, "TCMP") { - meta.compilation = Some(tcmp == "1"); - } - - meta.title_sort = Self::extract_text_frame(tag, "TSOT"); - meta.artist_sort = Self::extract_text_frame(tag, "TSOP"); - meta.album_sort = Self::extract_text_frame(tag, "TSOA"); - meta.album_artist_sort = Self::extract_text_frame(tag, "TSO2"); - - meta.mb_recording_id = tag - .get_user_text("MusicBrainz Recording Id") - .map(String::from); - meta.mb_album_id = tag.get_user_text("MusicBrainz Album Id").map(String::from); - meta.mb_artist_id = tag.get_user_text("MusicBrainz Artist Id").map(String::from); - meta.mb_album_artist_id = tag - .get_user_text("MusicBrainz Album Artist Id") - .map(String::from); - meta.mb_release_group_id = tag - .get_user_text("MusicBrainz Release Group Id") - .map(String::from); - - if let Some(gain_str) = tag.get_user_text("REPLAYGAIN_TRACK_GAIN") { - meta.replaygain_track_gain = Self::parse_replaygain_value(gain_str); - } - if let Some(peak_str) = tag.get_user_text("REPLAYGAIN_TRACK_PEAK") { - meta.replaygain_track_peak = peak_str.parse::().ok(); - } - if let Some(gain_str) = tag.get_user_text("REPLAYGAIN_ALBUM_GAIN") { - meta.replaygain_album_gain = Self::parse_replaygain_value(gain_str); - } - if let Some(peak_str) = tag.get_user_text("REPLAYGAIN_ALBUM_PEAK") { - meta.replaygain_album_peak = peak_str.parse::().ok(); - } - - meta.encoder = Self::extract_text_frame(tag, "TSSE"); - - meta - } -} - -impl Default for Id3v2Handler { - fn default() -> Self { - Self::new() - } -} - -impl FormatHandler for Id3v2Handler { - fn id(&self) -> &'static str { - "id3v2" - } - - fn name(&self) -> &'static str { - "ID3v2 (MP3)" - } - - fn extensions(&self) -> &[&'static str] { - &["mp3"] - } - - fn mime_types(&self) -> &[&'static str] { - &["audio/mpeg"] - } - - fn analyze(&self, data: &[u8], file_size: u64) -> Result { - let audio_start = Self::parse_id3v2_header(data).unwrap_or(0) as u64; - - let audio_end = if Self::has_id3v1_tag(data, file_size) { - file_size - ID3V1_TAG_SIZE as u64 - } else { - file_size - }; - - Ok(FormatLayout { - audio_start, - audio_end, - format: AudioFormat::Mp3, - format_data: None, - }) - } - - fn synthesize( - &self, - metadata: &AudioMeta, - _layout: &FormatLayout, - ) -> Result, FormatError> { - let tag = Self::build_tag_from_meta(metadata); - - let mut buffer = Cursor::new(Vec::new()); - let write_options = WriteOptions::new().preferred_padding(1024); - - tag.dump_to(&mut buffer, write_options) - .map_err(|e| FormatError::SynthesisFailed(e.to_string()))?; - - Ok(buffer.into_inner()) - } - - fn extract(&self, data: &[u8]) -> Result { - let mut cursor = Cursor::new(data); - - let mpeg_file = MpegFile::read_from(&mut cursor, ParseOptions::new()) - .map_err(|e| FormatError::InvalidData(e.to_string()))?; - - let tag = mpeg_file - .id3v2() - .ok_or_else(|| FormatError::InvalidData("No ID3v2 tag found".to_string()))?; - - Ok(Self::extract_from_tag(tag)) - } - - fn estimate_header_size(&self, _metadata: &AudioMeta) -> usize { - 4096 + 1024 - } -} - -fn syncsafe_decode(bytes: &[u8]) -> usize { - ((bytes[0] as usize) << 21) - | ((bytes[1] as usize) << 14) - | ((bytes[2] as usize) << 7) - | (bytes[3] as usize) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_test_meta() -> AudioMeta { - AudioMeta { - title: Some("Test Title".to_string()), - artist: Some("Test Artist".to_string()), - album: Some("Test Album".to_string()), - album_artist: Some("Test Album Artist".to_string()), - genre: Some("Rock".to_string()), - year: Some(2024), - track: Some(5), - track_total: Some(12), - disc: Some(1), - disc_total: Some(2), - format: AudioFormat::Mp3, - date: Some("2024-03-15".to_string()), - composer: Some("Test Composer".to_string()), - comment: Some("Test Comment".to_string()), - lyrics: Some("Test Lyrics\nLine 2".to_string()), - copyright: Some("2024 Test Copyright".to_string()), - compilation: Some(false), - title_sort: Some("Title, Test".to_string()), - artist_sort: Some("Artist, Test".to_string()), - album_sort: Some("Album, Test".to_string()), - album_artist_sort: Some("Album Artist, Test".to_string()), - mb_recording_id: Some("rec-12345".to_string()), - mb_album_id: Some("alb-12345".to_string()), - mb_artist_id: Some("art-12345".to_string()), - mb_album_artist_id: Some("albart-12345".to_string()), - mb_release_group_id: Some("rg-12345".to_string()), - replaygain_track_gain: Some(-6.5), - replaygain_track_peak: Some(0.987654), - replaygain_album_gain: Some(-5.2), - replaygain_album_peak: Some(0.999999), - encoder: Some("LAME 3.100".to_string()), - ..Default::default() - } - } - - #[test] - fn test_id_and_name() { - let handler = Id3v2Handler::new(); - assert_eq!(handler.id(), "id3v2"); - assert_eq!(handler.name(), "ID3v2 (MP3)"); - } - - #[test] - fn test_extensions_and_mime_types() { - let handler = Id3v2Handler::new(); - assert_eq!(handler.extensions(), &["mp3"]); - assert_eq!(handler.mime_types(), &["audio/mpeg"]); - } - - #[test] - fn test_estimate_header_size() { - let handler = Id3v2Handler::new(); - let meta = AudioMeta::default(); - assert_eq!(handler.estimate_header_size(&meta), 5120); - } - - #[test] - fn test_synthesize_creates_valid_id3v2() { - let handler = Id3v2Handler::new(); - let meta = make_test_meta(); - let layout = FormatLayout { - audio_start: 0, - audio_end: 1000, - format: AudioFormat::Mp3, - format_data: None, - }; - - let result = handler.synthesize(&meta, &layout); - assert!(result.is_ok()); - - let bytes = result.unwrap(); - assert!(bytes.len() >= 10); - assert_eq!(&bytes[0..3], b"ID3"); - assert_eq!(bytes[3], 0x04); - } - - #[test] - fn test_analyze_no_id3v2() { - let handler = Id3v2Handler::new(); - let data = vec![0xFF, 0xFB, 0x90, 0x00]; - let file_size = 1000; - - let result = handler.analyze(&data, file_size); - assert!(result.is_ok()); - - let layout = result.unwrap(); - assert_eq!(layout.audio_start, 0); - assert_eq!(layout.audio_end, 1000); - assert_eq!(layout.format, AudioFormat::Mp3); - } - - #[test] - fn test_analyze_with_id3v2() { - let handler = Id3v2Handler::new(); - - let mut data = vec![b'I', b'D', b'3', 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64]; - data.extend(vec![0u8; 100]); - let file_size = data.len() as u64; - - let result = handler.analyze(&data, file_size); - assert!(result.is_ok()); - - let layout = result.unwrap(); - assert_eq!(layout.audio_start, 110); - assert_eq!(layout.audio_end, file_size); - } - - #[test] - fn test_analyze_with_id3v1() { - let handler = Id3v2Handler::new(); - - let mut data = vec![0xFF, 0xFB, 0x90, 0x00]; - data.extend(vec![0u8; 100]); - data.extend(b"TAG"); - data.extend(vec![0u8; 125]); - let file_size = data.len() as u64; - - let result = handler.analyze(&data, file_size); - assert!(result.is_ok()); - - let layout = result.unwrap(); - assert_eq!(layout.audio_start, 0); - assert_eq!(layout.audio_end, file_size - 128); - } - - #[test] - fn test_syncsafe_decode() { - assert_eq!(syncsafe_decode(&[0x00, 0x00, 0x00, 0x7F]), 127); - assert_eq!(syncsafe_decode(&[0x00, 0x00, 0x01, 0x00]), 128); - assert_eq!(syncsafe_decode(&[0x00, 0x00, 0x00, 0x64]), 100); - } - - #[test] - fn test_parse_track_disc() { - assert_eq!(Id3v2Handler::parse_track_disc("5/12"), (Some(5), Some(12))); - assert_eq!(Id3v2Handler::parse_track_disc("5"), (Some(5), None)); - assert_eq!(Id3v2Handler::parse_track_disc(""), (None, None)); - } - - #[test] - fn test_parse_replaygain_value() { - assert_eq!( - Id3v2Handler::parse_replaygain_value("-6.50 dB"), - Some(-6.50) - ); - assert_eq!(Id3v2Handler::parse_replaygain_value("-6.50dB"), Some(-6.50)); - assert_eq!(Id3v2Handler::parse_replaygain_value("-6.50"), Some(-6.50)); - assert_eq!(Id3v2Handler::parse_replaygain_value("invalid"), None); - } - - #[test] - fn test_empty_metadata_produces_empty_tag() { - let handler = Id3v2Handler::new(); - let meta = AudioMeta::default(); - let layout = FormatLayout { - audio_start: 0, - audio_end: 1000, - format: AudioFormat::Mp3, - format_data: None, - }; - - let result = handler.synthesize(&meta, &layout); - assert!(result.is_ok()); - - let bytes = result.unwrap(); - assert!(bytes.is_empty()); - } - - #[test] - fn test_minimal_metadata_produces_valid_tag() { - let handler = Id3v2Handler::new(); - let mut meta = AudioMeta::default(); - meta.title = Some("Test".to_string()); - let layout = FormatLayout { - audio_start: 0, - audio_end: 1000, - format: AudioFormat::Mp3, - format_data: None, - }; - - let result = handler.synthesize(&meta, &layout); - assert!(result.is_ok()); - - let bytes = result.unwrap(); - assert!(bytes.len() >= 10); - assert_eq!(&bytes[0..3], b"ID3"); - assert_eq!(bytes[3], 0x04); - } - - #[test] - fn test_build_and_extract_tag() { - let original_meta = make_test_meta(); - let tag = Id3v2Handler::build_tag_from_meta(&original_meta); - let extracted = Id3v2Handler::extract_from_tag(&tag); - - assert_eq!(extracted.title, original_meta.title); - assert_eq!(extracted.artist, original_meta.artist); - assert_eq!(extracted.album, original_meta.album); - assert_eq!(extracted.album_artist, original_meta.album_artist); - assert_eq!(extracted.genre, original_meta.genre); - assert_eq!(extracted.track, original_meta.track); - assert_eq!(extracted.track_total, original_meta.track_total); - assert_eq!(extracted.disc, original_meta.disc); - assert_eq!(extracted.disc_total, original_meta.disc_total); - assert_eq!(extracted.composer, original_meta.composer); - assert_eq!(extracted.comment, original_meta.comment); - assert_eq!(extracted.lyrics, original_meta.lyrics); - assert_eq!(extracted.copyright, original_meta.copyright); - assert_eq!(extracted.compilation, original_meta.compilation); - assert_eq!(extracted.title_sort, original_meta.title_sort); - assert_eq!(extracted.artist_sort, original_meta.artist_sort); - assert_eq!(extracted.album_sort, original_meta.album_sort); - assert_eq!(extracted.album_artist_sort, original_meta.album_artist_sort); - assert_eq!(extracted.mb_recording_id, original_meta.mb_recording_id); - assert_eq!(extracted.mb_album_id, original_meta.mb_album_id); - assert_eq!(extracted.mb_artist_id, original_meta.mb_artist_id); - assert_eq!( - extracted.mb_album_artist_id, - original_meta.mb_album_artist_id - ); - assert_eq!( - extracted.mb_release_group_id, - original_meta.mb_release_group_id - ); - assert_eq!(extracted.encoder, original_meta.encoder); - - let orig_track_gain = original_meta.replaygain_track_gain.unwrap(); - let ext_track_gain = extracted.replaygain_track_gain.unwrap(); - assert!((orig_track_gain - ext_track_gain).abs() < 0.01); - - let orig_track_peak = original_meta.replaygain_track_peak.unwrap(); - let ext_track_peak = extracted.replaygain_track_peak.unwrap(); - assert!((orig_track_peak - ext_track_peak).abs() < 0.0001); - } -} diff --git a/crates/musicfs-cache/src/handlers/mod.rs b/crates/musicfs-cache/src/handlers/mod.rs deleted file mode 100644 index 238e531..0000000 --- a/crates/musicfs-cache/src/handlers/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Format-specific metadata handlers for audio file synthesis. -//! -//! Each handler implements the `FormatHandler` trait to support: -//! - Analyzing original files to find audio boundaries -//! - Synthesizing new headers from database metadata -//! - Extracting metadata from existing files - -mod flac; -mod id3v2; - -pub use flac::FlacHandler; -pub use id3v2::Id3v2Handler; diff --git a/crates/musicfs-cache/src/lib.rs b/crates/musicfs-cache/src/lib.rs deleted file mode 100644 index f70c872..0000000 --- a/crates/musicfs-cache/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -mod artwork; -mod db; -mod eviction; -mod format_handler; -mod format_layout; -pub mod handlers; -mod metadata; -mod overlay; -mod patterns; -mod prefetch; -mod tree; - -pub use artwork::{ArtworkCache, ArtworkError, CachedArtwork}; -pub use db::{Database, EnrichmentUpdate, TrashedFile, TrashedFilter}; -pub use eviction::{EvictionError, EvictionPolicy, LruEviction}; -pub use format_handler::{FormatError, FormatHandler, FormatHandlerRegistry}; -pub use format_layout::FormatLayout; -pub use handlers::{FlacHandler, Id3v2Handler}; -pub use metadata::MetadataCache; -pub use overlay::{OverlayError, OverlayReader}; -pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore}; -pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle}; -pub use tree::{ - DirNode, FileNode, Inode, RefreshPolicy, RemoveError, RenameError, TreeBuilder, VirtualNode, - VirtualTree, ROOT_INODE, -}; diff --git a/crates/musicfs-cache/src/metadata.rs b/crates/musicfs-cache/src/metadata.rs deleted file mode 100644 index f1960e7..0000000 --- a/crates/musicfs-cache/src/metadata.rs +++ /dev/null @@ -1,138 +0,0 @@ -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}; -use tracing::trace; - -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> { - let result = self.db.get_file_by_virtual_path(path)?; - let hit = result.is_some(); - trace!(path = path.as_str(), hit, "metadata cache lookup"); - Ok(result) - } - - 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(); - let hit = current_secs == cached_secs; - trace!(path = ?real_path, hit, "metadata freshness check"); - Ok(hit) - } else { - trace!(path = ?real_path, hit = false, "metadata freshness check"); - 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/crates/musicfs-cache/src/overlay.rs b/crates/musicfs-cache/src/overlay.rs deleted file mode 100644 index ad48113..0000000 --- a/crates/musicfs-cache/src/overlay.rs +++ /dev/null @@ -1,467 +0,0 @@ -//! OverlayReader: On-the-fly metadata overlay with header/audio splice logic. -//! -//! This module provides the core read path for metadata overlay. It synthesizes -//! headers on-the-fly from database metadata and splices them with original audio -//! data from the CAS. - -use crate::{Database, FormatError, FormatHandlerRegistry}; -use bytes::{Bytes, BytesMut}; -use musicfs_cas::{FileReader, ReaderError}; -use musicfs_core::{AudioFormat, FileId}; -use std::sync::Arc; -use tracing::{debug, trace}; - -/// Error types for overlay operations -#[derive(Debug, thiserror::Error)] -pub enum OverlayError { - #[error("Database error: {0}")] - Database(#[from] musicfs_core::Error), - - #[error("Format handler error: {0}")] - Handler(#[from] FormatError), - - #[error("CAS error: {0}")] - Cas(#[from] ReaderError), - - #[error("File not found: {0:?}")] - NotFound(FileId), - - #[error("No handler for format: {0:?}")] - NoHandler(AudioFormat), -} - -/// OverlayReader provides on-the-fly metadata overlay for audio files. -/// -/// It synthesizes headers from database metadata and splices them with -/// original audio data from the CAS, presenting a virtual file that -/// reflects the current metadata state. -pub struct OverlayReader { - db: Arc, - registry: Arc, - cas_reader: Arc, -} - -impl OverlayReader { - /// Create a new OverlayReader with the given dependencies. - pub fn new( - db: Arc, - registry: Arc, - cas_reader: Arc, - ) -> Self { - Self { - db, - registry, - cas_reader, - } - } - - /// Read bytes from a virtual file with metadata overlay. - /// - /// This method implements the three-region splice logic: - /// - Region 1: Synthetic header (offset < header_len) - /// - Region 2: Audio data from CAS (offset >= header_len) - /// - Region 3: Boundary crossing (spans header/audio) - /// - /// If no format_layout exists for the file, delegates directly to CAS reader. - pub async fn read( - &self, - file_id: FileId, - offset: u64, - size: u32, - ) -> Result { - // Get format layout - if None, passthrough to CAS - let layout = match self.db.get_format_layout(file_id)? { - Some(layout) => layout, - None => { - trace!(file_id = ?file_id, "No format_layout, passthrough to CAS"); - return Ok(self.cas_reader.read(file_id, offset, size).await?); - } - }; - - // Get metadata for synthesis - let metadata = self.db.get_file_metadata_row(file_id)?; - - // Get handler for this format (handler IDs are lowercase) - let format_id = format!("{:?}", layout.format).to_lowercase(); - let handler = self - .registry - .get_by_format(&format_id) - .ok_or_else(|| OverlayError::NoHandler(layout.format))?; - - // Synthesize header on-the-fly - let header = handler.synthesize(&metadata, &layout)?; - let header_len = header.len() as u64; - let audio_len = layout.audio_end - layout.audio_start; - let virtual_size = header_len + audio_len; - - trace!( - file_id = ?file_id, - header_len, - audio_len, - virtual_size, - offset, - size, - "Overlay read" - ); - - // Handle EOF - if offset >= virtual_size { - return Ok(Bytes::new()); - } - - let virtual_end = (offset + size as u64).min(virtual_size); - let mut result = BytesMut::with_capacity((virtual_end - offset) as usize); - - // Region 1: Synthetic header - if offset < header_len { - let end = virtual_end.min(header_len); - result.extend_from_slice(&header[offset as usize..end as usize]); - trace!( - file_id = ?file_id, - start = offset, - end, - bytes = end - offset, - "Read from synthetic header" - ); - } - - // Region 2: Origin audio data (from CAS) - if virtual_end > header_len { - let audio_start_in_virtual = header_len.max(offset); - let audio_offset_in_origin = layout.audio_start + (audio_start_in_virtual - header_len); - let audio_bytes_needed = (virtual_end - audio_start_in_virtual) as u32; - - trace!( - file_id = ?file_id, - audio_offset_in_origin, - audio_bytes_needed, - "Read from CAS audio" - ); - - let audio = self - .cas_reader - .read(file_id, audio_offset_in_origin, audio_bytes_needed) - .await?; - result.extend_from_slice(&audio); - } - - debug!( - file_id = ?file_id, - offset, - size, - returned = result.len(), - "Overlay read complete" - ); - - Ok(result.freeze()) - } - - /// Estimate the virtual size of a file for getattr. - /// - /// Returns the estimated size based on format layout. If no layout exists, - /// returns None to indicate the caller should use the original file size. - pub fn estimate_virtual_size(&self, file_id: FileId) -> Result, OverlayError> { - // Get format layout - if None, return None to indicate passthrough - let layout = match self.db.get_format_layout(file_id)? { - Some(layout) => layout, - None => return Ok(None), - }; - - // Get metadata for header size estimation - let metadata = self.db.get_file_metadata_row(file_id)?; - - let format_id = format!("{:?}", layout.format).to_lowercase(); - let handler = self - .registry - .get_by_format(&format_id) - .ok_or_else(|| OverlayError::NoHandler(layout.format))?; - - // Estimate header size - let estimated_header = handler.estimate_header_size(&metadata) as u64; - let audio_len = layout.audio_end - layout.audio_start; - let virtual_size = estimated_header + audio_len; - - trace!( - file_id = ?file_id, - estimated_header, - audio_len, - virtual_size, - "Estimated virtual size" - ); - - Ok(Some(virtual_size)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::handlers::FlacHandler; - use crate::FormatLayout; - use musicfs_cas::{CasConfig, CasStore, ChunkManifest, ChunkRef}; - use musicfs_core::{AudioFormat, AudioMeta, OriginId, VirtualPath}; - use std::path::Path; - use std::time::UNIX_EPOCH; - use tempfile::TempDir; - - fn make_test_metadata() -> AudioMeta { - AudioMeta { - title: Some("Test Track".to_string()), - artist: Some("Test Artist".to_string()), - album: Some("Test Album".to_string()), - track: Some(1), - format: AudioFormat::Flac, - sample_rate: Some(44100), - bits_per_sample: Some(16), - channels: Some(2), - ..Default::default() - } - } - - fn make_test_layout() -> FormatLayout { - // Simulate a file with minimal FLAC header, audio from 42 to 102442 (100KB audio) - // STREAMINFO data (34 bytes) - minimal valid values for FLAC synthesis - let streaminfo_data = vec![ - 0x10, 0x00, // min_block_size = 4096 - 0x10, 0x00, // max_block_size = 4096 - 0x00, 0x00, 0x00, // min_frame_size = 0 - 0x00, 0x00, 0x00, // max_frame_size = 0 - 0x0A, 0xC4, 0x42, 0xF0, // sample_rate=44100, channels=2, bits=16 - 0x00, 0x00, 0x00, 0x00, // total_samples - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // MD5 (16 bytes) - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ]; - FormatLayout { - audio_start: 42, // fLaC (4) + STREAMINFO block (38) - audio_end: 42 + 100 * 1024, // 100KB audio - format: AudioFormat::Flac, - format_data: Some(streaminfo_data), - } - } - - async fn setup_test_env() -> ( - TempDir, - Arc, - Arc, - Arc, - FileId, - ) { - let dir = TempDir::new().unwrap(); - - // Setup database - let db = Arc::new(Database::open_memory().unwrap()); - - // Setup registry with FLAC handler - let mut registry = FormatHandlerRegistry::new(); - registry.register(Arc::new(FlacHandler::new())); - let registry = Arc::new(registry); - - // Setup CAS store and reader - let cas_config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(cas_config).await.unwrap()); - - // Create test audio data (simulating 100KB of audio) - let audio_data: Vec = (0..100 * 1024).map(|i| (i % 256) as u8).collect(); - let hash = store.put(&audio_data).await.unwrap(); - - let reader = Arc::new(FileReader::new(store)); - - // Register manifest for the test file - // The manifest represents the ORIGINAL file in CAS, with audio starting at offset 42 - reader.register_manifest(ChunkManifest { - file_id: FileId(1), - total_size: 42 + 100 * 1024, // Original file size (42 byte header + 100KB audio) - mtime: 0, - chunks: vec![ChunkRef { - hash, - offset: 42, // Audio starts at offset 42 in the original file - size: audio_data.len() as u32, - }], - }); - - let file_id = db - .upsert_file_with_layout( - &OriginId::from("test"), - Path::new("/test.flac"), - &VirtualPath::new("/Test Artist/Test Album/01 - Test Track.flac"), - &make_test_metadata(), - UNIX_EPOCH, - 42 + 100 * 1024, - Some(&make_test_layout()), - None, - ) - .unwrap(); - - (dir, db, registry, reader, file_id) - } - - #[tokio::test] - async fn test_read_header_region() { - let (_dir, db, registry, reader, file_id) = setup_test_env().await; - let overlay = OverlayReader::new(db, registry, reader); - - // Read first 100 bytes (should be from synthetic header) - let result = overlay.read(file_id, 0, 100).await.unwrap(); - - // Should return data (synthetic header) - assert!(!result.is_empty()); - assert!(result.len() <= 100); - - // FLAC files start with "fLaC" magic - assert_eq!(&result[0..4], b"fLaC"); - } - - #[tokio::test] - async fn test_read_audio_region() { - let (_dir, db, registry, reader, file_id) = setup_test_env().await; - let overlay = OverlayReader::new(db.clone(), registry.clone(), reader.clone()); - - // First, get the actual header size by reading it - let _header_result = overlay.read(file_id, 0, 64 * 1024).await.unwrap(); - - // Get the layout to know where audio starts in virtual file - let layout = db.get_format_layout(file_id).unwrap().unwrap(); - let metadata = db.get_file_metadata_row(file_id).unwrap(); - let handler = registry.get_by_format("flac").unwrap(); - let header = handler.synthesize(&metadata, &layout).unwrap(); - let header_len = header.len() as u64; - - // Read from well into the audio region - let audio_offset = header_len + 1000; - let result = overlay.read(file_id, audio_offset, 1000).await.unwrap(); - - // Should return audio data - assert!(!result.is_empty()); - } - - #[tokio::test] - async fn test_read_boundary() { - let (_dir, db, registry, reader, file_id) = setup_test_env().await; - let overlay = OverlayReader::new(db.clone(), registry.clone(), reader.clone()); - - // Get the actual header size - let layout = db.get_format_layout(file_id).unwrap().unwrap(); - let metadata = db.get_file_metadata_row(file_id).unwrap(); - let handler = registry.get_by_format("flac").unwrap(); - let header = handler.synthesize(&metadata, &layout).unwrap(); - let header_len = header.len() as u64; - - // Read across the header/audio boundary - let boundary_offset = header_len - 50; - let result = overlay.read(file_id, boundary_offset, 100).await.unwrap(); - - // Should return 100 bytes spanning both regions - assert_eq!(result.len(), 100); - - // First 50 bytes should be from header - assert_eq!(&result[0..50], &header[(header_len - 50) as usize..]); - } - - #[tokio::test] - async fn test_passthrough() { - let dir = TempDir::new().unwrap(); - - let db = Arc::new(Database::open_memory().unwrap()); - let registry = Arc::new(FormatHandlerRegistry::new()); - - let cas_config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(cas_config).await.unwrap()); - - let test_data = b"Hello, World! This is test data."; - let hash = store.put(test_data).await.unwrap(); - - // Insert file WITHOUT format_layout first to get the file_id - let file_id = db - .upsert_file( - &OriginId::from("test"), - Path::new("/test.txt"), - &VirtualPath::new("/test.txt"), - &AudioMeta::default(), - UNIX_EPOCH, - test_data.len() as u64, - ) - .unwrap(); - - let reader = Arc::new(FileReader::new(store)); - // Register manifest with the actual file_id from database - reader.register_manifest(ChunkManifest { - file_id, - total_size: test_data.len() as u64, - mtime: 0, - chunks: vec![ChunkRef { - hash, - offset: 0, - size: test_data.len() as u32, - }], - }); - - let overlay = OverlayReader::new(db, registry, reader); - - let result = overlay - .read(file_id, 0, test_data.len() as u32) - .await - .unwrap(); - assert_eq!(&result[..], test_data); - } - - #[tokio::test] - async fn test_estimate_virtual_size() { - let (_dir, db, registry, reader, file_id) = setup_test_env().await; - let overlay = OverlayReader::new(db, registry, reader); - - // Should return estimated size - let size = overlay.estimate_virtual_size(file_id).unwrap(); - assert!(size.is_some()); - - let virtual_size = size.unwrap(); - // Virtual size should be header + audio (100KB audio) - assert!(virtual_size > 100 * 1024); - } - - #[tokio::test] - async fn test_estimate_virtual_size_passthrough() { - let dir = TempDir::new().unwrap(); - let db = Arc::new(Database::open_memory().unwrap()); - let registry = Arc::new(FormatHandlerRegistry::new()); - let cas_config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(cas_config).await.unwrap()); - let reader = Arc::new(FileReader::new(store)); - - // Insert file WITHOUT format_layout - let file_id = db - .upsert_file( - &OriginId::from("test"), - Path::new("/test.txt"), - &VirtualPath::new("/test.txt"), - &AudioMeta::default(), - UNIX_EPOCH, - 1000, - ) - .unwrap(); - - let overlay = OverlayReader::new(db, registry, reader); - - // Should return None for passthrough - let size = overlay.estimate_virtual_size(file_id).unwrap(); - assert!(size.is_none()); - } - - #[tokio::test] - async fn test_read_eof() { - let (_dir, db, registry, reader, file_id) = setup_test_env().await; - let overlay = OverlayReader::new(db, registry, reader); - - // Read past EOF - let result = overlay.read(file_id, 1_000_000, 100).await.unwrap(); - assert!(result.is_empty()); - } -} diff --git a/crates/musicfs-cache/src/patterns.rs b/crates/musicfs-cache/src/patterns.rs deleted file mode 100644 index 2a2b8c6..0000000 --- a/crates/musicfs-cache/src/patterns.rs +++ /dev/null @@ -1,291 +0,0 @@ -use musicfs_core::FileId; -use parking_lot::{Mutex, RwLock}; -use std::collections::HashMap; -use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; -use tracing::{debug, info, trace}; - -#[derive(Debug, Clone)] -pub struct AccessPattern { - pub file_id: FileId, - pub timestamp: SystemTime, - pub context: AccessContext, - pub hour_of_day: u8, -} - -#[derive(Debug, Clone, Default)] -pub struct AccessContext { - pub album_id: Option, - pub track_number: Option, - pub artist: Option, -} - -pub struct PatternStore { - db: Mutex, - sequence_counts: RwLock>, - time_patterns: RwLock>>, - max_history: usize, -} - -impl PatternStore { - pub fn new(db_path: &Path, max_history: usize) -> Result { - let db = rusqlite::Connection::open(db_path)?; - - db.execute( - "CREATE TABLE IF NOT EXISTS access_log ( - id INTEGER PRIMARY KEY, - file_id INTEGER NOT NULL, - access_time INTEGER NOT NULL, - hour_of_day INTEGER NOT NULL - )", - [], - )?; - - db.execute( - "CREATE INDEX IF NOT EXISTS idx_access_log_file ON access_log(file_id)", - [], - )?; - - db.execute( - "CREATE INDEX IF NOT EXISTS idx_access_log_time ON access_log(access_time)", - [], - )?; - - db.execute( - "CREATE TABLE IF NOT EXISTS sequence_counts ( - from_file_id INTEGER NOT NULL, - to_file_id INTEGER NOT NULL, - count INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (from_file_id, to_file_id) - )", - [], - )?; - - let sequence_counts = { - let mut map = HashMap::new(); - let mut stmt = - db.prepare("SELECT from_file_id, to_file_id, count FROM sequence_counts")?; - let rows = stmt.query_map([], |row| { - Ok(( - (FileId(row.get::<_, i64>(0)?), FileId(row.get::<_, i64>(1)?)), - row.get::<_, u32>(2)?, - )) - })?; - for row in rows { - let (key, count) = row?; - map.insert(key, count); - } - map - }; - - let store = Self { - db: Mutex::new(db), - sequence_counts: RwLock::new(sequence_counts), - time_patterns: RwLock::new(HashMap::new()), - max_history, - }; - let sequence_count = store.sequence_counts.read().len(); - info!(path = ?db_path, sequence_count = sequence_count, max_history = max_history, "Pattern store opened"); - Ok(store) - } - - pub fn record(&self, file_id: FileId, _context: AccessContext) -> Result<(), PatternError> { - trace!(file_id = file_id.0, "Recording access pattern"); - let now = SystemTime::now(); - let timestamp = now.duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; - let hour = (timestamp / 3600 % 24) as u8; - - let db = self.db.lock(); - - db.execute( - "INSERT INTO access_log (file_id, access_time, hour_of_day) VALUES (?1, ?2, ?3)", - rusqlite::params![file_id.0, timestamp, hour], - )?; - - { - let mut time_patterns = self.time_patterns.write(); - time_patterns.entry(hour).or_default().push(file_id); - } - - let prev_file_id: Option = db - .query_row( - "SELECT file_id FROM access_log WHERE id = (SELECT MAX(id) - 1 FROM access_log)", - [], - |row| row.get(0), - ) - .ok(); - - if let Some(prev_id) = prev_file_id { - let prev = FileId(prev_id); - - { - let mut sequences = self.sequence_counts.write(); - *sequences.entry((prev, file_id)).or_insert(0) += 1; - } - - db.execute( - "INSERT INTO sequence_counts (from_file_id, to_file_id, count) - VALUES (?1, ?2, 1) - ON CONFLICT(from_file_id, to_file_id) DO UPDATE SET count = count + 1", - rusqlite::params![prev_id, file_id.0], - )?; - } - - let cutoff = timestamp - (self.max_history as i64 * 86400); - db.execute("DELETE FROM access_log WHERE access_time < ?1", [cutoff])?; - - Ok(()) - } - - pub fn predict_next(&self, current: FileId, limit: usize) -> Vec { - let sequences = self.sequence_counts.read(); - - let mut predictions: Vec<_> = sequences - .iter() - .filter(|((from, _), count)| *from == current && **count >= 2) - .map(|((_, to), count)| (*to, *count)) - .collect(); - - predictions.sort_by(|a, b| b.1.cmp(&a.1)); - let result: Vec = predictions - .into_iter() - .take(limit) - .map(|(id, _)| id) - .collect(); - debug!( - file_id = current.0, - predictions = result.len(), - "Predicted next files" - ); - result - } - - pub fn predict_for_time(&self, hour: u8, limit: usize) -> Vec { - let time_patterns = self.time_patterns.read(); - - time_patterns - .get(&hour) - .map(|files| files.iter().rev().take(limit).copied().collect()) - .unwrap_or_default() - } - - pub fn recently_played(&self, days: u32) -> Result, PatternError> { - let cutoff = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64 - - (days as i64 * 86400); - - let db = self.db.lock(); - let mut stmt = db.prepare( - "SELECT DISTINCT file_id FROM access_log WHERE access_time >= ?1 ORDER BY access_time DESC", - )?; - - let files: Vec = stmt - .query_map([cutoff], |row| Ok(FileId(row.get(0)?)))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(files) - } - - pub fn most_played(&self, limit: u32) -> Result, PatternError> { - let db = self.db.lock(); - let mut stmt = db.prepare( - "SELECT file_id, COUNT(*) as play_count FROM access_log - GROUP BY file_id ORDER BY play_count DESC LIMIT ?1", - )?; - - let files: Vec = stmt - .query_map([limit], |row| Ok(FileId(row.get(0)?)))? - .filter_map(|r| r.ok()) - .collect(); - - Ok(files) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum PatternError { - #[error("database error: {0}")] - Database(#[from] rusqlite::Error), -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_pattern_prediction() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("patterns.db"); - let store = PatternStore::new(&db_path, 30).unwrap(); - let ctx = AccessContext::default(); - - for _ in 0..5 { - store.record(FileId(1), ctx.clone()).unwrap(); - store.record(FileId(2), ctx.clone()).unwrap(); - store.record(FileId(3), ctx.clone()).unwrap(); - } - - let predictions = store.predict_next(FileId(1), 3); - assert!(!predictions.is_empty()); - assert_eq!(predictions[0], FileId(2)); - } - - #[test] - fn test_pattern_persistence() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("patterns.db"); - let ctx = AccessContext::default(); - - { - let store = PatternStore::new(&db_path, 30).unwrap(); - for _ in 0..3 { - store.record(FileId(1), ctx.clone()).unwrap(); - store.record(FileId(2), ctx.clone()).unwrap(); - } - } - - { - let store = PatternStore::new(&db_path, 30).unwrap(); - let predictions = store.predict_next(FileId(1), 3); - assert!(!predictions.is_empty()); - assert_eq!(predictions[0], FileId(2)); - } - } - - #[test] - fn test_recently_played() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("patterns.db"); - let store = PatternStore::new(&db_path, 30).unwrap(); - let ctx = AccessContext::default(); - - store.record(FileId(100), ctx.clone()).unwrap(); - store.record(FileId(200), ctx.clone()).unwrap(); - - let recent = store.recently_played(7).unwrap(); - assert!(recent.contains(&FileId(100))); - assert!(recent.contains(&FileId(200))); - } - - #[test] - fn test_most_played() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("patterns.db"); - let store = PatternStore::new(&db_path, 30).unwrap(); - let ctx = AccessContext::default(); - - for _ in 0..5 { - store.record(FileId(1), ctx.clone()).unwrap(); - } - for _ in 0..2 { - store.record(FileId(2), ctx.clone()).unwrap(); - } - - let most = store.most_played(10).unwrap(); - assert_eq!(most[0], FileId(1)); - } -} diff --git a/crates/musicfs-cache/src/prefetch.rs b/crates/musicfs-cache/src/prefetch.rs deleted file mode 100644 index b937685..0000000 --- a/crates/musicfs-cache/src/prefetch.rs +++ /dev/null @@ -1,197 +0,0 @@ -use crate::patterns::{AccessContext, PatternStore}; -use musicfs_cas::ContentFetcher; -use musicfs_core::{Event, EventBus, FileId}; -use parking_lot::Mutex as ParkingMutex; -use std::collections::HashSet; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::Semaphore; -use tokio::task::JoinHandle; -use tracing::{debug, info, warn}; - -const DEFAULT_PREFETCH_LOOKAHEAD: usize = 3; -const DEFAULT_MAX_CONCURRENT: usize = 2; -const DEFAULT_COOLDOWN_MS: u64 = 100; - -#[derive(Debug, Clone)] -pub struct PrefetchConfig { - pub lookahead: usize, - pub max_concurrent: usize, - pub cooldown: Duration, - pub enabled: bool, -} - -impl Default for PrefetchConfig { - fn default() -> Self { - Self { - lookahead: DEFAULT_PREFETCH_LOOKAHEAD, - max_concurrent: DEFAULT_MAX_CONCURRENT, - cooldown: Duration::from_millis(DEFAULT_COOLDOWN_MS), - enabled: true, - } - } -} - -pub struct PrefetchEngine { - config: PrefetchConfig, - fetcher: Arc, - in_flight: Arc>>, - semaphore: Arc, - running: Arc, -} - -pub struct PrefetchHandle { - handle: JoinHandle<()>, - running: Arc, -} - -impl PrefetchHandle { - pub async fn stop(self) { - self.running.store(false, Ordering::SeqCst); - let _ = self.handle.await; - } -} - -impl PrefetchEngine { - pub fn new( - config: PrefetchConfig, - _pattern_store: Arc, - fetcher: Arc, - ) -> Self { - let semaphore = Arc::new(Semaphore::new(config.max_concurrent)); - - Self { - config, - fetcher, - in_flight: Arc::new(ParkingMutex::new(HashSet::new())), - semaphore, - running: Arc::new(AtomicBool::new(false)), - } - } - - pub fn start( - self: Arc, - event_bus: Arc, - pattern_store: Arc, - ) -> PrefetchHandle { - self.running.store(true, Ordering::SeqCst); - let running = self.running.clone(); - - let config = self.config.clone(); - let fetcher = self.fetcher.clone(); - let in_flight = self.in_flight.clone(); - let semaphore = self.semaphore.clone(); - let running_inner = running.clone(); - - let handle = tokio::spawn(async move { - let mut rx = event_bus.subscribe(); - - while running_inner.load(Ordering::SeqCst) { - match tokio::time::timeout(Duration::from_secs(1), rx.recv()).await { - Ok(Ok(event)) => { - if let Event::FileAccessed { file_id, .. } = event { - if config.enabled { - let ctx = AccessContext::default(); - if let Err(e) = pattern_store.record(file_id, ctx) { - warn!("Failed to record access pattern: {}", e); - continue; - } - - let predictions = - pattern_store.predict_next(file_id, config.lookahead); - - for predicted_id in predictions { - prefetch_file(predicted_id, &fetcher, &in_flight, &semaphore) - .await; - } - - tokio::time::sleep(config.cooldown).await; - } - } - } - Ok(Err(_)) => break, - Err(_) => continue, - } - } - - info!("Prefetch engine stopped"); - }); - - PrefetchHandle { handle, running } - } - - pub fn is_running(&self) -> bool { - self.running.load(Ordering::SeqCst) - } - - pub fn in_flight_count(&self) -> usize { - self.in_flight.lock().len() - } - - pub fn update_config(&mut self, config: PrefetchConfig) { - self.config = config; - } -} - -async fn prefetch_file( - file_id: FileId, - fetcher: &Arc, - in_flight: &Arc>>, - semaphore: &Arc, -) { - { - let mut guard = in_flight.lock(); - if guard.contains(&file_id) { - debug!("Skipping prefetch for {:?} - already in flight", file_id); - return; - } - guard.insert(file_id); - } - - let permit = match semaphore.clone().try_acquire_owned() { - Ok(p) => p, - Err(_) => { - debug!("Skipping prefetch for {:?} - concurrency limit", file_id); - in_flight.lock().remove(&file_id); - return; - } - }; - - let fetcher = fetcher.clone(); - let in_flight = in_flight.clone(); - - tokio::spawn(async move { - debug!("Prefetching file {:?}", file_id); - - match fetcher.ensure_cached(file_id).await { - Ok(manifest) => { - info!( - "Prefetched {:?}: {} chunks, {} bytes", - file_id, - manifest.chunks.len(), - manifest.total_size - ); - } - Err(e) => { - debug!("Prefetch failed for {:?}: {}", file_id, e); - } - } - - in_flight.lock().remove(&file_id); - drop(permit); - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_prefetch_config_defaults() { - let config = PrefetchConfig::default(); - assert_eq!(config.lookahead, 3); - assert_eq!(config.max_concurrent, 2); - assert!(config.enabled); - } -} diff --git a/crates/musicfs-cache/src/schema.sql b/crates/musicfs-cache/src/schema.sql deleted file mode 100644 index 430d82f..0000000 --- a/crates/musicfs-cache/src/schema.sql +++ /dev/null @@ -1,111 +0,0 @@ -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, - track_total INTEGER, - disc_total INTEGER, - date TEXT, - composer TEXT, - comment TEXT, - lyrics TEXT, - copyright TEXT, - compilation INTEGER, - artist_sort TEXT, - album_artist_sort TEXT, - album_sort TEXT, - title_sort TEXT, - mb_recording_id TEXT, - mb_album_id TEXT, - mb_artist_id TEXT, - mb_album_artist_id TEXT, - mb_release_group_id TEXT, - replaygain_track_gain REAL, - replaygain_track_peak REAL, - replaygain_album_gain REAL, - replaygain_album_peak REAL, - channels INTEGER, - bits_per_sample INTEGER, - encoder TEXT, - custom_tags TEXT, - format_layout BLOB, - - label TEXT, - album_type TEXT, - cover_url TEXT, - genres_json TEXT, - enrichment_source TEXT, - enriched_at INTEGER, - enrichment_attempts INTEGER NOT NULL DEFAULT 0, - last_enrichment_error 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')), - - trashed INTEGER NOT NULL DEFAULT 0, - original_path TEXT, - trashed_at INTEGER, - - 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_files_mb_album ON files(mb_album_id); -CREATE INDEX IF NOT EXISTS idx_files_mb_artist ON files(mb_artist_id); -CREATE INDEX IF NOT EXISTS idx_files_genre ON files(genre); -CREATE INDEX IF NOT EXISTS idx_files_year ON files(year); -CREATE INDEX IF NOT EXISTS idx_files_composer ON files(composer); -CREATE INDEX IF NOT EXISTS idx_artwork_file ON artwork(file_id); - -CREATE TABLE IF NOT EXISTS directories ( - id INTEGER PRIMARY KEY, - path TEXT NOT NULL UNIQUE, - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) -); - -CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path); -CREATE INDEX IF NOT EXISTS idx_files_trashed ON files(trashed) WHERE trashed = 1; diff --git a/crates/musicfs-cache/src/tree.rs b/crates/musicfs-cache/src/tree.rs deleted file mode 100644 index 13ac851..0000000 --- a/crates/musicfs-cache/src/tree.rs +++ /dev/null @@ -1,1224 +0,0 @@ -use musicfs_core::{FileId, FileMeta, VirtualPath}; -use parking_lot::RwLock; -use std::collections::{BTreeMap, HashMap}; -use std::ffi::{OsStr, OsString}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, SystemTime}; -use tracing::{debug, trace}; - -pub type Inode = u64; -pub const ROOT_INODE: Inode = 1; - -#[derive(Debug)] -pub enum VirtualNode { - Directory(DirNode), - File(FileNode), -} - -impl VirtualNode { - pub fn inode(&self) -> Inode { - match self { - VirtualNode::Directory(d) => d.inode, - VirtualNode::File(f) => f.inode, - } - } - - pub fn name(&self) -> &OsStr { - match self { - VirtualNode::Directory(d) => &d.name, - VirtualNode::File(f) => &f.name, - } - } - - pub fn is_dir(&self) -> bool { - matches!(self, VirtualNode::Directory(_)) - } -} - -#[derive(Debug)] -pub struct DirNode { - pub inode: Inode, - pub parent: Inode, - pub name: OsString, - pub children: BTreeMap, - pub mtime: SystemTime, -} - -#[derive(Debug)] -pub struct FileNode { - pub inode: Inode, - pub name: OsString, - pub file_id: FileId, - pub size: u64, - pub mtime: SystemTime, -} - -#[derive(Debug, Clone)] -pub struct RefreshPolicy { - pub ttl: Duration, - pub refresh_on_access: bool, - pub background_interval: Option, -} - -impl Default for RefreshPolicy { - fn default() -> Self { - Self { - ttl: Duration::from_secs(300), - refresh_on_access: false, - background_interval: None, - } - } -} - -pub struct VirtualTree { - nodes: HashMap, - path_to_inode: HashMap, - next_inode: AtomicU64, - last_refresh: RwLock, - refresh_policy: RefreshPolicy, -} - -impl VirtualTree { - pub fn new() -> Self { - Self::with_policy(RefreshPolicy::default()) - } - - pub fn with_policy(policy: RefreshPolicy) -> Self { - let mut tree = Self { - nodes: HashMap::new(), - path_to_inode: HashMap::new(), - next_inode: AtomicU64::new(ROOT_INODE + 1), - last_refresh: RwLock::new(SystemTime::now()), - refresh_policy: policy, - }; - - tree.nodes.insert( - ROOT_INODE, - VirtualNode::Directory(DirNode { - inode: ROOT_INODE, - parent: ROOT_INODE, - name: OsString::from(""), - children: BTreeMap::new(), - mtime: SystemTime::now(), - }), - ); - tree.path_to_inode.insert(VirtualPath::new("/"), ROOT_INODE); - - tree - } - - fn alloc_inode(&self) -> Inode { - self.next_inode.fetch_add(1, Ordering::SeqCst) - } - - pub fn get(&self, inode: Inode) -> Option<&VirtualNode> { - self.nodes.get(&inode) - } - - pub fn get_by_path(&self, path: &VirtualPath) -> Option<&VirtualNode> { - self.path_to_inode - .get(path) - .and_then(|ino| self.nodes.get(ino)) - } - - pub fn lookup(&self, parent_inode: Inode, name: &OsStr) -> Option { - if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&parent_inode) { - let result = dir.children.get(name).copied(); - let hit = result.is_some(); - trace!(inode = parent_inode, name = ?name, hit, "tree lookup"); - result - } else { - trace!(inode = parent_inode, name = ?name, hit = false, "tree lookup"); - None - } - } - - pub fn readdir(&self, inode: Inode) -> Option> { - if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&inode) { - Some( - dir.children - .iter() - .map(|(name, &ino)| { - let is_dir = self.nodes.get(&ino).map(|n| n.is_dir()).unwrap_or(false); - (name.clone(), ino, is_dir) - }) - .collect(), - ) - } else { - None - } - } - - pub fn get_parent(&self, inode: Inode) -> Option { - match self.nodes.get(&inode) { - Some(VirtualNode::Directory(dir)) => Some(dir.parent), - Some(VirtualNode::File(_)) => self.find_parent_by_path_lookup(inode), - None => None, - } - } - - fn find_parent_by_path_lookup(&self, inode: Inode) -> Option { - for (path, &ino) in &self.path_to_inode { - if ino == inode { - return std::path::Path::new(path.as_str()).parent().and_then(|p| { - self.path_to_inode - .get(&VirtualPath::new(p.to_string_lossy().into_owned())) - .copied() - }); - } - } - None - } - - pub fn insert_file(&mut self, meta: &FileMeta) -> Inode { - let path = &meta.virtual_path; - - let parent_inode = self.ensure_parents(path); - - let inode = self.alloc_inode(); - let name = std::path::Path::new(path.as_str()) - .file_name() - .unwrap_or_default() - .to_os_string(); - - let file_node = FileNode { - inode, - name: name.clone(), - file_id: meta.id, - size: meta.size, - mtime: meta.mtime, - }; - - self.nodes.insert(inode, VirtualNode::File(file_node)); - self.path_to_inode.insert(path.clone(), inode); - - if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) { - dir.children.insert(name, inode); - } - - debug!(inode, path = path.as_str(), file_id = ?meta.id, "add file to tree"); - inode - } - - fn ensure_parents(&mut self, path: &VirtualPath) -> Inode { - let path_str = path.as_str(); - let components: Vec<&str> = path_str - .trim_start_matches('/') - .split('/') - .filter(|s| !s.is_empty()) - .collect(); - - if components.len() <= 1 { - return ROOT_INODE; - } - - let mut current_inode = ROOT_INODE; - let mut current_path = String::from("/"); - - for component in &components[..components.len() - 1] { - current_path.push_str(component); - - let vpath = VirtualPath::new(¤t_path); - - if let Some(&existing) = self.path_to_inode.get(&vpath) { - current_inode = existing; - } else { - let new_inode = self.alloc_inode(); - let name = OsString::from(*component); - - let dir_node = DirNode { - inode: new_inode, - parent: current_inode, - name: name.clone(), - children: BTreeMap::new(), - mtime: SystemTime::now(), - }; - - self.nodes - .insert(new_inode, VirtualNode::Directory(dir_node)); - self.path_to_inode.insert(vpath, new_inode); - - if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(¤t_inode) { - parent.children.insert(name, new_inode); - } - - current_inode = new_inode; - } - - current_path.push('/'); - } - - current_inode - } - - pub fn remove_file(&mut self, path: &VirtualPath) -> Option { - let inode = self.path_to_inode.remove(path)?; - - if let Some(VirtualNode::File(file)) = self.nodes.remove(&inode) { - let parent_path = std::path::Path::new(path.as_str()) - .parent() - .map(|p| VirtualPath::new(p.to_string_lossy().into_owned())) - .unwrap_or_else(|| VirtualPath::new("/")); - - if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) { - if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) { - dir.children.remove(&file.name); - } - } - - debug!(inode, path = path.as_str(), file_id = ?file.file_id, "remove file from tree"); - Some(file.file_id) - } else { - None - } - } - - pub fn file_count(&self) -> usize { - self.nodes - .values() - .filter(|n| matches!(n, VirtualNode::File(_))) - .count() - } - - pub fn dir_count(&self) -> usize { - self.nodes - .values() - .filter(|n| matches!(n, VirtualNode::Directory(_))) - .count() - } - - pub fn needs_refresh(&self) -> bool { - let last = *self.last_refresh.read(); - last.elapsed().unwrap_or(Duration::MAX) > self.refresh_policy.ttl - } - - pub fn force_refresh(&mut self) { - self.nodes.retain(|&ino, _| ino == ROOT_INODE); - self.path_to_inode.retain(|p, _| p.as_str() == "/"); - - if let Some(VirtualNode::Directory(root)) = self.nodes.get_mut(&ROOT_INODE) { - root.children.clear(); - } - - *self.last_refresh.write() = SystemTime::now(); - } - - pub fn mark_refreshed(&self) { - *self.last_refresh.write() = SystemTime::now(); - } - - pub fn refresh_policy(&self) -> &RefreshPolicy { - &self.refresh_policy - } - - pub fn path_to_inode_iter(&self) -> impl Iterator { - self.path_to_inode.iter() - } - - pub fn mkdir(&mut self, path: &VirtualPath) -> std::result::Result { - if self.path_to_inode.contains_key(path) { - return Err(RenameError::TargetExists); - } - - let parent_path = std::path::Path::new(path.as_str()) - .parent() - .map(|p| { - let s = p.to_string_lossy(); - if s.is_empty() { - VirtualPath::new("/") - } else { - VirtualPath::new(s.into_owned()) - } - }) - .unwrap_or_else(|| VirtualPath::new("/")); - - let parent_inode = self - .path_to_inode - .get(&parent_path) - .copied() - .ok_or(RenameError::ParentNotFound)?; - - if !self - .nodes - .get(&parent_inode) - .map(|n| n.is_dir()) - .unwrap_or(false) - { - return Err(RenameError::ParentNotFound); - } - - let inode = self.alloc_inode(); - let name = std::path::Path::new(path.as_str()) - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_default(); - - let dir_node = DirNode { - inode, - parent: parent_inode, - name: name.clone(), - children: BTreeMap::new(), - mtime: SystemTime::now(), - }; - - self.nodes.insert(inode, VirtualNode::Directory(dir_node)); - self.path_to_inode.insert(path.clone(), inode); - - if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(&parent_inode) { - parent.children.insert(name, inode); - } - - debug!(path = path.as_str(), inode, "created directory"); - Ok(inode) - } - - pub fn rename_file( - &mut self, - old_path: &VirtualPath, - new_path: &VirtualPath, - ) -> std::result::Result<(), RenameError> { - let old_inode = self - .path_to_inode - .get(old_path) - .copied() - .ok_or(RenameError::SourceNotFound)?; - - if self.path_to_inode.contains_key(new_path) { - return Err(RenameError::TargetExists); - } - - let node = self - .nodes - .get(&old_inode) - .ok_or(RenameError::SourceNotFound)?; - - if node.is_dir() { - return Err(RenameError::IsDirectory); - } - - let new_parent_path = std::path::Path::new(new_path.as_str()) - .parent() - .map(|p| { - let s = p.to_string_lossy(); - if s.is_empty() { - VirtualPath::new("/") - } else { - VirtualPath::new(s.into_owned()) - } - }) - .unwrap_or_else(|| VirtualPath::new("/")); - - let new_parent_inode = self - .path_to_inode - .get(&new_parent_path) - .copied() - .ok_or(RenameError::ParentNotFound)?; - - if !self - .nodes - .get(&new_parent_inode) - .map(|n| n.is_dir()) - .unwrap_or(false) - { - return Err(RenameError::ParentNotFound); - } - - self.path_to_inode.remove(old_path); - - let old_parent_path = std::path::Path::new(old_path.as_str()) - .parent() - .map(|p| VirtualPath::new(p.to_string_lossy().into_owned())) - .unwrap_or_else(|| VirtualPath::new("/")); - - if let Some(&old_parent_inode) = self.path_to_inode.get(&old_parent_path) { - if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&old_parent_inode) { - let old_name = std::path::Path::new(old_path.as_str()) - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_default(); - dir.children.remove(&old_name); - } - } - - let new_name = std::path::Path::new(new_path.as_str()) - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_default(); - - if let Some(VirtualNode::File(file)) = self.nodes.get_mut(&old_inode) { - file.name = new_name.clone(); - } - - if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&new_parent_inode) { - dir.children.insert(new_name, old_inode); - } - - self.path_to_inode.insert(new_path.clone(), old_inode); - - debug!( - old = old_path.as_str(), - new = new_path.as_str(), - inode = old_inode, - "renamed file" - ); - Ok(()) - } - - pub fn rename_directory( - &mut self, - old_path: &VirtualPath, - new_path: &VirtualPath, - ) -> std::result::Result { - let old_inode = self - .path_to_inode - .get(old_path) - .copied() - .ok_or(RenameError::SourceNotFound)?; - - if !self - .nodes - .get(&old_inode) - .map(|n| n.is_dir()) - .unwrap_or(false) - { - return Err(RenameError::NotDirectory); - } - - if self.path_to_inode.contains_key(new_path) { - return Err(RenameError::TargetExists); - } - - let new_parent_path = std::path::Path::new(new_path.as_str()) - .parent() - .map(|p| { - let s = p.to_string_lossy(); - if s.is_empty() { - VirtualPath::new("/") - } else { - VirtualPath::new(s.into_owned()) - } - }) - .unwrap_or_else(|| VirtualPath::new("/")); - - let new_parent_inode = self - .path_to_inode - .get(&new_parent_path) - .copied() - .ok_or(RenameError::ParentNotFound)?; - - if !self - .nodes - .get(&new_parent_inode) - .map(|n| n.is_dir()) - .unwrap_or(false) - { - return Err(RenameError::ParentNotFound); - } - - let old_prefix = old_path.as_str(); - let new_prefix = new_path.as_str(); - - let paths_to_update: Vec<(VirtualPath, Inode)> = self - .path_to_inode - .iter() - .filter(|(p, _)| p.as_str().starts_with(old_prefix)) - .map(|(p, &i)| (p.clone(), i)) - .collect(); - - let count = paths_to_update.len() as u64; - - for (old_p, inode) in paths_to_update { - self.path_to_inode.remove(&old_p); - let new_p_str = format!("{}{}", new_prefix, &old_p.as_str()[old_prefix.len()..]); - let new_p = VirtualPath::new(&new_p_str); - self.path_to_inode.insert(new_p, inode); - } - - let old_parent_path = std::path::Path::new(old_path.as_str()) - .parent() - .map(|p| VirtualPath::new(p.to_string_lossy().into_owned())) - .unwrap_or_else(|| VirtualPath::new("/")); - - if let Some(&old_parent_inode) = self.path_to_inode.get(&old_parent_path) { - if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&old_parent_inode) { - let old_name = std::path::Path::new(old_path.as_str()) - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_default(); - dir.children.remove(&old_name); - } - } - - let new_name = std::path::Path::new(new_path.as_str()) - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_default(); - - if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&old_inode) { - dir.name = new_name.clone(); - dir.parent = new_parent_inode; - } - - if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&new_parent_inode) { - dir.children.insert(new_name, old_inode); - } - - debug!( - old = old_path.as_str(), - new = new_path.as_str(), - count, - "renamed directory" - ); - Ok(count) - } - - pub fn is_trash_path(path: &VirtualPath) -> bool { - path.as_str().starts_with("/.trash") || path.as_str() == "/.trash" - } - - pub fn ensure_trash_dir(&mut self) -> Inode { - let trash_path = VirtualPath::new("/.trash"); - if let Some(&inode) = self.path_to_inode.get(&trash_path) { - return inode; - } - - let inode = self.alloc_inode(); - let dir_node = DirNode { - inode, - parent: ROOT_INODE, - name: OsString::from(".trash"), - children: BTreeMap::new(), - mtime: SystemTime::now(), - }; - - self.nodes.insert(inode, VirtualNode::Directory(dir_node)); - self.path_to_inode.insert(trash_path, inode); - - if let Some(VirtualNode::Directory(root)) = self.nodes.get_mut(&ROOT_INODE) { - root.children.insert(OsString::from(".trash"), inode); - } - - debug!(inode, "created .trash directory"); - inode - } - - pub fn mkdir_p(&mut self, path: &VirtualPath) -> std::result::Result { - if let Some(&existing) = self.path_to_inode.get(path) { - if self - .nodes - .get(&existing) - .map(|n| n.is_dir()) - .unwrap_or(false) - { - return Ok(existing); - } - return Err(RenameError::TargetExists); - } - - let components: Vec<&str> = path - .as_str() - .trim_start_matches('/') - .split('/') - .filter(|s| !s.is_empty()) - .collect(); - - let mut current_inode = ROOT_INODE; - let mut current_path = String::from("/"); - - for component in &components { - if !current_path.ends_with('/') { - current_path.push('/'); - } - current_path.push_str(component); - - let vpath = VirtualPath::new(¤t_path); - - if let Some(&existing) = self.path_to_inode.get(&vpath) { - current_inode = existing; - } else { - let new_inode = self.alloc_inode(); - let name = OsString::from(*component); - - let dir_node = DirNode { - inode: new_inode, - parent: current_inode, - name: name.clone(), - children: BTreeMap::new(), - mtime: SystemTime::now(), - }; - - self.nodes - .insert(new_inode, VirtualNode::Directory(dir_node)); - self.path_to_inode.insert(vpath, new_inode); - - if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(¤t_inode) { - parent.children.insert(name, new_inode); - } - - current_inode = new_inode; - } - } - - Ok(current_inode) - } - - pub fn remove_directory(&mut self, path: &VirtualPath) -> std::result::Result<(), RemoveError> { - let inode = self - .path_to_inode - .get(path) - .copied() - .ok_or(RemoveError::NotFound)?; - - let node = self.nodes.get(&inode).ok_or(RemoveError::NotFound)?; - - match node { - VirtualNode::File(_) => return Err(RemoveError::NotDirectory), - VirtualNode::Directory(dir) => { - if !dir.children.is_empty() { - return Err(RemoveError::NotEmpty); - } - } - } - - let parent_path = std::path::Path::new(path.as_str()) - .parent() - .map(|p| VirtualPath::new(p.to_string_lossy().into_owned())) - .unwrap_or_else(|| VirtualPath::new("/")); - - if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) { - if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(&parent_inode) { - let name = std::path::Path::new(path.as_str()) - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_default(); - parent.children.remove(&name); - } - } - - self.path_to_inode.remove(path); - self.nodes.remove(&inode); - - debug!(path = path.as_str(), inode, "removed directory"); - Ok(()) - } - - pub fn remove_directory_recursive( - &mut self, - path: &VirtualPath, - ) -> std::result::Result, RemoveError> { - let inode = self - .path_to_inode - .get(path) - .copied() - .ok_or(RemoveError::NotFound)?; - - if !self.nodes.get(&inode).map(|n| n.is_dir()).unwrap_or(false) { - return Err(RemoveError::NotDirectory); - } - - let prefix = path.as_str(); - let paths_to_remove: Vec<(VirtualPath, Inode)> = self - .path_to_inode - .iter() - .filter(|(p, _)| p.as_str().starts_with(prefix)) - .map(|(p, &i)| (p.clone(), i)) - .collect(); - - let mut removed_files = Vec::new(); - - for (p, ino) in &paths_to_remove { - if let Some(VirtualNode::File(f)) = self.nodes.get(ino) { - removed_files.push(f.file_id); - } - self.path_to_inode.remove(p); - self.nodes.remove(ino); - } - - let parent_path = std::path::Path::new(path.as_str()) - .parent() - .map(|p| VirtualPath::new(p.to_string_lossy().into_owned())) - .unwrap_or_else(|| VirtualPath::new("/")); - - if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) { - if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(&parent_inode) { - let name = std::path::Path::new(path.as_str()) - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_default(); - parent.children.remove(&name); - } - } - - debug!( - path = path.as_str(), - file_count = removed_files.len(), - "removed directory recursively" - ); - Ok(removed_files) - } - - pub fn is_directory_empty(&self, path: &VirtualPath) -> Option { - let inode = self.path_to_inode.get(path)?; - if let Some(VirtualNode::Directory(dir)) = self.nodes.get(inode) { - Some(dir.children.is_empty()) - } else { - None - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RemoveError { - NotFound, - NotEmpty, - NotDirectory, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RenameError { - SourceNotFound, - TargetExists, - ParentNotFound, - IsDirectory, - NotDirectory, -} - -impl Default for VirtualTree { - fn default() -> Self { - Self::new() - } -} - -pub struct TreeBuilder { - tree: VirtualTree, -} - -impl TreeBuilder { - pub fn new() -> Self { - Self { - tree: VirtualTree::new(), - } - } - - pub fn with_policy(policy: RefreshPolicy) -> Self { - Self { - tree: VirtualTree::with_policy(policy), - } - } - - pub fn add_file(&mut self, meta: &FileMeta) { - self.tree.insert_file(meta); - } - - pub fn build(self) -> VirtualTree { - self.tree - } -} - -impl Default for TreeBuilder { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use musicfs_core::{OriginId, RealPath}; - use std::path::PathBuf; - - fn make_file_meta(id: i64, vpath: &str) -> FileMeta { - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(vpath), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from("/test"), - }, - size: 1000, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - } - } - - #[test] - fn test_tree_creation() { - let tree = VirtualTree::new(); - assert!(tree.get(ROOT_INODE).is_some()); - } - - #[test] - fn test_insert_file() { - let mut tree = VirtualTree::new(); - let meta = make_file_meta(1, "/Artist/Album/Track.flac"); - tree.insert_file(&meta); - - assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_some()); - assert!(tree - .get_by_path(&VirtualPath::new("/Artist/Album")) - .is_some()); - assert!(tree - .get_by_path(&VirtualPath::new("/Artist/Album/Track.flac")) - .is_some()); - } - - #[test] - fn test_readdir() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Artist/Album/Track1.flac")); - tree.insert_file(&make_file_meta(2, "/Artist/Album/Track2.flac")); - - let root_children = tree.readdir(ROOT_INODE).unwrap(); - assert_eq!(root_children.len(), 1); - assert_eq!(root_children[0].0, "Artist"); - } - - #[test] - fn test_lookup() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac")); - - let artist_inode = tree.lookup(ROOT_INODE, OsStr::new("Artist")).unwrap(); - assert!(tree.lookup(artist_inode, OsStr::new("Album")).is_some()); - } - - #[test] - fn test_file_and_dir_count() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/A/B/Track1.flac")); - tree.insert_file(&make_file_meta(2, "/A/B/Track2.flac")); - tree.insert_file(&make_file_meta(3, "/A/C/Track3.flac")); - - assert_eq!(tree.file_count(), 3); - assert_eq!(tree.dir_count(), 4); - } - - #[test] - fn test_remove_file() { - let mut tree = VirtualTree::new(); - let path = VirtualPath::new("/Artist/Album/Track.flac"); - tree.insert_file(&make_file_meta(1, path.as_str())); - - assert!(tree.get_by_path(&path).is_some()); - - let removed_id = tree.remove_file(&path); - assert_eq!(removed_id, Some(FileId(1))); - assert!(tree.get_by_path(&path).is_none()); - } - - #[test] - fn test_tree_builder() { - let mut builder = TreeBuilder::new(); - builder.add_file(&make_file_meta(1, "/A/Track1.flac")); - builder.add_file(&make_file_meta(2, "/A/Track2.flac")); - - let tree = builder.build(); - assert_eq!(tree.file_count(), 2); - } - - #[test] - fn test_rename_file() { - let mut tree = VirtualTree::new(); - let old_path = VirtualPath::new("/Artist/Album/Track.flac"); - let new_path = VirtualPath::new("/Artist/Album/Renamed.flac"); - - tree.insert_file(&make_file_meta(1, old_path.as_str())); - - assert!(tree.get_by_path(&old_path).is_some()); - - tree.rename_file(&old_path, &new_path).unwrap(); - - assert!(tree.get_by_path(&old_path).is_none()); - assert!(tree.get_by_path(&new_path).is_some()); - } - - #[test] - fn test_rename_file_to_new_dir() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac")); - - tree.mkdir(&VirtualPath::new("/New Artist")).unwrap(); - tree.mkdir(&VirtualPath::new("/New Artist/New Album")) - .unwrap(); - - let result = tree.rename_file( - &VirtualPath::new("/Artist/Album/Track.flac"), - &VirtualPath::new("/New Artist/New Album/Track.flac"), - ); - - assert!(result.is_ok()); - assert!(tree - .get_by_path(&VirtualPath::new("/New Artist/New Album/Track.flac")) - .is_some()); - } - - #[test] - fn test_rename_file_parent_not_found() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac")); - - let result = tree.rename_file( - &VirtualPath::new("/Artist/Album/Track.flac"), - &VirtualPath::new("/NonExistent/Album/Track.flac"), - ); - - assert_eq!(result, Err(RenameError::ParentNotFound)); - } - - #[test] - fn test_rename_file_target_exists() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/A/Track1.flac")); - tree.insert_file(&make_file_meta(2, "/A/Track2.flac")); - - let result = tree.rename_file( - &VirtualPath::new("/A/Track1.flac"), - &VirtualPath::new("/A/Track2.flac"), - ); - - assert_eq!(result, Err(RenameError::TargetExists)); - } - - #[test] - fn test_rename_file_source_not_found() { - let mut tree = VirtualTree::new(); - - let result = tree.rename_file( - &VirtualPath::new("/Nonexistent.flac"), - &VirtualPath::new("/New.flac"), - ); - - assert_eq!(result, Err(RenameError::SourceNotFound)); - } - - #[test] - fn test_rename_directory() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Artist/Album/Track1.flac")); - tree.insert_file(&make_file_meta(2, "/Artist/Album/Track2.flac")); - tree.insert_file(&make_file_meta(3, "/Artist/Other/Track3.flac")); - - let count = tree - .rename_directory( - &VirtualPath::new("/Artist"), - &VirtualPath::new("/Renamed Artist"), - ) - .unwrap(); - - assert_eq!(count, 6); - - assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_none()); - assert!(tree - .get_by_path(&VirtualPath::new("/Renamed Artist")) - .is_some()); - assert!(tree - .get_by_path(&VirtualPath::new("/Renamed Artist/Album/Track1.flac")) - .is_some()); - assert!(tree - .get_by_path(&VirtualPath::new("/Renamed Artist/Album/Track2.flac")) - .is_some()); - assert!(tree - .get_by_path(&VirtualPath::new("/Renamed Artist/Other/Track3.flac")) - .is_some()); - } - - #[test] - fn test_rename_directory_parent_not_found() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac")); - - let result = tree.rename_directory( - &VirtualPath::new("/Artist"), - &VirtualPath::new("/NonExistent/Renamed"), - ); - - assert_eq!(result, Err(RenameError::ParentNotFound)); - } - - #[test] - fn test_rename_directory_not_directory() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Artist/Track.flac")); - - let result = tree.rename_directory( - &VirtualPath::new("/Artist/Track.flac"), - &VirtualPath::new("/New"), - ); - - assert_eq!(result, Err(RenameError::NotDirectory)); - } - - #[test] - fn test_mkdir() { - let mut tree = VirtualTree::new(); - - let inode = tree.mkdir(&VirtualPath::new("/NewDir")).unwrap(); - assert!(inode > ROOT_INODE); - assert!(tree.get_by_path(&VirtualPath::new("/NewDir")).is_some()); - assert!(tree - .get_by_path(&VirtualPath::new("/NewDir")) - .unwrap() - .is_dir()); - } - - #[test] - fn test_mkdir_nested() { - let mut tree = VirtualTree::new(); - - tree.mkdir(&VirtualPath::new("/A")).unwrap(); - tree.mkdir(&VirtualPath::new("/A/B")).unwrap(); - tree.mkdir(&VirtualPath::new("/A/B/C")).unwrap(); - - assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some()); - } - - #[test] - fn test_mkdir_parent_not_found() { - let mut tree = VirtualTree::new(); - - let result = tree.mkdir(&VirtualPath::new("/A/B/C")); - assert_eq!(result, Err(RenameError::ParentNotFound)); - } - - #[test] - fn test_mkdir_already_exists() { - let mut tree = VirtualTree::new(); - - tree.mkdir(&VirtualPath::new("/Existing")).unwrap(); - let result = tree.mkdir(&VirtualPath::new("/Existing")); - - assert_eq!(result, Err(RenameError::TargetExists)); - } - - #[test] - fn test_is_trash_path() { - assert!(VirtualTree::is_trash_path(&VirtualPath::new("/.trash"))); - assert!(VirtualTree::is_trash_path(&VirtualPath::new( - "/.trash/Artist/Track.flac" - ))); - assert!(!VirtualTree::is_trash_path(&VirtualPath::new( - "/Artist/Track.flac" - ))); - assert!(!VirtualTree::is_trash_path(&VirtualPath::new( - "/trash/Artist/Track.flac" - ))); - } - - #[test] - fn test_ensure_trash_dir() { - let mut tree = VirtualTree::new(); - - assert!(tree.get_by_path(&VirtualPath::new("/.trash")).is_none()); - - let inode = tree.ensure_trash_dir(); - assert!(inode > ROOT_INODE); - - let node = tree.get_by_path(&VirtualPath::new("/.trash")); - assert!(node.is_some()); - assert!(node.unwrap().is_dir()); - - let inode2 = tree.ensure_trash_dir(); - assert_eq!(inode, inode2); - } - - #[test] - fn test_mkdir_p() { - let mut tree = VirtualTree::new(); - - tree.mkdir_p(&VirtualPath::new("/A/B/C/D")).unwrap(); - - assert!(tree.get_by_path(&VirtualPath::new("/A")).is_some()); - assert!(tree.get_by_path(&VirtualPath::new("/A/B")).is_some()); - assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some()); - assert!(tree.get_by_path(&VirtualPath::new("/A/B/C/D")).is_some()); - } - - #[test] - fn test_mkdir_p_partial_exists() { - let mut tree = VirtualTree::new(); - - tree.mkdir(&VirtualPath::new("/A")).unwrap(); - tree.mkdir(&VirtualPath::new("/A/B")).unwrap(); - - tree.mkdir_p(&VirtualPath::new("/A/B/C/D")).unwrap(); - - assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some()); - assert!(tree.get_by_path(&VirtualPath::new("/A/B/C/D")).is_some()); - } - - #[test] - fn test_remove_directory_empty() { - let mut tree = VirtualTree::new(); - - tree.mkdir(&VirtualPath::new("/EmptyDir")).unwrap(); - assert!(tree.get_by_path(&VirtualPath::new("/EmptyDir")).is_some()); - - tree.remove_directory(&VirtualPath::new("/EmptyDir")) - .unwrap(); - assert!(tree.get_by_path(&VirtualPath::new("/EmptyDir")).is_none()); - } - - #[test] - fn test_remove_directory_not_empty() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Artist/Track.flac")); - - let result = tree.remove_directory(&VirtualPath::new("/Artist")); - assert_eq!(result, Err(RemoveError::NotEmpty)); - } - - #[test] - fn test_remove_directory_not_found() { - let mut tree = VirtualTree::new(); - - let result = tree.remove_directory(&VirtualPath::new("/NonExistent")); - assert_eq!(result, Err(RemoveError::NotFound)); - } - - #[test] - fn test_remove_directory_is_file() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Track.flac")); - - let result = tree.remove_directory(&VirtualPath::new("/Track.flac")); - assert_eq!(result, Err(RemoveError::NotDirectory)); - } - - #[test] - fn test_remove_directory_recursive() { - let mut tree = VirtualTree::new(); - tree.insert_file(&make_file_meta(1, "/Artist/Album/Track1.flac")); - tree.insert_file(&make_file_meta(2, "/Artist/Album/Track2.flac")); - tree.insert_file(&make_file_meta(3, "/Artist/Other/Track3.flac")); - - let removed = tree - .remove_directory_recursive(&VirtualPath::new("/Artist")) - .unwrap(); - - assert_eq!(removed.len(), 3); - assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_none()); - } - - #[test] - fn test_is_directory_empty() { - let mut tree = VirtualTree::new(); - - tree.mkdir(&VirtualPath::new("/Empty")).unwrap(); - assert_eq!( - tree.is_directory_empty(&VirtualPath::new("/Empty")), - Some(true) - ); - - tree.insert_file(&make_file_meta(1, "/NonEmpty/Track.flac")); - assert_eq!( - tree.is_directory_empty(&VirtualPath::new("/NonEmpty")), - Some(false) - ); - - assert_eq!( - tree.is_directory_empty(&VirtualPath::new("/NonExistent")), - None - ); - } -} diff --git a/crates/musicfs-cas/Cargo.toml b/crates/musicfs-cas/Cargo.toml deleted file mode 100644 index 9ce0d52..0000000 --- a/crates/musicfs-cas/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "musicfs-cas" -version.workspace = true -edition.workspace = true - -[features] -default = [] -failpoints = ["fail/failpoints"] - -[dependencies] -fail = { workspace = true, optional = true } -musicfs-core = { path = "../musicfs-core" } -musicfs-origins = { path = "../musicfs-origins" } -musicfs-sync = { path = "../musicfs-sync" } -tokio.workspace = true -tracing.workspace = true -serde.workspace = true -sled.workspace = true -xxhash-rust.workspace = true -bytes.workspace = true -rmp-serde.workspace = true -hex.workspace = true -dirs.workspace = true -thiserror.workspace = true -parking_lot.workspace = true - -[dev-dependencies] -tempfile.workspace = true -musicfs-cache = { path = "../musicfs-cache" } diff --git a/crates/musicfs-cas/src/chunks.rs b/crates/musicfs-cas/src/chunks.rs deleted file mode 100644 index c79598c..0000000 --- a/crates/musicfs-cas/src/chunks.rs +++ /dev/null @@ -1,45 +0,0 @@ -use musicfs_core::ChunkHash; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkLocation { - pub path: PathBuf, - pub size: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkRef { - pub hash: ChunkHash, - pub offset: u64, - pub size: u32, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_chunk_hash_from_bytes() { - let data = b"hello world"; - let hash = ChunkHash::from_bytes(data); - assert_eq!(hash.as_hex().len(), 16); - } - - #[test] - fn test_chunk_hash_deterministic() { - let data = b"test data"; - let hash1 = ChunkHash::from_bytes(data); - let hash2 = ChunkHash::from_bytes(data); - assert_eq!(hash1, hash2); - } - - #[test] - fn test_chunk_hash_hex_roundtrip() { - let data = b"roundtrip test"; - let hash = ChunkHash::from_bytes(data); - let hex = hash.as_hex(); - let restored = ChunkHash::from_hex(&hex).unwrap(); - assert_eq!(hash, restored); - } -} diff --git a/crates/musicfs-cas/src/fetcher.rs b/crates/musicfs-cas/src/fetcher.rs deleted file mode 100644 index d3e94ee..0000000 --- a/crates/musicfs-cas/src/fetcher.rs +++ /dev/null @@ -1,284 +0,0 @@ -use crate::{CasStore, ChunkManifest, ChunkRef}; -use musicfs_core::{Event, EventBus, FileId, FileMeta, OriginId}; -use musicfs_origins::Origin; -use musicfs_sync::CdcChunker; -use parking_lot::RwLock; -use std::collections::HashMap; -use std::sync::Arc; -use tracing::{debug, info, warn}; - -pub struct ContentFetcher { - store: Arc, - origins: RwLock>>, - file_meta: RwLock>, - event_bus: Option>, - chunker: CdcChunker, -} - -impl ContentFetcher { - pub fn new(store: Arc) -> Self { - Self { - store, - origins: RwLock::new(HashMap::new()), - file_meta: RwLock::new(HashMap::new()), - event_bus: None, - chunker: CdcChunker::default(), - } - } - - pub fn with_event_bus(store: Arc, event_bus: Arc) -> Self { - Self { - store, - origins: RwLock::new(HashMap::new()), - file_meta: RwLock::new(HashMap::new()), - event_bus: Some(event_bus), - chunker: CdcChunker::default(), - } - } - - pub fn register_origin(&self, origin: Arc) { - let id = origin.id().clone(); - self.origins.write().insert(id, origin); - } - - pub fn register_file(&self, meta: FileMeta) { - self.file_meta.write().insert(meta.id, meta); - } - - pub fn register_files(&self, files: impl IntoIterator) { - let mut map = self.file_meta.write(); - for meta in files { - map.insert(meta.id, meta); - } - } - - pub async fn fetch_file(&self, file_id: FileId) -> Result { - let meta = { - let files = self.file_meta.read(); - files - .get(&file_id) - .cloned() - .ok_or(FetchError::FileNotFound(file_id))? - }; - - let origin = { - let origins = self.origins.read(); - origins - .get(&meta.real_path.origin_id) - .cloned() - .ok_or_else(|| FetchError::OriginNotFound(meta.real_path.origin_id.clone()))? - }; - - info!("Fetching file {:?} from origin {}", file_id, origin.id()); - - let data = origin - .read_full(&meta.real_path.path) - .await - .map_err(|e| FetchError::OriginRead(e.to_string()))?; - - let mtime = meta - .mtime - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - let chunks = self.chunker.chunk_refs(&data); - info!("Chunked {:?} into {} chunks", file_id, chunks.len()); - - let mut chunk_refs = Vec::with_capacity(chunks.len()); - for chunk in chunks { - if !self.store.exists(&chunk.hash) { - if let Err(e) = self.store.put(chunk.data).await { - warn!(hash = %chunk.hash, error = %e, "CAS write failed, continuing in passthrough mode"); - } - } - - chunk_refs.push(ChunkRef { - hash: chunk.hash, - offset: chunk.offset, - size: chunk.length, - }); - } - - let manifest = ChunkManifest { - file_id, - total_size: meta.size, - mtime, - chunks: chunk_refs, - }; - - debug!( - "Created manifest for {:?}: {} bytes, {} chunks", - file_id, - meta.size, - manifest.chunks.len() - ); - - Ok(manifest) - } - - pub async fn ensure_cached(&self, file_id: FileId) -> Result { - self.fetch_file(file_id).await - } - - pub fn get_file_meta(&self, file_id: FileId) -> Option { - self.file_meta.read().get(&file_id).cloned() - } - - pub fn emit_access_event(&self, meta: &FileMeta, offset: u64, size: u32) { - if let Some(bus) = &self.event_bus { - bus.publish(Event::FileAccessed { - file_id: meta.id, - path: meta.virtual_path.clone(), - origin_id: meta.real_path.origin_id.clone(), - offset, - size, - }); - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum FetchError { - #[error("File not found: {0:?}")] - FileNotFound(FileId), - - #[error("Origin not found: {0}")] - OriginNotFound(OriginId), - - #[error("Origin read error: {0}")] - OriginRead(String), - - #[error("Store error: {0}")] - Store(#[from] crate::CasError), -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::CasConfig; - use musicfs_core::{RealPath, VirtualPath}; - use musicfs_origins::LocalOrigin; - use std::path::PathBuf; - use std::time::SystemTime; - use tempfile::TempDir; - - #[tokio::test] - async fn test_fetch_file() { - let cas_dir = TempDir::new().unwrap(); - let origin_dir = TempDir::new().unwrap(); - - std::fs::write(origin_dir.path().join("test.flac"), b"fake audio data").unwrap(); - - let config = CasConfig { - chunks_dir: cas_dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - let fetcher = ContentFetcher::new(store.clone()); - - let origin = Arc::new(LocalOrigin::new("local", origin_dir.path())); - fetcher.register_origin(origin); - - let meta = FileMeta { - id: FileId(1), - virtual_path: VirtualPath::new("/Artist/Album/test.flac"), - real_path: RealPath { - origin_id: OriginId::from("local"), - path: PathBuf::from("/test.flac"), - }, - size: 15, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - }; - fetcher.register_file(meta); - - let manifest = fetcher.fetch_file(FileId(1)).await.unwrap(); - assert_eq!(manifest.total_size, 15); - assert_eq!(manifest.chunks.len(), 1); - - let data = store.get(&manifest.chunks[0].hash).await.unwrap(); - assert_eq!(&data[..], b"fake audio data"); - } - - #[tokio::test] - async fn test_fetch_file_not_found() { - let cas_dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: cas_dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - let fetcher = ContentFetcher::new(store); - - let result = fetcher.fetch_file(FileId(999)).await; - assert!(matches!(result, Err(FetchError::FileNotFound(_)))); - } - - #[tokio::test] - async fn test_fetch_emits_event() { - let cas_dir = TempDir::new().unwrap(); - let origin_dir = TempDir::new().unwrap(); - std::fs::write(origin_dir.path().join("test.flac"), b"audio").unwrap(); - - let config = CasConfig { - chunks_dir: cas_dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - let event_bus = Arc::new(EventBus::default()); - let mut rx = event_bus.subscribe(); - - let fetcher = ContentFetcher::with_event_bus(store, event_bus); - let origin = Arc::new(LocalOrigin::new("local", origin_dir.path())); - fetcher.register_origin(origin); - - let meta = FileMeta { - id: FileId(1), - virtual_path: VirtualPath::new("/Artist/test.flac"), - real_path: RealPath { - origin_id: OriginId::from("local"), - path: PathBuf::from("/test.flac"), - }, - size: 5, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - }; - fetcher.register_file(meta.clone()); - - fetcher.emit_access_event(&meta, 0, 5); - - let event = rx.try_recv().unwrap(); - assert!(matches!(event, Event::FileAccessed { .. })); - } - - #[tokio::test] - async fn test_fetch_origin_not_found() { - let cas_dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: cas_dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - let fetcher = ContentFetcher::new(store); - - let meta = FileMeta { - id: FileId(1), - virtual_path: VirtualPath::new("/test.flac"), - real_path: RealPath { - origin_id: OriginId::from("nonexistent"), - path: PathBuf::from("/test.flac"), - }, - size: 100, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - }; - fetcher.register_file(meta); - - let result = fetcher.fetch_file(FileId(1)).await; - assert!(matches!(result, Err(FetchError::OriginNotFound(_)))); - } -} diff --git a/crates/musicfs-cas/src/lib.rs b/crates/musicfs-cas/src/lib.rs deleted file mode 100644 index 81ec6ee..0000000 --- a/crates/musicfs-cas/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod chunks; -mod fetcher; -mod reader; -mod store; - -pub use chunks::{ChunkLocation, ChunkRef}; -pub use fetcher::{ContentFetcher, FetchError}; -pub use reader::{ChunkManifest, FileReader, ReaderError}; -pub use store::{CasConfig, CasError, CasStore, DedupStats}; diff --git a/crates/musicfs-cas/src/reader.rs b/crates/musicfs-cas/src/reader.rs deleted file mode 100644 index 31bc97c..0000000 --- a/crates/musicfs-cas/src/reader.rs +++ /dev/null @@ -1,332 +0,0 @@ -use crate::chunks::ChunkRef; -use crate::fetcher::{ContentFetcher, FetchError}; -use crate::store::{CasError, CasStore}; -use bytes::{Bytes, BytesMut}; -use musicfs_core::FileId; -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use tracing::{debug, trace, warn}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkManifest { - pub file_id: FileId, - pub total_size: u64, - pub mtime: i64, - pub chunks: Vec, -} - -impl ChunkManifest { - pub fn chunks_to_bytes(&self) -> Vec { - rmp_serde::to_vec(&self.chunks).unwrap_or_default() - } - - pub fn chunks_from_bytes(data: &[u8]) -> Option> { - rmp_serde::from_slice(data).ok() - } - - pub fn from_db( - file_id: FileId, - total_size: u64, - mtime: i64, - chunk_blob: &[u8], - ) -> Option { - let chunks = Self::chunks_from_bytes(chunk_blob)?; - Some(Self { - file_id, - total_size, - mtime, - chunks, - }) - } -} - -pub struct FileReader { - store: Arc, - fetcher: Option>, - manifests: RwLock>, -} - -impl FileReader { - pub fn new(store: Arc) -> Self { - Self { - store, - fetcher: None, - manifests: RwLock::new(HashMap::new()), - } - } - - pub fn with_fetcher(store: Arc, fetcher: Arc) -> Self { - Self { - store, - fetcher: Some(fetcher), - manifests: RwLock::new(HashMap::new()), - } - } - - pub fn register_manifest(&self, manifest: ChunkManifest) { - let mut manifests = self.manifests.write(); - manifests.insert(manifest.file_id, manifest); - } - - async fn get_or_fetch_manifest(&self, file_id: FileId) -> Result { - { - let manifests = self.manifests.read(); - if let Some(m) = manifests.get(&file_id) { - trace!(file_id = ?file_id, "manifest cache hit"); - return Ok(m.clone()); - } - } - - trace!(file_id = ?file_id, "manifest cache miss"); - let Some(fetcher) = &self.fetcher else { - return Err(ReaderError::ManifestNotFound(file_id)); - }; - - let manifest = fetcher.ensure_cached(file_id).await?; - self.manifests.write().insert(file_id, manifest.clone()); - Ok(manifest) - } - - pub async fn read( - &self, - file_id: FileId, - offset: u64, - size: u32, - ) -> Result { - let manifest = self.get_or_fetch_manifest(file_id).await?; - - if let Some(fetcher) = &self.fetcher { - if let Some(meta) = fetcher.get_file_meta(file_id) { - fetcher.emit_access_event(&meta, offset, size); - } - } - - if offset >= manifest.total_size { - return Ok(Bytes::new()); - } - - let end = std::cmp::min(offset + size as u64, manifest.total_size); - let mut result = BytesMut::with_capacity((end - offset) as usize); - let mut chunks_read = 0u32; - - for chunk_ref in &manifest.chunks { - let chunk_start = chunk_ref.offset; - let chunk_end = chunk_ref.offset + chunk_ref.size as u64; - - if chunk_end <= offset || chunk_start >= end { - continue; - } - - let chunk_data = match self.store.get(&chunk_ref.hash).await { - Ok(data) => data, - Err(CasError::IntegrityError { .. }) => { - warn!(hash = %chunk_ref.hash, "Chunk corrupt, deleting and re-fetching"); - let _ = self.store.delete(&chunk_ref.hash).await; - if let Some(fetcher) = &self.fetcher { - let new_manifest = fetcher.fetch_file(file_id).await?; - self.manifests.write().insert(file_id, new_manifest); - self.store.get(&chunk_ref.hash).await? - } else { - return Err(ReaderError::Cas(CasError::NotFound( - chunk_ref.hash.as_hex(), - ))); - } - } - Err(CasError::NotFound(_)) => { - warn!(hash = %chunk_ref.hash, "Chunk missing, attempting re-fetch"); - if let Some(fetcher) = &self.fetcher { - let new_manifest = fetcher.fetch_file(file_id).await?; - self.manifests.write().insert(file_id, new_manifest); - self.store.get(&chunk_ref.hash).await? - } else { - return Err(ReaderError::Cas(CasError::NotFound( - chunk_ref.hash.as_hex(), - ))); - } - } - Err(e) => return Err(ReaderError::Cas(e)), - }; - - let read_start = if offset > chunk_start { - (offset - chunk_start) as usize - } else { - 0 - }; - - let read_end = if end < chunk_end { - (end - chunk_start) as usize - } else { - chunk_ref.size as usize - }; - - result.extend_from_slice(&chunk_data[read_start..read_end]); - chunks_read += 1; - } - - let bytes_read = result.len() as u64; - debug!(file_id = ?file_id, offset, size, chunks_read, bytes_read, "read completed"); - Ok(result.freeze()) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum ReaderError { - #[error("Manifest not found for file {0:?}")] - ManifestNotFound(FileId), - - #[error("Fetch error: {0}")] - Fetch(#[from] FetchError), - - #[error("CAS error: {0}")] - Cas(#[from] crate::store::CasError), -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::store::CasConfig; - use musicfs_core::ChunkHash; - use tempfile::TempDir; - - #[tokio::test] - async fn test_file_reader_simple() { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - - let data = b"Hello, World!"; - let hash = store.put(data).await.unwrap(); - - let reader = FileReader::new(store); - reader.register_manifest(ChunkManifest { - file_id: FileId(1), - total_size: data.len() as u64, - mtime: 0, - chunks: vec![ChunkRef { - hash, - offset: 0, - size: data.len() as u32, - }], - }); - - let result = reader.read(FileId(1), 0, data.len() as u32).await.unwrap(); - assert_eq!(&result[..], data); - } - - #[tokio::test] - async fn test_file_reader_partial() { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - - let data = b"ABCDEFGHIJ"; - let hash = store.put(data).await.unwrap(); - - let reader = FileReader::new(store); - reader.register_manifest(ChunkManifest { - file_id: FileId(1), - total_size: data.len() as u64, - mtime: 0, - chunks: vec![ChunkRef { - hash, - offset: 0, - size: data.len() as u32, - }], - }); - - let result = reader.read(FileId(1), 3, 4).await.unwrap(); - assert_eq!(&result[..], b"DEFG"); - } - - #[tokio::test] - async fn test_file_reader_multi_chunk() { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - - let chunk1 = b"AAAA"; - let chunk2 = b"BBBB"; - let hash1 = store.put(chunk1).await.unwrap(); - let hash2 = store.put(chunk2).await.unwrap(); - - let reader = FileReader::new(store); - reader.register_manifest(ChunkManifest { - file_id: FileId(1), - total_size: 8, - mtime: 0, - chunks: vec![ - ChunkRef { - hash: hash1, - offset: 0, - size: 4, - }, - ChunkRef { - hash: hash2, - offset: 4, - size: 4, - }, - ], - }); - - let result = reader.read(FileId(1), 2, 4).await.unwrap(); - assert_eq!(&result[..], b"AABB"); - } - - #[tokio::test] - async fn test_file_reader_eof() { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - - let data = b"short"; - let hash = store.put(data).await.unwrap(); - - let reader = FileReader::new(store); - reader.register_manifest(ChunkManifest { - file_id: FileId(1), - total_size: data.len() as u64, - mtime: 0, - chunks: vec![ChunkRef { - hash, - offset: 0, - size: data.len() as u32, - }], - }); - - let result = reader.read(FileId(1), 100, 10).await.unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn test_chunk_manifest_serialization() { - let manifest = ChunkManifest { - file_id: FileId(42), - total_size: 1024, - mtime: 0, - chunks: vec![ChunkRef { - hash: ChunkHash::from_bytes(b"test"), - offset: 0, - size: 1024, - }], - }; - - let bytes = manifest.chunks_to_bytes(); - let restored = ChunkManifest::chunks_from_bytes(&bytes).unwrap(); - assert_eq!(restored.len(), 1); - assert_eq!(restored[0].size, 1024); - } -} diff --git a/crates/musicfs-cas/src/store.rs b/crates/musicfs-cas/src/store.rs deleted file mode 100644 index 5d804ab..0000000 --- a/crates/musicfs-cas/src/store.rs +++ /dev/null @@ -1,396 +0,0 @@ -use crate::chunks::ChunkLocation; -use bytes::Bytes; -use musicfs_core::ChunkHash; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use tokio::fs; -use tracing::{debug, info, trace, warn}; - -#[cfg(feature = "failpoints")] -use fail::fail_point; - -const DEFAULT_MAX_SIZE_10GB: u64 = 10 * 1024 * 1024 * 1024; -const DEFAULT_SHARD_LEVELS_256_SUBDIRS: u8 = 2; - -#[derive(Debug, Clone)] -pub struct CasConfig { - pub chunks_dir: PathBuf, - pub max_size: u64, - pub shard_levels: u8, -} - -impl Default for CasConfig { - fn default() -> Self { - let cache_dir = dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from(".cache")) - .join("musicfs") - .join("chunks"); - - Self { - chunks_dir: cache_dir, - max_size: DEFAULT_MAX_SIZE_10GB, - shard_levels: DEFAULT_SHARD_LEVELS_256_SUBDIRS, - } - } -} - -pub struct CasStore { - config: CasConfig, - index: sled::Db, - current_size: AtomicU64, -} - -impl CasStore { - pub async fn open(config: CasConfig) -> Result { - fs::create_dir_all(&config.chunks_dir).await?; - - let index_path = config.chunks_dir.join("index.sled"); - let index = match sled::open(&index_path) { - Ok(db) => db, - Err(e) => { - warn!(error = %e, path = ?index_path, "sled index corrupted, attempting recovery"); - - match sled::Config::new().path(&index_path).open() { - Ok(db) => { - info!("sled index repaired successfully"); - db - } - Err(repair_err) => { - warn!(error = %repair_err, "sled repair failed, recreating index"); - if index_path.exists() { - std::fs::remove_dir_all(&index_path).map_err(CasError::Io)?; - } - sled::open(&index_path)? - } - } - } - }; - - let current_size = Self::calculate_size(&config.chunks_dir).await; - - Ok(Self { - config, - index, - current_size: AtomicU64::new(current_size), - }) - } - - async fn calculate_size(dir: &Path) -> u64 { - Self::calculate_size_recursive(dir).await - } - - fn calculate_size_recursive( - dir: &Path, - ) -> std::pin::Pin + Send + '_>> { - Box::pin(async move { - let mut size = 0u64; - if let Ok(mut entries) = fs::read_dir(dir).await { - while let Ok(Some(entry)) = entries.next_entry().await { - if let Ok(meta) = entry.metadata().await { - if meta.is_file() { - size += meta.len(); - } else if meta.is_dir() { - // Skip sled index directory - let name = entry.file_name(); - if name != "index.sled" { - size += Self::calculate_size_recursive(&entry.path()).await; - } - } - } - } - } - size - }) - } - - pub async fn put(&self, data: &[u8]) -> Result { - let hash = ChunkHash::from_bytes(data); - let path = self.chunk_path(&hash); - - if path.exists() { - trace!(hash = %hash, size_bytes = data.len(), "dedup hit"); - return Ok(hash); - } - - if self.config.max_size > 0 { - let new_size = self.current_size.load(Ordering::SeqCst) + data.len() as u64; - if new_size > self.config.max_size { - warn!( - current_size = self.current_size.load(Ordering::SeqCst), - chunk_size = data.len(), - max_size = self.config.max_size, - "CAS store full, rejecting write" - ); - return Err(CasError::StoreFull { - current: self.current_size.load(Ordering::SeqCst), - max: self.config.max_size, - }); - } - } - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).await?; - } - - #[cfg(feature = "failpoints")] - fail_point!("cas-put-before-write", |_| { - Err(CasError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "Failpoint: cas-put-before-write", - ))) - }); - - fs::write(&path, data).await?; - - #[cfg(feature = "failpoints")] - fail_point!("cas-put-after-write-before-index", |_| { - Err(CasError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "Failpoint: cas-put-after-write-before-index", - ))) - }); - - let location = ChunkLocation { - path: path.clone(), - size: data.len() as u32, - }; - self.index.insert( - hash.0.as_slice(), - rmp_serde::to_vec(&location).map_err(|e| CasError::Serialization(e.to_string()))?, - )?; - - self.current_size - .fetch_add(data.len() as u64, Ordering::SeqCst); - - debug!(hash = %hash, size_bytes = data.len(), "chunk stored"); - Ok(hash) - } - - pub async fn get(&self, hash: &ChunkHash) -> Result { - let path = self.chunk_path(hash); - - if !path.exists() { - return Err(CasError::NotFound(hash.as_hex())); - } - - let data = fs::read(&path).await?; - - if self.config.max_size > 0 { - self.verify_integrity(hash, &data)?; - } - - debug!(hash = %hash, size_bytes = data.len(), "chunk retrieved"); - Ok(Bytes::from(data)) - } - - pub fn exists(&self, hash: &ChunkHash) -> bool { - self.chunk_path(hash).exists() - } - - fn verify_integrity(&self, expected: &ChunkHash, data: &[u8]) -> Result<(), CasError> { - let actual = ChunkHash::from_bytes(data); - if actual != *expected { - warn!( - "Chunk integrity failure: expected {}, got {}", - expected, actual - ); - return Err(CasError::IntegrityError { - expected: expected.as_hex(), - actual: actual.as_hex(), - }); - } - Ok(()) - } - - fn chunk_path(&self, hash: &ChunkHash) -> PathBuf { - let hex = hash.as_hex(); - let mut path = self.config.chunks_dir.clone(); - - for i in 0..self.config.shard_levels as usize { - let start = i * 2; - let end = start + 2; - if end <= hex.len() { - path = path.join(&hex[start..end]); - } - } - - path.join(&hex) - } - - pub async fn delete(&self, hash: &ChunkHash) -> Result<(), CasError> { - let path = self.chunk_path(hash); - - if path.exists() { - let meta = fs::metadata(&path).await?; - fs::remove_file(&path).await?; - self.index.remove(hash.0.as_slice())?; - self.current_size.fetch_sub(meta.len(), Ordering::SeqCst); - debug!(hash = %hash, size_bytes = meta.len(), "chunk deleted"); - } - - Ok(()) - } - - pub fn current_size(&self) -> u64 { - self.current_size.load(Ordering::SeqCst) - } - - pub fn max_size(&self) -> u64 { - self.config.max_size - } - - pub fn list_chunks(&self) -> impl Iterator + '_ { - self.index.iter().filter_map(|r| { - r.ok().and_then(|(k, _)| { - if k.len() == 8 { - let mut arr = [0u8; 8]; - arr.copy_from_slice(&k); - Some(ChunkHash(arr)) - } else { - None - } - }) - }) - } - - pub fn dedup_stats(&self) -> DedupStats { - let chunks_stored = self.index.len() as u64; - let size_bytes = self.current_size(); - - DedupStats { - chunks_stored, - chunks_unique: chunks_stored, - size_bytes, - size_limit_bytes: self.config.max_size, - } - } -} - -#[derive(Debug, Clone)] -pub struct DedupStats { - pub chunks_stored: u64, - pub chunks_unique: u64, - pub size_bytes: u64, - pub size_limit_bytes: u64, -} - -impl DedupStats { - pub fn dedup_ratio(&self) -> f64 { - if self.chunks_stored == 0 { - 0.0 - } else { - 1.0 - (self.chunks_unique as f64 / self.chunks_stored as f64) - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum CasError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("Sled error: {0}")] - Sled(#[from] sled::Error), - - #[error("Chunk not found: {0}")] - NotFound(String), - - #[error("Integrity error: expected {expected}, got {actual}")] - IntegrityError { expected: String, actual: String }, - - #[error("Serialization error: {0}")] - Serialization(String), - - #[error("Store full: {current} / {max} bytes")] - StoreFull { current: u64, max: u64 }, -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - async fn test_store() -> (CasStore, TempDir) { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - max_size: 1024 * 1024, - shard_levels: 2, - }; - let store = CasStore::open(config).await.unwrap(); - (store, dir) - } - - #[tokio::test] - async fn test_cas_put_get() { - let (store, _dir) = test_store().await; - - let data = b"test chunk data"; - let hash = store.put(data).await.unwrap(); - - let retrieved = store.get(&hash).await.unwrap(); - assert_eq!(&retrieved[..], data); - } - - #[tokio::test] - async fn test_cas_dedup() { - let (store, _dir) = test_store().await; - - let data = b"duplicate data"; - let hash1 = store.put(data).await.unwrap(); - let hash2 = store.put(data).await.unwrap(); - - assert_eq!(hash1, hash2); - } - - #[tokio::test] - async fn test_cas_exists() { - let (store, _dir) = test_store().await; - - let data = b"existence test"; - let hash = store.put(data).await.unwrap(); - - assert!(store.exists(&hash)); - - let fake_hash = ChunkHash::from_bytes(b"nonexistent"); - assert!(!store.exists(&fake_hash)); - } - - #[tokio::test] - async fn test_cas_delete() { - let (store, _dir) = test_store().await; - - let data = b"delete me"; - let hash = store.put(data).await.unwrap(); - - assert!(store.exists(&hash)); - - store.delete(&hash).await.unwrap(); - - assert!(!store.exists(&hash)); - } - - #[tokio::test] - async fn test_cas_integrity() { - let (store, _dir) = test_store().await; - - let data = b"integrity test"; - let hash = store.put(data).await.unwrap(); - - let retrieved = store.get(&hash).await.unwrap(); - assert_eq!(&retrieved[..], data); - } - - #[tokio::test] - async fn test_cas_dedup_stats() { - let (store, _dir) = test_store().await; - - store.put(b"chunk1").await.unwrap(); - store.put(b"chunk2").await.unwrap(); - store.put(b"chunk1").await.unwrap(); - - let stats = store.dedup_stats(); - assert_eq!(stats.chunks_stored, 2); - assert_eq!(stats.chunks_unique, 2); - } -} diff --git a/crates/musicfs-cas/tests/integration.rs b/crates/musicfs-cas/tests/integration.rs deleted file mode 100644 index 923fb40..0000000 --- a/crates/musicfs-cas/tests/integration.rs +++ /dev/null @@ -1,203 +0,0 @@ -use musicfs_cache::TreeBuilder; -use musicfs_cas::{CasConfig, CasStore, ChunkManifest, ChunkRef, ContentFetcher, FileReader}; -use musicfs_core::{FileId, FileMeta, OriginId, RealPath, VirtualPath}; -use musicfs_origins::LocalOrigin; -use std::path::PathBuf; -use std::sync::{Arc, RwLock}; -use std::time::SystemTime; -use tempfile::TempDir; - -fn make_file_meta(id: i64, vpath: &str, size: u64) -> FileMeta { - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(vpath), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from("/test"), - }, - size, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - } -} - -#[tokio::test] -async fn test_cas_and_tree_integration() { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - - let file_data = b"This is test audio file content for testing."; - let chunk_hash = store.put(file_data).await.unwrap(); - - let mut builder = TreeBuilder::new(); - builder.add_file(&make_file_meta( - 1, - "/Artist/Album/Track.flac", - file_data.len() as u64, - )); - let _tree = Arc::new(RwLock::new(builder.build())); - - let reader = Arc::new(FileReader::new(store.clone())); - reader.register_manifest(ChunkManifest { - file_id: FileId(1), - total_size: file_data.len() as u64, - mtime: 0, - chunks: vec![ChunkRef { - hash: chunk_hash, - offset: 0, - size: file_data.len() as u32, - }], - }); - - let result = reader - .read(FileId(1), 0, file_data.len() as u32) - .await - .unwrap(); - assert_eq!(&result[..], file_data); -} - -#[tokio::test] -async fn test_cache_persistence() { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - - let data = b"persistent data"; - let hash = { - let store = CasStore::open(config.clone()).await.unwrap(); - store.put(data).await.unwrap() - }; - - let store = CasStore::open(config).await.unwrap(); - let retrieved = store.get(&hash).await.unwrap(); - assert_eq!(&retrieved[..], data); -} - -#[tokio::test] -async fn test_deduplication() { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = CasStore::open(config).await.unwrap(); - - let data = b"duplicate this content"; - - let hash1 = store.put(data).await.unwrap(); - let size_after_first = store.current_size(); - - let hash2 = store.put(data).await.unwrap(); - let size_after_second = store.current_size(); - - assert_eq!(hash1, hash2); - assert_eq!(size_after_first, size_after_second); -} - -#[tokio::test] -async fn test_fetcher_cache_miss_flow() { - let origin_dir = TempDir::new().unwrap(); - let cas_dir = TempDir::new().unwrap(); - - let test_content = b"This is audio content that will be fetched on cache miss"; - let test_file_path = origin_dir.path().join("test.flac"); - std::fs::write(&test_file_path, test_content).unwrap(); - - let config = CasConfig { - chunks_dir: cas_dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - - let origin_id = OriginId::from("test-origin"); - let origin = Arc::new(LocalOrigin::new( - origin_id.clone(), - origin_dir.path().to_path_buf(), - )); - - let fetcher = ContentFetcher::new(store.clone()); - fetcher.register_origin(origin); - - let file_id = FileId(42); - let file_meta = FileMeta { - id: file_id, - virtual_path: VirtualPath::new("/Artist/Album/test.flac"), - real_path: RealPath { - origin_id, - path: PathBuf::from("/test.flac"), - }, - size: test_content.len() as u64, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - }; - fetcher.register_file(file_meta); - - let manifest = fetcher.fetch_file(file_id).await.unwrap(); - - assert_eq!(manifest.file_id, file_id); - assert_eq!(manifest.total_size, test_content.len() as u64); - assert_eq!(manifest.chunks.len(), 1); - - let chunk_data = store.get(&manifest.chunks[0].hash).await.unwrap(); - assert_eq!(&chunk_data[..], test_content); -} - -#[tokio::test] -async fn test_reader_with_fetcher_integration() { - let origin_dir = TempDir::new().unwrap(); - let cas_dir = TempDir::new().unwrap(); - - let test_content = b"Audio file content for reader integration test"; - let test_file_path = origin_dir.path().join("song.flac"); - std::fs::write(&test_file_path, test_content).unwrap(); - - let config = CasConfig { - chunks_dir: cas_dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(CasStore::open(config).await.unwrap()); - - let origin_id = OriginId::from("local"); - let origin = Arc::new(LocalOrigin::new( - origin_id.clone(), - origin_dir.path().to_path_buf(), - )); - - let fetcher = ContentFetcher::new(store.clone()); - fetcher.register_origin(origin); - - let file_id = FileId(100); - let file_meta = FileMeta { - id: file_id, - virtual_path: VirtualPath::new("/Test/song.flac"), - real_path: RealPath { - origin_id, - path: PathBuf::from("/song.flac"), - }, - size: test_content.len() as u64, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - }; - fetcher.register_file(file_meta); - - let reader = FileReader::with_fetcher(store, Arc::new(fetcher)); - - let result = reader - .read(file_id, 0, test_content.len() as u32) - .await - .unwrap(); - - assert_eq!(&result[..], test_content); - - let result2 = reader.read(file_id, 0, 10).await.unwrap(); - assert_eq!(&result2[..], &test_content[..10]); -} diff --git a/crates/musicfs-cli/Cargo.toml b/crates/musicfs-cli/Cargo.toml deleted file mode 100644 index 4374693..0000000 --- a/crates/musicfs-cli/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "musicfs-cli" -version.workspace = true -edition.workspace = true - -[[bin]] -name = "musicfs" -path = "src/main.rs" - -[dependencies] -musicfs-core.path = "../musicfs-core" -musicfs-origins.path = "../musicfs-origins" -musicfs-cache.path = "../musicfs-cache" -musicfs-cas.path = "../musicfs-cas" -musicfs-fuse.path = "../musicfs-fuse" -musicfs-metadata.path = "../musicfs-metadata" -musicfs-grpc.path = "../musicfs-grpc" - -clap.workspace = true -tokio.workspace = true -tokio-util.workspace = true -tokio-stream.workspace = true -tonic.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -tracing-appender.workspace = true -anyhow.workspace = true -dirs.workspace = true -toml.workspace = true -parking_lot.workspace = true -libc.workspace = true -serde.workspace = true -serde_json.workspace = true - -[target.'cfg(target_os = "linux")'.dependencies] -tracing-journald.workspace = true -sd-notify.workspace = true diff --git a/crates/musicfs-cli/src/lib.rs b/crates/musicfs-cli/src/lib.rs deleted file mode 100644 index f9da2c4..0000000 --- a/crates/musicfs-cli/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -#![allow(dead_code)] diff --git a/crates/musicfs-cli/src/main.rs b/crates/musicfs-cli/src/main.rs deleted file mode 100644 index 3d1a4a7..0000000 --- a/crates/musicfs-cli/src/main.rs +++ /dev/null @@ -1,1088 +0,0 @@ -mod metadata; - -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; -use metadata::MetadataCommand; -use musicfs_cache::{ - Database, FlacHandler, FormatHandlerRegistry, FormatLayout, Id3v2Handler, OverlayReader, - RenameError, TrashedFilter, TreeBuilder, VirtualTree, -}; -use musicfs_cas::{CasConfig, CasStore, ContentFetcher, FileReader}; -use musicfs_core::{FileId, FileMeta, LoggingConfig, OriginId, RealPath, VirtualPath}; -use musicfs_fuse::MusicFs; -use musicfs_grpc::{MetadataServiceImpl, MusicFsServer as GrpcServer}; -use musicfs_metadata::MetadataParser; -use musicfs_origins::{LocalOrigin, Origin}; -use parking_lot::RwLock; -use std::collections::HashMap; -use std::fs::File; -use std::io::{Read as _, Write}; -use std::os::unix::io::AsRawFd; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::SystemTime; -use toml::Value; -use tracing::{debug, info, warn}; -use tracing_appender::non_blocking::WorkerGuard; -use tracing_subscriber::{fmt, prelude::*, EnvFilter, Layer}; - -#[derive(Parser)] -#[command(name = "musicfs")] -#[command(about = "Virtual FUSE filesystem for music libraries")] -struct Cli { - #[arg(short, long, default_value = "info", help = "Log level")] - log_level: String, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - Mount { - #[arg(short, long, help = "Config file path")] - config: Option, - #[arg(help = "Mount point (optional if provided in config file)")] - mountpoint: Option, - #[arg(short, long, help = "Source music directory")] - origin: Option, - #[arg(short = 'd', long, help = "Cache directory")] - cache_dir: Option, - #[arg(long, default_value = "50052", help = "gRPC server port")] - grpc_port: u16, - }, - Status, - Cache { - #[command(subcommand)] - command: CacheCommands, - }, - Search { - query: String, - #[arg(short, long, default_value = "100")] - limit: u32, - }, - Origin { - #[command(subcommand)] - command: OriginCommands, - }, - Events { - #[arg(short, long, help = "Filter by event type")] - r#type: Option, - }, - Shutdown { - #[arg(short, long, default_value = "true")] - graceful: bool, - #[arg(short, long, default_value = "30")] - timeout: u32, - }, - Trash { - #[arg(short, long, help = "Config file path")] - config: Option, - #[arg(short = 'd', long, help = "Cache directory")] - cache_dir: Option, - #[command(subcommand)] - command: TrashCommands, - }, - Metadata { - #[arg(long, default_value = "http://[::1]:50051", help = "gRPC endpoint")] - endpoint: String, - #[command(subcommand)] - command: MetadataCommand, - }, -} - -#[derive(Subcommand)] -enum CacheCommands { - Stats, - Clear { - #[arg(help = "Origin to clear cache for")] - origin: Option, - }, - Prefetch { - #[arg(help = "Paths to prefetch")] - paths: Vec, - }, -} - -#[derive(Subcommand)] -enum OriginCommands { - List, - Health { origin_id: String }, - Rescan { origin_id: String }, -} - -#[derive(Subcommand)] -enum TrashCommands { - List { - #[arg(long, help = "Filter by origin")] - origin: Option, - #[arg(long, help = "Show files deleted within duration (e.g., 7d, 24h)")] - since: Option, - #[arg(long, help = "Filter by path prefix")] - path: Option, - }, - Restore { - #[arg(help = "Path to restore (restores folder recursively)")] - path: Option, - #[arg(long, help = "Restore all deleted files")] - all: bool, - }, - Empty { - #[arg(long, help = "Delete files older than duration (e.g., 30d)")] - older_than: Option, - #[arg(long, help = "Delete files matching pattern")] - pattern: Option, - }, -} - -struct LockFile { - _file: File, -} - -fn try_acquire_lock(path: &Path) -> Result { - let file = File::create(path).context("Failed to create lock file")?; - let fd = file.as_raw_fd(); - - let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) }; - if ret != 0 { - let err = std::io::Error::last_os_error(); - if err.kind() == std::io::ErrorKind::WouldBlock { - anyhow::bail!("MusicFS is already running (lock file: {:?})", path); - } - return Err(err).context("Failed to acquire lock"); - } - - let mut f = &file; - writeln!(f, "{}", std::process::id())?; - - Ok(LockFile { _file: file }) -} - -fn main() -> Result<()> { - musicfs_core::install_panic_hook(); - let cli = Cli::parse(); - - match cli.command { - Commands::Mount { - config, - mountpoint, - origin, - cache_dir, - grpc_port, - } => { - let mut config = if let Some(config_path) = config { - musicfs_core::Config::from_file(&config_path)? - } else { - let origin_path = origin - .context("--origin is required for mount if no config file is provided")?; - let mp = mountpoint - .clone() - .context("mount point is required if no config file is provided")?; - let cache_dir = cache_dir.clone().unwrap_or_else(|| { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("musicfs") - }); - - let mut settings = HashMap::new(); - settings.insert( - "path".to_string(), - Value::String(origin_path.to_string_lossy().into_owned()), - ); - - musicfs_core::Config { - mount_point: mp, - cache_dir: cache_dir.clone(), - origins: vec![musicfs_core::OriginConfig { - id: "local".to_string(), - origin_type: musicfs_core::OriginType::Local, - priority: 1, - enabled: true, - settings, - }], - cache: Default::default(), - health: Default::default(), - logging: LoggingConfig { - level: cli.log_level.clone(), - ..Default::default() - }, - } - }; - - if let Some(c_dir) = cache_dir { - config.cache_dir = c_dir; - } - if let Some(cli_mountpoint) = mountpoint { - config.mount_point = cli_mountpoint; - } - - let _guard = init_logging(&config.logging)?; - run_mount(config, grpc_port) - } - Commands::Status => { - init_basic_logging(&cli.log_level); - run_status() - } - Commands::Cache { command } => { - init_basic_logging(&cli.log_level); - run_cache(command) - } - Commands::Search { query, limit } => { - init_basic_logging(&cli.log_level); - run_search(&query, limit) - } - Commands::Origin { command } => { - init_basic_logging(&cli.log_level); - run_origin(command) - } - Commands::Events { r#type } => { - init_basic_logging(&cli.log_level); - run_events(r#type) - } - Commands::Shutdown { graceful, timeout } => { - init_basic_logging(&cli.log_level); - run_shutdown(graceful, timeout) - } - Commands::Trash { - config, - cache_dir, - command, - } => { - init_basic_logging(&cli.log_level); - run_trash(config, cache_dir, command) - } - Commands::Metadata { endpoint, command } => { - init_basic_logging(&cli.log_level); - run_metadata(endpoint, command) - } - } -} - -fn run_metadata(endpoint: String, command: MetadataCommand) -> Result<()> { - let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; - runtime.block_on(metadata::run_metadata(command, &endpoint)) -} - -fn run_mount(config: musicfs_core::Config, grpc_port: u16) -> Result<()> { - let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; - let handle = runtime.handle().clone(); - - let (tree, reader, db, overlay_reader, origin_root, fetcher) = runtime.block_on(async { - info!(mountpoint = ?config.mount_point, "Mount configuration"); - info!("Cache directory: {:?}", config.cache_dir); - - std::fs::create_dir_all(&config.cache_dir).context("Failed to create cache directory")?; - std::fs::create_dir_all(&config.mount_point).context("Failed to create mountpoint")?; - - let db_path = config.cache_dir.join("musicfs.db"); - let db = Arc::new(Database::open(&db_path).context("Failed to open metadata database")?); - info!("Metadata database opened at {:?}", db_path); - - let cas_config = CasConfig { - chunks_dir: config.cache_dir.join("chunks"), - ..Default::default() - }; - let store = Arc::new( - CasStore::open(cas_config) - .await - .context("Failed to open CAS store")?, - ); - info!("CAS store initialized"); - - let fetcher = Arc::new(ContentFetcher::new(store.clone())); - let mut files = Vec::new(); - - let mut format_registry = FormatHandlerRegistry::new(); - format_registry.register(Arc::new(Id3v2Handler::new())); - format_registry.register(Arc::new(FlacHandler::new())); - let format_registry = Arc::new(format_registry); - info!("Format handler registry initialized (MP3, FLAC)"); - - for origin_cfg in &config.origins { - if !origin_cfg.enabled { - continue; - } - - let origin_id = OriginId::from(origin_cfg.id.as_str()); - let origin: Arc = match origin_cfg.origin_type { - musicfs_core::OriginType::Local => { - let path_str = origin_cfg - .settings - .get("path") - .and_then(|v| v.as_str()) - .context("path required for local origin")?; - Arc::new(LocalOrigin::new(origin_id.clone(), PathBuf::from(path_str))) - } - _ => { - warn!( - "Origin type {:?} not supported in CLI yet, skipping", - origin_cfg.origin_type - ); - continue; - } - }; - - info!("Origin registered: {}", origin.display_name()); - fetcher.register_origin(origin.clone()); - - if origin_cfg.origin_type == musicfs_core::OriginType::Local { - let path_str = origin_cfg - .settings - .get("path") - .and_then(|v| v.as_str()) - .unwrap(); - let origin_path = PathBuf::from(path_str); - info!("Scanning music files for origin {}...", origin_cfg.id); - let origin_files = - scan_music_files(&origin_path, &origin_id, db.as_ref(), &format_registry) - .await?; - info!( - "Found {} music files for origin {}", - origin_files.len(), - origin_cfg.id - ); - files.extend(origin_files); - } - } - - let mut builder = TreeBuilder::new(); - for file in &files { - builder.add_file(file); - fetcher.register_file(file.clone()); - } - let mut tree = builder.build(); - - let dirs = db.list_directories().unwrap_or_default(); - for dir_path in &dirs { - if tree.get_by_path(dir_path).is_none() { - if let Err(e) = tree.mkdir(dir_path) { - debug!("Could not restore directory {:?}: {:?}", dir_path, e); - } - } - } - info!( - "Virtual tree built ({} files, {} user directories)", - tree.file_count(), - dirs.len() - ); - - let tree = Arc::new(RwLock::new(tree)); - - let reader = Arc::new(FileReader::with_fetcher(store.clone(), fetcher.clone())); - - // Create overlay reader for metadata synthesis - let overlay_reader = Arc::new(OverlayReader::new( - db.clone(), - format_registry, - reader.clone(), - )); - - let first_origin_root = config - .origins - .iter() - .find(|o| o.enabled && o.origin_type == musicfs_core::OriginType::Local) - .and_then(|o| o.settings.get("path").and_then(|v| v.as_str())) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/")); - - Ok::<_, anyhow::Error>((tree, reader, db, overlay_reader, first_origin_root, fetcher)) - })?; - - check_stale_mount(&config.mount_point)?; - - let lock_path = config.cache_dir.join("musicfs.lock"); - let _lock = try_acquire_lock(&lock_path) - .context("Failed to acquire lock — is another instance running?")?; - info!(lock_path = ?lock_path, "Lock acquired"); - - let pid_path = config.cache_dir.join("musicfs.pid"); - std::fs::write(&pid_path, std::process::id().to_string()) - .context("Failed to write PID file")?; - info!(pid_path = ?pid_path, "PID file written"); - - let grpc_db = db.clone(); - let tree_for_grpc = tree.clone(); - let tree_for_restore = tree.clone(); - let db_for_restore = db.clone(); - - let fs = MusicFs::with_reader(tree, reader, handle.clone()) - .with_db(db) - .with_overlay(overlay_reader); - - info!("Mounting filesystem at {:?}", config.mount_point); - - let session = fs - .spawn_mount(&config.mount_point) - .context("Failed to mount filesystem")?; - - #[cfg(target_os = "linux")] - { - if let Err(e) = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]) { - debug!("sd_notify not available (not running under systemd): {}", e); - } - } - info!("MusicFS ready, PID {}", std::process::id()); - - let shutdown_token = tokio_util::sync::CancellationToken::new(); - - let event_bus = Arc::new(musicfs_core::EventBus::default()); - let grpc_event_bus = event_bus.clone(); - let grpc_origin_root = origin_root.clone(); - let grpc_shutdown = shutdown_token.clone(); - - runtime.spawn(async move { - let addr = format!("0.0.0.0:{}", grpc_port).parse().unwrap(); - - let grpc_tree = tree_for_grpc.clone(); - let grpc_fetcher = fetcher.clone(); - let musicfs_server = GrpcServer::new(grpc_event_bus, grpc_db.clone(), grpc_tree, grpc_fetcher, grpc_origin_root); - let metadata_server = MetadataServiceImpl::new(grpc_db); - - info!(%addr, "gRPC server starting"); - - let result = tonic::transport::Server::builder() - .add_service(musicfs_grpc::proto::musicfs::v1::music_fs_server::MusicFsServer::new(musicfs_server)) - .add_service(musicfs_grpc::proto::musicfs::v1::metadata_service_server::MetadataServiceServer::new(metadata_server)) - .serve_with_shutdown(addr, async move { - grpc_shutdown.cancelled().await; - }) - .await; - - if let Err(e) = result { - tracing::error!(error = %e, "gRPC server error"); - } - }); - - runtime.block_on(async { - let mut sigterm = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; - let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?; - let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?; - - loop { - tokio::select! { - _ = sigterm.recv() => { - info!("Received SIGTERM, shutting down"); - break; - } - _ = sigint.recv() => { - info!("Received SIGINT, shutting down"); - break; - } - _ = sighup.recv() => { - info!("Received SIGHUP, processing pending restores"); - process_pending_restores(&tree_for_restore, &db_for_restore); - } - } - } - - info!("Beginning ordered shutdown"); - shutdown_token.cancel(); - - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - info!("Background tasks stopped"); - - Ok::<_, anyhow::Error>(()) - })?; - - #[cfg(target_os = "linux")] - { - let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Stopping]); - } - info!("Unmounting filesystem"); - drop(session); - - let _ = std::fs::remove_file(&pid_path); - info!("Shutdown complete"); - - Ok(()) -} - -fn run_status() -> Result<()> { - println!("Status: Not connected to daemon"); - println!("Hint: gRPC client integration pending"); - Ok(()) -} - -fn run_cache(command: CacheCommands) -> Result<()> { - match command { - CacheCommands::Stats => { - println!("Cache stats: gRPC client integration pending"); - } - CacheCommands::Clear { origin } => { - println!("Clearing cache for: {}", origin.as_deref().unwrap_or("all")); - println!("gRPC client integration pending"); - } - CacheCommands::Prefetch { paths } => { - println!("Prefetching {} paths", paths.len()); - println!("gRPC client integration pending"); - } - } - Ok(()) -} - -fn run_search(query: &str, limit: u32) -> Result<()> { - println!("Searching for: {} (limit: {})", query, limit); - println!("gRPC client integration pending"); - Ok(()) -} - -fn run_origin(command: OriginCommands) -> Result<()> { - match command { - OriginCommands::List => { - println!("Origins: gRPC client integration pending"); - } - OriginCommands::Health { origin_id } => { - println!("Health for {}: gRPC client integration pending", origin_id); - } - OriginCommands::Rescan { origin_id } => { - println!("Rescanning {}: gRPC client integration pending", origin_id); - } - } - Ok(()) -} - -fn run_events(event_type: Option) -> Result<()> { - println!( - "Subscribing to events: {}", - event_type.as_deref().unwrap_or("all") - ); - println!("gRPC client integration pending"); - Ok(()) -} - -fn run_shutdown(graceful: bool, timeout: u32) -> Result<()> { - println!( - "Shutdown requested (graceful: {}, timeout: {}s)", - graceful, timeout - ); - println!("gRPC client integration pending"); - Ok(()) -} - -fn run_trash( - config: Option, - cache_dir: Option, - command: TrashCommands, -) -> Result<()> { - let cache_dir = if let Some(dir) = cache_dir { - dir - } else if let Some(cfg_path) = config { - let content = std::fs::read_to_string(&cfg_path).context("Failed to read config file")?; - let config: Value = toml::from_str(&content).context("Failed to parse config file")?; - PathBuf::from( - config - .get("cache_dir") - .and_then(|v| v.as_str()) - .context("cache_dir not found in config")?, - ) - } else { - return Err(anyhow::anyhow!( - "Either --config or --cache-dir must be provided" - )); - }; - - let db_path = cache_dir.join("musicfs.db"); - let db = Database::open(&db_path).context("Failed to open database")?; - - match command { - TrashCommands::List { - origin, - since, - path, - } => { - let filter = TrashedFilter { - origin_id: origin.map(|s| OriginId::from(s.as_str())), - path_prefix: path, - since: since.and_then(|s| parse_duration(&s)), - }; - - let trashed = db.list_trashed(&filter)?; - - if trashed.is_empty() { - println!("No deleted files found."); - return Ok(()); - } - - println!("{:<6} {:<20} PATH", "IDX", "DELETED"); - println!("{}", "-".repeat(80)); - - for (i, file) in trashed.iter().enumerate() { - let ago = format_time_ago(file.trashed_at); - println!("{:<6} {:<20} {}", i, ago, file.original_path.as_str()); - } - - println!("\nTotal: {} deleted files", trashed.len()); - } - TrashCommands::Restore { path, all } => { - let trashed = if all { - db.list_trashed(&TrashedFilter::default())? - } else if let Some(ref p) = path { - db.get_trashed_by_prefix(p)? - } else { - return Err(anyhow::anyhow!("Either --all or a path must be provided")); - }; - - if trashed.is_empty() { - println!("No files to restore."); - return Ok(()); - } - - let restore_file = cache_dir.join("pending_restore.txt"); - let paths: Vec = trashed - .iter() - .map(|f| f.original_path.as_str().to_string()) - .collect(); - std::fs::write(&restore_file, paths.join("\n"))?; - - let pid_path = cache_dir.join("musicfs.pid"); - if pid_path.exists() { - let pid_str = std::fs::read_to_string(&pid_path)?; - let pid: i32 = pid_str.trim().parse().context("Invalid PID in pid file")?; - - std::env::set_var("MUSICFS_RESTORE_FILE", &restore_file); - - unsafe { - libc::kill(pid, libc::SIGHUP); - } - println!("Restore signal sent for {} files.", trashed.len()); - println!("Files will appear at their original locations."); - } else { - println!( - "Daemon not running. Marked {} files for restore.", - trashed.len() - ); - println!("Start the daemon to complete restore, or restore manually with 'mv'."); - } - } - TrashCommands::Empty { - older_than, - pattern, - } => { - let filter = TrashedFilter { - since: older_than.and_then(|s| parse_duration(&s)), - path_prefix: pattern, - ..Default::default() - }; - - let count = db.purge_trashed(&filter)?; - println!("Permanently deleted {} files from trash.", count); - } - } - - Ok(()) -} - -fn parse_duration(s: &str) -> Option { - let s = s.trim(); - if s.is_empty() { - return None; - } - - let (num_str, unit) = if s.ends_with('d') { - (&s[..s.len() - 1], 'd') - } else if s.ends_with('h') { - (&s[..s.len() - 1], 'h') - } else if s.ends_with('m') { - (&s[..s.len() - 1], 'm') - } else if s.ends_with('s') { - (&s[..s.len() - 1], 's') - } else { - return None; - }; - - let num: u64 = num_str.parse().ok()?; - let secs = match unit { - 'd' => num * 86400, - 'h' => num * 3600, - 'm' => num * 60, - 's' => num, - _ => return None, - }; - - Some(std::time::Duration::from_secs(secs)) -} - -fn format_time_ago(timestamp: i64) -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - - let diff = now - timestamp; - if diff < 60 { - format!("{}s ago", diff) - } else if diff < 3600 { - format!("{}m ago", diff / 60) - } else if diff < 86400 { - format!("{}h ago", diff / 3600) - } else { - format!("{}d ago", diff / 86400) - } -} - -fn process_pending_restores(tree: &Arc>, db: &Arc) { - let restore_file = match std::env::var("MUSICFS_RESTORE_FILE") { - Ok(path) => PathBuf::from(path), - Err(_) => { - debug!("MUSICFS_RESTORE_FILE not set, no restores to process"); - return; - } - }; - - let restore_paths: Vec = match std::fs::read_to_string(&restore_file) { - Ok(content) => content.lines().map(|s| s.to_string()).collect(), - Err(e) => { - warn!(error = %e, path = ?restore_file, "failed to read restore file"); - return; - } - }; - - if restore_paths.is_empty() { - debug!("no paths to restore"); - return; - } - - let trashed = match db.list_trashed(&TrashedFilter::default()) { - Ok(files) => files, - Err(e) => { - warn!(error = %e, "failed to list trashed files"); - return; - } - }; - - let mut restored = 0; - for original_path_str in &restore_paths { - let matching: Vec<_> = trashed - .iter() - .filter(|f| { - f.original_path.as_str() == original_path_str - || f.original_path - .as_str() - .starts_with(&format!("{}/", original_path_str)) - }) - .collect(); - - for file in matching { - let parent_path = std::path::Path::new(file.original_path.as_str()) - .parent() - .map(|p| { - let s = p.to_string_lossy(); - if s.is_empty() { - VirtualPath::new("/") - } else { - VirtualPath::new(s.into_owned()) - } - }) - .unwrap_or_else(|| VirtualPath::new("/")); - - let mut tree_guard = tree.write(); - - if let Err(e) = tree_guard.mkdir_p(&parent_path) { - if !matches!(e, RenameError::TargetExists) { - warn!(error = ?e, path = %parent_path.as_str(), "failed to create parent for restore"); - continue; - } - } - - if let Err(e) = tree_guard.rename_file(&file.current_path, &file.original_path) { - warn!(error = ?e, from = %file.current_path.as_str(), to = %file.original_path.as_str(), "failed to restore file"); - continue; - } - - drop(tree_guard); - - if let Err(e) = db.update_virtual_path(file.file_id, &file.original_path) { - warn!(error = %e, "failed to update virtual path after restore"); - } - if let Err(e) = db.unmark_trashed(file.file_id) { - warn!(error = %e, "failed to unmark trashed after restore"); - } - - restored += 1; - info!(path = %file.original_path.as_str(), "restored file from trash"); - } - } - - let _ = std::fs::remove_file(&restore_file); - info!(count = restored, "restore complete"); -} - -fn init_logging(config: &LoggingConfig) -> Result { - std::fs::create_dir_all(&config.log_dir)?; - - let file_appender = tracing_appender::rolling::daily(&config.log_dir, "musicfs.log"); - let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); - - let file_layer = if config.json_output { - fmt::layer() - .json() - .with_writer(non_blocking) - .with_ansi(false) - .boxed() - } else { - fmt::layer() - .with_writer(non_blocking) - .with_ansi(false) - .boxed() - }; - - let stderr_layer = fmt::layer().with_writer(std::io::stderr).compact(); - - let filter = - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.level)); - - let subscriber = tracing_subscriber::registry() - .with(filter) - .with(file_layer) - .with(stderr_layer); - - #[cfg(target_os = "linux")] - let subscriber = { - let journald_layer = if config.journald { - tracing_journald::layer() - .ok() - .map(|l| l.with_syslog_identifier("musicfs".to_string())) - } else { - None - }; - subscriber.with(journald_layer) - }; - - subscriber.init(); - - info!(version = env!("CARGO_PKG_VERSION"), "MusicFS starting"); - Ok(guard) -} - -fn init_basic_logging(level: &str) { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); - - tracing_subscriber::registry() - .with(fmt::layer().compact()) - .with(filter) - .init(); -} - -async fn scan_music_files( - dir: &Path, - origin_id: &OriginId, - db: &Database, - format_registry: &Arc, -) -> Result> { - let parser = MetadataParser::new(); - let mut files = Vec::new(); - let mut file_id_counter = 1i64; - - scan_dir_recursive( - dir, - dir, - origin_id, - &parser, - db, - format_registry, - &mut files, - &mut file_id_counter, - ) - .await?; - - Ok(files) -} - -async fn scan_dir_recursive( - base: &Path, - dir: &Path, - origin_id: &OriginId, - parser: &MetadataParser, - db: &Database, - format_registry: &Arc, - files: &mut Vec, - id_counter: &mut i64, -) -> Result<()> { - let mut entries = tokio::fs::read_dir(dir).await?; - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - let metadata = entry.metadata().await?; - - if metadata.is_dir() { - Box::pin(scan_dir_recursive( - base, - &path, - origin_id, - parser, - db, - format_registry, - files, - id_counter, - )) - .await?; - } else if is_audio_file(&path) { - let relative_path = path.strip_prefix(base).unwrap_or(&path); - let real_path_for_db = PathBuf::from("/").join(relative_path); - - let audio_meta = match parser.parse_file(&path) { - Ok(meta) => Some(meta), - Err(e) => { - debug!("Failed to parse metadata for {:?}: {}", path, e); - None - } - }; - - let virtual_path = if let Ok(Some(stored_path)) = - db.get_file_by_real_path(origin_id, &real_path_for_db) - { - stored_path - } else { - build_virtual_path(&path, audio_meta.as_ref()) - }; - - let real_path = RealPath { - origin_id: origin_id.clone(), - path: real_path_for_db.clone(), - }; - - let format_layout = analyze_format_layout(&path, metadata.len(), format_registry); - - let file_id = db - .upsert_file_with_layout( - origin_id, - &real_path.path, - &virtual_path, - audio_meta.as_ref().unwrap_or(&Default::default()), - metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), - metadata.len(), - format_layout.as_ref(), - None, - ) - .unwrap_or_else(|e| { - debug!("Failed to upsert file to DB: {}", e); - FileId(*id_counter) - }); - - let file_meta = FileMeta { - id: file_id, - virtual_path, - real_path, - size: metadata.len(), - mtime: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), - content_hash: None, - audio: audio_meta, - }; - - debug!( - "Found: {:?} -> {:?}", - file_meta.real_path.path, file_meta.virtual_path - ); - files.push(file_meta); - *id_counter += 1; - } - } - - Ok(()) -} - -fn is_audio_file(path: &Path) -> bool { - matches!( - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .as_deref(), - Some("flac" | "mp3" | "ogg" | "wav" | "m4a" | "aac" | "opus") - ) -} - -const HEADER_READ_SIZE: usize = 65536; - -fn analyze_format_layout( - path: &Path, - file_size: u64, - registry: &FormatHandlerRegistry, -) -> Option { - let ext = path.extension().and_then(|e| e.to_str())?; - let handler = registry.get_by_extension(&ext.to_lowercase())?; - - let mut file = match std::fs::File::open(path) { - Ok(f) => f, - Err(e) => { - warn!("Failed to open file for format analysis {:?}: {}", path, e); - return None; - } - }; - - let mut buffer = vec![0u8; HEADER_READ_SIZE.min(file_size as usize)]; - if let Err(e) = file.read_exact(&mut buffer) { - warn!( - "Failed to read header for format analysis {:?}: {}", - path, e - ); - return None; - } - - match handler.analyze(&buffer, file_size) { - Ok(layout) => { - debug!( - "Format layout analyzed for {:?}: audio_start={}, audio_end={}", - path, layout.audio_start, layout.audio_end - ); - Some(layout) - } - Err(e) => { - debug!("Format analysis failed for {:?}: {}", path, e); - None - } - } -} - -fn build_virtual_path(path: &Path, audio: Option<&musicfs_core::AudioMeta>) -> VirtualPath { - if let Some(meta) = audio { - let artist = meta.artist.as_deref().unwrap_or("Unknown Artist"); - let album = meta.album.as_deref().unwrap_or("Unknown Album"); - let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("track"); - - VirtualPath::new(&format!( - "/{}/{}/{}", - sanitize(artist), - sanitize(album), - filename - )) - } else { - let filename = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - VirtualPath::new(&format!("/Unknown Artist/Unknown Album/{}", filename)) - } -} - -fn sanitize(s: &str) -> String { - s.chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', - _ => c, - }) - .collect() -} - -fn check_stale_mount(mountpoint: &Path) -> Result<()> { - if let Ok(mounts) = std::fs::read_to_string("/proc/mounts") { - for line in mounts.lines() { - if line.contains(mountpoint.to_string_lossy().as_ref()) && line.contains("fuse") { - warn!( - "Stale FUSE mount detected at {:?}, attempting cleanup", - mountpoint - ); - let status = std::process::Command::new("fusermount") - .args(["-uz", &mountpoint.to_string_lossy()]) - .status(); - match status { - Ok(s) if s.success() => info!("Stale mount cleaned up"), - Ok(s) => warn!("fusermount exited with: {}", s), - Err(e) => warn!("Failed to run fusermount: {}", e), - } - } - } - } - Ok(()) -} diff --git a/crates/musicfs-cli/src/metadata.rs b/crates/musicfs-cli/src/metadata.rs deleted file mode 100644 index 324d6ab..0000000 --- a/crates/musicfs-cli/src/metadata.rs +++ /dev/null @@ -1,638 +0,0 @@ -//! CLI subcommands for metadata overlay management. - -use anyhow::{Context, Result}; -use clap::Subcommand; -use musicfs_grpc::proto::musicfs::v1::{ - metadata_service_client::MetadataServiceClient, ClearOverlayRequest, GetMetadataRequest, - ImportMetadataRequest, UpdateMetadataRequest, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; -use tokio_stream::StreamExt; -use tonic::transport::Channel; -use tracing::{debug, info}; - -/// Metadata overlay management subcommands. -#[derive(Subcommand)] -pub enum MetadataCommand { - /// Get metadata for a file (prints as JSON) - Get { - /// Virtual path of the file - path: String, - /// Print only a specific field - #[arg(long)] - field: Option, - }, - /// Set metadata fields for a file - Set { - /// Virtual path of the file - path: String, - /// Track title - #[arg(long)] - title: Option, - /// Artist name - #[arg(long)] - artist: Option, - /// Album name - #[arg(long)] - album: Option, - /// Album artist - #[arg(long)] - album_artist: Option, - /// Track number - #[arg(long)] - track: Option, - /// Disc number - #[arg(long)] - disc: Option, - /// Genre - #[arg(long)] - genre: Option, - /// Date (YYYY-MM-DD or YYYY) - #[arg(long)] - date: Option, - /// Composer - #[arg(long)] - composer: Option, - /// Comment - #[arg(long)] - comment: Option, - /// Set metadata from JSON string - #[arg(long, conflicts_with_all = ["title", "artist", "album", "album_artist", "track", "disc", "genre", "date", "composer", "comment"])] - json: Option, - }, - /// Clear metadata overlay (revert to original) - Clear { - /// Virtual path of the file - path: String, - }, - /// Show difference between current and original metadata - Diff { - /// Virtual path of the file - path: String, - }, - /// Import metadata from CSV or JSON file - Import { - /// Import file path - file: PathBuf, - /// File format (csv or json, auto-detected if not specified) - #[arg(long)] - format: Option, - }, - /// Export metadata to file - Export { - /// Output file path - #[arg(long, short)] - output: PathBuf, - /// Filter by search query - #[arg(long)] - query: Option, - /// Output format (csv or json, auto-detected from extension) - #[arg(long)] - format: Option, - }, -} - -/// Metadata fields for JSON serialization. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct MetadataFields { - #[serde(skip_serializing_if = "Option::is_none")] - pub file_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub artist: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub album: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub album_artist: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub year: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub track: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub disc: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub genre: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub format: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub duration_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub bitrate: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub track_total: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub disc_total: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub composer: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub comment: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub lyrics: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub copyright: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub compilation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub artist_sort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub album_artist_sort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub album_sort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub title_sort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mb_recording_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mb_album_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mb_artist_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mb_album_artist_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mb_release_group_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub replaygain_track_gain: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub replaygain_track_peak: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub replaygain_album_gain: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub replaygain_album_peak: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub channels: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub bits_per_sample: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub encoder: Option, - #[serde(skip_serializing_if = "HashMap::is_empty", default)] - pub custom_tags: HashMap, -} - -/// Execute a metadata subcommand. -pub async fn run_metadata(command: MetadataCommand, endpoint: &str) -> Result<()> { - match command { - MetadataCommand::Get { path, field } => run_get(endpoint, &path, field.as_deref()).await, - MetadataCommand::Set { - path, - title, - artist, - album, - album_artist, - track, - disc, - genre, - date, - composer, - comment, - json, - } => { - run_set( - endpoint, - &path, - title, - artist, - album, - album_artist, - track, - disc, - genre, - date, - composer, - comment, - json, - ) - .await - } - MetadataCommand::Clear { path } => run_clear(endpoint, &path).await, - MetadataCommand::Diff { path } => run_diff(endpoint, &path).await, - MetadataCommand::Import { file, format } => run_import(endpoint, &file, format).await, - MetadataCommand::Export { - output, - query, - format, - } => run_export(endpoint, &output, query, format).await, - } -} - -async fn connect(endpoint: &str) -> Result> { - MetadataServiceClient::connect(endpoint.to_string()) - .await - .context("Failed to connect to gRPC server") -} - -async fn run_get(endpoint: &str, path: &str, field: Option<&str>) -> Result<()> { - let mut client = connect(endpoint).await?; - - let response = client - .get_metadata(GetMetadataRequest { - virtual_path: path.to_string(), - }) - .await - .context("GetMetadata RPC failed")?; - - let meta = response.into_inner(); - let fields = MetadataFields { - file_id: Some(meta.file_id), - title: meta.title, - artist: meta.artist, - album: meta.album, - album_artist: meta.album_artist, - year: meta.year, - track: meta.track, - disc: meta.disc, - genre: meta.genre, - format: meta.format, - duration_ms: meta.duration_ms, - bitrate: meta.bitrate, - track_total: meta.track_total, - disc_total: meta.disc_total, - date: meta.date, - composer: meta.composer, - comment: meta.comment, - lyrics: meta.lyrics, - copyright: meta.copyright, - compilation: meta.compilation, - artist_sort: meta.artist_sort, - album_artist_sort: meta.album_artist_sort, - album_sort: meta.album_sort, - title_sort: meta.title_sort, - mb_recording_id: meta.mb_recording_id, - mb_album_id: meta.mb_album_id, - mb_artist_id: meta.mb_artist_id, - mb_album_artist_id: meta.mb_album_artist_id, - mb_release_group_id: meta.mb_release_group_id, - replaygain_track_gain: meta.replaygain_track_gain, - replaygain_track_peak: meta.replaygain_track_peak, - replaygain_album_gain: meta.replaygain_album_gain, - replaygain_album_peak: meta.replaygain_album_peak, - channels: meta.channels, - bits_per_sample: meta.bits_per_sample, - encoder: meta.encoder, - custom_tags: meta.custom_tags, - }; - - if let Some(field_name) = field { - let value = get_field_value(&fields, field_name)?; - println!("{}", value); - } else { - let json = serde_json::to_string_pretty(&fields)?; - println!("{}", json); - } - - Ok(()) -} - -fn get_field_value(fields: &MetadataFields, field_name: &str) -> Result { - let value = match field_name { - "file_id" => fields.file_id.map(|v| v.to_string()), - "title" => fields.title.clone(), - "artist" => fields.artist.clone(), - "album" => fields.album.clone(), - "album_artist" => fields.album_artist.clone(), - "year" => fields.year.map(|v| v.to_string()), - "track" => fields.track.map(|v| v.to_string()), - "disc" => fields.disc.map(|v| v.to_string()), - "genre" => fields.genre.clone(), - "format" => fields.format.clone(), - "duration_ms" => fields.duration_ms.map(|v| v.to_string()), - "bitrate" => fields.bitrate.map(|v| v.to_string()), - "track_total" => fields.track_total.map(|v| v.to_string()), - "disc_total" => fields.disc_total.map(|v| v.to_string()), - "date" => fields.date.clone(), - "composer" => fields.composer.clone(), - "comment" => fields.comment.clone(), - "lyrics" => fields.lyrics.clone(), - "copyright" => fields.copyright.clone(), - "compilation" => fields.compilation.map(|v| v.to_string()), - "artist_sort" => fields.artist_sort.clone(), - "album_artist_sort" => fields.album_artist_sort.clone(), - "album_sort" => fields.album_sort.clone(), - "title_sort" => fields.title_sort.clone(), - "mb_recording_id" => fields.mb_recording_id.clone(), - "mb_album_id" => fields.mb_album_id.clone(), - "mb_artist_id" => fields.mb_artist_id.clone(), - "mb_album_artist_id" => fields.mb_album_artist_id.clone(), - "mb_release_group_id" => fields.mb_release_group_id.clone(), - "replaygain_track_gain" => fields.replaygain_track_gain.map(|v| v.to_string()), - "replaygain_track_peak" => fields.replaygain_track_peak.map(|v| v.to_string()), - "replaygain_album_gain" => fields.replaygain_album_gain.map(|v| v.to_string()), - "replaygain_album_peak" => fields.replaygain_album_peak.map(|v| v.to_string()), - "channels" => fields.channels.map(|v| v.to_string()), - "bits_per_sample" => fields.bits_per_sample.map(|v| v.to_string()), - "encoder" => fields.encoder.clone(), - _ => return Err(anyhow::anyhow!("Unknown field: {}", field_name)), - }; - - Ok(value.unwrap_or_else(|| "null".to_string())) -} - -#[allow(clippy::too_many_arguments)] -async fn run_set( - endpoint: &str, - path: &str, - title: Option, - artist: Option, - album: Option, - album_artist: Option, - track: Option, - disc: Option, - genre: Option, - date: Option, - composer: Option, - comment: Option, - json: Option, -) -> Result<()> { - let mut client = connect(endpoint).await?; - - let get_response = client - .get_metadata(GetMetadataRequest { - virtual_path: path.to_string(), - }) - .await - .context("Failed to get file metadata")?; - - let file_id = get_response.into_inner().file_id; - - let request = if let Some(json_str) = json { - let fields: MetadataFields = - serde_json::from_str(&json_str).context("Failed to parse JSON metadata")?; - UpdateMetadataRequest { - file_id, - title: fields.title, - artist: fields.artist, - album: fields.album, - album_artist: fields.album_artist, - track_number: fields.track, - disc_number: fields.disc, - genre: fields.genre, - date: fields.date, - composer: fields.composer, - comment: fields.comment, - lyrics: fields.lyrics, - copyright: fields.copyright, - compilation: fields.compilation, - artist_sort: fields.artist_sort, - album_artist_sort: fields.album_artist_sort, - album_sort: fields.album_sort, - title_sort: fields.title_sort, - mb_recording_id: fields.mb_recording_id, - mb_album_id: fields.mb_album_id, - mb_artist_id: fields.mb_artist_id, - replaygain_track_gain: fields.replaygain_track_gain, - replaygain_track_peak: fields.replaygain_track_peak, - replaygain_album_gain: fields.replaygain_album_gain, - replaygain_album_peak: fields.replaygain_album_peak, - label: None, - album_type: None, - cover_url: None, - custom_tags: fields.custom_tags, - } - } else { - UpdateMetadataRequest { - file_id, - title, - artist, - album, - album_artist, - track_number: track, - disc_number: disc, - genre, - date, - composer, - comment, - lyrics: None, - copyright: None, - compilation: None, - artist_sort: None, - album_artist_sort: None, - album_sort: None, - title_sort: None, - mb_recording_id: None, - mb_album_id: None, - mb_artist_id: None, - replaygain_track_gain: None, - replaygain_track_peak: None, - replaygain_album_gain: None, - replaygain_album_peak: None, - label: None, - album_type: None, - cover_url: None, - custom_tags: HashMap::new(), - } - }; - - let response = client - .update_metadata(request) - .await - .context("UpdateMetadata RPC failed")?; - - let result = response.into_inner(); - if result.success { - info!(file_id = result.file_id, "Metadata updated successfully"); - println!("Metadata updated for file_id={}", result.file_id); - } else { - let msg = result - .error_message - .unwrap_or_else(|| "Unknown error".to_string()); - anyhow::bail!("Failed to update metadata: {}", msg); - } - - Ok(()) -} - -async fn run_clear(endpoint: &str, path: &str) -> Result<()> { - let mut client = connect(endpoint).await?; - - let get_response = client - .get_metadata(GetMetadataRequest { - virtual_path: path.to_string(), - }) - .await - .context("Failed to get file metadata")?; - - let file_id = get_response.into_inner().file_id; - - let response = client - .clear_overlay(ClearOverlayRequest { file_id }) - .await - .context("ClearOverlay RPC failed")?; - - let result = response.into_inner(); - if result.success { - info!(file_id = result.file_id, "Overlay cleared successfully"); - println!("Metadata overlay cleared for file_id={}", result.file_id); - } else { - let msg = result - .error_message - .unwrap_or_else(|| "Unknown error".to_string()); - anyhow::bail!("Failed to clear overlay: {}", msg); - } - - Ok(()) -} - -async fn run_diff(endpoint: &str, path: &str) -> Result<()> { - let mut client = connect(endpoint).await?; - - let response = client - .get_metadata(GetMetadataRequest { - virtual_path: path.to_string(), - }) - .await - .context("GetMetadata RPC failed")?; - - let meta = response.into_inner(); - debug!(file_id = meta.file_id, "Retrieved metadata for diff"); - - println!("Current metadata for: {}", path); - println!("---"); - - let fields = MetadataFields { - file_id: Some(meta.file_id), - title: meta.title, - artist: meta.artist, - album: meta.album, - album_artist: meta.album_artist, - year: meta.year, - track: meta.track, - disc: meta.disc, - genre: meta.genre, - format: meta.format, - duration_ms: meta.duration_ms, - bitrate: meta.bitrate, - track_total: meta.track_total, - disc_total: meta.disc_total, - date: meta.date, - composer: meta.composer, - comment: meta.comment, - lyrics: meta.lyrics, - copyright: meta.copyright, - compilation: meta.compilation, - artist_sort: meta.artist_sort, - album_artist_sort: meta.album_artist_sort, - album_sort: meta.album_sort, - title_sort: meta.title_sort, - mb_recording_id: meta.mb_recording_id, - mb_album_id: meta.mb_album_id, - mb_artist_id: meta.mb_artist_id, - mb_album_artist_id: meta.mb_album_artist_id, - mb_release_group_id: meta.mb_release_group_id, - replaygain_track_gain: meta.replaygain_track_gain, - replaygain_track_peak: meta.replaygain_track_peak, - replaygain_album_gain: meta.replaygain_album_gain, - replaygain_album_peak: meta.replaygain_album_peak, - channels: meta.channels, - bits_per_sample: meta.bits_per_sample, - encoder: meta.encoder, - custom_tags: meta.custom_tags, - }; - - let json = serde_json::to_string_pretty(&fields)?; - println!("{}", json); - println!("---"); - println!("Note: Original metadata comparison requires re-parsing the source file."); - println!("Use 'musicfs metadata clear ' to revert to original metadata."); - - Ok(()) -} - -async fn run_import(endpoint: &str, file: &PathBuf, format: Option) -> Result<()> { - let mut client = connect(endpoint).await?; - - let file_format = format.or_else(|| { - file.extension() - .and_then(|e| e.to_str()) - .map(|s| s.to_lowercase()) - }); - - let source_path = file - .canonicalize() - .unwrap_or_else(|_| file.clone()) - .to_string_lossy() - .to_string(); - - info!(source_path = %source_path, format = ?file_format, "Starting metadata import"); - - let response = client - .import_metadata(ImportMetadataRequest { - source_path, - format: file_format, - }) - .await - .context("ImportMetadata RPC failed")?; - - let mut stream = response.into_inner(); - let mut last_imported = 0u32; - let mut last_total = 0u32; - let mut errors = Vec::new(); - - while let Some(progress) = stream.next().await { - let progress = progress.context("Stream error")?; - last_imported = progress.imported; - last_total = progress.total; - - if let Some(ref err) = progress.error_message { - let file = progress.current_file.as_deref().unwrap_or("unknown"); - errors.push(format!("{}: {}", file, err)); - } - - if let Some(ref current) = progress.current_file { - print!( - "\rImporting: {}/{} - {}", - progress.imported, progress.total, current - ); - std::io::Write::flush(&mut std::io::stdout())?; - } - } - - println!(); - println!( - "Import complete: {}/{} files imported", - last_imported, last_total - ); - - if !errors.is_empty() { - println!("\nErrors ({}):", errors.len()); - for err in errors.iter().take(10) { - println!(" - {}", err); - } - if errors.len() > 10 { - println!(" ... and {} more", errors.len() - 10); - } - } - - Ok(()) -} - -async fn run_export( - _endpoint: &str, - output: &PathBuf, - query: Option, - format: Option, -) -> Result<()> { - let output_format = format.or_else(|| { - output - .extension() - .and_then(|e| e.to_str()) - .map(|s| s.to_lowercase()) - }); - - println!("Export metadata to: {}", output.display()); - if let Some(ref q) = query { - println!("Filter query: {}", q); - } - println!("Format: {}", output_format.as_deref().unwrap_or("json")); - println!(); - println!("Note: Export requires file listing capability."); - println!("This feature requires integration with the Search service."); - println!( - "Use 'musicfs search ' to find files, then 'musicfs metadata get ' for each." - ); - - Ok(()) -} diff --git a/crates/musicfs-core/Cargo.toml b/crates/musicfs-core/Cargo.toml deleted file mode 100644 index 35a2597..0000000 --- a/crates/musicfs-core/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "musicfs-core" -version.workspace = true -edition.workspace = true - -[dependencies] -thiserror.workspace = true -serde.workspace = true -serde_json.workspace = true -toml.workspace = true -tokio = { workspace = true, features = ["sync"] } -tracing.workspace = true -xxhash-rust.workspace = true -hex.workspace = true -parking_lot.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/musicfs-core/src/config.rs b/crates/musicfs-core/src/config.rs deleted file mode 100644 index 8a2808a..0000000 --- a/crates/musicfs-core/src/config.rs +++ /dev/null @@ -1,239 +0,0 @@ -use crate::OriginId; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - pub mount_point: PathBuf, - pub cache_dir: PathBuf, - pub origins: Vec, - - #[serde(default)] - pub cache: CacheConfig, - - #[serde(default)] - pub health: HealthConfig, - - #[serde(default)] - pub logging: LoggingConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OriginConfig { - pub id: String, - pub origin_type: OriginType, - pub priority: u8, - - #[serde(default = "default_enabled")] - pub enabled: bool, - - #[serde(flatten)] - pub settings: HashMap, -} - -fn default_enabled() -> bool { - true -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "lowercase")] -pub enum OriginType { - Local, - Nfs, - Smb, - S3, - Sftp, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CacheConfig { - #[serde(default = "default_metadata_cache_mb")] - pub metadata_cache_mb: u64, - - #[serde(default = "default_content_cache_gb")] - pub content_cache_gb: u64, -} - -impl Default for CacheConfig { - fn default() -> Self { - Self { - metadata_cache_mb: default_metadata_cache_mb(), - content_cache_gb: default_content_cache_gb(), - } - } -} - -fn default_metadata_cache_mb() -> u64 { - 100 -} -fn default_content_cache_gb() -> u64 { - 10 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HealthConfig { - #[serde(default = "default_check_interval_secs")] - pub check_interval_secs: u64, - - #[serde(default = "default_timeout_ms")] - pub timeout_ms: u64, - - #[serde(default = "default_unhealthy_threshold")] - pub unhealthy_threshold: u32, - - #[serde(default)] - pub per_origin_thresholds: HashMap, -} - -impl Default for HealthConfig { - fn default() -> Self { - let mut per_origin = HashMap::new(); - per_origin.insert(OriginType::Local, 1); - per_origin.insert(OriginType::Nfs, 3); - per_origin.insert(OriginType::Smb, 3); - per_origin.insert(OriginType::S3, 3); - per_origin.insert(OriginType::Sftp, 3); - - Self { - check_interval_secs: default_check_interval_secs(), - timeout_ms: default_timeout_ms(), - unhealthy_threshold: default_unhealthy_threshold(), - per_origin_thresholds: per_origin, - } - } -} - -impl HealthConfig { - pub fn threshold_for(&self, origin_type: OriginType) -> u32 { - self.per_origin_thresholds - .get(&origin_type) - .copied() - .unwrap_or(self.unhealthy_threshold) - } -} - -fn default_check_interval_secs() -> u64 { - 30 -} -fn default_timeout_ms() -> u64 { - 5000 -} -fn default_unhealthy_threshold() -> u32 { - 3 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoggingConfig { - #[serde(default = "default_log_dir")] - pub log_dir: PathBuf, - - #[serde(default)] - pub json_output: bool, - - #[serde(default = "default_true")] - pub journald: bool, - - #[serde(default = "default_log_level")] - pub level: String, - - #[serde(default = "default_sample_rate")] - pub trace_sample_rate: f32, -} - -impl Default for LoggingConfig { - fn default() -> Self { - Self { - log_dir: default_log_dir(), - json_output: false, - journald: true, - level: default_log_level(), - trace_sample_rate: default_sample_rate(), - } - } -} - -fn default_log_dir() -> PathBuf { - PathBuf::from("/var/log/musicfs") -} - -fn default_log_level() -> String { - "musicfs=info,warn".to_string() -} - -fn default_true() -> bool { - true -} - -fn default_sample_rate() -> f32 { - 1.0 -} - -impl Config { - pub fn from_file(path: &std::path::Path) -> Result { - let content = - std::fs::read_to_string(path).map_err(|e| ConfigError::Read(e.to_string()))?; - toml::from_str(&content).map_err(|e| ConfigError::Parse(e.to_string())) - } - - pub fn origin_id(&self, id: &str) -> Option { - self.origins - .iter() - .find(|o| o.id == id) - .map(|_| OriginId::from(id)) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum ConfigError { - #[error("Failed to read config: {0}")] - Read(String), - - #[error("Failed to parse config: {0}")] - Parse(String), -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_config() { - let toml = r#" -mount_point = "/mnt/music" -cache_dir = "/home/user/.cache/musicfs" - -[[origins]] -id = "local" -origin_type = "local" -priority = 1 -path = "/mnt/nas/music" - -[[origins]] -id = "backup" -origin_type = "s3" -priority = 2 -bucket = "music-backup" -region = "us-east-1" -"#; - - let config: Config = toml::from_str(toml).unwrap(); - assert_eq!(config.origins.len(), 2); - assert_eq!(config.origins[0].priority, 1); - assert_eq!(config.origins[1].origin_type, OriginType::S3); - } - - #[test] - fn test_default_health_thresholds() { - let health = HealthConfig::default(); - assert_eq!(health.threshold_for(OriginType::Local), 1); - assert_eq!(health.threshold_for(OriginType::Sftp), 3); - } - - #[test] - fn test_cache_defaults() { - let cache = CacheConfig::default(); - assert_eq!(cache.metadata_cache_mb, 100); - assert_eq!(cache.content_cache_gb, 10); - } -} diff --git a/crates/musicfs-core/src/credentials.rs b/crates/musicfs-core/src/credentials.rs deleted file mode 100644 index d7c75f1..0000000 --- a/crates/musicfs-core/src/credentials.rs +++ /dev/null @@ -1,284 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; -use thiserror::Error; -use tracing::{debug, info, trace, warn}; - -#[derive(Clone)] -pub struct CredentialStore { - cache: HashMap, -} - -impl std::fmt::Debug for CredentialStore { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CredentialStore") - .field("cache_keys", &self.cache.keys().collect::>()) - .finish() - } -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum Credential { - Basic { - username: String, - #[serde(skip_serializing)] - password: String, - }, - - AwsKey { - access_key_id: String, - #[serde(skip_serializing)] - secret_access_key: String, - session_token: Option, - region: String, - }, - - SshKey { - username: String, - private_key_path: PathBuf, - #[serde(skip_serializing)] - passphrase: Option, - }, - - EnvVar { - var_name: String, - }, -} - -impl std::fmt::Debug for Credential { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Basic { username, .. } => f - .debug_struct("Basic") - .field("username", username) - .field("password", &"[REDACTED]") - .finish(), - Self::AwsKey { - access_key_id, - session_token, - region, - .. - } => { - let key_preview = if access_key_id.len() > 4 { - format!("{}...", &access_key_id[..4]) - } else { - "****".to_string() - }; - let token_display = if session_token.is_some() { - "[REDACTED]" - } else { - "None" - }; - f.debug_struct("AwsKey") - .field("access_key_id", &key_preview) - .field("secret_access_key", &"[REDACTED]") - .field("session_token", &token_display) - .field("region", region) - .finish() - } - Self::SshKey { - username, - private_key_path, - .. - } => f - .debug_struct("SshKey") - .field("username", username) - .field("private_key_path", private_key_path) - .field("passphrase", &"[REDACTED]") - .finish(), - Self::EnvVar { var_name } => f - .debug_struct("EnvVar") - .field("var_name", var_name) - .finish(), - } - } -} - -impl CredentialStore { - pub fn new() -> Self { - Self { - cache: HashMap::new(), - } - } - - pub fn load( - &mut self, - origin_id: &str, - config: &CredentialConfig, - ) -> Result { - debug!(origin_id = %origin_id, "Loading credentials"); - - if let Some(cred) = self.cache.get(origin_id) { - trace!(origin_id = %origin_id, "Credential cache hit"); - return Ok(cred.clone()); - } - - let cred = match config { - CredentialConfig::Environment { prefix } => { - trace!(origin_id = %origin_id, prefix = %prefix, "Loading from environment"); - self.load_from_env(prefix)? - } - CredentialConfig::File { path } => { - trace!(origin_id = %origin_id, path = ?path, "Loading from file"); - self.load_from_file(path)? - } - CredentialConfig::Inline(cred) => { - trace!(origin_id = %origin_id, "Using inline credential"); - cred.clone() - } - }; - - let cred_type = match &cred { - Credential::Basic { .. } => "Basic", - Credential::AwsKey { .. } => "AwsKey", - Credential::SshKey { .. } => "SshKey", - Credential::EnvVar { .. } => "EnvVar", - }; - info!(origin_id = %origin_id, cred_type = %cred_type, "Credential loaded"); - - self.cache.insert(origin_id.to_string(), cred.clone()); - Ok(cred) - } - - fn load_from_env(&self, prefix: &str) -> Result { - if let (Ok(key), Ok(secret)) = ( - std::env::var(format!("{}_ACCESS_KEY_ID", prefix)), - std::env::var(format!("{}_SECRET_ACCESS_KEY", prefix)), - ) { - return Ok(Credential::AwsKey { - access_key_id: key, - secret_access_key: secret, - session_token: std::env::var(format!("{}_SESSION_TOKEN", prefix)).ok(), - region: std::env::var(format!("{}_REGION", prefix)) - .unwrap_or_else(|_| "us-east-1".to_string()), - }); - } - - if let (Ok(user), Ok(pass)) = ( - std::env::var(format!("{}_USERNAME", prefix)), - std::env::var(format!("{}_PASSWORD", prefix)), - ) { - return Ok(Credential::Basic { - username: user, - password: pass, - }); - } - - warn!(prefix = %prefix, "No credentials found in environment"); - Err(CredentialError::NotFound(format!( - "No credentials found with prefix {}", - prefix - ))) - } - - fn load_from_file(&self, path: &PathBuf) -> Result { - let content = - std::fs::read_to_string(path).map_err(|e| CredentialError::FileRead(e.to_string()))?; - - if path.extension().map(|e| e == "json").unwrap_or(false) { - serde_json::from_str(&content).map_err(|e| CredentialError::Parse(e.to_string())) - } else { - toml::from_str(&content).map_err(|e| CredentialError::Parse(e.to_string())) - } - } - - pub fn clear(&mut self) { - self.cache.clear(); - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "source")] -pub enum CredentialConfig { - Environment { prefix: String }, - File { path: PathBuf }, - Inline(Credential), -} - -#[derive(Debug, Error)] -pub enum CredentialError { - #[error("Credential not found: {0}")] - NotFound(String), - - #[error("Failed to read credential file: {0}")] - FileRead(String), - - #[error("Failed to parse credential: {0}")] - Parse(String), -} - -impl Default for CredentialStore { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_credential_debug_redacted() { - let cred = Credential::Basic { - username: "user".to_string(), - password: "secret123".to_string(), - }; - - let debug_output = format!("{:?}", cred); - assert!(debug_output.contains("user")); - assert!(!debug_output.contains("secret123")); - assert!(debug_output.contains("[REDACTED]")); - } - - #[test] - fn test_aws_credential_debug_redacted() { - let cred = Credential::AwsKey { - access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), - secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), - session_token: None, - region: "us-east-1".to_string(), - }; - - let debug_output = format!("{:?}", cred); - assert!(debug_output.contains("AKIA...")); - assert!(!debug_output.contains("wJalrXUtnFEMI")); - assert!(debug_output.contains("[REDACTED]")); - } - - #[test] - fn test_credential_store_debug() { - let mut store = CredentialStore::new(); - store.cache.insert( - "test".to_string(), - Credential::Basic { - username: "user".to_string(), - password: "secret".to_string(), - }, - ); - - let debug_output = format!("{:?}", store); - assert!(debug_output.contains("test")); - assert!(!debug_output.contains("secret")); - } - - #[test] - fn test_load_from_env() { - std::env::set_var("TEST_ORIGIN_USERNAME", "testuser"); - std::env::set_var("TEST_ORIGIN_PASSWORD", "testpass"); - - let store = CredentialStore::new(); - let cred = store.load_from_env("TEST_ORIGIN").unwrap(); - - match cred { - Credential::Basic { username, password } => { - assert_eq!(username, "testuser"); - assert_eq!(password, "testpass"); - } - _ => panic!("Expected Basic credential"), - } - - std::env::remove_var("TEST_ORIGIN_USERNAME"); - std::env::remove_var("TEST_ORIGIN_PASSWORD"); - } -} diff --git a/crates/musicfs-core/src/error.rs b/crates/musicfs-core/src/error.rs deleted file mode 100644 index 4b754e5..0000000 --- a/crates/musicfs-core/src/error.rs +++ /dev/null @@ -1,70 +0,0 @@ -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum Error { - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - - #[error("Origin not found: {0}")] - OriginNotFound(String), - - #[error("File not found: {0}")] - FileNotFound(String), - - #[error("Path resolution failed: {0}")] - PathResolution(String), - - #[error("Cache error: {0}")] - Cache(String), - - #[error("Metadata extraction error: {0}")] - Metadata(String), - - #[error("Database error: {0}")] - Database(String), - - #[error("Database corrupted: {0}")] - DatabaseCorrupted(String), - - #[error("NFS stale file handle")] - NfsStaleHandle, - - #[error("Operation not permitted (read-only filesystem)")] - ReadOnly, - - #[error("No origin available to serve request")] - NoOriginAvailable, - - #[error("Maximum retries exceeded")] - MaxRetriesExceeded, - - #[error("Origin error: {0}")] - Origin(String), - - #[error("S3 error: {0}")] - S3(String), - - #[error("SFTP error: {0}")] - Sftp(String), - - #[error("Operation timed out: {0}")] - Timeout(String), - - #[error("Credential error: {0}")] - Credential(String), -} - -pub type Result = std::result::Result; - -impl Error { - pub fn is_not_found(&self) -> bool { - matches!(self, Error::FileNotFound(_)) - } - - pub fn downcast_io(&self) -> Option<&std::io::Error> { - match self { - Error::Io(e) => Some(e), - _ => None, - } - } -} diff --git a/crates/musicfs-core/src/events.rs b/crates/musicfs-core/src/events.rs deleted file mode 100644 index 2aea6d6..0000000 --- a/crates/musicfs-core/src/events.rs +++ /dev/null @@ -1,113 +0,0 @@ -use crate::types::{FileId, OriginId, VirtualPath}; -use tokio::sync::broadcast; -use tracing::{debug, trace}; - -pub struct EventBus { - sender: broadcast::Sender, -} - -impl EventBus { - pub fn new(capacity: usize) -> Self { - let (sender, _) = broadcast::channel(capacity); - Self { sender } - } - - pub fn publish(&self, event: Event) { - trace!(event = ?event, "Publishing event"); - let receiver_count = self.sender.receiver_count(); - if self.sender.send(event).is_err() && receiver_count > 0 { - debug!( - receiver_count = receiver_count, - "Event dropped, no active receivers" - ); - } - } - - pub fn subscribe(&self) -> broadcast::Receiver { - self.sender.subscribe() - } -} - -impl Default for EventBus { - fn default() -> Self { - Self::new(1024) - } -} - -#[derive(Clone, Debug)] -pub enum Event { - FileAdded { - path: VirtualPath, - origin_id: OriginId, - }, - FileRemoved { - path: VirtualPath, - file_id: Option, - }, - FileModified { - path: VirtualPath, - }, - FileAccessed { - file_id: FileId, - path: VirtualPath, - origin_id: OriginId, - offset: u64, - size: u32, - }, - OriginConnected { - origin_id: OriginId, - }, - OriginDisconnected { - origin_id: OriginId, - }, - SyncStarted { - origin_id: OriginId, - }, - SyncCompleted { - origin_id: OriginId, - files_changed: u64, - }, - CacheEviction { - bytes_freed: u64, - }, - AllOriginsUnhealthy { - candidate_count: usize, - }, - OriginHealthChanged { - origin_id: OriginId, - healthy: bool, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_event_bus() { - let bus = EventBus::new(16); - let mut rx = bus.subscribe(); - - bus.publish(Event::SyncStarted { - origin_id: OriginId::from("test"), - }); - - let event = rx.recv().await.unwrap(); - assert!(matches!(event, Event::SyncStarted { .. })); - } - - #[tokio::test] - async fn test_event_bus_multiple_subscribers() { - let bus = EventBus::new(16); - let mut rx1 = bus.subscribe(); - let mut rx2 = bus.subscribe(); - - bus.publish(Event::CacheEviction { bytes_freed: 1024 }); - - let e1 = rx1.recv().await.unwrap(); - let e2 = rx2.recv().await.unwrap(); - - assert!(matches!(e1, Event::CacheEviction { bytes_freed: 1024 })); - assert!(matches!(e2, Event::CacheEviction { bytes_freed: 1024 })); - } -} diff --git a/crates/musicfs-core/src/lib.rs b/crates/musicfs-core/src/lib.rs deleted file mode 100644 index bf51af4..0000000 --- a/crates/musicfs-core/src/lib.rs +++ /dev/null @@ -1,60 +0,0 @@ -pub mod config; -pub mod credentials; -pub mod error; -pub mod events; -pub mod metrics; -pub mod resolver; -pub mod supervisor; -pub mod types; - -pub use config::{ - CacheConfig, Config, ConfigError, HealthConfig, LoggingConfig, OriginConfig, OriginType, -}; - -use std::path::Path; - -pub fn sanitize_path(path: &Path) -> String { - if let Ok(home) = std::env::var("HOME") { - path.to_string_lossy().replace(&home, "~") - } else { - path.to_string_lossy().to_string() - } -} - -/// Install a custom panic hook that logs panics via tracing before the default behavior. -/// This ensures panics are captured in log files and journald. -pub fn install_panic_hook() { - let default_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let thread = std::thread::current(); - let thread_name = thread.name().unwrap_or(""); - - let message = if let Some(s) = info.payload().downcast_ref::<&str>() { - (*s).to_string() - } else if let Some(s) = info.payload().downcast_ref::() { - s.clone() - } else { - "unknown panic".to_string() - }; - - let location = info - .location() - .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) - .unwrap_or_else(|| "unknown location".to_string()); - - tracing::error!( - thread = thread_name, - location = %location, - "PANIC: {}", - message - ); - - default_hook(info); - })); -} -pub use credentials::{Credential, CredentialConfig, CredentialError, CredentialStore}; -pub use error::{Error, Result}; -pub use events::{Event, EventBus}; -pub use metrics::{CacheMetrics, FuseOpsMetrics, Metrics, OriginsMetrics}; -pub use resolver::{PathResolver, PathTemplate}; -pub use types::*; diff --git a/crates/musicfs-core/src/metrics.rs b/crates/musicfs-core/src/metrics.rs deleted file mode 100644 index d1491c8..0000000 --- a/crates/musicfs-core/src/metrics.rs +++ /dev/null @@ -1,322 +0,0 @@ -use parking_lot::RwLock; -use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Instant; - -#[derive(Default)] -pub struct Metrics { - pub fuse_ops: FuseOpsMetrics, - pub fuse_latency: FuseLatencyMetrics, - pub cache: CacheMetrics, - pub origins: OriginsMetrics, - pub origin_health: OriginHealthMetrics, - start_time: Option, -} - -impl Metrics { - pub fn new() -> Self { - Self { - start_time: Some(Instant::now()), - ..Default::default() - } - } - - pub fn uptime_secs(&self) -> u64 { - self.start_time.map(|t| t.elapsed().as_secs()).unwrap_or(0) - } - - pub fn to_prometheus(&self) -> String { - let mut output = String::new(); - - output.push_str(&format!( - "# HELP musicfs_fuse_ops_total Total FUSE operations\n\ - # TYPE musicfs_fuse_ops_total counter\n\ - musicfs_fuse_ops_total{{op=\"lookup\"}} {}\n\ - musicfs_fuse_ops_total{{op=\"getattr\"}} {}\n\ - musicfs_fuse_ops_total{{op=\"read\"}} {}\n\ - musicfs_fuse_ops_total{{op=\"readdir\"}} {}\n\ - musicfs_fuse_ops_total{{op=\"open\"}} {}\n", - self.fuse_ops.lookup.load(Ordering::Relaxed), - self.fuse_ops.getattr.load(Ordering::Relaxed), - self.fuse_ops.read.load(Ordering::Relaxed), - self.fuse_ops.readdir.load(Ordering::Relaxed), - self.fuse_ops.open.load(Ordering::Relaxed), - )); - - for (op, histogram) in self.fuse_latency.histograms.read().iter() { - let quantiles = histogram.quantiles(); - output.push_str(&format!( - "# HELP musicfs_fuse_latency_seconds FUSE operation latency\n\ - # TYPE musicfs_fuse_latency_seconds summary\n\ - musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.5\"}} {:.6}\n\ - musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.95\"}} {:.6}\n\ - musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.99\"}} {:.6}\n\ - musicfs_fuse_latency_seconds_sum{{op=\"{}\"}} {:.6}\n\ - musicfs_fuse_latency_seconds_count{{op=\"{}\"}} {}\n", - op, - quantiles.p50, - op, - quantiles.p95, - op, - quantiles.p99, - op, - histogram.sum_secs(), - op, - histogram.count(), - )); - } - - output.push_str(&format!( - "# HELP musicfs_cache_hits_total Cache hits\n\ - # TYPE musicfs_cache_hits_total counter\n\ - musicfs_cache_hits_total {}\n", - self.cache.hits.load(Ordering::Relaxed), - )); - - output.push_str(&format!( - "# HELP musicfs_cache_misses_total Cache misses\n\ - # TYPE musicfs_cache_misses_total counter\n\ - musicfs_cache_misses_total {}\n", - self.cache.misses.load(Ordering::Relaxed), - )); - - output.push_str(&format!( - "# HELP musicfs_cache_size_bytes Current cache size in bytes\n\ - # TYPE musicfs_cache_size_bytes gauge\n\ - musicfs_cache_size_bytes {}\n", - self.cache.size_bytes.load(Ordering::Relaxed), - )); - - output.push_str(&format!( - "# HELP musicfs_cache_chunks_total Number of cached chunks\n\ - # TYPE musicfs_cache_chunks_total gauge\n\ - musicfs_cache_chunks_total {}\n", - self.cache.chunk_count.load(Ordering::Relaxed), - )); - - output.push_str( - "# HELP musicfs_origin_health Origin health status (1=healthy, 0=unhealthy)\n\ - # TYPE musicfs_origin_health gauge\n", - ); - for (origin_id, healthy) in self.origin_health.status.read().iter() { - output.push_str(&format!( - "musicfs_origin_health{{origin=\"{}\"}} {}\n", - origin_id, - if *healthy { 1 } else { 0 } - )); - } - - output.push_str(&format!( - "# HELP musicfs_uptime_seconds Daemon uptime in seconds\n\ - # TYPE musicfs_uptime_seconds gauge\n\ - musicfs_uptime_seconds {}\n", - self.uptime_secs(), - )); - - output - } - - pub fn hit_ratio(&self) -> f64 { - let hits = self.cache.hits.load(Ordering::Relaxed) as f64; - let misses = self.cache.misses.load(Ordering::Relaxed) as f64; - let total = hits + misses; - - if total == 0.0 { - 0.0 - } else { - hits / total - } - } -} - -#[derive(Default)] -pub struct FuseOpsMetrics { - pub lookup: AtomicU64, - pub getattr: AtomicU64, - pub read: AtomicU64, - pub readdir: AtomicU64, - pub open: AtomicU64, -} - -impl FuseOpsMetrics { - pub fn record_lookup(&self) { - self.lookup.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_getattr(&self) { - self.getattr.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_read(&self) { - self.read.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_readdir(&self) { - self.readdir.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_open(&self) { - self.open.fetch_add(1, Ordering::Relaxed); - } -} - -#[derive(Default)] -pub struct CacheMetrics { - pub hits: AtomicU64, - pub misses: AtomicU64, - pub size_bytes: AtomicU64, - pub chunk_count: AtomicU64, -} - -impl CacheMetrics { - pub fn record_hit(&self) { - self.hits.fetch_add(1, Ordering::Relaxed); - } - - pub fn record_miss(&self) { - self.misses.fetch_add(1, Ordering::Relaxed); - } - - pub fn update_size(&self, size: u64) { - self.size_bytes.store(size, Ordering::Relaxed); - } - - pub fn update_chunk_count(&self, count: u64) { - self.chunk_count.store(count, Ordering::Relaxed); - } -} - -#[derive(Default)] -pub struct OriginsMetrics { - pub healthy_count: AtomicU64, - pub total_count: AtomicU64, -} - -impl OriginsMetrics { - pub fn update(&self, healthy: u64, total: u64) { - self.healthy_count.store(healthy, Ordering::Relaxed); - self.total_count.store(total, Ordering::Relaxed); - } -} - -#[derive(Default)] -pub struct FuseLatencyMetrics { - pub histograms: RwLock>, -} - -impl FuseLatencyMetrics { - pub fn record(&self, op: &str, latency_secs: f64) { - let mut histograms = self.histograms.write(); - histograms - .entry(op.to_string()) - .or_default() - .record(latency_secs); - } -} - -#[derive(Default)] -pub struct LatencyHistogram { - samples: Vec, - sum: f64, -} - -impl LatencyHistogram { - pub fn record(&mut self, latency_secs: f64) { - self.samples.push(latency_secs); - self.sum += latency_secs; - - if self.samples.len() > 10000 { - self.samples.drain(..5000); - } - } - - pub fn quantiles(&self) -> Quantiles { - if self.samples.is_empty() { - return Quantiles::default(); - } - - let mut sorted = self.samples.clone(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - - let len = sorted.len(); - Quantiles { - p50: sorted[len / 2], - p95: sorted[(len as f64 * 0.95) as usize], - p99: sorted[(len as f64 * 0.99) as usize], - } - } - - pub fn sum_secs(&self) -> f64 { - self.sum - } - - pub fn count(&self) -> u64 { - self.samples.len() as u64 - } -} - -#[derive(Default)] -pub struct Quantiles { - pub p50: f64, - pub p95: f64, - pub p99: f64, -} - -#[derive(Default)] -pub struct OriginHealthMetrics { - pub status: RwLock>, -} - -impl OriginHealthMetrics { - pub fn set_health(&self, origin_id: &str, healthy: bool) { - self.status.write().insert(origin_id.to_string(), healthy); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_metrics_new() { - let metrics = Metrics::new(); - assert!(metrics.uptime_secs() < 5); - } - - #[test] - fn test_fuse_ops_recording() { - let metrics = Metrics::new(); - metrics.fuse_ops.record_lookup(); - metrics.fuse_ops.record_lookup(); - metrics.fuse_ops.record_read(); - - assert_eq!(metrics.fuse_ops.lookup.load(Ordering::Relaxed), 2); - assert_eq!(metrics.fuse_ops.read.load(Ordering::Relaxed), 1); - } - - #[test] - fn test_cache_hit_ratio() { - let metrics = Metrics::new(); - metrics.cache.hits.store(8, Ordering::Relaxed); - metrics.cache.misses.store(2, Ordering::Relaxed); - - assert!((metrics.hit_ratio() - 0.8).abs() < 0.001); - } - - #[test] - fn test_cache_hit_ratio_zero() { - let metrics = Metrics::new(); - assert_eq!(metrics.hit_ratio(), 0.0); - } - - #[test] - fn test_prometheus_format() { - let metrics = Metrics::new(); - metrics.fuse_ops.record_lookup(); - metrics.cache.record_hit(); - - let output = metrics.to_prometheus(); - assert!(output.contains("musicfs_fuse_ops_total")); - assert!(output.contains("musicfs_cache_hits_total")); - } -} diff --git a/crates/musicfs-core/src/resolver.rs b/crates/musicfs-core/src/resolver.rs deleted file mode 100644 index b6c5dd6..0000000 --- a/crates/musicfs-core/src/resolver.rs +++ /dev/null @@ -1,174 +0,0 @@ -use crate::{AudioMeta, VirtualPath}; - -#[derive(Debug, Clone)] -pub struct PathTemplate { - pub pattern: String, - pub fallback_artist: String, - pub fallback_album: String, - pub fallback_title: String, - pub fallback_year: String, -} - -impl Default for PathTemplate { - fn default() -> Self { - Self { - pattern: "$artist/$album ($year) [$format_upper]/$track - $title.$format".to_string(), - fallback_artist: "Unknown Artist".to_string(), - fallback_album: "Unknown Album".to_string(), - fallback_title: "Unknown Track".to_string(), - fallback_year: "Unknown".to_string(), - } - } -} - -pub struct PathResolver { - template: PathTemplate, -} - -impl PathResolver { - pub fn new(template: PathTemplate) -> Self { - Self { template } - } - - pub fn resolve(&self, meta: &AudioMeta, extension: &str) -> VirtualPath { - let artist = meta - .artist - .as_deref() - .unwrap_or(&self.template.fallback_artist); - let album = meta - .album - .as_deref() - .unwrap_or(&self.template.fallback_album); - let title = meta - .title - .as_deref() - .unwrap_or(&self.template.fallback_title); - let year = meta - .year - .map(|y| y.to_string()) - .unwrap_or_else(|| self.template.fallback_year.clone()); - let track = meta.track.unwrap_or(0); - let disc = meta.disc.unwrap_or(1); - let genre = meta.genre.as_deref().unwrap_or("Unknown"); - let format = extension.to_lowercase(); - let format_upper = extension.to_uppercase(); - - let artist = sanitize_path_component(artist); - let album = sanitize_path_component(album); - let title = sanitize_path_component(title); - let genre = sanitize_path_component(genre); - - let path = self - .template - .pattern - .replace("$artist", &artist) - .replace("$album", &album) - .replace("$title", &title) - .replace("$track", &format!("{:02}", track)) - .replace("$disc", &disc.to_string()) - .replace("$year", &year) - .replace("$genre", &genre) - .replace("$format_upper", &format_upper) - .replace("$format", &format); - - VirtualPath::new(path) - } -} - -impl Default for PathResolver { - fn default() -> Self { - Self::new(PathTemplate::default()) - } -} - -fn sanitize_path_component(s: &str) -> String { - s.chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_', - c => c, - }) - .collect::() - .trim() - .to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::AudioFormat; - - #[test] - fn test_resolve_complete_metadata() { - let resolver = PathResolver::default(); - let meta = AudioMeta { - artist: Some("Metallica".to_string()), - album: Some("Master of Puppets".to_string()), - title: Some("Battery".to_string()), - track: Some(1), - year: Some(1986), - format: AudioFormat::Flac, - ..Default::default() - }; - - let path = resolver.resolve(&meta, "flac"); - assert_eq!( - path.as_str(), - "Metallica/Master of Puppets (1986) [FLAC]/01 - Battery.flac" - ); - } - - #[test] - fn test_resolve_missing_album() { - let resolver = PathResolver::default(); - let meta = AudioMeta { - artist: Some("Artist".to_string()), - title: Some("Track".to_string()), - track: Some(5), - ..Default::default() - }; - - let path = resolver.resolve(&meta, "mp3"); - assert_eq!( - path.as_str(), - "Artist/Unknown Album (Unknown) [MP3]/05 - Track.mp3" - ); - } - - #[test] - fn test_sanitize_special_chars() { - let resolver = PathResolver::default(); - let meta = AudioMeta { - artist: Some("AC/DC".to_string()), - album: Some("Who Made Who?".to_string()), - title: Some("Test:Track".to_string()), - track: Some(1), - year: Some(1986), - ..Default::default() - }; - - let path = resolver.resolve(&meta, "flac"); - assert!(!path.as_str().contains(':')); - assert!(!path.as_str().contains('?')); - assert!(path.as_str().contains("AC_DC")); - } - - #[test] - fn test_custom_template() { - let template = PathTemplate { - pattern: "$genre/$artist - $album/$track $title.$format".to_string(), - ..Default::default() - }; - let resolver = PathResolver::new(template); - let meta = AudioMeta { - artist: Some("Artist".to_string()), - album: Some("Album".to_string()), - title: Some("Song".to_string()), - genre: Some("Rock".to_string()), - track: Some(3), - ..Default::default() - }; - - let path = resolver.resolve(&meta, "flac"); - assert_eq!(path.as_str(), "Rock/Artist - Album/03 Song.flac"); - } -} diff --git a/crates/musicfs-core/src/supervisor.rs b/crates/musicfs-core/src/supervisor.rs deleted file mode 100644 index 6c8df86..0000000 --- a/crates/musicfs-core/src/supervisor.rs +++ /dev/null @@ -1,181 +0,0 @@ -use parking_lot::RwLock; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::task::JoinHandle; -use tracing::{error, warn}; - -pub struct TaskSupervisor { - tasks: Arc>>, -} - -struct TaskEntry { - handle: JoinHandle<()>, - status: TaskStatus, - restart_count: u32, - last_restart: Option, -} - -#[derive(Debug, Clone)] -pub enum TaskStatus { - Running, - Failed { error: String, at: Instant }, - Restarting { attempt: u32 }, - Stopped, -} - -impl Default for TaskSupervisor { - fn default() -> Self { - Self::new() - } -} - -impl TaskSupervisor { - pub fn new() -> Self { - Self { - tasks: Arc::new(RwLock::new(HashMap::new())), - } - } - - pub fn spawn_supervised(&self, name: &str, future: F) - where - F: std::future::Future + Send + 'static, - { - let name_owned = name.to_string(); - - let handle = tokio::spawn(async move { - future.await; - }); - - self.tasks.write().insert( - name_owned, - TaskEntry { - handle, - status: TaskStatus::Running, - restart_count: 0, - last_restart: None, - }, - ); - } - - pub fn spawn_critical(&self, name: &str, factory: F) - where - F: Fn() -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - let tasks = self.tasks.clone(); - let name_owned = name.to_string(); - - let monitor_handle = tokio::spawn(async move { - let mut restart_count = 0u32; - let max_restarts = 5u32; - let backoff_durations = [ - Duration::from_secs(1), - Duration::from_secs(5), - Duration::from_secs(30), - ]; - - loop { - let handle = tokio::spawn(factory()); - - { - let mut t = tasks.write(); - if let Some(entry) = t.get_mut(&name_owned) { - entry.status = TaskStatus::Running; - } - } - - match handle.await { - Ok(()) => { - let mut t = tasks.write(); - if let Some(entry) = t.get_mut(&name_owned) { - entry.status = TaskStatus::Stopped; - } - break; - } - Err(e) => { - restart_count += 1; - - if restart_count > max_restarts { - error!(task = %name_owned, "Task exceeded max restarts ({}), giving up", max_restarts); - let mut t = tasks.write(); - if let Some(entry) = t.get_mut(&name_owned) { - entry.status = TaskStatus::Failed { - error: format!("Exceeded max restarts: {}", e), - at: Instant::now(), - }; - } - break; - } - - let backoff_idx = - (restart_count as usize - 1).min(backoff_durations.len() - 1); - let backoff = backoff_durations[backoff_idx]; - - warn!( - task = %name_owned, - error = %e, - attempt = restart_count, - backoff_ms = backoff.as_millis() as u64, - "Critical task failed, restarting with backoff" - ); - - { - let mut t = tasks.write(); - if let Some(entry) = t.get_mut(&name_owned) { - entry.status = TaskStatus::Restarting { - attempt: restart_count, - }; - entry.restart_count = restart_count; - entry.last_restart = Some(Instant::now()); - } - } - - tokio::time::sleep(backoff).await; - } - } - } - }); - - self.tasks.write().insert( - name.to_string(), - TaskEntry { - handle: monitor_handle, - status: TaskStatus::Running, - restart_count: 0, - last_restart: None, - }, - ); - } - - pub fn task_status(&self, name: &str) -> TaskStatus { - let mut tasks = self.tasks.write(); - if let Some(entry) = tasks.get_mut(name) { - if entry.handle.is_finished() { - entry.status = TaskStatus::Failed { - error: "Task exited".into(), - at: Instant::now(), - }; - } - entry.status.clone() - } else { - TaskStatus::Stopped - } - } - - pub fn check_all(&self) -> Vec<(String, TaskStatus)> { - let mut tasks = self.tasks.write(); - tasks - .iter_mut() - .map(|(name, entry)| { - if entry.handle.is_finished() { - entry.status = TaskStatus::Failed { - error: "Task exited".into(), - at: Instant::now(), - }; - } - (name.clone(), entry.status.clone()) - }) - .collect() - } -} diff --git a/crates/musicfs-core/src/types.rs b/crates/musicfs-core/src/types.rs deleted file mode 100644 index 1249183..0000000 --- a/crates/musicfs-core/src/types.rs +++ /dev/null @@ -1,223 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::time::SystemTime; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct OriginId(pub String); - -impl From<&str> for OriginId { - fn from(s: &str) -> Self { - Self(s.to_string()) - } -} - -impl std::fmt::Display for OriginId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct FileId(pub i64); - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct VirtualPath(pub PathBuf); - -impl VirtualPath { - pub fn new(path: impl Into) -> Self { - Self(path.into()) - } - - pub fn as_path(&self) -> &std::path::Path { - &self.0 - } - - pub fn as_str(&self) -> &str { - self.0.to_str().unwrap_or("") - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RealPath { - pub origin_id: OriginId, - pub path: PathBuf, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ContentHash(pub [u8; 8]); - -impl ContentHash { - pub fn from_bytes(data: &[u8]) -> Self { - use xxhash_rust::xxh64::xxh64; - Self(xxh64(data, 0).to_le_bytes()) - } - - pub fn to_hex(&self) -> String { - hex::encode(self.0) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ChunkHash(pub [u8; 8]); - -impl ChunkHash { - pub fn from_bytes(data: &[u8]) -> Self { - use xxhash_rust::xxh64::xxh64; - Self(xxh64(data, 0).to_le_bytes()) - } - - pub fn as_hex(&self) -> String { - hex::encode(self.0) - } - - pub fn to_hex(&self) -> String { - self.as_hex() - } - - pub fn from_hex(s: &str) -> Option { - let bytes = hex::decode(s).ok()?; - if bytes.len() != 8 { - return None; - } - let mut arr = [0u8; 8]; - arr.copy_from_slice(&bytes); - Some(Self(arr)) - } -} - -impl std::fmt::Display for ChunkHash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_hex()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -pub enum AudioFormat { - Flac, - Mp3, - Opus, - Vorbis, - Aac, - Alac, - Wav, - #[default] - Unknown, -} - -impl AudioFormat { - pub fn from_extension(ext: &str) -> Self { - match ext.to_lowercase().as_str() { - "flac" => Self::Flac, - "mp3" => Self::Mp3, - "opus" => Self::Opus, - "ogg" => Self::Vorbis, - "m4a" | "aac" => Self::Aac, - "wav" => Self::Wav, - _ => Self::Unknown, - } - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct AudioMeta { - pub title: Option, - pub artist: Option, - pub album: Option, - pub album_artist: Option, - pub genre: Option, - pub year: Option, - pub track: Option, - pub disc: Option, - pub duration_ms: Option, - pub bitrate: Option, - pub sample_rate: Option, - pub format: AudioFormat, - pub track_total: Option, - pub disc_total: Option, - pub date: Option, - pub composer: Option, - pub comment: Option, - pub lyrics: Option, - pub copyright: Option, - pub compilation: Option, - pub artist_sort: Option, - pub album_artist_sort: Option, - pub album_sort: Option, - pub title_sort: Option, - pub mb_recording_id: Option, - pub mb_album_id: Option, - pub mb_artist_id: Option, - pub mb_album_artist_id: Option, - pub mb_release_group_id: Option, - pub replaygain_track_gain: Option, - pub replaygain_track_peak: Option, - pub replaygain_album_gain: Option, - pub replaygain_album_peak: Option, - pub channels: Option, - pub bits_per_sample: Option, - pub encoder: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FileMeta { - pub id: FileId, - pub virtual_path: VirtualPath, - pub real_path: RealPath, - pub size: u64, - pub mtime: SystemTime, - pub content_hash: Option, - pub audio: Option, -} - -#[derive(Debug, Clone)] -pub struct DirEntry { - pub name: String, - pub is_dir: bool, - pub size: u64, - pub mtime: SystemTime, -} - -#[derive(Debug, Clone)] -pub struct FileStat { - pub size: u64, - pub mtime: SystemTime, - pub is_dir: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum HealthStatus { - Healthy, - Degraded, - Unhealthy, - #[default] - Unknown, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_content_hash() { - let data = b"hello world"; - let hash1 = ContentHash::from_bytes(data); - let hash2 = ContentHash::from_bytes(data); - assert_eq!(hash1, hash2); - - let hash3 = ContentHash::from_bytes(b"different"); - assert_ne!(hash1, hash3); - } - - #[test] - fn test_audio_format_from_extension() { - assert_eq!(AudioFormat::from_extension("flac"), AudioFormat::Flac); - assert_eq!(AudioFormat::from_extension("MP3"), AudioFormat::Mp3); - assert_eq!(AudioFormat::from_extension("unknown"), AudioFormat::Unknown); - } - - #[test] - fn test_virtual_path() { - let path = VirtualPath::new("/Artist/Album/Track.flac"); - assert_eq!(path.as_str(), "/Artist/Album/Track.flac"); - } -} diff --git a/crates/musicfs-fuse/Cargo.toml b/crates/musicfs-fuse/Cargo.toml deleted file mode 100644 index 645f6cf..0000000 --- a/crates/musicfs-fuse/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "musicfs-fuse" -version.workspace = true -edition.workspace = true - -[dependencies] -musicfs-core = { path = "../musicfs-core" } -musicfs-cache = { path = "../musicfs-cache" } -musicfs-cas = { path = "../musicfs-cas" } -musicfs-search = { path = "../musicfs-search" } -fuser.workspace = true -tokio.workspace = true -tracing.workspace = true -moka.workspace = true -parking_lot.workspace = true -libc = "0.2" - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/musicfs-fuse/src/filesystem.rs b/crates/musicfs-fuse/src/filesystem.rs deleted file mode 100644 index 7e5cdc6..0000000 --- a/crates/musicfs-fuse/src/filesystem.rs +++ /dev/null @@ -1,954 +0,0 @@ -use crate::ops::SearchOps; -use fuser::{ - FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen, - Request, -}; -use musicfs_cache::{ - Database, OverlayError, OverlayReader, RemoveError, RenameError, VirtualNode, VirtualTree, - ROOT_INODE, -}; -use musicfs_cas::FileReader; -use musicfs_core::{Result, VirtualPath}; -use parking_lot::RwLock; -use std::collections::HashMap; -use std::ffi::OsStr; -use std::path::Path; -use std::sync::Arc; -use std::time::{Duration, SystemTime}; -use tokio::runtime::Handle; -use tracing::{debug, info, instrument, trace, warn}; - -const TTL: Duration = Duration::from_secs(1); -const BLOCK_SIZE: u32 = 512; -const SEARCH_QUERY_INODE_BASE: u64 = 0xFFFF_FFFF_0000_0100; - -pub struct MusicFs { - tree: Arc>, - reader: Option>, - db: Option>, - overlay_reader: Option>, - runtime_handle: Handle, - search_ops: Option, - query_inodes: RwLock>, - inode_queries: RwLock>, - next_query_inode: RwLock, - uid: u32, - gid: u32, -} - -impl MusicFs { - pub fn new(tree: Arc>, runtime_handle: Handle) -> Self { - Self { - tree, - reader: None, - db: None, - overlay_reader: None, - runtime_handle, - search_ops: None, - query_inodes: RwLock::new(HashMap::new()), - inode_queries: RwLock::new(HashMap::new()), - next_query_inode: RwLock::new(SEARCH_QUERY_INODE_BASE), - uid: unsafe { libc::getuid() }, - gid: unsafe { libc::getgid() }, - } - } - - pub fn with_reader( - tree: Arc>, - reader: Arc, - runtime_handle: Handle, - ) -> Self { - Self { - tree, - reader: Some(reader), - db: None, - overlay_reader: None, - runtime_handle, - search_ops: None, - query_inodes: RwLock::new(HashMap::new()), - inode_queries: RwLock::new(HashMap::new()), - next_query_inode: RwLock::new(SEARCH_QUERY_INODE_BASE), - uid: unsafe { libc::getuid() }, - gid: unsafe { libc::getgid() }, - } - } - - pub fn with_db(mut self, db: Arc) -> Self { - self.db = Some(db); - self - } - - pub fn with_overlay(mut self, overlay: Arc) -> Self { - self.overlay_reader = Some(overlay); - self - } - - pub fn with_search(mut self, search_ops: SearchOps) -> Self { - self.search_ops = Some(search_ops); - self - } - - fn resolve_path(&self, parent_inode: u64, name: &OsStr) -> Option { - let tree = self.tree.read(); - let parent_path = self.inode_to_path_inner(&tree, parent_inode)?; - let name_str = name.to_string_lossy(); - let full_path = if parent_path == "/" { - format!("/{}", name_str) - } else { - format!("{}/{}", parent_path, name_str) - }; - Some(VirtualPath::new(full_path)) - } - - fn inode_to_path_inner(&self, tree: &VirtualTree, inode: u64) -> Option { - for (path, &ino) in tree.path_to_inode_iter() { - if ino == inode { - return Some(path.as_str().to_string()); - } - } - None - } - - fn get_or_create_query_inode(&self, query: &str) -> u64 { - let query_inodes = self.query_inodes.read(); - if let Some(&inode) = query_inodes.get(query) { - return inode; - } - drop(query_inodes); - - let mut query_inodes = self.query_inodes.write(); - let mut inode_queries = self.inode_queries.write(); - let mut next_inode = self.next_query_inode.write(); - - if let Some(&inode) = query_inodes.get(query) { - return inode; - } - - let inode = *next_inode; - *next_inode += 1; - query_inodes.insert(query.to_string(), inode); - inode_queries.insert(inode, query.to_string()); - inode - } - - fn get_query_for_inode(&self, inode: u64) -> Option { - self.inode_queries.read().get(&inode).cloned() - } - - pub fn mount(self, mountpoint: &Path) -> Result<()> { - info!("Mounting MusicFS at {:?}", mountpoint); - - let options = vec![ - fuser::MountOption::FSName("musicfs".to_string()), - fuser::MountOption::AutoUnmount, - fuser::MountOption::AllowOther, - ]; - - fuser::mount2(self, mountpoint, &options).map_err(musicfs_core::Error::Io)?; - - Ok(()) - } - - pub fn spawn_mount(self, mountpoint: &Path) -> Result { - info!("Mounting MusicFS at {:?}", mountpoint); - - let options = vec![ - fuser::MountOption::FSName("musicfs".to_string()), - fuser::MountOption::AutoUnmount, - fuser::MountOption::AllowOther, - ]; - - let session = - fuser::spawn_mount2(self, mountpoint, &options).map_err(musicfs_core::Error::Io)?; - - Ok(session) - } - - fn node_to_attr(&self, node: &VirtualNode) -> FileAttr { - match node { - VirtualNode::Directory(dir) => FileAttr { - ino: dir.inode, - size: 0, - blocks: 0, - atime: dir.mtime, - mtime: dir.mtime, - ctime: dir.mtime, - crtime: dir.mtime, - kind: FileType::Directory, - perm: 0o755, - nlink: 2, - uid: self.uid, - gid: self.gid, - rdev: 0, - blksize: BLOCK_SIZE, - flags: 0, - }, - VirtualNode::File(file) => FileAttr { - ino: file.inode, - size: file.size, - blocks: (file.size + BLOCK_SIZE as u64 - 1) / BLOCK_SIZE as u64, - atime: file.mtime, - mtime: file.mtime, - ctime: file.mtime, - crtime: file.mtime, - kind: FileType::RegularFile, - perm: 0o644, - nlink: 1, - uid: self.uid, - gid: self.gid, - rdev: 0, - blksize: BLOCK_SIZE, - flags: 0, - }, - } - } -} - -impl Filesystem for MusicFs { - fn init( - &mut self, - _req: &Request<'_>, - _config: &mut fuser::KernelConfig, - ) -> std::result::Result<(), libc::c_int> { - info!("MusicFS initialized"); - Ok(()) - } - - fn destroy(&mut self) { - info!("MusicFS destroyed"); - } - - #[instrument(level = "debug", skip(self, reply))] - fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) { - let name_str = name.to_string_lossy(); - - if parent == ROOT_INODE && SearchOps::is_search_dir_name(&name_str) { - trace!(parent, name = %name_str, "search_dir_name matched"); - if let Some(ref search_ops) = self.search_ops { - search_ops.lookup_search_dir(reply); - return; - } - } - - if parent == SearchOps::search_dir_inode() { - trace!(parent, name = %name_str, "search_dir_inode matched"); - if let Some(ref search_ops) = self.search_ops { - let inode = self.get_or_create_query_inode(&name_str); - search_ops.lookup_query_dir(&name_str, inode, reply); - return; - } - } - - if let Some(query) = self.get_query_for_inode(parent) { - trace!(parent, name = %name_str, query = %query, "query_inode matched"); - if let Some(ref search_ops) = self.search_ops { - let inode = self.get_or_create_query_inode(&format!("{}:{}", query, name_str)); - search_ops.lookup_result(inode, reply); - return; - } - } - - let tree = self.tree.read(); - - if let Some(inode) = tree.lookup(parent, name) { - trace!(parent, name = %name_str, ino = inode, "file found in tree"); - if let Some(node) = tree.get(inode) { - let attr = self.node_to_attr(node); - reply.entry(&TTL, &attr, 0); - return; - } - } - - trace!(parent, name = %name_str, "file not found"); - reply.error(libc::ENOENT); - } - - #[instrument(level = "debug", skip(self, reply))] - fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) { - if ino == SearchOps::search_dir_inode() { - trace!(ino, "search_dir_inode matched"); - if let Some(ref search_ops) = self.search_ops { - search_ops.getattr_search_dir(reply); - return; - } - } - - if SearchOps::is_search_inode(ino) { - trace!(ino, "search_inode matched"); - if let Some(ref search_ops) = self.search_ops { - search_ops.getattr_result(ino, reply); - return; - } - } - - if self.get_query_for_inode(ino).is_some() { - trace!(ino, "query_inode matched"); - if let Some(ref search_ops) = self.search_ops { - search_ops.getattr_search_dir(reply); - return; - } - } - - let tree = self.tree.read(); - - if let Some(node) = tree.get(ino) { - trace!(ino, "inode found in tree"); - let mut attr = self.node_to_attr(node); - - if let VirtualNode::File(file) = node { - if let Some(ref overlay) = self.overlay_reader { - match overlay.estimate_virtual_size(file.file_id) { - Ok(Some(virtual_size)) => { - trace!(ino, file_id = ?file.file_id, virtual_size, "using overlay virtual size"); - attr.size = virtual_size; - attr.blocks = - (virtual_size + BLOCK_SIZE as u64 - 1) / BLOCK_SIZE as u64; - } - Ok(None) => { - trace!(ino, file_id = ?file.file_id, "no overlay, using original size"); - } - Err(e) => { - warn!(ino, file_id = ?file.file_id, error = %e, "overlay size estimation failed, using original"); - } - } - } - } - - reply.attr(&TTL, &attr); - } else { - trace!(ino, "inode not found"); - reply.error(libc::ENOENT); - } - } - - #[instrument(level = "debug", skip(self, reply))] - fn readdir( - &mut self, - _req: &Request, - ino: u64, - _fh: u64, - offset: i64, - mut reply: ReplyDirectory, - ) { - if ino == SearchOps::search_dir_inode() { - trace!(ino, offset, "search_dir_inode matched"); - if let Some(ref search_ops) = self.search_ops { - search_ops.readdir_search_root(offset, reply); - return; - } - } - - if let Some(query) = self.get_query_for_inode(ino) { - trace!(ino, offset, query = %query, "query_inode matched"); - if let Some(ref search_ops) = self.search_ops { - search_ops.readdir_query(&query, offset, reply); - return; - } - } - - let tree = self.tree.read(); - - if let Some(children) = tree.readdir(ino) { - trace!( - ino, - offset, - children_count = children.len(), - "directory found" - ); - let parent_ino = tree.get_parent(ino).unwrap_or(ROOT_INODE); - - let entries: Vec<(u64, FileType, &str)> = vec![ - (ino, FileType::Directory, "."), - (parent_ino, FileType::Directory, ".."), - ]; - - let child_entries: Vec<(u64, FileType, String)> = children - .iter() - .map(|(name, child_ino, is_dir)| { - let kind = if *is_dir { - FileType::Directory - } else { - FileType::RegularFile - }; - (*child_ino, kind, name.to_string_lossy().to_string()) - }) - .collect(); - - for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) { - if reply.add(*inode, (i + 1) as i64, *kind, name) { - reply.ok(); - return; - } - } - - let base_offset = entries.len(); - for (i, (inode, kind, name)) in child_entries.iter().enumerate() { - let entry_offset = base_offset + i; - if entry_offset < offset as usize { - continue; - } - if reply.add(*inode, (entry_offset + 1) as i64, *kind, name) { - break; - } - } - - reply.ok(); - } else { - trace!(ino, offset, "directory not found"); - reply.error(libc::ENOENT); - } - } - - #[instrument(level = "debug", skip(self, reply))] - fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen) { - let write_flags = libc::O_WRONLY | libc::O_RDWR | libc::O_APPEND | libc::O_TRUNC; - if flags & write_flags != 0 { - trace!(ino, flags, "write flags detected"); - reply.error(libc::EROFS); - return; - } - - let tree = self.tree.read(); - - if tree.get(ino).is_some() { - trace!(ino, "inode found"); - reply.opened(0, 0); - } else { - trace!(ino, "inode not found"); - reply.error(libc::ENOENT); - } - } - - #[instrument(level = "debug", skip(self, reply))] - fn read( - &mut self, - _req: &Request, - ino: u64, - _fh: u64, - offset: i64, - size: u32, - _flags: i32, - _lock_owner: Option, - reply: ReplyData, - ) { - let file_id = { - let tree = self.tree.read(); - if let Some(VirtualNode::File(file)) = tree.get(ino) { - trace!(ino, "file found in tree"); - file.file_id - } else { - trace!(ino, "file not found"); - reply.error(libc::ENOENT); - return; - } - }; - - let handle = self.runtime_handle.clone(); - - if let Some(ref overlay) = self.overlay_reader { - let overlay = overlay.clone(); - let result = std::thread::scope(|_| { - handle.block_on(async { - tokio::time::timeout( - Duration::from_secs(30), - overlay.read(file_id, offset as u64, size), - ) - .await - }) - }); - - match result { - Ok(Ok(data)) => { - trace!( - ino, - offset, - size_bytes = size, - bytes_read = data.len(), - "overlay read successful" - ); - reply.data(&data); - } - Ok(Err(e)) => { - let errno = match &e { - OverlayError::NotFound(_) => libc::ENOENT, - OverlayError::Database(_) => libc::EIO, - OverlayError::Handler(_) => libc::EIO, - OverlayError::Cas(_) => libc::EIO, - OverlayError::NoHandler(_) => libc::EIO, - }; - warn!(ino, offset, size_bytes = size, error = %e, "overlay read failed"); - reply.error(errno); - } - Err(_timeout) => { - warn!( - ino, - offset, - size_bytes = size, - "overlay read timed out after 30s" - ); - reply.error(libc::EIO); - } - } - } else { - let Some(reader) = &self.reader else { - trace!(ino, "no reader available"); - reply.data(&[]); - return; - }; - - let reader = reader.clone(); - let result = std::thread::scope(|_| { - handle.block_on(async { - tokio::time::timeout( - Duration::from_secs(30), - reader.read(file_id, offset as u64, size), - ) - .await - }) - }); - - match result { - Ok(Ok(data)) => { - trace!( - ino, - offset, - size_bytes = size, - bytes_read = data.len(), - "read successful" - ); - reply.data(&data); - } - Ok(Err(e)) => { - warn!(ino, offset, size_bytes = size, error = %e, "read failed"); - reply.error(libc::EIO); - } - Err(_timeout) => { - warn!(ino, offset, size_bytes = size, "read timed out after 30s"); - reply.error(libc::EIO); - } - } - } - } - - #[instrument(level = "debug", skip(self, reply))] - fn release( - &mut self, - _req: &Request, - ino: u64, - _fh: u64, - _flags: i32, - _lock_owner: Option, - _flush: bool, - reply: fuser::ReplyEmpty, - ) { - trace!(ino, "releasing file handle"); - reply.ok(); - } - - fn readlink(&mut self, _req: &Request, ino: u64, reply: ReplyData) { - debug!("readlink(ino={})", ino); - - if SearchOps::is_search_inode(ino) { - if let Some(ref search_ops) = self.search_ops { - search_ops.readlink(ino, reply); - return; - } - } - - reply.error(libc::EINVAL); - } - - fn write( - &mut self, - _req: &Request, - _ino: u64, - _fh: u64, - _offset: i64, - _data: &[u8], - _write_flags: u32, - _flags: i32, - _lock_owner: Option, - reply: fuser::ReplyWrite, - ) { - reply.error(libc::EROFS); - } - - fn mkdir( - &mut self, - _req: &Request, - parent: u64, - name: &OsStr, - _mode: u32, - _umask: u32, - reply: ReplyEntry, - ) { - let path = match self.resolve_path(parent, name) { - Some(p) => p, - None => { - reply.error(libc::ENOENT); - return; - } - }; - - let mut tree = self.tree.write(); - match tree.mkdir(&path) { - Ok(inode) => { - if let Some(ref db) = self.db { - if let Err(e) = db.insert_directory(&path) { - warn!(error = %e, "failed to persist directory to database"); - } - } - let attr = FileAttr { - ino: inode, - size: 0, - blocks: 0, - atime: SystemTime::now(), - mtime: SystemTime::now(), - ctime: SystemTime::now(), - crtime: SystemTime::now(), - kind: FileType::Directory, - perm: 0o755, - nlink: 2, - uid: self.uid, - gid: self.gid, - rdev: 0, - blksize: BLOCK_SIZE, - flags: 0, - }; - debug!(path = %path.as_str(), inode, "mkdir successful"); - reply.entry(&TTL, &attr, 0); - } - Err(RenameError::TargetExists) => reply.error(libc::EEXIST), - Err(RenameError::ParentNotFound) => reply.error(libc::ENOENT), - Err(_) => reply.error(libc::EIO), - } - } - - fn unlink(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: fuser::ReplyEmpty) { - let path = match self.resolve_path(parent, name) { - Some(p) => p, - None => { - reply.error(libc::ENOENT); - return; - } - }; - - let (file_id, is_dir) = { - let tree = self.tree.read(); - match tree.get_by_path(&path) { - Some(VirtualNode::File(f)) => (Some(f.file_id), false), - Some(VirtualNode::Directory(_)) => (None, true), - None => { - reply.error(libc::ENOENT); - return; - } - } - }; - - if is_dir { - reply.error(libc::EISDIR); - return; - } - - let trash_path = VirtualPath::new(format!("/.trash{}", path.as_str())); - - { - let mut tree = self.tree.write(); - tree.ensure_trash_dir(); - - let trash_parent = std::path::Path::new(trash_path.as_str()) - .parent() - .map(|p| VirtualPath::new(p.to_string_lossy().into_owned())) - .unwrap_or_else(|| VirtualPath::new("/.trash")); - - if let Err(e) = tree.mkdir_p(&trash_parent) { - if !matches!(e, RenameError::TargetExists) { - warn!(error = ?e, "failed to create trash parent directories"); - reply.error(libc::EIO); - return; - } - } - - if let Err(e) = tree.rename_file(&path, &trash_path) { - match e { - RenameError::SourceNotFound => reply.error(libc::ENOENT), - RenameError::TargetExists => reply.error(libc::EEXIST), - _ => reply.error(libc::EIO), - } - return; - } - } - - if let (Some(ref db), Some(id)) = (&self.db, file_id) { - if let Err(e) = db.update_virtual_path(id, &trash_path) { - warn!(error = %e, "failed to update virtual path in database"); - } - if let Err(e) = db.mark_trashed(id, &path) { - warn!(error = %e, "failed to mark file as trashed in database"); - } - } - - debug!(path = %path.as_str(), trash = %trash_path.as_str(), "file moved to trash"); - reply.ok(); - } - - fn rmdir(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: fuser::ReplyEmpty) { - let path = match self.resolve_path(parent, name) { - Some(p) => p, - None => { - reply.error(libc::ENOENT); - return; - } - }; - - if VirtualTree::is_trash_path(&path) { - reply.error(libc::EPERM); - return; - } - - { - let mut tree = self.tree.write(); - match tree.remove_directory(&path) { - Ok(()) => {} - Err(RemoveError::NotFound) => { - reply.error(libc::ENOENT); - return; - } - Err(RemoveError::NotEmpty) => { - reply.error(libc::ENOTEMPTY); - return; - } - Err(RemoveError::NotDirectory) => { - reply.error(libc::ENOTDIR); - return; - } - } - } - - if let Some(ref db) = self.db { - if let Err(e) = db.delete_directory(&path) { - warn!(error = %e, "failed to delete directory from database"); - } - } - - debug!(path = %path.as_str(), "directory removed"); - reply.ok(); - } - - fn rename( - &mut self, - _req: &Request, - parent: u64, - name: &OsStr, - newparent: u64, - newname: &OsStr, - _flags: u32, - reply: fuser::ReplyEmpty, - ) { - let old_path = match self.resolve_path(parent, name) { - Some(p) => p, - None => { - reply.error(libc::ENOENT); - return; - } - }; - - let new_path = match self.resolve_path(newparent, newname) { - Some(p) => p, - None => { - reply.error(libc::ENOENT); - return; - } - }; - - if old_path.as_str() == new_path.as_str() { - reply.ok(); - return; - } - - let is_dir = { - let tree = self.tree.read(); - tree.get_by_path(&old_path) - .map(|n| n.is_dir()) - .unwrap_or(false) - }; - - let result = if is_dir { - let mut tree = self.tree.write(); - match tree.rename_directory(&old_path, &new_path) { - Ok(count) => { - if let Some(ref db) = self.db { - let old_prefix = if old_path.as_str().ends_with('/') { - old_path.as_str().to_string() - } else { - format!("{}/", old_path.as_str()) - }; - let new_prefix = if new_path.as_str().ends_with('/') { - new_path.as_str().to_string() - } else { - format!("{}/", new_path.as_str()) - }; - if let Err(e) = db.rename_directory(&old_prefix, &new_prefix) { - warn!(error = %e, "failed to persist file path rename to database"); - } - if let Err(e) = db.rename_directories(&old_prefix, &new_prefix) { - warn!(error = %e, "failed to persist directory rename to database"); - } - } - debug!(old = %old_path.as_str(), new = %new_path.as_str(), count, "directory renamed"); - Ok(()) - } - Err(e) => Err(e), - } - } else { - let file_id = { - let tree = self.tree.read(); - match tree.get_by_path(&old_path) { - Some(VirtualNode::File(f)) => Some(f.file_id), - _ => None, - } - }; - - let mut tree = self.tree.write(); - match tree.rename_file(&old_path, &new_path) { - Ok(()) => { - if let (Some(ref db), Some(id)) = (&self.db, file_id) { - if let Err(e) = db.update_virtual_path(id, &new_path) { - warn!(error = %e, "failed to persist file rename to database"); - } - let was_in_trash = VirtualTree::is_trash_path(&old_path); - let now_in_trash = VirtualTree::is_trash_path(&new_path); - if was_in_trash && !now_in_trash { - if let Err(e) = db.unmark_trashed(id) { - warn!(error = %e, "failed to unmark trashed after restore"); - } - debug!(path = %new_path.as_str(), "file restored from trash"); - } - } - debug!(old = %old_path.as_str(), new = %new_path.as_str(), "file renamed"); - Ok(()) - } - Err(e) => Err(e), - } - }; - - match result { - Ok(()) => reply.ok(), - Err(RenameError::SourceNotFound) => reply.error(libc::ENOENT), - Err(RenameError::TargetExists) => reply.error(libc::EEXIST), - Err(RenameError::ParentNotFound) => reply.error(libc::ENOENT), - Err(RenameError::IsDirectory) => reply.error(libc::EISDIR), - Err(RenameError::NotDirectory) => reply.error(libc::ENOTDIR), - } - } - - fn create( - &mut self, - _req: &Request, - _parent: u64, - _name: &OsStr, - _mode: u32, - _umask: u32, - _flags: i32, - reply: fuser::ReplyCreate, - ) { - reply.error(libc::EROFS); - } - - fn setattr( - &mut self, - _req: &Request, - _ino: u64, - _mode: Option, - _uid: Option, - _gid: Option, - _size: Option, - _atime: Option, - _mtime: Option, - _ctime: Option, - _fh: Option, - _crtime: Option, - _chgtime: Option, - _bkuptime: Option, - _flags: Option, - reply: ReplyAttr, - ) { - reply.error(libc::EROFS); - } - - fn symlink( - &mut self, - _req: &Request, - _parent: u64, - _name: &OsStr, - _link: &Path, - reply: ReplyEntry, - ) { - reply.error(libc::EROFS); - } - - fn link( - &mut self, - _req: &Request, - _ino: u64, - _newparent: u64, - _newname: &OsStr, - reply: ReplyEntry, - ) { - reply.error(libc::EROFS); - } - - fn mknod( - &mut self, - _req: &Request, - _parent: u64, - _name: &OsStr, - _mode: u32, - _umask: u32, - _rdev: u32, - reply: ReplyEntry, - ) { - reply.error(libc::EROFS); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use musicfs_cache::TreeBuilder; - use musicfs_core::{FileId, FileMeta, OriginId, RealPath, VirtualPath}; - use std::path::PathBuf; - - fn make_file_meta(id: i64, vpath: &str, size: u64) -> FileMeta { - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(vpath), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from("/test"), - }, - size, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - } - } - - #[test] - fn test_tree_integration() { - let runtime = tokio::runtime::Runtime::new().unwrap(); - let handle = runtime.handle().clone(); - - let mut builder = TreeBuilder::new(); - builder.add_file(&make_file_meta(1, "/Artist/Album/Track.flac", 30_000_000)); - let tree = Arc::new(RwLock::new(builder.build())); - - let _fs = MusicFs::new(tree.clone(), handle); - - let tree_read = tree.read(); - assert!(tree_read.get(ROOT_INODE).is_some()); - assert!(tree_read - .get_by_path(&VirtualPath::new("/Artist")) - .is_some()); - } -} diff --git a/crates/musicfs-fuse/src/lib.rs b/crates/musicfs-fuse/src/lib.rs deleted file mode 100644 index d8daf9f..0000000 --- a/crates/musicfs-fuse/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod filesystem; -pub mod ops; - -pub use filesystem::MusicFs; -pub use ops::SearchOps; diff --git a/crates/musicfs-fuse/src/ops/mod.rs b/crates/musicfs-fuse/src/ops/mod.rs deleted file mode 100644 index 3f5195e..0000000 --- a/crates/musicfs-fuse/src/ops/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod prefetch; -mod search; - -pub use prefetch::PrefetchOps; -pub use search::SearchOps; diff --git a/crates/musicfs-fuse/src/ops/prefetch.rs b/crates/musicfs-fuse/src/ops/prefetch.rs deleted file mode 100644 index 4c84448..0000000 --- a/crates/musicfs-fuse/src/ops/prefetch.rs +++ /dev/null @@ -1,298 +0,0 @@ -use fuser::{FileAttr, FileType, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry}; -use musicfs_cache::{PatternStore, PrefetchConfig, PrefetchEngine}; -use musicfs_cas::ContentFetcher; -use musicfs_core::{EventBus, FileId}; -use std::sync::Arc; -use std::time::{Duration, SystemTime}; - -const PREFETCH_DIR_INODE: u64 = 0xFFFF_FFFF_0000_0002; -const PREFETCH_STATUS_INODE: u64 = 0xFFFF_FFFF_0000_0003; -const PREFETCH_HINTS_BASE: u64 = 0xFFFF_FFFF_2000_0000; - -pub struct PrefetchOps { - pattern_store: Arc, - engine: Option>, - uid: u32, - gid: u32, -} - -impl PrefetchOps { - pub fn new(pattern_store: Arc, uid: u32, gid: u32) -> Self { - Self { - pattern_store, - engine: None, - uid, - gid, - } - } - - pub fn with_engine( - pattern_store: Arc, - fetcher: Arc, - config: PrefetchConfig, - uid: u32, - gid: u32, - ) -> Self { - let engine = Arc::new(PrefetchEngine::new(config, pattern_store.clone(), fetcher)); - - Self { - pattern_store, - engine: Some(engine), - uid, - gid, - } - } - - pub fn start_engine(&self, event_bus: Arc) -> Option { - self.engine - .as_ref() - .map(|e| e.clone().start(event_bus, self.pattern_store.clone())) - } - - pub fn is_prefetch_dir_name(name: &str) -> bool { - name == ".prefetch" - } - - pub fn is_prefetch_inode(inode: u64) -> bool { - inode == PREFETCH_DIR_INODE - || inode == PREFETCH_STATUS_INODE - || inode >= PREFETCH_HINTS_BASE - } - - pub fn prefetch_dir_inode() -> u64 { - PREFETCH_DIR_INODE - } - - pub fn lookup_prefetch_dir(&self, reply: ReplyEntry) { - let attr = self.dir_attr(PREFETCH_DIR_INODE); - reply.entry(&Duration::from_secs(60), &attr, 0); - } - - pub fn lookup_status(&self, reply: ReplyEntry) { - let status = self.generate_status(); - let attr = self.file_attr(PREFETCH_STATUS_INODE, status.len() as u64); - reply.entry(&Duration::from_secs(1), &attr, 0); - } - - pub fn lookup_hint(&self, name: &str, reply: ReplyEntry) { - if let Some(inode) = self.hint_name_to_inode(name) { - let attr = self.file_attr(inode, 256); - reply.entry(&Duration::from_secs(1), &attr, 0); - } else { - reply.error(libc::ENOENT); - } - } - - pub fn getattr_prefetch_dir(&self, reply: ReplyAttr) { - let attr = self.dir_attr(PREFETCH_DIR_INODE); - reply.attr(&Duration::from_secs(60), &attr); - } - - pub fn getattr_status(&self, reply: ReplyAttr) { - let status = self.generate_status(); - let attr = self.file_attr(PREFETCH_STATUS_INODE, status.len() as u64); - reply.attr(&Duration::from_secs(1), &attr); - } - - pub fn getattr_hint(&self, inode: u64, reply: ReplyAttr) { - let attr = self.file_attr(inode, 256); - reply.attr(&Duration::from_secs(1), &attr); - } - - pub fn readdir_prefetch_root(&self, offset: i64, mut reply: ReplyDirectory) { - let entries: Vec<(u64, FileType, &str)> = vec![ - (PREFETCH_DIR_INODE, FileType::Directory, "."), - (1, FileType::Directory, ".."), - (PREFETCH_STATUS_INODE, FileType::RegularFile, "status"), - ]; - - let recently_played = self.pattern_store.recently_played(7).unwrap_or_default(); - let predictions: Vec<(u64, FileType, String)> = recently_played - .iter() - .take(10) - .enumerate() - .map(|(i, file_id)| { - let inode = PREFETCH_HINTS_BASE + i as u64; - let name = format!("hint_{:04}", file_id.0); - (inode, FileType::RegularFile, name) - }) - .collect(); - - for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) { - if reply.add(*inode, (i + 1) as i64, *kind, *name) { - reply.ok(); - return; - } - } - - let base_offset = entries.len(); - for (i, (inode, kind, name)) in predictions.iter().enumerate() { - let entry_offset = base_offset + i; - if entry_offset < offset as usize { - continue; - } - if reply.add(*inode, (entry_offset + 1) as i64, *kind, name) { - break; - } - } - - reply.ok(); - } - - pub fn read_status(&self, offset: i64, size: u32, reply: ReplyData) { - let status = self.generate_status(); - let start = offset as usize; - let end = std::cmp::min(start + size as usize, status.len()); - - if start >= status.len() { - reply.data(&[]); - } else { - reply.data(&status.as_bytes()[start..end]); - } - } - - pub fn read_hint(&self, inode: u64, offset: i64, size: u32, reply: ReplyData) { - let file_id = self.inode_to_file_id(inode); - let predictions = self.pattern_store.predict_next(file_id, 5); - - let content = predictions - .iter() - .map(|id| format!("{}", id.0)) - .collect::>() - .join("\n"); - - let start = offset as usize; - let end = std::cmp::min(start + size as usize, content.len()); - - if start >= content.len() { - reply.data(&[]); - } else { - reply.data(&content.as_bytes()[start..end]); - } - } - - fn generate_status(&self) -> String { - let engine_status = if let Some(engine) = &self.engine { - format!( - "running: {}\nin_flight: {}", - engine.is_running(), - engine.in_flight_count() - ) - } else { - "engine: disabled".to_string() - }; - - let most_played = self - .pattern_store - .most_played(5) - .unwrap_or_default() - .iter() - .map(|id| format!("{}", id.0)) - .collect::>() - .join(", "); - - format!( - "MusicFS Prefetch Status\n\ - =======================\n\ - {}\n\ - most_played: [{}]\n", - engine_status, most_played - ) - } - - fn hint_name_to_inode(&self, name: &str) -> Option { - if name.starts_with("hint_") { - let id_str = name.strip_prefix("hint_")?; - let id: i64 = id_str.parse().ok()?; - Some(PREFETCH_HINTS_BASE + id as u64) - } else { - None - } - } - - fn inode_to_file_id(&self, inode: u64) -> FileId { - FileId((inode - PREFETCH_HINTS_BASE) as i64) - } - - fn dir_attr(&self, inode: u64) -> FileAttr { - FileAttr { - ino: inode, - size: 0, - blocks: 0, - atime: SystemTime::UNIX_EPOCH, - mtime: SystemTime::UNIX_EPOCH, - ctime: SystemTime::UNIX_EPOCH, - crtime: SystemTime::UNIX_EPOCH, - kind: FileType::Directory, - perm: 0o555, - nlink: 2, - uid: self.uid, - gid: self.gid, - rdev: 0, - blksize: 512, - flags: 0, - } - } - - fn file_attr(&self, inode: u64, size: u64) -> FileAttr { - FileAttr { - ino: inode, - size, - blocks: (size + 511) / 512, - atime: SystemTime::UNIX_EPOCH, - mtime: SystemTime::UNIX_EPOCH, - ctime: SystemTime::UNIX_EPOCH, - crtime: SystemTime::UNIX_EPOCH, - kind: FileType::RegularFile, - perm: 0o444, - nlink: 1, - uid: self.uid, - gid: self.gid, - rdev: 0, - blksize: 512, - flags: 0, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_prefetch_ops_new() { - let dir = TempDir::new().unwrap(); - let pattern_store = - Arc::new(PatternStore::new(&dir.path().join("patterns.db"), 30).unwrap()); - let _ops = PrefetchOps::new(pattern_store, 1000, 1000); - } - - #[test] - fn test_is_prefetch_inode() { - assert!(PrefetchOps::is_prefetch_inode(PREFETCH_DIR_INODE)); - assert!(PrefetchOps::is_prefetch_inode(PREFETCH_STATUS_INODE)); - assert!(PrefetchOps::is_prefetch_inode(PREFETCH_HINTS_BASE)); - assert!(PrefetchOps::is_prefetch_inode(PREFETCH_HINTS_BASE + 100)); - assert!(!PrefetchOps::is_prefetch_inode(1)); - assert!(!PrefetchOps::is_prefetch_inode(1000)); - } - - #[test] - fn test_hint_name_to_inode() { - let dir = TempDir::new().unwrap(); - let pattern_store = - Arc::new(PatternStore::new(&dir.path().join("patterns.db"), 30).unwrap()); - let ops = PrefetchOps::new(pattern_store, 1000, 1000); - - assert_eq!( - ops.hint_name_to_inode("hint_0001"), - Some(PREFETCH_HINTS_BASE + 1) - ); - assert_eq!( - ops.hint_name_to_inode("hint_9999"), - Some(PREFETCH_HINTS_BASE + 9999) - ); - assert_eq!(ops.hint_name_to_inode("invalid"), None); - } -} diff --git a/crates/musicfs-fuse/src/ops/search.rs b/crates/musicfs-fuse/src/ops/search.rs deleted file mode 100644 index 92a5f5b..0000000 --- a/crates/musicfs-fuse/src/ops/search.rs +++ /dev/null @@ -1,273 +0,0 @@ -use fuser::{FileAttr, FileType, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry}; -use moka::sync::Cache; -use musicfs_search::{SearchHit, SearchIndex}; -use std::path::Path; -use std::sync::Arc; -use std::time::{Duration, SystemTime}; - -const SEARCH_DIR_INODE: u64 = 0xFFFF_FFFF_0000_0001; -const SEARCH_RESULT_BASE: u64 = 0xFFFF_FFFF_1000_0000; -const RESULT_CACHE_MAX_ENTRIES: u64 = 1000; -const RESULT_CACHE_TTL_SECS: u64 = 300; -const INODE_CACHE_MAX_ENTRIES: u64 = 10000; -const MAX_QUERY_LENGTH: usize = 256; - -pub struct SearchOps { - index: Arc, - result_cache: Cache>, - inode_to_result: Cache, - mount_point: String, - uid: u32, - gid: u32, -} - -impl SearchOps { - pub fn new(index: Arc, mount_point: &str, uid: u32, gid: u32) -> Self { - let result_cache = Cache::builder() - .max_capacity(RESULT_CACHE_MAX_ENTRIES) - .time_to_live(Duration::from_secs(RESULT_CACHE_TTL_SECS)) - .build(); - - let inode_to_result = Cache::builder() - .max_capacity(INODE_CACHE_MAX_ENTRIES) - .time_to_live(Duration::from_secs(RESULT_CACHE_TTL_SECS)) - .build(); - - Self { - index, - result_cache, - inode_to_result, - mount_point: mount_point.to_string(), - uid, - gid, - } - } - - pub fn is_search_dir_name(name: &str) -> bool { - name == ".search" - } - - pub fn is_search_inode(inode: u64) -> bool { - inode == SEARCH_DIR_INODE || inode >= SEARCH_RESULT_BASE - } - - pub fn search_dir_inode() -> u64 { - SEARCH_DIR_INODE - } - - pub fn lookup_search_dir(&self, reply: ReplyEntry) { - let attr = self.dir_attr(SEARCH_DIR_INODE); - reply.entry(&Duration::from_secs(60), &attr, 0); - } - - pub fn lookup_query_dir(&self, query: &str, inode: u64, reply: ReplyEntry) { - let results = self.execute_query(query); - if results.is_empty() { - reply.error(libc::ENOENT); - return; - } - - let attr = self.dir_attr(inode); - reply.entry(&Duration::from_secs(1), &attr, 0); - } - - pub fn lookup_result(&self, inode: u64, reply: ReplyEntry) { - if self.inode_to_result.contains_key(&inode) { - let attr = self.symlink_attr(inode, 256); - reply.entry(&Duration::from_secs(1), &attr, 0); - } else { - reply.error(libc::ENOENT); - } - } - - pub fn getattr_search_dir(&self, reply: ReplyAttr) { - let attr = self.dir_attr(SEARCH_DIR_INODE); - reply.attr(&Duration::from_secs(60), &attr); - } - - pub fn getattr_result(&self, inode: u64, reply: ReplyAttr) { - let attr = self.symlink_attr(inode, 256); - reply.attr(&Duration::from_secs(1), &attr); - } - - pub fn readdir_search_root(&self, offset: i64, mut reply: ReplyDirectory) { - let entries = vec![ - (SEARCH_DIR_INODE, FileType::Directory, "."), - (1, FileType::Directory, ".."), - ]; - - for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) { - if reply.add(*inode, (i + 1) as i64, *kind, name) { - break; - } - } - reply.ok(); - } - - pub fn readdir_query(&self, query: &str, offset: i64, mut reply: ReplyDirectory) { - let results = self.execute_query(query); - - let entries = vec![ - (SEARCH_DIR_INODE + 1, FileType::Directory, ".".to_string()), - (SEARCH_DIR_INODE, FileType::Directory, "..".to_string()), - ]; - - for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) { - if reply.add(*inode, (i + 1) as i64, *kind, name) { - reply.ok(); - return; - } - } - - let base_offset = entries.len(); - for (i, hit) in results.iter().enumerate() { - let entry_offset = base_offset + i; - if entry_offset < offset as usize { - continue; - } - - let inode = SEARCH_RESULT_BASE + i as u64; - let name = self.result_filename(hit, i); - - self.inode_to_result.insert(inode, (query.to_string(), i)); - - if reply.add(inode, (entry_offset + 1) as i64, FileType::Symlink, &name) { - break; - } - } - reply.ok(); - } - - pub fn readlink(&self, inode: u64, reply: ReplyData) { - let (query, index) = match self.inode_to_result.get(&inode) { - Some((q, i)) => (q, i), - None => { - reply.error(libc::ENOENT); - return; - } - }; - - let results = self.execute_query(&query); - if let Some(hit) = results.get(index) { - if let Some(target) = self.safe_symlink_target(hit.virtual_path.as_str()) { - reply.data(target.as_bytes()); - } else { - reply.error(libc::EINVAL); - } - } else { - reply.error(libc::ENOENT); - } - } - - fn safe_symlink_target(&self, virtual_path: &str) -> Option { - let normalized = Path::new(virtual_path).components().fold( - std::path::PathBuf::new(), - |mut acc, comp| { - match comp { - std::path::Component::Normal(s) => acc.push(s), - std::path::Component::RootDir => acc.push("/"), - _ => {} - } - acc - }, - ); - - let path_str = normalized.to_string_lossy(); - if path_str.contains("..") { - return None; - } - - Some(format!("{}{}", self.mount_point, path_str)) - } - - fn execute_query(&self, query: &str) -> Vec { - let query = if query.len() > MAX_QUERY_LENGTH { - &query[..MAX_QUERY_LENGTH] - } else { - query - }; - - if let Some(results) = self.result_cache.get(query) { - return results; - } - - let results = self.index.search(query, 1000).unwrap_or_default(); - self.result_cache.insert(query.to_string(), results.clone()); - results - } - - fn result_filename(&self, hit: &SearchHit, index: usize) -> String { - let artist = hit.artist.as_deref().unwrap_or("Unknown"); - let title = hit.title.as_deref().unwrap_or("Unknown"); - let ext = hit - .virtual_path - .as_str() - .rsplit('.') - .next() - .unwrap_or("flac"); - format!("{:03}. {} - {}.{}", index + 1, artist, title, ext) - } - - fn dir_attr(&self, inode: u64) -> FileAttr { - FileAttr { - ino: inode, - size: 0, - blocks: 0, - atime: SystemTime::UNIX_EPOCH, - mtime: SystemTime::UNIX_EPOCH, - ctime: SystemTime::UNIX_EPOCH, - crtime: SystemTime::UNIX_EPOCH, - kind: FileType::Directory, - perm: 0o555, - nlink: 2, - uid: self.uid, - gid: self.gid, - rdev: 0, - blksize: 512, - flags: 0, - } - } - - fn symlink_attr(&self, inode: u64, target_len: u64) -> FileAttr { - FileAttr { - ino: inode, - size: target_len, - blocks: 0, - atime: SystemTime::UNIX_EPOCH, - mtime: SystemTime::UNIX_EPOCH, - ctime: SystemTime::UNIX_EPOCH, - crtime: SystemTime::UNIX_EPOCH, - kind: FileType::Symlink, - perm: 0o777, - nlink: 1, - uid: self.uid, - gid: self.gid, - rdev: 0, - blksize: 512, - flags: 0, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use musicfs_search::SearchIndex; - use tempfile::TempDir; - - #[test] - fn test_search_ops_new() { - let dir = TempDir::new().unwrap(); - let index = Arc::new(SearchIndex::open(dir.path()).unwrap()); - let _ops = SearchOps::new(index, "/mnt/music", 1000, 1000); - } - - #[test] - fn test_is_search_inode() { - assert!(SearchOps::is_search_inode(SEARCH_DIR_INODE)); - assert!(SearchOps::is_search_inode(SEARCH_RESULT_BASE)); - assert!(SearchOps::is_search_inode(SEARCH_RESULT_BASE + 100)); - assert!(!SearchOps::is_search_inode(1)); - assert!(!SearchOps::is_search_inode(1000)); - } -} diff --git a/crates/musicfs-grpc/Cargo.toml b/crates/musicfs-grpc/Cargo.toml deleted file mode 100644 index 341a08d..0000000 --- a/crates/musicfs-grpc/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "musicfs-grpc" -version.workspace = true -edition.workspace = true - -[dependencies] -musicfs-cache = { path = "../musicfs-cache" } -musicfs-cas = { path = "../musicfs-cas" } -musicfs-metadata = { path = "../musicfs-metadata" } -musicfs-search = { path = "../musicfs-search" } -musicfs-core = { path = "../musicfs-core" } -parking_lot.workspace = true -tonic.workspace = true -prost.workspace = true -tokio.workspace = true -tokio-stream.workspace = true -tracing.workspace = true -thiserror.workspace = true -serde.workspace = true -serde_json.workspace = true -chrono.workspace = true -csv = "1.3" -reqwest = { version = "0.11", features = ["json"] } -hmac = "0.12" -sha2 = "0.10" -hex.workspace = true - -[build-dependencies] -tonic-build.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/musicfs-grpc/build.rs b/crates/musicfs-grpc/build.rs deleted file mode 100644 index d84192f..0000000 --- a/crates/musicfs-grpc/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() -> Result<(), Box> { - tonic_build::compile_protos("proto/musicfs.proto")?; - Ok(()) -} diff --git a/crates/musicfs-grpc/proto/musicfs.proto b/crates/musicfs-grpc/proto/musicfs.proto deleted file mode 100644 index f4a01ff..0000000 --- a/crates/musicfs-grpc/proto/musicfs.proto +++ /dev/null @@ -1,322 +0,0 @@ -syntax = "proto3"; - -package musicfs.v1; - -option go_package = "homelab.lan/music-agregator/gen/musicfs/v1;musicfsv1"; - -service MusicFS { - rpc Search(SearchRequest) returns (SearchResponse); - rpc SearchStream(SearchRequest) returns (stream SearchResult); - rpc GetStatus(Empty) returns (StatusResponse); - rpc Shutdown(ShutdownRequest) returns (Empty); - rpc GetCacheStats(Empty) returns (CacheStats); - rpc ClearCache(ClearCacheRequest) returns (ClearCacheResponse); - rpc Prefetch(PrefetchRequest) returns (stream PrefetchProgress); - rpc ListOrigins(Empty) returns (OriginsResponse); - rpc GetOriginHealth(OriginRequest) returns (OriginHealthResponse); - rpc RescanOrigin(OriginRequest) returns (stream SyncProgress); - rpc SubscribeEvents(EventFilter) returns (stream Event); -} - -service MetadataService { - rpc GetMetadata(GetMetadataRequest) returns (MetadataResponse); - rpc UpdateMetadata(UpdateMetadataRequest) returns (UpdateMetadataResponse); - rpc ClearOverlay(ClearOverlayRequest) returns (ClearOverlayResponse); - rpc BatchUpdateMetadata(BatchUpdateRequest) returns (stream BatchUpdateProgress); - rpc ImportMetadata(ImportMetadataRequest) returns (stream ImportProgress); -} - -message Empty {} - -message SearchRequest { - string query = 1; - optional uint32 limit = 2; - optional uint32 offset = 3; - optional string origin_id = 4; -} - -message SearchResponse { - repeated SearchResult results = 1; - uint64 total_matches = 2; - uint32 query_time_ms = 3; -} - -message SearchResult { - int64 file_id = 1; - string virtual_path = 2; - optional string artist = 3; - optional string album = 4; - optional string title = 5; - float score = 6; - map highlights = 7; -} - -enum MountState { - MOUNT_UNKNOWN = 0; - MOUNT_MOUNTING = 1; - MOUNT_READY = 2; - MOUNT_SYNCING = 3; - MOUNT_DEGRADED = 4; - MOUNT_UNMOUNTING = 5; -} - -message StatusResponse { - string version = 1; - uint64 uptime_secs = 2; - string mount_point = 3; - MountState state = 4; - uint32 open_file_handles = 5; - uint64 fuse_ops_total = 6; - uint64 files_indexed = 7; - uint64 cache_size_bytes = 8; - repeated OriginStatus origins = 9; -} - -message OriginStatus { - string id = 1; - string origin_type = 2; - HealthStatus health = 3; - uint64 files_count = 4; -} - -enum HealthStatus { - HEALTH_UNKNOWN = 0; - HEALTH_HEALTHY = 1; - HEALTH_DEGRADED = 2; - HEALTH_UNHEALTHY = 3; -} - -message ShutdownRequest { - bool graceful = 1; - uint32 timeout_secs = 2; -} - -message TierStats { - uint64 entries = 1; - uint64 size_bytes = 2; - uint64 hits = 3; - uint64 misses = 4; -} - -message CacheStats { - uint64 total_size_bytes = 1; - uint64 used_size_bytes = 2; - uint64 size_limit_bytes = 3; - uint64 chunk_count = 4; - uint64 chunks_unique = 5; - double dedup_ratio = 6; - uint64 hit_count = 7; - uint64 miss_count = 8; - double hit_ratio = 9; - uint64 metadata_entries = 10; - uint64 metadata_bytes = 11; - TierStats l1_metadata = 12; - TierStats l2_headers = 13; - TierStats l3_chunks = 14; -} - -message ClearCacheRequest { - optional string origin_id = 1; - bool clear_metadata = 2; - bool clear_chunks = 3; -} - -message ClearCacheResponse { - uint64 bytes_cleared = 1; - uint64 chunks_cleared = 2; -} - -message PrefetchRequest { - repeated string paths = 1; - optional string origin_id = 2; -} - -message PrefetchProgress { - string current_path = 1; - uint32 completed = 2; - uint32 total = 3; - uint64 bytes_fetched = 4; -} - -message OriginsResponse { - repeated OriginInfo origins = 1; -} - -message OriginInfo { - string id = 1; - string origin_type = 2; - string display_name = 3; - string root_path = 4; - HealthStatus health = 5; - uint64 files_count = 6; - uint64 total_size_bytes = 7; -} - -message OriginRequest { - string origin_id = 1; - // Optional subdirectory to scope the scan (relative to origin root). - // If empty, scans the entire origin. - // Example: "Metallica - Master of Puppets (1986) [FLAC]" - optional string subdir = 2; -} - -message OriginHealthResponse { - string origin_id = 1; - HealthStatus status = 2; - optional string message = 3; - uint64 last_check_secs = 4; -} - -message SyncProgress { - string phase = 1; - uint32 current = 2; - uint32 total = 3; - string current_path = 4; - uint64 bytes_synced = 5; - repeated SyncedFile new_files = 6; -} - -message SyncedFile { - string path = 1; - int64 file_id = 2; - string virtual_path = 3; -} - -message EventFilter { - repeated string event_types = 1; - optional string origin_id = 2; -} - -message Event { - string event_type = 1; - int64 timestamp_ms = 2; - optional string origin_id = 3; - optional string path = 4; - optional int64 file_id = 5; - map metadata = 6; -} - -// MetadataService messages - -message GetMetadataRequest { - string virtual_path = 1; -} - -message MetadataResponse { - int64 file_id = 1; - optional string title = 2; - optional string artist = 3; - optional string album = 4; - optional string album_artist = 5; - optional uint32 year = 6; - optional uint32 track = 7; - optional uint32 disc = 8; - optional string genre = 9; - optional string format = 10; - optional uint64 duration_ms = 11; - optional uint64 bitrate = 12; - optional uint32 track_total = 13; - optional uint32 disc_total = 14; - optional string date = 15; - optional string composer = 16; - optional string comment = 17; - optional string lyrics = 18; - optional string copyright = 19; - optional bool compilation = 20; - optional string artist_sort = 21; - optional string album_artist_sort = 22; - optional string album_sort = 23; - optional string title_sort = 24; - optional string mb_recording_id = 25; - optional string mb_album_id = 26; - optional string mb_artist_id = 27; - optional string mb_album_artist_id = 28; - optional string mb_release_group_id = 29; - optional float replaygain_track_gain = 30; - optional float replaygain_track_peak = 31; - optional float replaygain_album_gain = 32; - optional float replaygain_album_peak = 33; - optional uint32 channels = 34; - optional uint32 bits_per_sample = 35; - optional string encoder = 36; - optional string label = 40; - optional string album_type = 41; - optional string cover_url = 42; - map custom_tags = 50; -} - -message UpdateMetadataRequest { - int64 file_id = 1; - optional string title = 2; - optional string artist = 3; - optional string album = 4; - optional string album_artist = 5; - optional uint32 track_number = 6; - optional uint32 disc_number = 7; - optional string date = 8; - optional string genre = 9; - optional string composer = 10; - optional string comment = 11; - optional string lyrics = 12; - optional string copyright = 13; - optional bool compilation = 14; - optional string artist_sort = 15; - optional string album_artist_sort = 16; - optional string album_sort = 17; - optional string title_sort = 18; - optional string mb_recording_id = 20; - optional string mb_album_id = 21; - optional string mb_artist_id = 22; - optional float replaygain_track_gain = 30; - optional float replaygain_track_peak = 31; - optional float replaygain_album_gain = 32; - optional float replaygain_album_peak = 33; - optional string label = 40; - optional string album_type = 41; - optional string cover_url = 42; - map custom_tags = 50; -} - -message UpdateMetadataResponse { - int64 file_id = 1; - bool success = 2; - optional string error_message = 3; -} - -message ClearOverlayRequest { - int64 file_id = 1; -} - -message ClearOverlayResponse { - int64 file_id = 1; - bool success = 2; - optional string error_message = 3; -} - -message BatchUpdateRequest { - repeated BatchUpdateItem items = 1; -} - -message BatchUpdateItem { - int64 file_id = 1; - UpdateMetadataRequest metadata = 2; -} - -message BatchUpdateProgress { - uint32 completed = 1; - uint32 total = 2; - optional int64 current_file_id = 3; - optional string error_message = 4; -} - -message ImportMetadataRequest { - string source_path = 1; - optional string format = 2; -} - -message ImportProgress { - uint32 imported = 1; - uint32 total = 2; - optional string current_file = 3; - optional string error_message = 4; -} diff --git a/crates/musicfs-grpc/src/lib.rs b/crates/musicfs-grpc/src/lib.rs deleted file mode 100644 index 1fff46a..0000000 --- a/crates/musicfs-grpc/src/lib.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub mod proto { - pub mod musicfs { - pub mod v1 { - tonic::include_proto!("musicfs.v1"); - } - } -} - -mod metadata; -pub mod scanner; -mod search_service; -mod server; -mod webhook; - -pub use metadata::MetadataServiceImpl; -pub use proto::musicfs::v1::metadata_service_server::MetadataServiceServer; -pub use proto::musicfs::v1::music_fs_server::{MusicFs, MusicFsServer as MusicFsGrpcServer}; -pub use proto::musicfs::v1::*; -pub use search_service::SearchService; -pub use server::MusicFsServer; -pub use webhook::{WebhookConfig, WebhookHandler, WebhookPayload}; diff --git a/crates/musicfs-grpc/src/metadata.rs b/crates/musicfs-grpc/src/metadata.rs deleted file mode 100644 index 3929372..0000000 --- a/crates/musicfs-grpc/src/metadata.rs +++ /dev/null @@ -1,794 +0,0 @@ -//! MetadataService gRPC handlers for metadata overlay operations. - -use crate::proto::musicfs::v1::{ - metadata_service_server::MetadataService, BatchUpdateProgress, BatchUpdateRequest, - ClearOverlayRequest, ClearOverlayResponse, GetMetadataRequest, ImportMetadataRequest, - ImportProgress, MetadataResponse, UpdateMetadataRequest, UpdateMetadataResponse, -}; -use musicfs_cache::{Database, EnrichmentUpdate}; -use musicfs_core::{AudioMeta, FileId, VirtualPath}; -use std::sync::Arc; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; -use tonic::{Request, Response, Status}; -use tracing::{debug, info, instrument, warn}; - -/// gRPC service implementation for metadata operations. -pub struct MetadataServiceImpl { - db: Arc, -} - -impl MetadataServiceImpl { - /// Create a new MetadataServiceImpl with the given database. - pub fn new(db: Arc) -> Self { - Self { db } - } - - /// Convert AudioMeta to MetadataResponse proto message. - fn audio_meta_to_response(file_id: FileId, meta: &AudioMeta) -> MetadataResponse { - MetadataResponse { - file_id: file_id.0, - title: meta.title.clone(), - artist: meta.artist.clone(), - album: meta.album.clone(), - album_artist: meta.album_artist.clone(), - year: meta.year, - track: meta.track, - disc: meta.disc, - genre: meta.genre.clone(), - format: Some(format!("{:?}", meta.format)), - duration_ms: meta.duration_ms, - bitrate: meta.bitrate.map(|b| b as u64), - track_total: meta.track_total, - disc_total: meta.disc_total, - date: meta.date.clone(), - composer: meta.composer.clone(), - comment: meta.comment.clone(), - lyrics: meta.lyrics.clone(), - copyright: meta.copyright.clone(), - compilation: meta.compilation, - artist_sort: meta.artist_sort.clone(), - album_artist_sort: meta.album_artist_sort.clone(), - album_sort: meta.album_sort.clone(), - title_sort: meta.title_sort.clone(), - mb_recording_id: meta.mb_recording_id.clone(), - mb_album_id: meta.mb_album_id.clone(), - mb_artist_id: meta.mb_artist_id.clone(), - mb_album_artist_id: meta.mb_album_artist_id.clone(), - mb_release_group_id: meta.mb_release_group_id.clone(), - replaygain_track_gain: meta.replaygain_track_gain, - replaygain_track_peak: meta.replaygain_track_peak, - replaygain_album_gain: meta.replaygain_album_gain, - replaygain_album_peak: meta.replaygain_album_peak, - channels: meta.channels, - bits_per_sample: meta.bits_per_sample, - encoder: meta.encoder.clone(), - label: None, - album_type: None, - cover_url: None, - custom_tags: Default::default(), - } - } - - /// Convert UpdateMetadataRequest to AudioMeta for database update. - fn request_to_audio_meta(req: &UpdateMetadataRequest) -> AudioMeta { - AudioMeta { - title: req.title.clone(), - artist: req.artist.clone(), - album: req.album.clone(), - album_artist: req.album_artist.clone(), - genre: req.genre.clone(), - year: None, - track: req.track_number, - disc: req.disc_number, - duration_ms: None, - bitrate: None, - sample_rate: None, - format: musicfs_core::AudioFormat::Unknown, - track_total: None, - disc_total: None, - date: req.date.clone(), - composer: req.composer.clone(), - comment: req.comment.clone(), - lyrics: req.lyrics.clone(), - copyright: req.copyright.clone(), - compilation: req.compilation, - artist_sort: req.artist_sort.clone(), - album_artist_sort: req.album_artist_sort.clone(), - album_sort: req.album_sort.clone(), - title_sort: req.title_sort.clone(), - mb_recording_id: req.mb_recording_id.clone(), - mb_album_id: req.mb_album_id.clone(), - mb_artist_id: req.mb_artist_id.clone(), - mb_album_artist_id: None, - mb_release_group_id: None, - replaygain_track_gain: req.replaygain_track_gain, - replaygain_track_peak: req.replaygain_track_peak, - replaygain_album_gain: req.replaygain_album_gain, - replaygain_album_peak: req.replaygain_album_peak, - channels: None, - bits_per_sample: None, - encoder: None, - } - } -} - -#[tonic::async_trait] -impl MetadataService for MetadataServiceImpl { - #[instrument(level = "debug", skip(self, request), fields(method = "get_metadata"))] - async fn get_metadata( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - debug!(virtual_path = %req.virtual_path, "GetMetadata request"); - - if req.virtual_path.is_empty() { - return Err(Status::invalid_argument("virtual_path cannot be empty")); - } - - let vpath = VirtualPath::new(&req.virtual_path); - - let file_meta = self - .db - .get_file_by_virtual_path(&vpath) - .map_err(|e| Status::internal(format!("Database error: {}", e)))? - .ok_or_else(|| Status::not_found(format!("File not found: {}", req.virtual_path)))?; - - let audio_meta = self - .db - .get_file_metadata_row(file_meta.id) - .map_err(|e| Status::internal(format!("Failed to get metadata: {}", e)))?; - - let response = Self::audio_meta_to_response(file_meta.id, &audio_meta); - Ok(Response::new(response)) - } - - #[instrument( - level = "info", - skip(self, request), - fields(method = "update_metadata") - )] - async fn update_metadata( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - let file_id = FileId(req.file_id); - info!(file_id = req.file_id, "UpdateMetadata request"); - - if req.file_id <= 0 { - return Err(Status::invalid_argument("file_id must be positive")); - } - - let audio_meta = Self::request_to_audio_meta(&req); - - if let Err(e) = self.db.update_metadata(file_id, &audio_meta) { - warn!(file_id = req.file_id, error = %e, "Failed to update metadata"); - return Ok(Response::new(UpdateMetadataResponse { - file_id: req.file_id, - success: false, - error_message: Some(e.to_string()), - })); - } - - if req.label.is_some() || req.album_type.is_some() || req.cover_url.is_some() { - let enrichment = EnrichmentUpdate { - label: req.label.clone(), - album_type: req.album_type.clone(), - cover_url: req.cover_url.clone(), - genres_json: None, - primary_genre: None, - source: "orchestrator".to_string(), - }; - if let Err(e) = self.db.update_enrichment(file_id, &enrichment) { - warn!(file_id = req.file_id, error = %e, "Failed to update enrichment"); - return Ok(Response::new(UpdateMetadataResponse { - file_id: req.file_id, - success: false, - error_message: Some(e.to_string()), - })); - } - } - - debug!(file_id = req.file_id, "Metadata updated successfully"); - Ok(Response::new(UpdateMetadataResponse { - file_id: req.file_id, - success: true, - error_message: None, - })) - } - - #[instrument(level = "info", skip(self, request), fields(method = "clear_overlay"))] - async fn clear_overlay( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - let file_id = FileId(req.file_id); - info!(file_id = req.file_id, "ClearOverlay request"); - - if req.file_id <= 0 { - return Err(Status::invalid_argument("file_id must be positive")); - } - - match self.db.clear_overlay(file_id) { - Ok(()) => { - debug!(file_id = req.file_id, "Overlay cleared successfully"); - Ok(Response::new(ClearOverlayResponse { - file_id: req.file_id, - success: true, - error_message: None, - })) - } - Err(e) => { - warn!(file_id = req.file_id, error = %e, "Failed to clear overlay"); - Ok(Response::new(ClearOverlayResponse { - file_id: req.file_id, - success: false, - error_message: Some(e.to_string()), - })) - } - } - } - - type BatchUpdateMetadataStream = ReceiverStream>; - - #[instrument( - level = "info", - skip(self, request), - fields(method = "batch_update_metadata") - )] - async fn batch_update_metadata( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - let total = req.items.len() as u32; - info!(item_count = total, "BatchUpdateMetadata request"); - - let (tx, rx) = mpsc::channel(32); - let db = Arc::clone(&self.db); - - tokio::spawn(async move { - for (i, item) in req.items.into_iter().enumerate() { - let file_id = FileId(item.file_id); - let completed = (i + 1) as u32; - - let error_message = if let Some(ref metadata_req) = item.metadata { - let audio_meta = MetadataServiceImpl::request_to_audio_meta(metadata_req); - match db.update_metadata(file_id, &audio_meta) { - Ok(()) => { - if metadata_req.label.is_some() - || metadata_req.album_type.is_some() - || metadata_req.cover_url.is_some() - { - let enrichment = EnrichmentUpdate { - label: metadata_req.label.clone(), - album_type: metadata_req.album_type.clone(), - cover_url: metadata_req.cover_url.clone(), - genres_json: None, - primary_genre: None, - source: "orchestrator".to_string(), - }; - if let Err(e) = db.update_enrichment(file_id, &enrichment) { - Some(e.to_string()) - } else { - None - } - } else { - None - } - } - Err(e) => Some(e.to_string()), - } - } else { - Some("Missing metadata in batch item".to_string()) - }; - - let progress = BatchUpdateProgress { - completed, - total, - current_file_id: Some(item.file_id), - error_message, - }; - - if tx.send(Ok(progress)).await.is_err() { - break; - } - } - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - type ImportMetadataStream = ReceiverStream>; - - #[instrument( - level = "info", - skip(self, request), - fields(method = "import_metadata") - )] - async fn import_metadata( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - info!(source_path = %req.source_path, format = ?req.format, "ImportMetadata request"); - - if req.source_path.is_empty() { - return Err(Status::invalid_argument("source_path cannot be empty")); - } - - let (tx, rx) = mpsc::channel(32); - let db = Arc::clone(&self.db); - let source_path = req.source_path.clone(); - let format = req.format.clone(); - - tokio::spawn(async move { - let file_format = format.as_deref().unwrap_or_else(|| { - if source_path.ends_with(".csv") { - "csv" - } else if source_path.ends_with(".json") { - "json" - } else { - "unknown" - } - }); - - let content = match tokio::fs::read_to_string(&source_path).await { - Ok(c) => c, - Err(e) => { - let _ = tx - .send(Ok(ImportProgress { - imported: 0, - total: 0, - current_file: None, - error_message: Some(format!("Failed to read file: {}", e)), - })) - .await; - return; - } - }; - - let entries: Vec = match file_format { - "json" => match serde_json::from_str::>(&content) { - Ok(e) => e, - Err(e) => { - let _ = tx - .send(Ok(ImportProgress { - imported: 0, - total: 0, - current_file: None, - error_message: Some(format!("Failed to parse JSON: {}", e)), - })) - .await; - return; - } - }, - "csv" => match parse_csv_entries(&content) { - Ok(e) => e, - Err(e) => { - let _ = tx - .send(Ok(ImportProgress { - imported: 0, - total: 0, - current_file: None, - error_message: Some(format!("Failed to parse CSV: {}", e)), - })) - .await; - return; - } - }, - _ => { - let _ = tx - .send(Ok(ImportProgress { - imported: 0, - total: 0, - current_file: None, - error_message: Some(format!("Unsupported format: {}", file_format)), - })) - .await; - return; - } - }; - - let total = entries.len() as u32; - let mut imported = 0u32; - - for entry in entries { - let vpath = VirtualPath::new(&entry.virtual_path); - - let file_meta = match db.get_file_by_virtual_path(&vpath) { - Ok(Some(f)) => f, - Ok(None) => { - let progress = ImportProgress { - imported, - total, - current_file: Some(entry.virtual_path.clone()), - error_message: Some(format!("File not found: {}", entry.virtual_path)), - }; - if tx.send(Ok(progress)).await.is_err() { - break; - } - continue; - } - Err(e) => { - let progress = ImportProgress { - imported, - total, - current_file: Some(entry.virtual_path.clone()), - error_message: Some(format!("Database error: {}", e)), - }; - if tx.send(Ok(progress)).await.is_err() { - break; - } - continue; - } - }; - - let audio_meta = entry.to_audio_meta(); - let error_message = match db.update_metadata(file_meta.id, &audio_meta) { - Ok(()) => { - imported += 1; - None - } - Err(e) => Some(e.to_string()), - }; - - let progress = ImportProgress { - imported, - total, - current_file: Some(entry.virtual_path), - error_message, - }; - - if tx.send(Ok(progress)).await.is_err() { - break; - } - } - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } -} - -/// Entry from import file (CSV or JSON). -#[derive(Debug, Clone, serde::Deserialize)] -struct ImportEntry { - virtual_path: String, - #[serde(default)] - title: Option, - #[serde(default)] - artist: Option, - #[serde(default)] - album: Option, - #[serde(default)] - album_artist: Option, - #[serde(default)] - genre: Option, - #[serde(default)] - year: Option, - #[serde(default)] - track: Option, - #[serde(default)] - disc: Option, - #[serde(default)] - date: Option, - #[serde(default)] - composer: Option, - #[serde(default)] - comment: Option, -} - -impl ImportEntry { - fn to_audio_meta(&self) -> AudioMeta { - AudioMeta { - title: self.title.clone(), - artist: self.artist.clone(), - album: self.album.clone(), - album_artist: self.album_artist.clone(), - genre: self.genre.clone(), - year: self.year, - track: self.track, - disc: self.disc, - duration_ms: None, - bitrate: None, - sample_rate: None, - format: musicfs_core::AudioFormat::Unknown, - track_total: None, - disc_total: None, - date: self.date.clone(), - composer: self.composer.clone(), - comment: self.comment.clone(), - lyrics: None, - copyright: None, - compilation: None, - artist_sort: None, - album_artist_sort: None, - album_sort: None, - title_sort: None, - mb_recording_id: None, - mb_album_id: None, - mb_artist_id: None, - mb_album_artist_id: None, - mb_release_group_id: None, - replaygain_track_gain: None, - replaygain_track_peak: None, - replaygain_album_gain: None, - replaygain_album_peak: None, - channels: None, - bits_per_sample: None, - encoder: None, - } - } -} - -/// Parse CSV content into ImportEntry list. -fn parse_csv_entries(content: &str) -> Result, String> { - let mut reader = csv::Reader::from_reader(content.as_bytes()); - let mut entries = Vec::new(); - - for result in reader.deserialize() { - let entry: ImportEntry = result.map_err(|e| format!("CSV parse error: {}", e))?; - entries.push(entry); - } - - Ok(entries) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::proto::musicfs::v1::BatchUpdateItem; - use musicfs_core::{AudioFormat, OriginId}; - use std::path::Path; - use std::time::UNIX_EPOCH; - use tempfile::TempDir; - use tokio_stream::StreamExt; - - fn create_test_db() -> (TempDir, Arc) { - let dir = TempDir::new().unwrap(); - let db = Arc::new(Database::open_memory().unwrap()); - (dir, db) - } - - fn insert_test_file(db: &Database, vpath: &str) -> FileId { - let real_path = format!("/music{}", vpath); - db.upsert_file( - &OriginId::from("local"), - Path::new(&real_path), - &VirtualPath::new(vpath), - &AudioMeta { - title: Some("Test Track".to_string()), - artist: Some("Test Artist".to_string()), - album: Some("Test Album".to_string()), - format: AudioFormat::Flac, - ..Default::default() - }, - UNIX_EPOCH, - 1000, - ) - .unwrap() - } - - #[tokio::test] - async fn test_get_metadata_success() { - let (_dir, db) = create_test_db(); - let vpath = "/Artist/Album/Track.flac"; - insert_test_file(&db, vpath); - - let service = MetadataServiceImpl::new(db); - let request = Request::new(GetMetadataRequest { - virtual_path: vpath.to_string(), - }); - - let response = service.get_metadata(request).await.unwrap(); - let meta = response.into_inner(); - - assert_eq!(meta.title, Some("Test Track".to_string())); - assert_eq!(meta.artist, Some("Test Artist".to_string())); - assert_eq!(meta.album, Some("Test Album".to_string())); - } - - #[tokio::test] - async fn test_get_metadata_not_found() { - let (_dir, db) = create_test_db(); - let service = MetadataServiceImpl::new(db); - - let request = Request::new(GetMetadataRequest { - virtual_path: "/nonexistent.flac".to_string(), - }); - - let result = service.get_metadata(request).await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound); - } - - #[tokio::test] - async fn test_get_metadata_empty_path() { - let (_dir, db) = create_test_db(); - let service = MetadataServiceImpl::new(db); - - let request = Request::new(GetMetadataRequest { - virtual_path: String::new(), - }); - - let result = service.get_metadata(request).await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument); - } - - #[tokio::test] - async fn test_update_metadata_success() { - let (_dir, db) = create_test_db(); - let vpath = "/Artist/Album/Track.flac"; - let file_id = insert_test_file(&db, vpath); - - let service = MetadataServiceImpl::new(db.clone()); - let request = Request::new(UpdateMetadataRequest { - file_id: file_id.0, - title: Some("Updated Title".to_string()), - artist: Some("Updated Artist".to_string()), - ..Default::default() - }); - - let response = service.update_metadata(request).await.unwrap(); - let result = response.into_inner(); - - assert!(result.success); - assert!(result.error_message.is_none()); - - let meta = db.get_file_metadata_row(file_id).unwrap(); - assert_eq!(meta.title, Some("Updated Title".to_string())); - assert_eq!(meta.artist, Some("Updated Artist".to_string())); - } - - #[tokio::test] - async fn test_update_metadata_invalid_id() { - let (_dir, db) = create_test_db(); - let service = MetadataServiceImpl::new(db); - - let request = Request::new(UpdateMetadataRequest { - file_id: 0, - title: Some("Title".to_string()), - ..Default::default() - }); - - let result = service.update_metadata(request).await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument); - } - - #[tokio::test] - async fn test_clear_overlay_success() { - let (_dir, db) = create_test_db(); - let vpath = "/Artist/Album/Track.flac"; - let file_id = insert_test_file(&db, vpath); - - let service = MetadataServiceImpl::new(db.clone()); - let request = Request::new(ClearOverlayRequest { file_id: file_id.0 }); - - let response = service.clear_overlay(request).await.unwrap(); - let result = response.into_inner(); - - assert!(result.success); - assert!(result.error_message.is_none()); - - let meta = db.get_file_metadata_row(file_id).unwrap(); - assert!(meta.title.is_none()); - assert!(meta.artist.is_none()); - } - - #[tokio::test] - async fn test_clear_overlay_invalid_id() { - let (_dir, db) = create_test_db(); - let service = MetadataServiceImpl::new(db); - - let request = Request::new(ClearOverlayRequest { file_id: -1 }); - - let result = service.clear_overlay(request).await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument); - } - - #[tokio::test] - async fn test_batch_update_metadata() { - let (_dir, db) = create_test_db(); - let file_id1 = insert_test_file(&db, "/Track1.flac"); - let file_id2 = insert_test_file(&db, "/Track2.flac"); - - let service = MetadataServiceImpl::new(db.clone()); - let request = Request::new(BatchUpdateRequest { - items: vec![ - BatchUpdateItem { - file_id: file_id1.0, - metadata: Some(UpdateMetadataRequest { - file_id: file_id1.0, - title: Some("Batch Title 1".to_string()), - ..Default::default() - }), - }, - BatchUpdateItem { - file_id: file_id2.0, - metadata: Some(UpdateMetadataRequest { - file_id: file_id2.0, - title: Some("Batch Title 2".to_string()), - ..Default::default() - }), - }, - ], - }); - - let response = service.batch_update_metadata(request).await.unwrap(); - let mut stream = response.into_inner(); - - let mut progress_count = 0; - while let Some(Ok(result)) = stream.next().await { - progress_count += 1; - assert!(result.error_message.is_none()); - } - - assert_eq!(progress_count, 2); - - let meta1 = db.get_file_metadata_row(file_id1).unwrap(); - assert_eq!(meta1.title, Some("Batch Title 1".to_string())); - - let meta2 = db.get_file_metadata_row(file_id2).unwrap(); - assert_eq!(meta2.title, Some("Batch Title 2".to_string())); - } - - #[tokio::test] - async fn test_import_metadata_empty_path() { - let (_dir, db) = create_test_db(); - let service = MetadataServiceImpl::new(db); - - let request = Request::new(ImportMetadataRequest { - source_path: String::new(), - format: None, - }); - - let result = service.import_metadata(request).await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument); - } - - #[test] - fn test_parse_csv_entries() { - let csv_content = r#"virtual_path,title,artist,album -/Track1.flac,Title 1,Artist 1,Album 1 -/Track2.flac,Title 2,Artist 2,Album 2"#; - - let entries = parse_csv_entries(csv_content).unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].virtual_path, "/Track1.flac"); - assert_eq!(entries[0].title, Some("Title 1".to_string())); - assert_eq!(entries[1].virtual_path, "/Track2.flac"); - assert_eq!(entries[1].artist, Some("Artist 2".to_string())); - } - - #[test] - fn test_import_entry_to_audio_meta() { - let entry = ImportEntry { - virtual_path: "/test.flac".to_string(), - title: Some("Test".to_string()), - artist: Some("Artist".to_string()), - album: None, - album_artist: None, - genre: Some("Rock".to_string()), - year: Some(2024), - track: Some(1), - disc: None, - date: None, - composer: None, - comment: None, - }; - - let meta = entry.to_audio_meta(); - assert_eq!(meta.title, Some("Test".to_string())); - assert_eq!(meta.artist, Some("Artist".to_string())); - assert_eq!(meta.genre, Some("Rock".to_string())); - assert_eq!(meta.year, Some(2024)); - assert_eq!(meta.track, Some(1)); - } -} diff --git a/crates/musicfs-grpc/src/scanner.rs b/crates/musicfs-grpc/src/scanner.rs deleted file mode 100644 index 50bdd4e..0000000 --- a/crates/musicfs-grpc/src/scanner.rs +++ /dev/null @@ -1,261 +0,0 @@ -use musicfs_cache::{Database, VirtualTree}; -use musicfs_cas::ContentFetcher; -use musicfs_core::{ - AudioMeta, Error, Event, EventBus, FileId, FileMeta, OriginId, RealPath, Result, VirtualPath, -}; -use musicfs_metadata::MetadataParser; -use parking_lot::RwLock; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::UNIX_EPOCH; -use tokio::sync::mpsc; -use tracing::{info, warn}; - -pub struct ScanResult { - pub new_files: Vec, - pub changed: u32, - pub deleted: u32, - pub unchanged: u32, - pub bytes_synced: u64, -} - -pub struct SyncedFileInfo { - pub path: String, - pub file_id: FileId, - pub virtual_path: String, -} - -#[derive(Debug, Clone)] -pub struct ScanProgress { - pub phase: String, - pub current: u32, - pub total: u32, - pub current_path: String, - pub bytes_synced: u64, -} - -pub struct OriginScanner { - db: Arc, - event_bus: Arc, - tree: Arc>, - fetcher: Arc, - parser: MetadataParser, -} - -impl OriginScanner { - pub fn new( - db: Arc, - event_bus: Arc, - tree: Arc>, - fetcher: Arc, - ) -> Self { - Self { - db, - event_bus, - tree, - fetcher, - parser: MetadataParser, - } - } - - pub async fn scan( - &self, - origin_id: &OriginId, - origin_root: &Path, - subdir: Option<&str>, - progress_tx: mpsc::Sender, - ) -> Result { - let scan_root = match subdir { - Some(sub) if !sub.is_empty() => origin_root.join(sub), - _ => origin_root.to_path_buf(), - }; - - if !scan_root.exists() { - return Err(Error::Origin(format!( - "scan path does not exist: {}", - scan_root.display() - ))); - } - - // Phase 1: Scanning - let audio_files = self.collect_audio_files(&scan_root, &progress_tx)?; - let total_files = audio_files.len() as u32; - info!(files = total_files, "scan phase complete"); - - // Phase 2: Hashing + categorization - let mut new_files = Vec::new(); - let mut unchanged = 0u32; - - for (i, abs_path) in audio_files.iter().enumerate() { - let _ = progress_tx.try_send(ScanProgress { - phase: "hashing".to_string(), - current: i as u32 + 1, - total: total_files, - current_path: abs_path.display().to_string(), - bytes_synced: 0, - }); - - let rel_path = abs_path.strip_prefix(origin_root).unwrap_or(abs_path); - - let existing = self.db.get_file_by_real_path(origin_id, rel_path)?; - if existing.is_some() { - unchanged += 1; - continue; - } - - let size = std::fs::metadata(abs_path).map(|m| m.len()).unwrap_or(0); - - new_files.push(DiscoveredFile { - abs_path: abs_path.clone(), - rel_path: rel_path.to_path_buf(), - size, - }); - } - - info!( - new = new_files.len(), - unchanged = unchanged, - "hash phase complete" - ); - - // Phase 3: Indexing - let mut synced = Vec::new(); - let mut bytes_synced = 0u64; - let ingest_total = new_files.len() as u32; - - for (i, file) in new_files.iter().enumerate() { - let _ = progress_tx.try_send(ScanProgress { - phase: "indexing".to_string(), - current: i as u32 + 1, - total: ingest_total, - current_path: file.abs_path.display().to_string(), - bytes_synced, - }); - - let audio_meta = match self.parser.parse_file(&file.abs_path) { - Ok(meta) => meta, - Err(e) => { - warn!(path = %file.abs_path.display(), error = %e, "parse failed, using defaults"); - AudioMeta::default() - } - }; - - let virtual_path = derive_virtual_path(&audio_meta, &file.rel_path); - - let file_id = self.db.upsert_file( - origin_id, - &file.rel_path, - &virtual_path, - &audio_meta, - UNIX_EPOCH, - file.size, - )?; - - let file_meta = FileMeta { - id: file_id, - virtual_path: virtual_path.clone(), - real_path: RealPath { - origin_id: origin_id.clone(), - path: file.rel_path.clone(), - }, - size: file.size, - mtime: UNIX_EPOCH, - content_hash: None, - audio: Some(audio_meta), - }; - - { - let mut tree = self.tree.write(); - tree.insert_file(&file_meta); - } - - self.fetcher.register_file(file_meta.clone()); - - self.event_bus.publish(Event::FileAdded { - path: virtual_path.clone(), - origin_id: origin_id.clone(), - }); - - bytes_synced += file.size; - - synced.push(SyncedFileInfo { - path: file.abs_path.display().to_string(), - file_id, - virtual_path: virtual_path.as_str().to_string(), - }); - } - - Ok(ScanResult { - new_files: synced, - changed: 0, - deleted: 0, - unchanged, - bytes_synced, - }) - } - - fn collect_audio_files( - &self, - scan_root: &Path, - progress_tx: &mpsc::Sender, - ) -> Result> { - let mut files = Vec::new(); - self.walk_dir(scan_root, &mut files, progress_tx)?; - Ok(files) - } - - fn walk_dir( - &self, - dir: &Path, - files: &mut Vec, - progress_tx: &mpsc::Sender, - ) -> Result<()> { - let entries = std::fs::read_dir(dir) - .map_err(|e| Error::Origin(format!("read_dir {}: {}", dir.display(), e)))?; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - self.walk_dir(&path, files, progress_tx)?; - } else if is_audio_file(&path) { - files.push(path.clone()); - let _ = progress_tx.try_send(ScanProgress { - phase: "scanning".to_string(), - current: files.len() as u32, - total: 0, - current_path: path.display().to_string(), - bytes_synced: 0, - }); - } - } - - Ok(()) - } -} - -fn derive_virtual_path(meta: &AudioMeta, rel_path: &Path) -> VirtualPath { - let artist = meta.artist.as_deref().unwrap_or("Unknown Artist"); - let album = meta.album.as_deref().unwrap_or("Unknown Album"); - let filename = rel_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - - VirtualPath::new(format!("/{}/{}/{}", artist, album, filename)) -} - -fn is_audio_file(path: &Path) -> bool { - matches!( - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .as_deref(), - Some("flac" | "mp3" | "ogg" | "wav" | "m4a" | "aac" | "opus") - ) -} - -struct DiscoveredFile { - abs_path: PathBuf, - rel_path: PathBuf, - size: u64, -} diff --git a/crates/musicfs-grpc/src/search_service.rs b/crates/musicfs-grpc/src/search_service.rs deleted file mode 100644 index 39f9d37..0000000 --- a/crates/musicfs-grpc/src/search_service.rs +++ /dev/null @@ -1,251 +0,0 @@ -use crate::proto::musicfs::v1::{ - music_fs_server::MusicFs, CacheStats, ClearCacheRequest, ClearCacheResponse, Empty, Event, - EventFilter, OriginHealthResponse, OriginRequest, OriginsResponse, PrefetchProgress, - PrefetchRequest, SearchRequest, SearchResponse, SearchResult, ShutdownRequest, StatusResponse, - SyncProgress, -}; -use musicfs_search::SearchIndex; -use std::sync::Arc; -use std::time::Instant; -use tokio_stream::wrappers::ReceiverStream; -use tonic::{Request, Response, Status}; -use tracing::debug; - -pub struct SearchService { - index: Arc, -} - -impl SearchService { - pub fn new(index: Arc) -> Self { - Self { index } - } -} - -#[tonic::async_trait] -impl MusicFs for SearchService { - async fn search( - &self, - request: Request, - ) -> Result, Status> { - let start = Instant::now(); - let req = request.into_inner(); - - if req.query.is_empty() { - return Err(Status::invalid_argument("Query cannot be empty")); - } - - if req.query.len() > 256 { - return Err(Status::invalid_argument( - "Query exceeds maximum length (256)", - )); - } - - let limit = req.limit.unwrap_or(100).min(10000) as usize; - let offset = req.offset.unwrap_or(0) as usize; - - let results = self - .index - .search(&req.query, limit + offset) - .map_err(|e| Status::internal(format!("Search failed: {}", e)))?; - - let hits: Vec = results - .into_iter() - .skip(offset) - .take(limit) - .map(|hit| SearchResult { - file_id: hit.file_id.0, - virtual_path: hit.virtual_path.as_str().to_string(), - artist: hit.artist, - album: hit.album, - title: hit.title, - score: hit.score, - highlights: Default::default(), - }) - .collect(); - - let total_matches = self.index.count(); - let query_time_ms = start.elapsed().as_millis() as u32; - - debug!( - "Search '{}' returned {} results in {}ms", - req.query, - hits.len(), - query_time_ms - ); - - Ok(Response::new(SearchResponse { - results: hits, - total_matches, - query_time_ms, - })) - } - - type SearchStreamStream = ReceiverStream>; - - async fn search_stream( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - - if req.query.is_empty() { - return Err(Status::invalid_argument("Query cannot be empty")); - } - - let limit = req.limit.unwrap_or(1000).min(10000) as usize; - - let results = self - .index - .search(&req.query, limit) - .map_err(|e| Status::internal(format!("Search failed: {}", e)))?; - - let (tx, rx) = tokio::sync::mpsc::channel(100); - - tokio::spawn(async move { - for hit in results { - let result = SearchResult { - file_id: hit.file_id.0, - virtual_path: hit.virtual_path.as_str().to_string(), - artist: hit.artist, - album: hit.album, - title: hit.title, - score: hit.score, - highlights: Default::default(), - }; - if tx.send(Ok(result)).await.is_err() { - break; - } - } - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - async fn get_status( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use MusicFsServer for control operations", - )) - } - - async fn shutdown( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use MusicFsServer for control operations", - )) - } - - async fn get_cache_stats( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use MusicFsServer for control operations", - )) - } - - async fn clear_cache( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use MusicFsServer for control operations", - )) - } - - type PrefetchStream = ReceiverStream>; - - async fn prefetch( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use MusicFsServer for control operations", - )) - } - - async fn list_origins( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use MusicFsServer for control operations", - )) - } - - async fn get_origin_health( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use MusicFsServer for control operations", - )) - } - - type RescanOriginStream = ReceiverStream>; - - async fn rescan_origin( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use MusicFsServer for control operations", - )) - } - - type SubscribeEventsStream = ReceiverStream>; - - async fn subscribe_events( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use MusicFsServer for control operations", - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn test_grpc_search_empty_query() { - let dir = TempDir::new().unwrap(); - let index = Arc::new(SearchIndex::open(dir.path()).unwrap()); - let service = SearchService::new(index); - - let request = Request::new(SearchRequest { - query: String::new(), - limit: Some(10), - offset: None, - origin_id: None, - }); - - let result = service.search(request).await; - assert!(result.is_err()); - assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument); - } - - #[tokio::test] - async fn test_grpc_search_returns_response() { - let dir = TempDir::new().unwrap(); - let index = Arc::new(SearchIndex::open(dir.path()).unwrap()); - let service = SearchService::new(index); - - let request = Request::new(SearchRequest { - query: "test".to_string(), - limit: Some(10), - offset: None, - origin_id: None, - }); - - let response = service.search(request).await.unwrap(); - assert!(response.get_ref().results.is_empty()); - } -} diff --git a/crates/musicfs-grpc/src/server.rs b/crates/musicfs-grpc/src/server.rs deleted file mode 100644 index e2d090a..0000000 --- a/crates/musicfs-grpc/src/server.rs +++ /dev/null @@ -1,561 +0,0 @@ -use crate::proto::musicfs::v1::{ - music_fs_server::MusicFs, CacheStats, ClearCacheRequest, ClearCacheResponse, Empty, Event, - EventFilter, HealthStatus, MountState, OriginHealthResponse, OriginRequest, OriginsResponse, - PrefetchProgress, PrefetchRequest, SearchRequest, SearchResponse, SearchResult, - ShutdownRequest, StatusResponse, SyncProgress, SyncedFile, TierStats, -}; -use musicfs_core::{Event as CoreEvent, EventBus}; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; -use tonic::{Request, Response, Status}; -use tracing::{debug, info, instrument}; - -pub struct MusicFsServer { - start_time: Instant, - event_bus: Arc, - version: String, - scanner: Arc, - origin_root: std::path::PathBuf, -} - -impl MusicFsServer { - pub fn new( - event_bus: Arc, - db: Arc, - tree: Arc>, - fetcher: Arc, - origin_root: std::path::PathBuf, - ) -> Self { - let scanner = Arc::new(crate::scanner::OriginScanner::new( - db, - event_bus.clone(), - tree, - fetcher, - )); - Self { - start_time: Instant::now(), - event_bus, - version: env!("CARGO_PKG_VERSION").to_string(), - scanner, - origin_root, - } - } - - fn event_to_proto(event: &CoreEvent) -> Event { - let (event_type, origin_id, path, file_id) = match event { - CoreEvent::FileAccessed { - file_id, - origin_id, - path, - .. - } => ( - "file_accessed".to_string(), - Some(origin_id.to_string()), - Some(path.as_str().to_string()), - Some(file_id.0), - ), - CoreEvent::FileAdded { path, origin_id } => ( - "file_added".to_string(), - Some(origin_id.to_string()), - Some(path.as_str().to_string()), - None, - ), - CoreEvent::FileRemoved { path, file_id } => ( - "file_removed".to_string(), - None, - Some(path.as_str().to_string()), - file_id.map(|id| id.0), - ), - CoreEvent::FileModified { path } => ( - "file_modified".to_string(), - None, - Some(path.as_str().to_string()), - None, - ), - CoreEvent::SyncStarted { origin_id } => ( - "sync_started".to_string(), - Some(origin_id.to_string()), - None, - None, - ), - CoreEvent::SyncCompleted { - origin_id, - files_changed, - } => { - let mut metadata = std::collections::HashMap::new(); - metadata.insert("files_changed".to_string(), files_changed.to_string()); - return Event { - event_type: "sync_completed".to_string(), - timestamp_ms: chrono::Utc::now().timestamp_millis(), - origin_id: Some(origin_id.to_string()), - path: None, - file_id: None, - metadata, - }; - } - CoreEvent::OriginHealthChanged { origin_id, healthy } => { - let mut metadata = std::collections::HashMap::new(); - metadata.insert("healthy".to_string(), healthy.to_string()); - return Event { - event_type: "origin_health_changed".to_string(), - timestamp_ms: chrono::Utc::now().timestamp_millis(), - origin_id: Some(origin_id.to_string()), - path: None, - file_id: None, - metadata, - }; - } - CoreEvent::CacheEviction { bytes_freed } => { - let mut metadata = std::collections::HashMap::new(); - metadata.insert("bytes_freed".to_string(), bytes_freed.to_string()); - return Event { - event_type: "cache_eviction".to_string(), - timestamp_ms: chrono::Utc::now().timestamp_millis(), - origin_id: None, - path: None, - file_id: None, - metadata, - }; - } - CoreEvent::OriginConnected { origin_id } => ( - "origin_connected".to_string(), - Some(origin_id.to_string()), - None, - None, - ), - CoreEvent::OriginDisconnected { origin_id } => ( - "origin_disconnected".to_string(), - Some(origin_id.to_string()), - None, - None, - ), - CoreEvent::AllOriginsUnhealthy { candidate_count } => { - let mut metadata = std::collections::HashMap::new(); - metadata.insert("candidate_count".to_string(), candidate_count.to_string()); - return Event { - event_type: "all_origins_unhealthy".to_string(), - timestamp_ms: chrono::Utc::now().timestamp_millis(), - origin_id: None, - path: None, - file_id: None, - metadata, - }; - } - }; - - Event { - event_type, - timestamp_ms: chrono::Utc::now().timestamp_millis(), - origin_id, - path, - file_id, - metadata: Default::default(), - } - } - - fn matches_filter(event: &CoreEvent, filter: &EventFilter) -> bool { - if !filter.event_types.is_empty() { - let event_type = match event { - CoreEvent::FileAccessed { .. } => "file_accessed", - CoreEvent::FileAdded { .. } => "file_added", - CoreEvent::FileRemoved { .. } => "file_removed", - CoreEvent::FileModified { .. } => "file_modified", - CoreEvent::SyncStarted { .. } => "sync_started", - CoreEvent::SyncCompleted { .. } => "sync_completed", - CoreEvent::OriginHealthChanged { .. } => "origin_health_changed", - CoreEvent::CacheEviction { .. } => "cache_eviction", - CoreEvent::OriginConnected { .. } => "origin_connected", - CoreEvent::OriginDisconnected { .. } => "origin_disconnected", - CoreEvent::AllOriginsUnhealthy { .. } => "all_origins_unhealthy", - }; - - if !filter.event_types.iter().any(|t| t == event_type) { - return false; - } - } - - if let Some(ref origin_filter) = filter.origin_id { - let event_origin = match event { - CoreEvent::FileAccessed { origin_id, .. } - | CoreEvent::FileAdded { origin_id, .. } - | CoreEvent::SyncStarted { origin_id } - | CoreEvent::SyncCompleted { origin_id, .. } - | CoreEvent::OriginHealthChanged { origin_id, .. } - | CoreEvent::OriginConnected { origin_id } - | CoreEvent::OriginDisconnected { origin_id } => Some(origin_id.to_string()), - CoreEvent::FileRemoved { .. } - | CoreEvent::FileModified { .. } - | CoreEvent::CacheEviction { .. } - | CoreEvent::AllOriginsUnhealthy { .. } => None, - }; - - if event_origin.as_ref() != Some(origin_filter) { - return false; - } - } - - true - } -} - -#[tonic::async_trait] -impl MusicFs for MusicFsServer { - async fn search( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use SearchService for search operations", - )) - } - - type SearchStreamStream = ReceiverStream>; - - async fn search_stream( - &self, - _request: Request, - ) -> Result, Status> { - Err(Status::unimplemented( - "Use SearchService for search operations", - )) - } - - #[instrument(level = "debug", skip(self, _request), fields(method = "get_status"))] - async fn get_status( - &self, - _request: Request, - ) -> Result, Status> { - debug!("gRPC get_status called"); - let uptime = self.start_time.elapsed().as_secs(); - - Ok(Response::new(StatusResponse { - version: self.version.clone(), - uptime_secs: uptime, - mount_point: String::new(), - state: MountState::MountReady as i32, - open_file_handles: 0, - fuse_ops_total: 0, - files_indexed: 0, - cache_size_bytes: 0, - origins: vec![], - })) - } - - #[instrument(level = "info", skip(self, request), fields(method = "shutdown"))] - async fn shutdown(&self, request: Request) -> Result, Status> { - let req = request.into_inner(); - info!( - graceful = req.graceful, - timeout_secs = req.timeout_secs, - "gRPC shutdown requested" - ); - - Ok(Response::new(Empty {})) - } - - #[instrument( - level = "debug", - skip(self, _request), - fields(method = "get_cache_stats") - )] - async fn get_cache_stats( - &self, - _request: Request, - ) -> Result, Status> { - debug!("gRPC get_cache_stats called"); - Ok(Response::new(CacheStats { - total_size_bytes: 0, - used_size_bytes: 0, - size_limit_bytes: 0, - chunk_count: 0, - chunks_unique: 0, - dedup_ratio: 0.0, - hit_count: 0, - miss_count: 0, - hit_ratio: 0.0, - metadata_entries: 0, - metadata_bytes: 0, - l1_metadata: Some(TierStats { - entries: 0, - size_bytes: 0, - hits: 0, - misses: 0, - }), - l2_headers: Some(TierStats { - entries: 0, - size_bytes: 0, - hits: 0, - misses: 0, - }), - l3_chunks: Some(TierStats { - entries: 0, - size_bytes: 0, - hits: 0, - misses: 0, - }), - })) - } - - #[instrument(level = "info", skip(self, request), fields(method = "clear_cache"))] - async fn clear_cache( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - info!( - origin_id = ?req.origin_id, - clear_metadata = req.clear_metadata, - clear_chunks = req.clear_chunks, - "gRPC clear_cache" - ); - - Ok(Response::new(ClearCacheResponse { - bytes_cleared: 0, - chunks_cleared: 0, - })) - } - - type PrefetchStream = ReceiverStream>; - - #[instrument(level = "debug", skip(self, request), fields(method = "prefetch"))] - async fn prefetch( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - let total = req.paths.len() as u32; - debug!(file_count = total, "gRPC prefetch started"); - - let (tx, rx) = mpsc::channel(32); - - tokio::spawn(async move { - for (i, path) in req.paths.into_iter().enumerate() { - let progress = PrefetchProgress { - current_path: path, - completed: i as u32 + 1, - total, - bytes_fetched: 0, - }; - if tx.send(Ok(progress)).await.is_err() { - break; - } - } - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - #[instrument(level = "debug", skip(self, _request), fields(method = "list_origins"))] - async fn list_origins( - &self, - _request: Request, - ) -> Result, Status> { - debug!("gRPC list_origins called"); - Ok(Response::new(OriginsResponse { origins: vec![] })) - } - - #[instrument( - level = "debug", - skip(self, request), - fields(method = "get_origin_health") - )] - async fn get_origin_health( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - debug!(origin_id = %req.origin_id, "gRPC get_origin_health"); - - Ok(Response::new(OriginHealthResponse { - origin_id: req.origin_id, - status: HealthStatus::HealthUnknown as i32, - message: None, - last_check_secs: 0, - })) - } - - type RescanOriginStream = ReceiverStream>; - - #[instrument(level = "info", skip(self, request), fields(method = "rescan_origin"))] - async fn rescan_origin( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - let subdir = req.subdir.as_deref().filter(|s| !s.is_empty()); - info!( - origin_id = %req.origin_id, - subdir = ?subdir, - "gRPC rescan_origin started" - ); - - let (tx, rx) = mpsc::channel(32); - let (progress_tx, mut progress_rx) = mpsc::channel::(64); - - let origin_id = musicfs_core::OriginId::from(req.origin_id.as_str()); - let scanner = self.scanner.clone(); - let origin_root = self.origin_root.clone(); - let subdir_owned = subdir.map(|s| s.to_string()); - - tokio::spawn(async move { - let forward_handle = { - let tx = tx.clone(); - tokio::spawn(async move { - while let Some(progress) = progress_rx.recv().await { - let proto = SyncProgress { - phase: progress.phase, - current: progress.current, - total: progress.total, - current_path: progress.current_path, - bytes_synced: progress.bytes_synced, - new_files: vec![], - }; - if tx.send(Ok(proto)).await.is_err() { - break; - } - } - }) - }; - - let result = scanner - .scan( - &origin_id, - &origin_root, - subdir_owned.as_deref(), - progress_tx, - ) - .await; - - forward_handle.abort(); - - match result { - Ok(scan_result) => { - let synced_files: Vec = scan_result - .new_files - .iter() - .map(|f| SyncedFile { - path: f.path.clone(), - file_id: f.file_id.0, - virtual_path: f.virtual_path.clone(), - }) - .collect(); - - let _ = tx - .send(Ok(SyncProgress { - phase: "complete".to_string(), - current: scan_result.new_files.len() as u32 - + scan_result.changed - + scan_result.deleted, - total: scan_result.new_files.len() as u32 - + scan_result.changed - + scan_result.deleted - + scan_result.unchanged, - current_path: String::new(), - bytes_synced: scan_result.bytes_synced, - new_files: synced_files, - })) - .await; - } - Err(e) => { - let _ = tx - .send(Err(Status::internal(format!("rescan failed: {}", e)))) - .await; - } - } - }); - - Ok(Response::new(ReceiverStream::new(rx))) - } - - type SubscribeEventsStream = ReceiverStream>; - - #[instrument( - level = "info", - skip(self, request), - fields(method = "subscribe_events") - )] - async fn subscribe_events( - &self, - request: Request, - ) -> Result, Status> { - info!("gRPC subscribe_events: client connected"); - let filter = request.into_inner(); - let mut rx = self.event_bus.subscribe(); - let (tx, out_rx) = mpsc::channel(100); - - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(event) => { - if Self::matches_filter(&event, &filter) { - let proto_event = Self::event_to_proto(&event); - if tx.send(Ok(proto_event)).await.is_err() { - break; - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!(skipped = n, "Event subscriber lagged, skipped events"); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - tracing::debug!("Event channel closed"); - break; - } - } - } - }); - - Ok(Response::new(ReceiverStream::new(out_rx))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn make_test_server() -> (MusicFsServer, tempfile::TempDir) { - let event_bus = Arc::new(EventBus::new(16)); - let db = Arc::new(musicfs_cache::Database::open_memory().unwrap()); - let tree = Arc::new(parking_lot::RwLock::new( - musicfs_cache::TreeBuilder::new().build(), - )); - let dir = tempfile::tempdir().unwrap(); - let cfg = musicfs_cas::CasConfig { - chunks_dir: dir.path().join("chunks"), - ..Default::default() - }; - let store = Arc::new(musicfs_cas::CasStore::open(cfg).await.unwrap()); - let fetcher = Arc::new(musicfs_cas::ContentFetcher::new(store)); - let origin_root = std::path::PathBuf::from("/tmp/test-origin"); - ( - MusicFsServer::new(event_bus, db, tree, fetcher, origin_root), - dir, - ) - } - - #[tokio::test] - async fn test_get_status() { - let (server, _dir) = make_test_server().await; - - let response = server.get_status(Request::new(Empty {})).await.unwrap(); - let status = response.into_inner(); - - assert!(!status.version.is_empty()); - assert!(status.uptime_secs < 5); - } - - #[tokio::test] - async fn test_get_cache_stats() { - let (server, _dir) = make_test_server().await; - - let response = server - .get_cache_stats(Request::new(Empty {})) - .await - .unwrap(); - let stats = response.into_inner(); - - assert_eq!(stats.hit_ratio, 0.0); - } -} diff --git a/crates/musicfs-grpc/src/webhook.rs b/crates/musicfs-grpc/src/webhook.rs deleted file mode 100644 index 200cee4..0000000 --- a/crates/musicfs-grpc/src/webhook.rs +++ /dev/null @@ -1,327 +0,0 @@ -use musicfs_core::Event; -use serde::{Deserialize, Serialize}; -use std::time::Duration; -use tokio::sync::broadcast; -use tracing::{debug, error, warn}; - -#[derive(Debug, Clone, Serialize)] -pub struct WebhookPayload { - pub event_type: String, - pub timestamp: i64, - pub data: serde_json::Value, -} - -#[derive(Clone, Deserialize)] -pub struct WebhookConfig { - pub url: String, - #[serde(skip_serializing)] - pub secret: Option, - pub events: Vec, - #[serde(default = "default_retry_count")] - pub retry_count: u32, - #[serde(default = "default_timeout_ms")] - pub timeout_ms: u64, -} - -impl std::fmt::Debug for WebhookConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("WebhookConfig") - .field("url", &self.url) - .field("secret", &self.secret.as_ref().map(|_| "[REDACTED]")) - .field("events", &self.events) - .field("retry_count", &self.retry_count) - .field("timeout_ms", &self.timeout_ms) - .finish() - } -} - -fn default_retry_count() -> u32 { - 3 -} - -fn default_timeout_ms() -> u64 { - 5000 -} - -#[derive(Debug, thiserror::Error)] -pub enum WebhookError { - #[error("Failed to initialize HTTP client: {0}")] - ClientInit(String), -} - -pub struct WebhookHandler { - client: reqwest::Client, - configs: Vec, -} - -impl WebhookHandler { - pub fn new(configs: Vec) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(|e| { - error!(error = %e, "Failed to create webhook HTTP client"); - WebhookError::ClientInit(e.to_string()) - })?; - - Ok(Self { client, configs }) - } - - pub async fn run(&self, mut rx: broadcast::Receiver) { - loop { - match rx.recv().await { - Ok(event) => { - for config in &self.configs { - if self.matches_filter(&event, config) { - self.dispatch(config, &event).await; - } - } - } - Err(broadcast::error::RecvError::Lagged(n)) => { - warn!(skipped = n, "Webhook handler lagged, skipped events"); - } - Err(broadcast::error::RecvError::Closed) => { - debug!("Event channel closed, webhook handler stopping"); - break; - } - } - } - } - - async fn dispatch(&self, config: &WebhookConfig, event: &Event) { - let payload = WebhookPayload { - event_type: self.event_type_name(event), - timestamp: chrono::Utc::now().timestamp_millis(), - data: self.event_to_json(event), - }; - - let signature = self.sign(&payload, config); - - let mut attempts = 0u32; - loop { - let result = self - .client - .post(&config.url) - .timeout(Duration::from_millis(config.timeout_ms)) - .header("Content-Type", "application/json") - .header("X-MusicFS-Signature", &signature) - .header("X-MusicFS-Event", &payload.event_type) - .json(&payload) - .send() - .await; - - match result { - Ok(resp) if resp.status().is_success() => { - debug!( - "Webhook delivered to {} for {}", - config.url, payload.event_type - ); - break; - } - Ok(resp) => { - warn!( - "Webhook to {} returned status {}, attempt {}/{}", - config.url, - resp.status(), - attempts + 1, - config.retry_count + 1 - ); - } - Err(e) => { - warn!( - "Webhook to {} failed: {}, attempt {}/{}", - config.url, - e, - attempts + 1, - config.retry_count + 1 - ); - } - } - - if attempts >= config.retry_count { - warn!( - "Webhook delivery to {} failed after {} attempts", - config.url, - attempts + 1 - ); - break; - } - - attempts += 1; - let delay = Duration::from_millis(100 * 2u64.pow(attempts)); - tokio::time::sleep(delay).await; - } - } - - fn sign(&self, payload: &WebhookPayload, config: &WebhookConfig) -> String { - match &config.secret { - Some(secret) => { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - - type HmacSha256 = Hmac; - - let body = serde_json::to_string(payload).unwrap_or_default(); - let mac = match HmacSha256::new_from_slice(secret.as_bytes()) { - Ok(m) => m, - Err(e) => { - error!(error = %e, "Invalid HMAC key for webhook signature"); - return String::new(); - } - }; - let mut mac = mac; - mac.update(body.as_bytes()); - let result = mac.finalize(); - - format!("sha256={}", hex::encode(result.into_bytes())) - } - None => String::new(), - } - } - - fn matches_filter(&self, event: &Event, config: &WebhookConfig) -> bool { - if config.events.is_empty() { - return true; - } - - let event_type = self.event_type_name(event); - config.events.iter().any(|e| e == &event_type) - } - - fn event_type_name(&self, event: &Event) -> String { - match event { - Event::FileAccessed { .. } => "file_accessed", - Event::FileAdded { .. } => "file_added", - Event::FileRemoved { .. } => "file_removed", - Event::FileModified { .. } => "file_modified", - Event::SyncStarted { .. } => "sync_started", - Event::SyncCompleted { .. } => "sync_completed", - Event::OriginHealthChanged { .. } => "origin_health_changed", - Event::CacheEviction { .. } => "cache_eviction", - Event::OriginConnected { .. } => "origin_connected", - Event::OriginDisconnected { .. } => "origin_disconnected", - Event::AllOriginsUnhealthy { .. } => "all_origins_unhealthy", - } - .to_string() - } - - fn event_to_json(&self, event: &Event) -> serde_json::Value { - match event { - Event::FileAccessed { - file_id, - origin_id, - path, - offset, - size, - } => serde_json::json!({ - "file_id": file_id.0, - "origin_id": origin_id.to_string(), - "path": path.as_str(), - "offset": offset, - "size": size, - }), - Event::FileAdded { path, origin_id } => serde_json::json!({ - "path": path.as_str(), - "origin_id": origin_id.to_string(), - }), - Event::FileRemoved { path, file_id } => serde_json::json!({ - "path": path.as_str(), - "file_id": file_id.map(|id| id.0), - }), - Event::FileModified { path } => serde_json::json!({ - "path": path.as_str(), - }), - Event::SyncStarted { origin_id } => serde_json::json!({ - "origin_id": origin_id.to_string(), - }), - Event::SyncCompleted { - origin_id, - files_changed, - } => serde_json::json!({ - "origin_id": origin_id.to_string(), - "files_changed": files_changed, - }), - Event::OriginHealthChanged { origin_id, healthy } => serde_json::json!({ - "origin_id": origin_id.to_string(), - "healthy": healthy, - }), - Event::CacheEviction { bytes_freed } => serde_json::json!({ - "bytes_freed": bytes_freed, - }), - Event::OriginConnected { origin_id } => serde_json::json!({ - "origin_id": origin_id.to_string(), - }), - Event::OriginDisconnected { origin_id } => serde_json::json!({ - "origin_id": origin_id.to_string(), - }), - Event::AllOriginsUnhealthy { candidate_count } => serde_json::json!({ - "candidate_count": candidate_count, - }), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use musicfs_core::OriginId; - - #[test] - fn test_webhook_config_defaults() { - let json = r#"{"url": "http://example.com", "events": []}"#; - let config: WebhookConfig = serde_json::from_str(json).unwrap(); - - assert_eq!(config.retry_count, 3); - assert_eq!(config.timeout_ms, 5000); - } - - #[test] - fn test_event_type_name() { - let handler = WebhookHandler::new(vec![]).unwrap(); - - let event = Event::SyncStarted { - origin_id: OriginId::from("test"), - }; - assert_eq!(handler.event_type_name(&event), "sync_started"); - } - - #[test] - fn test_matches_filter_empty() { - let handler = WebhookHandler::new(vec![]).unwrap(); - let config = WebhookConfig { - url: "http://example.com".to_string(), - secret: None, - events: vec![], - retry_count: 3, - timeout_ms: 5000, - }; - - let event = Event::SyncStarted { - origin_id: OriginId::from("test"), - }; - assert!(handler.matches_filter(&event, &config)); - } - - #[test] - fn test_matches_filter_specific() { - let handler = WebhookHandler::new(vec![]).unwrap(); - let config = WebhookConfig { - url: "http://example.com".to_string(), - secret: None, - events: vec!["sync_started".to_string()], - retry_count: 3, - timeout_ms: 5000, - }; - - let event = Event::SyncStarted { - origin_id: OriginId::from("test"), - }; - assert!(handler.matches_filter(&event, &config)); - - let event2 = Event::SyncCompleted { - origin_id: OriginId::from("test"), - files_changed: 0, - }; - assert!(!handler.matches_filter(&event2, &config)); - } -} diff --git a/crates/musicfs-metadata/Cargo.toml b/crates/musicfs-metadata/Cargo.toml deleted file mode 100644 index 7178ebb..0000000 --- a/crates/musicfs-metadata/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "musicfs-metadata" -version.workspace = true -edition.workspace = true - -[dependencies] -musicfs-core = { path = "../musicfs-core" } -symphonia.workspace = true -thiserror.workspace = true -tracing.workspace = true -image.workspace = true diff --git a/crates/musicfs-metadata/src/artwork.rs b/crates/musicfs-metadata/src/artwork.rs deleted file mode 100644 index 19a8163..0000000 --- a/crates/musicfs-metadata/src/artwork.rs +++ /dev/null @@ -1,116 +0,0 @@ -use image::ImageFormat; -use std::io::Cursor; -use symphonia::core::meta::Visual; -use tracing::debug; - -#[derive(Debug, Clone)] -pub struct Artwork { - pub art_type: ArtType, - pub mime_type: String, - pub width: u32, - pub height: u32, - pub data: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ArtType { - Front, - Back, - Other, -} - -#[derive(Debug, Clone, Copy)] -pub enum ArtSize { - Thumbnail, - Medium, - Full, -} - -impl ArtSize { - pub fn max_dimension(&self) -> Option { - match self { - ArtSize::Thumbnail => Some(150), - ArtSize::Medium => Some(300), - ArtSize::Full => None, - } - } -} - -pub struct ArtworkExtractor; - -impl ArtworkExtractor { - pub fn extract_from_visual(visual: &Visual) -> Option { - let data = visual.data.to_vec(); - - let img = image::load_from_memory(&data).ok()?; - - let art_type = match visual.usage { - Some(symphonia::core::meta::StandardVisualKey::FrontCover) => ArtType::Front, - Some(symphonia::core::meta::StandardVisualKey::BackCover) => ArtType::Back, - _ => ArtType::Other, - }; - - let mime_type = if visual.media_type.is_empty() { - "image/jpeg".to_string() - } else { - visual.media_type.clone() - }; - - Some(Artwork { - art_type, - mime_type, - width: img.width(), - height: img.height(), - data, - }) - } - - pub fn resize(artwork: &Artwork, size: ArtSize) -> Option { - let max_dim = size.max_dimension()?; - - if artwork.width <= max_dim && artwork.height <= max_dim { - return Some(artwork.clone()); - } - - let img = image::load_from_memory(&artwork.data).ok()?; - let resized = img.thumbnail(max_dim, max_dim); - - let mut output = Vec::new(); - let mut cursor = Cursor::new(&mut output); - resized.write_to(&mut cursor, ImageFormat::Jpeg).ok()?; - - debug!( - "Resized artwork from {}x{} to {}x{}", - artwork.width, - artwork.height, - resized.width(), - resized.height() - ); - - Some(Artwork { - art_type: artwork.art_type, - mime_type: "image/jpeg".to_string(), - width: resized.width(), - height: resized.height(), - data: output, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_art_size_dimensions() { - assert_eq!(ArtSize::Thumbnail.max_dimension(), Some(150)); - assert_eq!(ArtSize::Medium.max_dimension(), Some(300)); - assert_eq!(ArtSize::Full.max_dimension(), None); - } - - #[test] - fn test_art_type_equality() { - assert_eq!(ArtType::Front, ArtType::Front); - assert_ne!(ArtType::Front, ArtType::Back); - } -} diff --git a/crates/musicfs-metadata/src/lib.rs b/crates/musicfs-metadata/src/lib.rs deleted file mode 100644 index 658d11a..0000000 --- a/crates/musicfs-metadata/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod artwork; -mod parser; - -pub use artwork::{ArtSize, ArtType, Artwork, ArtworkExtractor}; -pub use parser::MetadataParser; diff --git a/crates/musicfs-metadata/src/parser.rs b/crates/musicfs-metadata/src/parser.rs deleted file mode 100644 index 78919b5..0000000 --- a/crates/musicfs-metadata/src/parser.rs +++ /dev/null @@ -1,209 +0,0 @@ -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(channels) = params.channels { - audio_meta.channels = Some(channels.count() as u32); - } - - if let Some(bits_per_sample) = params.bits_per_sample { - audio_meta.bits_per_sample = Some(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 { - // Basic metadata - 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), - - // Track/disc with totals (parse "X/Y" format) - StandardTagKey::TrackNumber => { - let parts: Vec<&str> = value.split('/').collect(); - meta.track = parts.first().and_then(|s| s.trim().parse().ok()); - if parts.len() > 1 { - meta.track_total = parts.get(1).and_then(|s| s.trim().parse().ok()); - } - } - StandardTagKey::DiscNumber => { - let parts: Vec<&str> = value.split('/').collect(); - meta.disc = parts.first().and_then(|s| s.trim().parse().ok()); - if parts.len() > 1 { - meta.disc_total = parts.get(1).and_then(|s| s.trim().parse().ok()); - } - } - StandardTagKey::TrackTotal => { - meta.track_total = value.trim().parse().ok(); - } - StandardTagKey::DiscTotal => { - meta.disc_total = value.trim().parse().ok(); - } - - // Date handling: store full date string, extract year - StandardTagKey::Date | StandardTagKey::ReleaseDate => { - meta.date = Some(value.clone()); - meta.year = value.chars().take(4).collect::().parse().ok(); - } - - // Additional metadata - StandardTagKey::Composer => meta.composer = Some(value), - StandardTagKey::Comment => meta.comment = Some(value), - StandardTagKey::Lyrics => meta.lyrics = Some(value), - StandardTagKey::Copyright => meta.copyright = Some(value), - StandardTagKey::Compilation => { - meta.compilation = Some(value == "1" || value.eq_ignore_ascii_case("true")); - } - StandardTagKey::Encoder => meta.encoder = Some(value), - - // Sort keys - StandardTagKey::SortTrackTitle => meta.title_sort = Some(value), - StandardTagKey::SortArtist => meta.artist_sort = Some(value), - StandardTagKey::SortAlbum => meta.album_sort = Some(value), - StandardTagKey::SortAlbumArtist => meta.album_artist_sort = Some(value), - - // MusicBrainz IDs - StandardTagKey::MusicBrainzRecordingId => meta.mb_recording_id = Some(value), - StandardTagKey::MusicBrainzAlbumId => meta.mb_album_id = Some(value), - StandardTagKey::MusicBrainzArtistId => meta.mb_artist_id = Some(value), - StandardTagKey::MusicBrainzAlbumArtistId => { - meta.mb_album_artist_id = Some(value) - } - StandardTagKey::MusicBrainzReleaseGroupId => { - meta.mb_release_group_id = Some(value) - } - - // ReplayGain (parse as f32, values may have "dB" suffix) - StandardTagKey::ReplayGainTrackGain => { - meta.replaygain_track_gain = parse_replaygain(&value); - } - StandardTagKey::ReplayGainTrackPeak => { - meta.replaygain_track_peak = value.trim().parse().ok(); - } - StandardTagKey::ReplayGainAlbumGain => { - meta.replaygain_album_gain = parse_replaygain(&value); - } - StandardTagKey::ReplayGainAlbumPeak => { - meta.replaygain_album_peak = value.trim().parse().ok(); - } - - _ => {} - } - } - } - } -} - -/// Parse ReplayGain value, stripping optional "dB" suffix -fn parse_replaygain(value: &str) -> Option { - let trimmed = value.trim(); - let without_db = trimmed - .strip_suffix("dB") - .or_else(|| trimmed.strip_suffix(" dB")) - .unwrap_or(trimmed); - without_db.trim().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/crates/musicfs-origins/Cargo.toml b/crates/musicfs-origins/Cargo.toml deleted file mode 100644 index 08a9d01..0000000 --- a/crates/musicfs-origins/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "musicfs-origins" -version.workspace = true -edition.workspace = true - -[features] -default = [] -s3 = [] -sftp = [] - -[dependencies] -musicfs-core = { path = "../musicfs-core" } -async-trait.workspace = true -dashmap.workspace = true -futures.workspace = true -libc.workspace = true -thiserror.workspace = true -tokio = { workspace = true, features = ["fs", "sync", "time"] } -tracing.workspace = true -parking_lot.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/musicfs-origins/src/failover.rs b/crates/musicfs-origins/src/failover.rs deleted file mode 100644 index 0a3a2d7..0000000 --- a/crates/musicfs-origins/src/failover.rs +++ /dev/null @@ -1,226 +0,0 @@ -use crate::registry::OriginRegistry; -use crate::traits::Origin; -use musicfs_core::{Error, RealPath, Result}; -use std::sync::Arc; -use std::time::Duration; -use tracing::{trace, warn}; - -#[derive(Debug, Clone)] -pub struct RetryConfig { - pub max_attempts: u32, - pub delays: Vec, -} - -impl Default for RetryConfig { - fn default() -> Self { - Self::spec_compliant() - } -} - -impl RetryConfig { - pub fn spec_compliant() -> Self { - Self { - max_attempts: 3, - delays: vec![ - Duration::from_millis(100), - Duration::from_millis(500), - Duration::from_millis(2000), - ], - } - } - - pub fn with_delays(delays: Vec) -> Self { - Self { - max_attempts: delays.len() as u32, - delays, - } - } - - fn delay_for_attempt(&self, attempt: u32) -> Duration { - self.delays - .get(attempt as usize) - .copied() - .unwrap_or(*self.delays.last().unwrap_or(&Duration::from_millis(100))) - } -} - -pub struct FailoverExecutor { - registry: Arc, - retry_config: RetryConfig, -} - -impl FailoverExecutor { - pub fn new(registry: Arc, retry_config: RetryConfig) -> Self { - Self { - registry, - retry_config, - } - } - - pub async fn read_with_failover( - &self, - path: &RealPath, - offset: u64, - size: u32, - ) -> Result> { - let origins = self.registry.route_all(path); - - if origins.is_empty() { - if let Some(origin) = self.registry.route_with_fallback(path) { - warn!("No healthy origins, using fallback origin {}", origin.id()); - return self - .read_with_retry(&origin, &path.path, offset, size) - .await; - } - return Err(Error::NoOriginAvailable); - } - - let mut last_error = None; - - for origin in origins { - trace!(origin_id = %origin.id(), "Attempting read from origin"); - let start = std::time::Instant::now(); - match self - .read_with_retry(&origin, &path.path, offset, size) - .await - { - Ok(data) => { - let latency = start.elapsed().as_millis() as u64; - self.registry.record_latency(origin.id(), latency); - return Ok(data); - } - Err(e) => { - warn!(origin_id = %origin.id(), error = %e, "Origin failed, trying next"); - last_error = Some(e); - } - } - } - - Err(last_error.unwrap_or(Error::NoOriginAvailable)) - } - - async fn read_with_retry( - &self, - origin: &Arc, - path: &std::path::Path, - offset: u64, - size: u32, - ) -> Result> { - for attempt in 0..self.retry_config.max_attempts { - match origin.read(path, offset, size).await { - Ok(data) => return Ok(data), - Err(e) if attempt + 1 < self.retry_config.max_attempts => { - let delay = self.retry_config.delay_for_attempt(attempt); - warn!( - origin_id = %origin.id(), - attempt = attempt + 1, - max_attempts = self.retry_config.max_attempts, - error = %e, - delay_ms = delay.as_millis() as u64, - "Retrying read operation" - ); - tokio::time::sleep(delay).await; - } - Err(e) => return Err(e), - } - } - - Err(Error::MaxRetriesExceeded) - } - - pub async fn read_full_with_failover(&self, path: &RealPath) -> Result> { - let origins = self.registry.route_all(path); - - if origins.is_empty() { - if let Some(origin) = self.registry.route_with_fallback(path) { - warn!( - "No healthy origins for full read, using fallback {}", - origin.id() - ); - return self.read_full_with_retry(&origin, &path.path).await; - } - return Err(Error::NoOriginAvailable); - } - - let mut last_error = None; - - for origin in origins { - trace!(origin_id = %origin.id(), "Attempting full read from origin"); - let start = std::time::Instant::now(); - match self.read_full_with_retry(&origin, &path.path).await { - Ok(data) => { - let latency = start.elapsed().as_millis() as u64; - self.registry.record_latency(origin.id(), latency); - return Ok(data); - } - Err(e) => { - warn!(origin_id = %origin.id(), error = %e, "Origin failed full read, trying next"); - last_error = Some(e); - } - } - } - - Err(last_error.unwrap_or(Error::NoOriginAvailable)) - } - - async fn read_full_with_retry( - &self, - origin: &Arc, - path: &std::path::Path, - ) -> Result> { - for attempt in 0..self.retry_config.max_attempts { - match origin.read_full(path).await { - Ok(data) => return Ok(data), - Err(e) if attempt + 1 < self.retry_config.max_attempts => { - let delay = self.retry_config.delay_for_attempt(attempt); - warn!( - origin_id = %origin.id(), - attempt = attempt + 1, - max_attempts = self.retry_config.max_attempts, - error = %e, - delay_ms = delay.as_millis() as u64, - "Retrying full read operation" - ); - tokio::time::sleep(delay).await; - } - Err(e) => return Err(e), - } - } - - Err(Error::MaxRetriesExceeded) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_retry_config_default() { - let config = RetryConfig::default(); - assert_eq!(config.max_attempts, 3); - assert_eq!(config.delays[0], Duration::from_millis(100)); - assert_eq!(config.delays[1], Duration::from_millis(500)); - assert_eq!(config.delays[2], Duration::from_millis(2000)); - } - - #[test] - fn test_delay_for_attempt() { - let config = RetryConfig::spec_compliant(); - - assert_eq!(config.delay_for_attempt(0), Duration::from_millis(100)); - assert_eq!(config.delay_for_attempt(1), Duration::from_millis(500)); - assert_eq!(config.delay_for_attempt(2), Duration::from_millis(2000)); - assert_eq!(config.delay_for_attempt(10), Duration::from_millis(2000)); - } - - #[test] - fn test_custom_delays() { - let config = - RetryConfig::with_delays(vec![Duration::from_millis(50), Duration::from_millis(100)]); - - assert_eq!(config.max_attempts, 2); - assert_eq!(config.delay_for_attempt(0), Duration::from_millis(50)); - assert_eq!(config.delay_for_attempt(1), Duration::from_millis(100)); - } -} diff --git a/crates/musicfs-origins/src/health.rs b/crates/musicfs-origins/src/health.rs deleted file mode 100644 index 2e6b23e..0000000 --- a/crates/musicfs-origins/src/health.rs +++ /dev/null @@ -1,400 +0,0 @@ -use crate::traits::Origin; -use dashmap::DashMap; -use futures::future::join_all; -use musicfs_core::{Event, EventBus, HealthStatus, OriginId, OriginType}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::mpsc; -use tracing::{debug, info, info_span, warn, Instrument}; - -pub struct HealthMonitor { - origins: DashMap>, - state: DashMap, - check_interval: Duration, - default_threshold: u32, - per_type_thresholds: HashMap, - event_bus: Option>, -} - -#[derive(Debug, Clone)] -pub struct OriginHealthState { - pub status: HealthStatus, - pub last_check: Instant, - pub consecutive_failures: u32, - pub last_latency_ms: Option, -} - -impl Default for OriginHealthState { - fn default() -> Self { - Self { - status: HealthStatus::Unknown, - last_check: Instant::now(), - consecutive_failures: 0, - last_latency_ms: None, - } - } -} - -#[derive(Debug, Clone)] -pub struct HealthSnapshot { - pub healthy: Vec, - pub degraded: Vec, - pub unhealthy: Vec, - pub failure_counts: HashMap, -} - -impl HealthSnapshot { - pub fn is_healthy(&self, id: &OriginId) -> bool { - self.healthy.contains(id) - } - - pub fn is_degraded(&self, id: &OriginId) -> bool { - self.degraded.contains(id) - } - - pub fn is_unhealthy(&self, id: &OriginId) -> bool { - self.unhealthy.contains(id) - } - - pub fn failure_count(&self, id: &OriginId) -> Option { - self.failure_counts.get(id).copied() - } - - pub fn all_unhealthy(&self) -> bool { - self.healthy.is_empty() && self.degraded.is_empty() - } - - pub fn total_candidates(&self) -> usize { - self.healthy.len() + self.degraded.len() + self.unhealthy.len() - } -} - -impl HealthMonitor { - pub fn new(check_interval: Duration) -> Self { - let mut per_type = HashMap::new(); - per_type.insert(OriginType::Local, 1); - per_type.insert(OriginType::Nfs, 3); - per_type.insert(OriginType::Smb, 3); - per_type.insert(OriginType::S3, 3); - per_type.insert(OriginType::Sftp, 3); - - Self { - origins: DashMap::new(), - state: DashMap::new(), - check_interval, - default_threshold: 3, - per_type_thresholds: per_type, - event_bus: None, - } - } - - pub fn with_threshold(mut self, threshold: u32) -> Self { - self.default_threshold = threshold; - self - } - - pub fn with_per_type_thresholds(mut self, thresholds: HashMap) -> Self { - self.per_type_thresholds = thresholds; - self - } - - pub fn with_event_bus(mut self, bus: Arc) -> Self { - self.event_bus = Some(bus); - self - } - - fn threshold_for(&self, origin_type: OriginType) -> u32 { - self.per_type_thresholds - .get(&origin_type) - .copied() - .unwrap_or(self.default_threshold) - } - - pub fn add_origin(&self, origin: Arc) { - let id = origin.id().clone(); - self.origins.insert(id.clone(), origin); - self.state.insert(id, OriginHealthState::default()); - } - - pub fn remove_origin(&self, id: &OriginId) { - self.origins.remove(id); - self.state.remove(id); - } - - pub fn snapshot(&self) -> HealthSnapshot { - let mut healthy = Vec::new(); - let mut degraded = Vec::new(); - let mut unhealthy = Vec::new(); - let mut failure_counts = HashMap::new(); - - for entry in self.state.iter() { - let id = entry.key().clone(); - failure_counts.insert(id.clone(), entry.value().consecutive_failures); - - match entry.value().status { - HealthStatus::Healthy => healthy.push(id), - HealthStatus::Degraded => degraded.push(id), - HealthStatus::Unhealthy => unhealthy.push(id), - HealthStatus::Unknown => degraded.push(id), - } - } - - HealthSnapshot { - healthy, - degraded, - unhealthy, - failure_counts, - } - } - - pub fn start(self: Arc) -> HealthCheckHandle { - let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1); - let monitor = self.clone(); - let interval_secs = monitor.check_interval.as_secs(); - - info!( - interval_secs = interval_secs, - origin_count = monitor.origins.len(), - "Health monitor starting" - ); - - tokio::spawn( - async move { - let mut interval = tokio::time::interval(monitor.check_interval); - - loop { - tokio::select! { - _ = interval.tick() => { - monitor.check_all().await; - } - _ = stop_rx.recv() => { - info!("Health monitor stopping"); - break; - } - } - } - } - .instrument(info_span!("health_monitor")), - ); - - HealthCheckHandle { stop_tx } - } - - pub async fn check_all(&self) { - let origins: Vec<_> = self - .origins - .iter() - .map(|e| (e.key().clone(), e.value().clone())) - .collect(); - - let checks: Vec<_> = origins - .iter() - .map(|(id, origin)| self.check_one(id, origin)) - .collect(); - - join_all(checks).await; - } - - async fn check_one(&self, id: &OriginId, origin: &Arc) { - let start = Instant::now(); - let health_timeout = Duration::from_millis(1500); - - let status = match tokio::time::timeout(health_timeout, origin.health()).await { - Ok(status) => status, - Err(_) => { - warn!( - origin_id = %id, - timeout_ms = health_timeout.as_millis() as u64, - "Health check timed out" - ); - HealthStatus::Unhealthy - } - }; - - let latency_ms = start.elapsed().as_millis() as u64; - - let threshold = self.threshold_for(origin.origin_type()); - let prev_healthy = self - .state - .get(id) - .map(|s| s.status == HealthStatus::Healthy) - .unwrap_or(false); - - let mut state = self.state.entry(id.clone()).or_default(); - - match status { - HealthStatus::Healthy => { - if state.status != HealthStatus::Healthy { - info!( - origin_id = %id, - previous_status = ?state.status, - duration_ms = latency_ms, - "Origin health state transition to healthy" - ); - } - state.status = HealthStatus::Healthy; - state.consecutive_failures = 0; - } - HealthStatus::Degraded => { - if state.status != HealthStatus::Degraded { - info!( - origin_id = %id, - previous_status = ?state.status, - duration_ms = latency_ms, - "Origin health state transition to degraded" - ); - } - state.status = HealthStatus::Degraded; - } - HealthStatus::Unhealthy => { - state.consecutive_failures += 1; - if state.consecutive_failures >= threshold { - if state.status != HealthStatus::Unhealthy { - info!( - origin_id = %id, - previous_status = ?state.status, - consecutive_failures = state.consecutive_failures, - threshold = threshold, - duration_ms = latency_ms, - "Origin health state transition to unhealthy" - ); - } - state.status = HealthStatus::Unhealthy; - } else { - debug!( - origin_id = %id, - consecutive_failures = state.consecutive_failures, - threshold = threshold, - "Origin health check failed" - ); - state.status = HealthStatus::Degraded; - } - } - HealthStatus::Unknown => { - state.status = HealthStatus::Unknown; - } - } - - state.last_check = Instant::now(); - state.last_latency_ms = Some(latency_ms); - - let now_healthy = state.status == HealthStatus::Healthy; - if prev_healthy != now_healthy { - if let Some(bus) = &self.event_bus { - bus.publish(Event::OriginHealthChanged { - origin_id: id.clone(), - healthy: now_healthy, - }); - } - } - } - - pub async fn check_now(&self, id: &OriginId) { - if let Some(origin) = self.origins.get(id) { - self.check_one(id, &origin.clone()).await; - } - } - - pub fn get_state(&self, id: &OriginId) -> Option { - self.state.get(id).map(|e| e.value().clone()) - } -} - -pub struct HealthCheckHandle { - stop_tx: mpsc::Sender<()>, -} - -impl HealthCheckHandle { - pub async fn stop(self) { - let _ = self.stop_tx.send(()).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::LocalOrigin; - use tempfile::TempDir; - - #[tokio::test] - async fn test_health_monitor_basic() { - let monitor = HealthMonitor::new(Duration::from_secs(30)); - - let dir = TempDir::new().unwrap(); - let origin = Arc::new(LocalOrigin::new("test", dir.path())); - - monitor.add_origin(origin); - - let snapshot = monitor.snapshot(); - assert!(!snapshot.is_healthy(&OriginId::from("test"))); - } - - #[tokio::test] - async fn test_health_check() { - let monitor = Arc::new(HealthMonitor::new(Duration::from_secs(30))); - - let dir = TempDir::new().unwrap(); - let origin = Arc::new(LocalOrigin::new("test", dir.path())); - - monitor.add_origin(origin); - monitor.check_now(&OriginId::from("test")).await; - - let snapshot = monitor.snapshot(); - assert!(snapshot.is_healthy(&OriginId::from("test"))); - } - - #[tokio::test] - async fn test_failure_tracking() { - let mut thresholds = HashMap::new(); - thresholds.insert(OriginType::Local, 3); - - let monitor = - HealthMonitor::new(Duration::from_secs(30)).with_per_type_thresholds(thresholds); - - let origin = Arc::new(LocalOrigin::new( - "missing", - std::path::Path::new("/nonexistent"), - )); - monitor.add_origin(origin); - - monitor.check_now(&OriginId::from("missing")).await; - let state = monitor.get_state(&OriginId::from("missing")).unwrap(); - assert_eq!(state.consecutive_failures, 1); - assert_eq!(state.status, HealthStatus::Degraded); - - monitor.check_now(&OriginId::from("missing")).await; - monitor.check_now(&OriginId::from("missing")).await; - - let state = monitor.get_state(&OriginId::from("missing")).unwrap(); - assert_eq!(state.consecutive_failures, 3); - assert_eq!(state.status, HealthStatus::Unhealthy); - } - - #[tokio::test] - async fn test_local_origin_threshold_is_one() { - let monitor = HealthMonitor::new(Duration::from_secs(30)); - - let origin = Arc::new(LocalOrigin::new( - "missing", - std::path::Path::new("/nonexistent"), - )); - monitor.add_origin(origin); - - monitor.check_now(&OriginId::from("missing")).await; - let state = monitor.get_state(&OriginId::from("missing")).unwrap(); - assert_eq!(state.consecutive_failures, 1); - assert_eq!(state.status, HealthStatus::Unhealthy); - } - - #[test] - fn test_snapshot_all_unhealthy() { - let snapshot = HealthSnapshot { - healthy: Vec::new(), - degraded: Vec::new(), - unhealthy: vec![OriginId::from("a")], - failure_counts: HashMap::new(), - }; - assert!(snapshot.all_unhealthy()); - } -} diff --git a/crates/musicfs-origins/src/lib.rs b/crates/musicfs-origins/src/lib.rs deleted file mode 100644 index 517f91e..0000000 --- a/crates/musicfs-origins/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -mod failover; -mod health; -mod local; -mod nfs; -mod registry; -mod router; -mod s3; -mod sftp; -mod smb; -mod traits; - -pub use failover::{FailoverExecutor, RetryConfig}; -pub use health::{HealthCheckHandle, HealthMonitor, HealthSnapshot, OriginHealthState}; -pub use local::LocalOrigin; -pub use musicfs_core::OriginType; -pub use nfs::NfsOrigin; -pub use registry::OriginRegistry; -pub use router::{LatencyStats, Router}; -pub use smb::SmbOrigin; -pub use traits::{Origin, WatchCallback, WatchEvent, WatchHandle}; diff --git a/crates/musicfs-origins/src/local.rs b/crates/musicfs-origins/src/local.rs deleted file mode 100644 index 2f49bf8..0000000 --- a/crates/musicfs-origins/src/local.rs +++ /dev/null @@ -1,218 +0,0 @@ -use crate::traits::{Origin, WatchCallback, WatchHandle}; -use async_trait::async_trait; -use musicfs_core::{DirEntry, FileStat, HealthStatus, OriginId, OriginType, Result}; -use std::path::{Path, PathBuf}; -use tokio::fs; -use tokio::io::AsyncRead; -use tracing::debug; - -pub struct LocalOrigin { - id: OriginId, - root: PathBuf, - display_name: String, -} - -impl LocalOrigin { - pub fn new(id: impl Into, root: impl Into) -> Self { - let root = root.into(); - let display_name = format!("Local: {}", root.display()); - Self { - id: id.into(), - root, - display_name, - } - } - - fn full_path(&self, path: &Path) -> PathBuf { - if path.as_os_str().is_empty() || path == Path::new("/") { - self.root.clone() - } else { - self.root.join(path.strip_prefix("/").unwrap_or(path)) - } - } -} - -#[async_trait] -impl Origin for LocalOrigin { - fn id(&self) -> &OriginId { - &self.id - } - - fn origin_type(&self) -> OriginType { - OriginType::Local - } - - fn display_name(&self) -> &str { - &self.display_name - } - - async fn readdir(&self, path: &Path) -> Result> { - let full_path = self.full_path(path); - debug!("LocalOrigin::readdir({:?})", full_path); - - let mut entries = Vec::new(); - let mut dir = fs::read_dir(&full_path).await?; - - while let Some(entry) = dir.next_entry().await? { - let metadata = entry.metadata().await?; - let name = entry.file_name().to_string_lossy().into_owned(); - - entries.push(DirEntry { - name, - is_dir: metadata.is_dir(), - size: metadata.len(), - mtime: metadata.modified().unwrap_or(std::time::UNIX_EPOCH), - }); - } - - Ok(entries) - } - - async fn stat(&self, path: &Path) -> Result { - let full_path = self.full_path(path); - debug!("LocalOrigin::stat({:?})", full_path); - - let metadata = fs::metadata(&full_path).await?; - - Ok(FileStat { - size: metadata.len(), - mtime: metadata.modified().unwrap_or(std::time::UNIX_EPOCH), - is_dir: metadata.is_dir(), - }) - } - - async fn read(&self, path: &Path, offset: u64, size: u32) -> Result> { - use tokio::io::{AsyncReadExt, AsyncSeekExt}; - - let full_path = self.full_path(path); - debug!( - "LocalOrigin::read({:?}, offset={}, size={})", - full_path, offset, size - ); - - let mut file = fs::File::open(&full_path).await?; - file.seek(std::io::SeekFrom::Start(offset)).await?; - - // FIX: Loop until all requested bytes are read or EOF - // Single read() only returns kernel buffer (~2MB), not full request - let mut buffer = Vec::with_capacity(size as usize); - let mut temp_buf = vec![0u8; 64 * 1024]; // 64KB chunks - let mut total_read = 0usize; - - while total_read < size as usize { - let to_read = std::cmp::min(temp_buf.len(), size as usize - total_read); - let n = file.read(&mut temp_buf[..to_read]).await?; - if n == 0 { - break; // EOF - } - buffer.extend_from_slice(&temp_buf[..n]); - total_read += n; - } - - Ok(buffer) - } - - async fn read_full(&self, path: &Path) -> Result> { - let full_path = self.full_path(path); - debug!("LocalOrigin::read_full({:?})", full_path); - Ok(fs::read(&full_path).await?) - } - - async fn exists(&self, path: &Path) -> Result { - let full_path = self.full_path(path); - Ok(fs::try_exists(&full_path).await?) - } - - async fn health(&self) -> HealthStatus { - match fs::try_exists(&self.root).await { - Ok(true) => HealthStatus::Healthy, - Ok(false) => HealthStatus::Unhealthy, - Err(_) => HealthStatus::Unhealthy, - } - } - - async fn open_read(&self, path: &Path) -> Result> { - let full_path = self.full_path(path); - let file = fs::File::open(&full_path).await?; - Ok(Box::new(file)) - } - - async fn watch(&self, path: &Path, _callback: WatchCallback) -> Result { - debug!("LocalOrigin::watch({:?}) - stub implementation", path); - let (tx, _rx) = tokio::sync::oneshot::channel(); - Ok(WatchHandle::new(tx)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn test_local_origin_readdir() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("test.txt"), "hello").unwrap(); - std::fs::create_dir(dir.path().join("subdir")).unwrap(); - - let origin = LocalOrigin::new("test", dir.path()); - let entries = origin.readdir(Path::new("/")).await.unwrap(); - - assert_eq!(entries.len(), 2); - assert!(entries.iter().any(|e| e.name == "test.txt" && !e.is_dir)); - assert!(entries.iter().any(|e| e.name == "subdir" && e.is_dir)); - } - - #[tokio::test] - async fn test_local_origin_stat() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("test.txt"), "hello world").unwrap(); - - let origin = LocalOrigin::new("test", dir.path()); - let stat = origin.stat(Path::new("/test.txt")).await.unwrap(); - - assert_eq!(stat.size, 11); - assert!(!stat.is_dir); - } - - #[tokio::test] - async fn test_local_origin_read() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("test.txt"), "hello world").unwrap(); - - let origin = LocalOrigin::new("test", dir.path()); - let data = origin.read(Path::new("/test.txt"), 0, 5).await.unwrap(); - - assert_eq!(data, b"hello"); - } - - #[tokio::test] - async fn test_local_origin_read_offset() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("test.txt"), "hello world").unwrap(); - - let origin = LocalOrigin::new("test", dir.path()); - let data = origin.read(Path::new("/test.txt"), 6, 5).await.unwrap(); - - assert_eq!(data, b"world"); - } - - #[tokio::test] - async fn test_local_origin_exists() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("test.txt"), "hello").unwrap(); - - let origin = LocalOrigin::new("test", dir.path()); - - assert!(origin.exists(Path::new("/test.txt")).await.unwrap()); - assert!(!origin.exists(Path::new("/nonexistent.txt")).await.unwrap()); - } - - #[tokio::test] - async fn test_local_origin_health() { - let dir = TempDir::new().unwrap(); - let origin = LocalOrigin::new("test", dir.path()); - - assert_eq!(origin.health().await, HealthStatus::Healthy); - } -} diff --git a/crates/musicfs-origins/src/nfs.rs b/crates/musicfs-origins/src/nfs.rs deleted file mode 100644 index 629af89..0000000 --- a/crates/musicfs-origins/src/nfs.rs +++ /dev/null @@ -1,162 +0,0 @@ -use crate::local::LocalOrigin; -use crate::traits::{Origin, WatchCallback, WatchHandle}; -use async_trait::async_trait; -use musicfs_core::{DirEntry, FileStat, HealthStatus, OriginId, OriginType, Result}; -use std::path::{Path, PathBuf}; -use std::time::Duration; -use tokio::time::sleep; -use tracing::{debug, warn}; - -pub struct NfsOrigin { - inner: LocalOrigin, - max_retries: u32, - display_name: String, -} - -impl NfsOrigin { - pub fn new(id: impl Into, mount_point: impl Into) -> Self { - let mount_point = mount_point.into(); - let display_name = format!("NFS: {}", mount_point.display()); - - Self { - inner: LocalOrigin::new(id, &mount_point), - max_retries: 3, - display_name, - } - } - - pub fn with_max_retries(mut self, retries: u32) -> Self { - self.max_retries = retries; - self - } - - async fn retry_on_stale(&self, op: F) -> Result - where - F: Fn() -> Fut, - Fut: std::future::Future>, - { - let mut delay = Duration::from_millis(100); - - for attempt in 0..self.max_retries { - match op().await { - Ok(result) => return Ok(result), - Err(e) => { - if let Some(io_err) = e.downcast_io() { - #[cfg(unix)] - if io_err.raw_os_error() == Some(libc::ESTALE) { - warn!( - "NFS stale handle (attempt {}/{}), retrying after {:?}", - attempt + 1, - self.max_retries, - delay - ); - sleep(delay).await; - delay *= 2; - continue; - } - } - return Err(e); - } - } - } - - Err(musicfs_core::Error::NfsStaleHandle) - } -} - -#[async_trait] -impl Origin for NfsOrigin { - fn id(&self) -> &OriginId { - self.inner.id() - } - - fn origin_type(&self) -> OriginType { - OriginType::Nfs - } - - fn display_name(&self) -> &str { - &self.display_name - } - - async fn readdir(&self, path: &Path) -> Result> { - self.retry_on_stale(|| self.inner.readdir(path)).await - } - - async fn stat(&self, path: &Path) -> Result { - self.retry_on_stale(|| self.inner.stat(path)).await - } - - async fn read(&self, path: &Path, offset: u64, size: u32) -> Result> { - self.retry_on_stale(|| self.inner.read(path, offset, size)) - .await - } - - async fn read_full(&self, path: &Path) -> Result> { - self.retry_on_stale(|| self.inner.read_full(path)).await - } - - async fn exists(&self, path: &Path) -> Result { - self.retry_on_stale(|| self.inner.exists(path)).await - } - - async fn health(&self) -> HealthStatus { - let health_timeout = Duration::from_secs(5); - match tokio::time::timeout(health_timeout, self.inner.stat(Path::new("/"))).await { - Ok(Ok(_)) => HealthStatus::Healthy, - Ok(Err(_)) | Err(_) => HealthStatus::Unhealthy, - } - } - - async fn open_read(&self, path: &Path) -> Result> { - self.inner.open_read(path).await - } - - async fn watch(&self, path: &Path, callback: WatchCallback) -> Result { - debug!("NFS watch - inotify may be unreliable over NFS, consider polling"); - self.inner.watch(path, callback).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn test_nfs_origin_basic() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("test.flac"), b"audio").unwrap(); - - let origin = NfsOrigin::new("nfs-test", dir.path()); - - let entries = origin.readdir(Path::new("/")).await.unwrap(); - assert_eq!(entries.len(), 1); - - let data = origin.read(Path::new("/test.flac"), 0, 5).await.unwrap(); - assert_eq!(&data, b"audio"); - } - - #[tokio::test] - async fn test_nfs_origin_health() { - let dir = TempDir::new().unwrap(); - let origin = NfsOrigin::new("nfs-test", dir.path()); - - assert_eq!(origin.health().await, HealthStatus::Healthy); - } - - #[tokio::test] - async fn test_nfs_origin_type() { - let dir = TempDir::new().unwrap(); - let origin = NfsOrigin::new("nfs-test", dir.path()); - - assert_eq!(origin.origin_type(), OriginType::Nfs); - } - - #[test] - fn test_retry_uses_fn_not_fnmut() { - fn assert_fn Fut, Fut>(_: F) {} - - let closure = || async { Ok::<_, musicfs_core::Error>(()) }; - assert_fn(closure); - } -} diff --git a/crates/musicfs-origins/src/registry.rs b/crates/musicfs-origins/src/registry.rs deleted file mode 100644 index 9a29a42..0000000 --- a/crates/musicfs-origins/src/registry.rs +++ /dev/null @@ -1,215 +0,0 @@ -use crate::health::{HealthMonitor, HealthSnapshot}; -use crate::router::Router; -use crate::traits::{Origin, WatchHandle}; -use musicfs_core::{OriginId, RealPath}; -use parking_lot::RwLock; -use std::collections::HashMap; -use std::sync::Arc; -use tracing::{info, warn}; - -pub struct OriginRegistry { - origins: RwLock>>, - router: Router, - health_monitor: Arc, - watch_handles: RwLock>>, -} - -impl OriginRegistry { - pub fn new(health_monitor: Arc) -> Self { - Self { - origins: RwLock::new(HashMap::new()), - router: Router::new(), - health_monitor, - watch_handles: RwLock::new(HashMap::new()), - } - } - - pub fn register(&self, origin: Arc, priority: u8) { - let id = origin.id().clone(); - info!("Registering origin {} with priority {}", id, priority); - - self.router.set_priority(id.clone(), priority); - self.health_monitor.add_origin(origin.clone()); - self.origins.write().insert(id, origin); - } - - pub fn unregister(&self, id: &OriginId) { - info!("Unregistering origin {}", id); - - if let Some(handles) = self.watch_handles.write().remove(id) { - info!("Dropping {} watch handles for origin {}", handles.len(), id); - } - - self.origins.write().remove(id); - self.router.remove_priority(id); - self.health_monitor.remove_origin(id); - } - - pub fn register_watch(&self, origin_id: &OriginId, handle: WatchHandle) { - self.watch_handles - .write() - .entry(origin_id.clone()) - .or_default() - .push(handle); - } - - pub fn get(&self, id: &OriginId) -> Option> { - self.origins.read().get(id).cloned() - } - - pub fn list(&self) -> Vec> { - self.origins.read().values().cloned().collect() - } - - pub fn route(&self, path: &RealPath) -> Option> { - let origins = self.origins.read(); - let health = self.health_monitor.snapshot(); - - let candidates: Vec<_> = origins - .iter() - .filter(|(id, _)| self.can_serve(id, path)) - .map(|(id, origin)| (id.clone(), origin.clone())) - .collect(); - - if candidates.is_empty() { - warn!("No origin can serve path: {:?}", path); - return None; - } - - let candidate_ids: Vec<_> = candidates.iter().map(|(id, _)| id.clone()).collect(); - let selected = self.router.select(&candidate_ids, &health)?; - - candidates - .into_iter() - .find(|(id, _)| id == &selected) - .map(|(_, origin)| origin) - } - - pub fn route_with_fallback(&self, path: &RealPath) -> Option> { - let origins = self.origins.read(); - let health = self.health_monitor.snapshot(); - - let candidates: Vec<_> = origins - .iter() - .filter(|(id, _)| self.can_serve(id, path)) - .map(|(id, origin)| (id.clone(), origin.clone())) - .collect(); - - if candidates.is_empty() { - return None; - } - - let candidate_ids: Vec<_> = candidates.iter().map(|(id, _)| id.clone()).collect(); - let selected = self.router.select_with_fallback(&candidate_ids, &health)?; - - candidates - .into_iter() - .find(|(id, _)| id == &selected) - .map(|(_, origin)| origin) - } - - pub fn route_all(&self, path: &RealPath) -> Vec> { - let origins = self.origins.read(); - let health = self.health_monitor.snapshot(); - - let mut result: Vec<_> = origins - .iter() - .filter(|(id, _)| self.can_serve(id, path) && health.is_healthy(id)) - .map(|(_, origin)| origin.clone()) - .collect(); - - result.sort_by_key(|o| self.router.get_priority(o.id())); - result - } - - fn can_serve(&self, origin_id: &OriginId, path: &RealPath) -> bool { - path.origin_id == *origin_id - } - - pub fn health(&self) -> HealthSnapshot { - self.health_monitor.snapshot() - } - - pub fn record_latency(&self, id: &OriginId, latency_ms: u64) { - self.router.record_latency(id, latency_ms); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::LocalOrigin; - use std::path::PathBuf; - use std::time::Duration; - use tempfile::TempDir; - - #[test] - fn test_register_and_get() { - let monitor = Arc::new(HealthMonitor::new(Duration::from_secs(30))); - let registry = OriginRegistry::new(monitor); - - let dir = TempDir::new().unwrap(); - let origin = Arc::new(LocalOrigin::new("test", dir.path())); - - registry.register(origin.clone(), 1); - - let retrieved = registry.get(&OriginId::from("test")); - assert!(retrieved.is_some()); - } - - #[test] - fn test_unregister() { - let monitor = Arc::new(HealthMonitor::new(Duration::from_secs(30))); - let registry = OriginRegistry::new(monitor); - - let dir = TempDir::new().unwrap(); - let origin = Arc::new(LocalOrigin::new("test", dir.path())); - - registry.register(origin, 1); - registry.unregister(&OriginId::from("test")); - - assert!(registry.get(&OriginId::from("test")).is_none()); - } - - #[tokio::test] - async fn test_route_by_priority() { - let monitor = Arc::new(HealthMonitor::new(Duration::from_secs(30))); - let registry = OriginRegistry::new(monitor.clone()); - - let dir1 = TempDir::new().unwrap(); - let dir2 = TempDir::new().unwrap(); - - let origin1 = Arc::new(LocalOrigin::new("primary", dir1.path())); - let origin2 = Arc::new(LocalOrigin::new("backup", dir2.path())); - - registry.register(origin1, 1); - registry.register(origin2, 2); - - monitor.check_now(&OriginId::from("primary")).await; - monitor.check_now(&OriginId::from("backup")).await; - - let path = RealPath { - origin_id: OriginId::from("primary"), - path: PathBuf::from("/test.flac"), - }; - - let routed = registry.route(&path); - assert!(routed.is_some()); - assert_eq!(routed.unwrap().id(), &OriginId::from("primary")); - } - - #[test] - fn test_list_origins() { - let monitor = Arc::new(HealthMonitor::new(Duration::from_secs(30))); - let registry = OriginRegistry::new(monitor); - - let dir1 = TempDir::new().unwrap(); - let dir2 = TempDir::new().unwrap(); - - registry.register(Arc::new(LocalOrigin::new("a", dir1.path())), 1); - registry.register(Arc::new(LocalOrigin::new("b", dir2.path())), 2); - - let list = registry.list(); - assert_eq!(list.len(), 2); - } -} diff --git a/crates/musicfs-origins/src/router.rs b/crates/musicfs-origins/src/router.rs deleted file mode 100644 index 091c009..0000000 --- a/crates/musicfs-origins/src/router.rs +++ /dev/null @@ -1,255 +0,0 @@ -use crate::health::HealthSnapshot; -use dashmap::DashMap; -use musicfs_core::{Event, EventBus, OriginId}; -use std::sync::Arc; -use std::time::Instant; -use tracing::{debug, trace, warn}; - -pub struct Router { - priorities: DashMap, - latency_stats: DashMap, - event_bus: Option>, -} - -#[derive(Debug, Clone, Default)] -pub struct LatencyStats { - pub samples: Vec, - pub p50_ms: u64, - pub p99_ms: u64, - pub last_update: Option, -} - -impl LatencyStats { - pub fn record(&mut self, latency_ms: u64) { - self.samples.push(latency_ms); - - if self.samples.len() > 100 { - self.samples.remove(0); - } - - if !self.samples.is_empty() { - let mut sorted = self.samples.clone(); - sorted.sort_unstable(); - - let p50_idx = sorted.len() / 2; - let p99_idx = (sorted.len() * 99) / 100; - - self.p50_ms = sorted[p50_idx]; - self.p99_ms = sorted.get(p99_idx).copied().unwrap_or(self.p50_ms); - } - - self.last_update = Some(Instant::now()); - } -} - -impl Router { - pub fn new() -> Self { - Self { - priorities: DashMap::new(), - latency_stats: DashMap::new(), - event_bus: None, - } - } - - pub fn with_event_bus(mut self, bus: Arc) -> Self { - self.event_bus = Some(bus); - self - } - - pub fn set_priority(&self, id: OriginId, priority: u8) { - self.priorities.insert(id, priority); - } - - pub fn remove_priority(&self, id: &OriginId) { - self.priorities.remove(id); - self.latency_stats.remove(id); - } - - pub fn get_priority(&self, id: &OriginId) -> u8 { - self.priorities.get(id).map(|p| *p).unwrap_or(100) - } - - pub fn record_latency(&self, id: &OriginId, latency_ms: u64) { - self.latency_stats - .entry(id.clone()) - .or_default() - .record(latency_ms); - } - - pub fn select(&self, candidates: &[OriginId], health: &HealthSnapshot) -> Option { - let selected = candidates - .iter() - .filter(|id| health.is_healthy(id)) - .min_by_key(|id| { - let priority = self.get_priority(id); - let latency = self.latency_stats.get(*id).map(|s| s.p50_ms).unwrap_or(0); - (priority, latency) - }) - .cloned(); - - if let Some(ref id) = selected { - let priority = self.get_priority(id); - let latency = self.latency_stats.get(id).map(|s| s.p50_ms).unwrap_or(0); - trace!( - origin_id = %id, - priority = priority, - latency_ms = latency, - "Selected healthy origin" - ); - } - - selected - } - - pub fn select_with_fallback( - &self, - candidates: &[OriginId], - health: &HealthSnapshot, - ) -> Option { - if let Some(id) = self.select(candidates, health) { - return Some(id); - } - - debug!("No healthy origins, trying degraded"); - if let Some(id) = candidates - .iter() - .filter(|id| health.is_degraded(id)) - .min_by_key(|id| self.get_priority(id)) - .cloned() - { - trace!( - origin_id = %id, - priority = self.get_priority(&id), - "Selected degraded origin as fallback" - ); - return Some(id); - } - - warn!("All origins unhealthy, selecting least-bad by failure count"); - - if let Some(bus) = &self.event_bus { - bus.publish(Event::AllOriginsUnhealthy { - candidate_count: candidates.len(), - }); - } - - let selected = candidates - .iter() - .min_by_key(|id| { - let failures = health.failure_count(id).unwrap_or(u32::MAX); - let priority = self.get_priority(id); - (failures, priority) - }) - .cloned(); - - if let Some(ref id) = selected { - let failures = health.failure_count(id).unwrap_or(u32::MAX); - trace!( - origin_id = %id, - failure_count = failures, - priority = self.get_priority(id), - "Selected least-bad unhealthy origin" - ); - } - - selected - } -} - -impl Default for Router { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - fn mock_health(healthy: &[&str], degraded: &[&str]) -> HealthSnapshot { - HealthSnapshot { - healthy: healthy.iter().map(|s| OriginId::from(*s)).collect(), - degraded: degraded.iter().map(|s| OriginId::from(*s)).collect(), - unhealthy: Vec::new(), - failure_counts: HashMap::new(), - } - } - - #[test] - fn test_select_by_priority() { - let router = Router::new(); - router.set_priority(OriginId::from("high"), 1); - router.set_priority(OriginId::from("low"), 2); - - let candidates = vec![OriginId::from("low"), OriginId::from("high")]; - let health = mock_health(&["high", "low"], &[]); - - let selected = router.select(&candidates, &health); - assert_eq!(selected, Some(OriginId::from("high"))); - } - - #[test] - fn test_select_skips_unhealthy() { - let router = Router::new(); - router.set_priority(OriginId::from("high"), 1); - router.set_priority(OriginId::from("low"), 2); - - let candidates = vec![OriginId::from("high"), OriginId::from("low")]; - let health = mock_health(&["low"], &[]); - - let selected = router.select(&candidates, &health); - assert_eq!(selected, Some(OriginId::from("low"))); - } - - #[test] - fn test_latency_affects_tiebreak() { - let router = Router::new(); - router.set_priority(OriginId::from("a"), 1); - router.set_priority(OriginId::from("b"), 1); - - router.record_latency(&OriginId::from("a"), 100); - router.record_latency(&OriginId::from("b"), 10); - - let candidates = vec![OriginId::from("a"), OriginId::from("b")]; - let health = mock_health(&["a", "b"], &[]); - - let selected = router.select(&candidates, &health); - assert_eq!(selected, Some(OriginId::from("b"))); - } - - #[test] - fn test_fallback_to_degraded() { - let router = Router::new(); - router.set_priority(OriginId::from("a"), 1); - router.set_priority(OriginId::from("b"), 2); - - let candidates = vec![OriginId::from("a"), OriginId::from("b")]; - let health = mock_health(&[], &["b"]); - - let selected = router.select_with_fallback(&candidates, &health); - assert_eq!(selected, Some(OriginId::from("b"))); - } - - #[test] - fn test_fallback_least_bad() { - let router = Router::new(); - router.set_priority(OriginId::from("a"), 1); - router.set_priority(OriginId::from("b"), 2); - - let candidates = vec![OriginId::from("a"), OriginId::from("b")]; - let mut failure_counts = HashMap::new(); - failure_counts.insert(OriginId::from("a"), 5); - failure_counts.insert(OriginId::from("b"), 2); - - let health = HealthSnapshot { - healthy: Vec::new(), - degraded: Vec::new(), - unhealthy: vec![OriginId::from("a"), OriginId::from("b")], - failure_counts, - }; - - let selected = router.select_with_fallback(&candidates, &health); - assert_eq!(selected, Some(OriginId::from("b"))); - } -} diff --git a/crates/musicfs-origins/src/s3.rs b/crates/musicfs-origins/src/s3.rs deleted file mode 100644 index f1d8d5e..0000000 --- a/crates/musicfs-origins/src/s3.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! S3-compatible object storage origin -//! -//! This module is feature-gated behind the `s3` feature to avoid heavy AWS SDK dependencies. -//! -//! # Oracle Security Fixes (MUST IMPLEMENT) -//! -//! 1. **Range EOF** - Clamp range to `min(requested_end, file_size)` to avoid 416 errors -//! 2. **Health check** - Use `head_bucket` not `list_objects_v2` (lighter operation) -//! 3. **Timeout handling** - Wrap all remote calls with `tokio::time::timeout(30s)` -//! -//! # Example Implementation (when feature enabled) -//! -//! ```ignore -//! async fn read(&self, path: &Path, offset: u64, size: u32) -> Result> { -//! // Oracle fix: Clamp range to file size to avoid 416 error -//! let file_size = self.stat(path).await?.size; -//! let end = std::cmp::min(offset + size as u64, file_size).saturating_sub(1); -//! -//! if offset >= file_size { -//! return Ok(Vec::new()); // EOF -//! } -//! -//! let range = format!("bytes={}-{}", offset, end); -//! -//! // Oracle fix: Add timeout to prevent hung connections -//! let resp = tokio::time::timeout( -//! Duration::from_secs(30), -//! self.client.get_object().bucket(&self.bucket).key(&key).range(range).send() -//! ) -//! .await -//! .map_err(|_| Error::Timeout("S3 read timed out".into()))? -//! .map_err(|e| Error::S3(e.to_string()))?; -//! -//! // ... -//! } -//! -//! async fn health(&self) -> HealthStatus { -//! // Oracle fix: Use head_bucket instead of list_objects_v2 (lighter) -//! match self.client.head_bucket().bucket(&self.bucket).send().await { -//! Ok(_) => HealthStatus::Healthy, -//! Err(_) => HealthStatus::Unhealthy, -//! } -//! } -//! ``` - -#[cfg(feature = "s3")] -mod implementation { - // Full S3 implementation would go here when aws-sdk-s3 is enabled -} diff --git a/crates/musicfs-origins/src/sftp.rs b/crates/musicfs-origins/src/sftp.rs deleted file mode 100644 index 6ac2336..0000000 --- a/crates/musicfs-origins/src/sftp.rs +++ /dev/null @@ -1,12 +0,0 @@ -#![allow(dead_code)] -//! SFTP origin - feature-gated to avoid russh/deadpool dependencies - -#[cfg(feature = "sftp")] -mod implementation { - // Full SFTP implementation with connection pooling - // Oracle fixes to implement: - // 1. Use deadpool connection pool, not Arc> - // 2. Verify SSH host keys against ~/.ssh/known_hosts - // 3. Wrap all operations with tokio::time::timeout(30s) - // 4. Cap open_read to actual file size, not u32::MAX -} diff --git a/crates/musicfs-origins/src/smb.rs b/crates/musicfs-origins/src/smb.rs deleted file mode 100644 index b9e18a8..0000000 --- a/crates/musicfs-origins/src/smb.rs +++ /dev/null @@ -1,156 +0,0 @@ -use crate::local::LocalOrigin; -use crate::traits::{Origin, WatchCallback, WatchHandle}; -use async_trait::async_trait; -use musicfs_core::{DirEntry, FileStat, HealthStatus, OriginId, OriginType, Result}; -use std::future::Future; -use std::path::{Path, PathBuf}; -use tracing::{debug, warn}; - -pub struct SmbOrigin { - inner: LocalOrigin, - share_path: String, -} - -impl SmbOrigin { - pub fn from_mount( - id: impl Into, - mount_point: impl Into, - share_path: impl Into, - ) -> Self { - let mount_point = mount_point.into(); - let share_path = share_path.into(); - - Self { - inner: LocalOrigin::new(id, &mount_point), - share_path, - } - } - - pub async fn is_mounted(&self) -> bool { - self.inner.exists(Path::new("/")).await.unwrap_or(false) - } - - async fn retry_on_disconnect(&self, op: F) -> Result - where - F: Fn() -> Fut, - Fut: Future>, - { - const MAX_RETRIES: u32 = 3; - - for attempt in 0..MAX_RETRIES { - match op().await { - Ok(val) => return Ok(val), - Err(e) => { - if Self::is_enotconn(&e) && attempt < MAX_RETRIES - 1 { - debug!(attempt, "SMB ENOTCONN, retrying"); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - continue; - } - return Err(e); - } - } - } - unreachable!() - } - - #[cfg(unix)] - fn is_enotconn(err: &musicfs_core::Error) -> bool { - if let musicfs_core::Error::Io(io_err) = err { - io_err.raw_os_error() == Some(libc::ENOTCONN) - } else { - false - } - } - - #[cfg(not(unix))] - fn is_enotconn(_err: &musicfs_core::Error) -> bool { - false - } -} - -#[async_trait] -impl Origin for SmbOrigin { - fn id(&self) -> &OriginId { - self.inner.id() - } - - fn origin_type(&self) -> OriginType { - OriginType::Smb - } - - fn display_name(&self) -> &str { - &self.share_path - } - - async fn readdir(&self, path: &Path) -> Result> { - self.retry_on_disconnect(|| self.inner.readdir(path)).await - } - - async fn stat(&self, path: &Path) -> Result { - self.retry_on_disconnect(|| self.inner.stat(path)).await - } - - async fn read(&self, path: &Path, offset: u64, size: u32) -> Result> { - self.retry_on_disconnect(|| self.inner.read(path, offset, size)) - .await - } - - async fn read_full(&self, path: &Path) -> Result> { - self.retry_on_disconnect(|| self.inner.read_full(path)) - .await - } - - async fn exists(&self, path: &Path) -> Result { - self.retry_on_disconnect(|| self.inner.exists(path)).await - } - - async fn health(&self) -> HealthStatus { - let health_timeout = std::time::Duration::from_secs(5); - match tokio::time::timeout(health_timeout, self.is_mounted()).await { - Ok(true) => HealthStatus::Healthy, - Ok(false) | Err(_) => HealthStatus::Unhealthy, - } - } - - async fn open_read(&self, path: &Path) -> Result> { - self.inner.open_read(path).await - } - - async fn watch(&self, path: &Path, callback: WatchCallback) -> Result { - warn!("SMB watch using inotify - may be unreliable. Consider polling for remote mounts."); - self.inner.watch(path, callback).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn test_smb_origin_basic() { - let dir = TempDir::new().unwrap(); - std::fs::write(dir.path().join("test.flac"), b"audio").unwrap(); - - let origin = SmbOrigin::from_mount("smb-test", dir.path(), "//server/share"); - - let entries = origin.readdir(Path::new("/")).await.unwrap(); - assert_eq!(entries.len(), 1); - } - - #[tokio::test] - async fn test_smb_origin_type() { - let dir = TempDir::new().unwrap(); - let origin = SmbOrigin::from_mount("smb-test", dir.path(), "//server/share"); - - assert_eq!(origin.origin_type(), OriginType::Smb); - } - - #[tokio::test] - async fn test_smb_display_name() { - let dir = TempDir::new().unwrap(); - let origin = SmbOrigin::from_mount("smb-test", dir.path(), "//server/music"); - - assert_eq!(origin.display_name(), "//server/music"); - } -} diff --git a/crates/musicfs-origins/src/traits.rs b/crates/musicfs-origins/src/traits.rs deleted file mode 100644 index 343c7d1..0000000 --- a/crates/musicfs-origins/src/traits.rs +++ /dev/null @@ -1,49 +0,0 @@ -use async_trait::async_trait; -use musicfs_core::{DirEntry, FileStat, HealthStatus, OriginId, OriginType, Result}; -use std::path::{Path, PathBuf}; -use tokio::io::AsyncRead; - -#[async_trait] -pub trait Origin: Send + Sync { - fn id(&self) -> &OriginId; - - fn origin_type(&self) -> OriginType; - - fn display_name(&self) -> &str; - - async fn readdir(&self, path: &Path) -> Result>; - - async fn stat(&self, path: &Path) -> Result; - - async fn read(&self, path: &Path, offset: u64, size: u32) -> Result>; - - /// Read entire file content (for CDC chunking of files <4GB) - async fn read_full(&self, path: &Path) -> Result>; - - async fn exists(&self, path: &Path) -> Result; - - async fn health(&self) -> HealthStatus; - - async fn open_read(&self, path: &Path) -> Result>; - - async fn watch(&self, path: &Path, callback: WatchCallback) -> Result; -} - -pub type WatchCallback = Box; - -pub struct WatchHandle { - _cancel: tokio::sync::oneshot::Sender<()>, -} - -impl WatchHandle { - pub fn new(cancel: tokio::sync::oneshot::Sender<()>) -> Self { - Self { _cancel: cancel } - } -} - -#[derive(Debug, Clone)] -pub enum WatchEvent { - Created(PathBuf), - Modified(PathBuf), - Deleted(PathBuf), -} diff --git a/crates/musicfs-plugins/Cargo.toml b/crates/musicfs-plugins/Cargo.toml deleted file mode 100644 index ea8f6c0..0000000 --- a/crates/musicfs-plugins/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "musicfs-plugins" -version.workspace = true -edition.workspace = true - -[dependencies] -musicfs-core = { path = "../musicfs-core" } -async-trait.workspace = true -tokio.workspace = true -thiserror.workspace = true -serde.workspace = true -serde_json.workspace = true -tracing.workspace = true -libloading = "0.8" -wasmtime = { version = "19", optional = true } -semver = "1" - -[features] -default = [] -wasm = ["wasmtime"] - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/musicfs-plugins/src/error.rs b/crates/musicfs-plugins/src/error.rs deleted file mode 100644 index ad44795..0000000 --- a/crates/musicfs-plugins/src/error.rs +++ /dev/null @@ -1,42 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum PluginError { - #[error("Plugin not found: {0}")] - NotFound(String), - - #[error("Plugin load failed: {0}")] - LoadFailed(String), - - #[error("Plugin initialization failed: {0}")] - InitFailed(String), - - #[error("Plugin API version mismatch: expected {expected}, got {actual}")] - VersionMismatch { expected: String, actual: String }, - - #[error("Plugin already loaded: {0}")] - AlreadyLoaded(String), - - #[error("Plugin symbol not found: {0}")] - SymbolNotFound(String), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("Plugin execution error: {0}")] - Execution(String), - - #[error("Plugin shutdown error: {0}")] - Shutdown(String), - - #[error("Configuration error: {0}")] - Config(String), - - #[error("WASM error: {0}")] - Wasm(String), - - #[error("Resource limit exceeded: {0}")] - ResourceLimit(String), -} - -pub type Result = std::result::Result; diff --git a/crates/musicfs-plugins/src/lib.rs b/crates/musicfs-plugins/src/lib.rs deleted file mode 100644 index 9561dd6..0000000 --- a/crates/musicfs-plugins/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod error; -pub mod manager; -pub mod native; -pub mod traits; -pub mod wasm; - -pub use error::{PluginError, Result}; -pub use manager::{PluginConfig, PluginEntry, PluginManager, WasmConfig}; -pub use native::NativePluginHost; -pub use traits::{ - ExternalMetadata, FormatPlugin, MetadataPlugin, MetadataQuery, MetadataQueryType, - OriginDirEntry, OriginHealth, OriginInstance, OriginPlugin, OriginStat, Plugin, PluginId, - PluginInfo, PluginType, WatchEvent, WatchHandle, PLUGIN_API_VERSION, -}; -pub use wasm::{ResourceLimits, WasmPluginHost}; diff --git a/crates/musicfs-plugins/src/manager.rs b/crates/musicfs-plugins/src/manager.rs deleted file mode 100644 index 96512c5..0000000 --- a/crates/musicfs-plugins/src/manager.rs +++ /dev/null @@ -1,346 +0,0 @@ -use crate::error::{PluginError, Result}; -use crate::native::NativePluginHost; -use crate::traits::{Plugin, PluginId, PluginInfo, PluginType}; -use crate::wasm::{ResourceLimits, WasmPluginHost}; -use serde::Deserialize; -use serde_json::Value; -use std::collections::HashMap; -use std::path::PathBuf; -use tracing::{debug, info}; - -#[derive(Debug, Clone, Deserialize, Default)] -pub struct PluginConfig { - #[serde(default)] - pub enabled: bool, - - #[serde(default)] - pub search_paths: Vec, - - #[serde(default)] - pub plugins: HashMap, - - #[serde(default)] - pub wasm: WasmConfig, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PluginEntry { - pub path: PathBuf, - - #[serde(default)] - pub enabled: bool, - - #[serde(default)] - pub config: Value, -} - -impl Default for PluginEntry { - fn default() -> Self { - Self { - path: PathBuf::new(), - enabled: true, - config: Value::Null, - } - } -} - -#[derive(Debug, Clone, Deserialize, Default)] -pub struct WasmConfig { - #[serde(default)] - pub enabled: bool, - - #[serde(default)] - pub max_memory_mb: Option, - - #[serde(default)] - pub max_cpu_time_ms: Option, -} - -pub struct PluginManager { - native_host: NativePluginHost, - wasm_host: WasmPluginHost, - registry: PluginRegistry, - config: PluginConfig, -} - -struct PluginRegistry { - origin_plugins: Vec, - metadata_plugins: Vec, - format_plugins: Vec, -} - -impl PluginRegistry { - fn new() -> Self { - Self { - origin_plugins: Vec::new(), - metadata_plugins: Vec::new(), - format_plugins: Vec::new(), - } - } - - fn register(&mut self, id: PluginId, plugin_type: PluginType) { - match plugin_type { - PluginType::Origin => { - if !self.origin_plugins.contains(&id) { - self.origin_plugins.push(id); - } - } - PluginType::Metadata => { - if !self.metadata_plugins.contains(&id) { - self.metadata_plugins.push(id); - } - } - PluginType::Format => { - if !self.format_plugins.contains(&id) { - self.format_plugins.push(id); - } - } - } - } - - fn unregister(&mut self, id: PluginId) { - self.origin_plugins.retain(|&x| x != id); - self.metadata_plugins.retain(|&x| x != id); - self.format_plugins.retain(|&x| x != id); - } -} - -impl PluginManager { - pub fn new() -> Result { - Ok(Self { - native_host: NativePluginHost::new(), - wasm_host: WasmPluginHost::new()?, - registry: PluginRegistry::new(), - config: PluginConfig::default(), - }) - } - - pub fn init(config: &PluginConfig) -> Result { - let mut manager = Self::new()?; - manager.config = config.clone(); - - if !config.enabled { - info!("Plugin system disabled"); - return Ok(manager); - } - - info!("Initializing plugin system"); - - for path in &config.search_paths { - manager.native_host.add_search_path(path.clone()); - } - - if config.wasm.enabled { - let limits = ResourceLimits { - max_memory_mb: config.wasm.max_memory_mb.unwrap_or(64), - max_cpu_time_ms: config.wasm.max_cpu_time_ms.unwrap_or(5000), - ..Default::default() - }; - manager.wasm_host.set_limits(limits); - } - - for (name, entry) in &config.plugins { - if !entry.enabled { - debug!("Skipping disabled plugin: {}", name); - continue; - } - - match manager.load_and_init(&entry.path, &entry.config) { - Ok(id) => { - info!("Loaded plugin '{}' with id {:?}", name, id); - } - Err(e) => { - tracing::warn!("Failed to load plugin '{}': {}", name, e); - } - } - } - - let discovered = manager.native_host.discover()?; - for id in discovered { - if let Some(info) = manager.native_host.list().iter().find(|i| i.id == id) { - manager.registry.register(id, info.plugin_type); - } - } - - Ok(manager) - } - - pub fn load_and_init(&mut self, path: &PathBuf, config: &Value) -> Result { - let id = self.native_host.load(path)?; - - if let Some(plugin) = self.native_host.get_mut(id) { - plugin.init(config.clone())?; - } - - if let Some(info) = self.native_host.list().iter().find(|i| i.id == id) { - self.registry.register(id, info.plugin_type); - } - - Ok(id) - } - - pub fn load_wasm(&mut self, wasm_bytes: &[u8]) -> Result { - if !self.config.wasm.enabled { - return Err(PluginError::Config("WASM plugins disabled".to_string())); - } - - self.wasm_host.load(wasm_bytes) - } - - pub fn unload(&mut self, id: PluginId) -> Result<()> { - self.registry.unregister(id); - - if let Err(native_err) = self.native_host.unload(id) { - if let Err(wasm_err) = self.wasm_host.unload(id) { - return Err(PluginError::NotFound(format!( - "Plugin {:?} not found in native ({}) or WASM ({}) hosts", - id, native_err, wasm_err - ))); - } - } - - Ok(()) - } - - pub fn reload(&mut self, id: PluginId) -> Result<()> { - self.native_host.reload(id) - } - - pub fn reload_all(&mut self) -> Result<()> { - let ids: Vec = self.native_host.list().iter().map(|i| i.id).collect(); - - for id in ids { - self.reload(id)?; - } - - Ok(()) - } - - pub fn list(&self) -> Vec { - let mut all = self.native_host.list(); - - for (id, name) in self.wasm_host.list() { - all.push(PluginInfo { - id, - name: name.to_string(), - version: semver::Version::new(0, 0, 0), - description: "WASM plugin".to_string(), - plugin_type: PluginType::Origin, - }); - } - - all - } - - pub fn get(&self, id: PluginId) -> Option<&dyn Plugin> { - self.native_host.get(id) - } - - pub fn get_mut(&mut self, id: PluginId) -> Option<&mut dyn Plugin> { - self.native_host.get_mut(id) - } - - pub fn origin_plugin_ids(&self) -> &[PluginId] { - &self.registry.origin_plugins - } - - pub fn metadata_plugin_ids(&self) -> &[PluginId] { - &self.registry.metadata_plugins - } - - pub fn format_plugin_ids(&self) -> &[PluginId] { - &self.registry.format_plugins - } - - pub fn shutdown(&mut self) -> Result<()> { - info!("Shutting down plugin system"); - - let ids: Vec = self.list().iter().map(|i| i.id).collect(); - - for id in ids { - if let Err(e) = self.unload(id) { - tracing::warn!("Failed to unload plugin {:?}: {}", id, e); - } - } - - Ok(()) - } -} - -impl Default for PluginManager { - fn default() -> Self { - Self::new().expect("Failed to create plugin manager") - } -} - -impl Drop for PluginManager { - fn drop(&mut self) { - debug!(plugin_count = self.list().len(), "PluginManager dropping"); - let _ = self.shutdown(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_plugin_manager_new() { - let manager = PluginManager::new(); - assert!(manager.is_ok()); - } - - #[test] - fn test_plugin_manager_disabled() { - let config = PluginConfig { - enabled: false, - ..Default::default() - }; - - let manager = PluginManager::init(&config); - assert!(manager.is_ok()); - - let manager = manager.unwrap(); - assert!(manager.list().is_empty()); - } - - #[test] - fn test_registry() { - let mut registry = PluginRegistry::new(); - - let id1 = PluginId::new(1); - let id2 = PluginId::new(2); - - registry.register(id1, PluginType::Origin); - registry.register(id2, PluginType::Metadata); - - assert_eq!(registry.origin_plugins.len(), 1); - assert_eq!(registry.metadata_plugins.len(), 1); - - registry.unregister(id1); - assert!(registry.origin_plugins.is_empty()); - } - - #[test] - fn test_plugin_config_deserialize() { - let json = r#"{ - "enabled": true, - "search_paths": ["/usr/lib/musicfs/plugins"], - "plugins": { - "example": { - "path": "/path/to/plugin.so", - "enabled": true, - "config": {"key": "value"} - } - }, - "wasm": { - "enabled": false - } - }"#; - - let config: PluginConfig = serde_json::from_str(json).unwrap(); - assert!(config.enabled); - assert_eq!(config.search_paths.len(), 1); - assert!(config.plugins.contains_key("example")); - } -} diff --git a/crates/musicfs-plugins/src/native.rs b/crates/musicfs-plugins/src/native.rs deleted file mode 100644 index f3b0796..0000000 --- a/crates/musicfs-plugins/src/native.rs +++ /dev/null @@ -1,300 +0,0 @@ -use crate::error::{PluginError, Result}; -use crate::traits::{Plugin, PluginId, PluginInfo, PluginType, PLUGIN_API_VERSION}; -use libloading::{Library, Symbol}; -use semver::Version; -use std::collections::HashMap; -use std::ffi::CStr; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use tracing::{debug, info, warn}; - -static NEXT_PLUGIN_ID: AtomicU64 = AtomicU64::new(1); - -fn next_plugin_id() -> PluginId { - PluginId::new(NEXT_PLUGIN_ID.fetch_add(1, Ordering::SeqCst)) -} - -struct LoadedPlugin { - id: PluginId, - path: PathBuf, - library: Library, - instance: Box, - plugin_type: PluginType, -} - -pub struct NativePluginHost { - plugins: HashMap, - search_paths: Vec, -} - -impl NativePluginHost { - pub fn new() -> Self { - Self { - plugins: HashMap::new(), - search_paths: Vec::new(), - } - } - - pub fn add_search_path(&mut self, path: PathBuf) { - if !self.search_paths.contains(&path) { - self.search_paths.push(path); - } - } - - pub fn load(&mut self, path: &Path) -> Result { - let canonical = path.canonicalize().map_err(|e| { - PluginError::LoadFailed(format!("Cannot resolve path {}: {}", path.display(), e)) - })?; - - for plugin in self.plugins.values() { - if plugin.path == canonical { - return Err(PluginError::AlreadyLoaded(canonical.display().to_string())); - } - } - - info!("Loading native plugin from {:?}", canonical); - - let library = unsafe { - Library::new(&canonical) - .map_err(|e| PluginError::LoadFailed(format!("Failed to load library: {}", e)))? - }; - - self.verify_api_version(&library)?; - - let instance = self.create_plugin_instance(&library)?; - let id = next_plugin_id(); - - let plugin_type = self.detect_plugin_type(&*instance); - - debug!( - "Loaded plugin '{}' v{} as {:?}", - instance.name(), - instance.version(), - plugin_type - ); - - self.plugins.insert( - id, - LoadedPlugin { - id, - path: canonical, - library, - instance, - plugin_type, - }, - ); - - Ok(id) - } - - pub fn unload(&mut self, id: PluginId) -> Result<()> { - let mut plugin = self - .plugins - .remove(&id) - .ok_or_else(|| PluginError::NotFound(format!("Plugin {:?}", id)))?; - - info!("Unloading plugin '{}'", plugin.instance.name()); - - plugin.instance.shutdown()?; - - drop(plugin.instance); - drop(plugin.library); - - Ok(()) - } - - pub fn reload(&mut self, id: PluginId) -> Result<()> { - let plugin = self - .plugins - .get(&id) - .ok_or_else(|| PluginError::NotFound(format!("Plugin {:?}", id)))?; - - let path = plugin.path.clone(); - - info!("Hot-reloading plugin from {:?}", path); - - self.unload(id)?; - - let new_id = self.load(&path)?; - - if let Some(plugin) = self.plugins.remove(&new_id) { - self.plugins.insert(id, LoadedPlugin { id, ..plugin }); - } - - Ok(()) - } - - pub fn get(&self, id: PluginId) -> Option<&dyn Plugin> { - self.plugins.get(&id).map(|p| &*p.instance as &dyn Plugin) - } - - pub fn get_mut(&mut self, id: PluginId) -> Option<&mut dyn Plugin> { - self.plugins - .get_mut(&id) - .map(|p| &mut *p.instance as &mut dyn Plugin) - } - - pub fn list(&self) -> Vec { - self.plugins - .values() - .map(|p| PluginInfo { - id: p.id, - name: p.instance.name().to_string(), - version: p.instance.version(), - description: p.instance.description().to_string(), - plugin_type: p.plugin_type, - }) - .collect() - } - - pub fn find_by_name(&self, name: &str) -> Option { - self.plugins - .iter() - .find(|(_, p)| p.instance.name() == name) - .map(|(id, _)| *id) - } - - pub fn discover(&mut self) -> Result> { - let mut loaded = Vec::new(); - - for search_path in self.search_paths.clone() { - if !search_path.exists() { - continue; - } - - let entries = std::fs::read_dir(&search_path).map_err(|e| { - PluginError::LoadFailed(format!( - "Cannot read plugin directory {}: {}", - search_path.display(), - e - )) - })?; - - for entry in entries.flatten() { - let path = entry.path(); - - if self.is_plugin_library(&path) { - match self.load(&path) { - Ok(id) => loaded.push(id), - Err(e) => { - warn!("Failed to load plugin {:?}: {}", path, e); - } - } - } - } - } - - Ok(loaded) - } - - fn verify_api_version(&self, library: &Library) -> Result<()> { - let version_fn: Symbol *const std::ffi::c_char> = unsafe { - library.get(b"musicfs_plugin_api_version").map_err(|_| { - PluginError::SymbolNotFound("musicfs_plugin_api_version".to_string()) - })? - }; - - let version_ptr = unsafe { version_fn() }; - let version_str = unsafe { CStr::from_ptr(version_ptr) } - .to_str() - .map_err(|_| PluginError::VersionMismatch { - expected: PLUGIN_API_VERSION.to_string(), - actual: "".to_string(), - })?; - - let plugin_version = - Version::parse(version_str).map_err(|_| PluginError::VersionMismatch { - expected: PLUGIN_API_VERSION.to_string(), - actual: version_str.to_string(), - })?; - - let expected_version = Version::parse(PLUGIN_API_VERSION).unwrap(); - - if plugin_version.major != expected_version.major { - return Err(PluginError::VersionMismatch { - expected: PLUGIN_API_VERSION.to_string(), - actual: version_str.to_string(), - }); - } - - Ok(()) - } - - fn create_plugin_instance(&self, library: &Library) -> Result> { - let create_fn: Symbol *mut dyn Plugin> = unsafe { - library - .get(b"musicfs_plugin_create") - .map_err(|_| PluginError::SymbolNotFound("musicfs_plugin_create".to_string()))? - }; - - let plugin_ptr = unsafe { create_fn() }; - if plugin_ptr.is_null() { - return Err(PluginError::LoadFailed( - "Plugin factory returned null".to_string(), - )); - } - - let plugin = unsafe { Box::from_raw(plugin_ptr) }; - Ok(plugin) - } - - fn detect_plugin_type(&self, plugin: &dyn Plugin) -> PluginType { - plugin.plugin_type() - } - - fn is_plugin_library(&self, path: &Path) -> bool { - let extension = path.extension().and_then(|e| e.to_str()); - - match extension { - Some("so") => true, - Some("dylib") => true, - Some("dll") => true, - _ => false, - } - } -} - -impl Default for NativePluginHost { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_native_host_creation() { - let host = NativePluginHost::new(); - assert!(host.plugins.is_empty()); - assert!(host.search_paths.is_empty()); - } - - #[test] - fn test_add_search_path() { - let mut host = NativePluginHost::new(); - host.add_search_path(PathBuf::from("/usr/lib/musicfs/plugins")); - host.add_search_path(PathBuf::from("/usr/lib/musicfs/plugins")); - - assert_eq!(host.search_paths.len(), 1); - } - - #[test] - fn test_is_plugin_library() { - let host = NativePluginHost::new(); - - assert!(host.is_plugin_library(Path::new("plugin.so"))); - assert!(host.is_plugin_library(Path::new("plugin.dylib"))); - assert!(host.is_plugin_library(Path::new("plugin.dll"))); - assert!(!host.is_plugin_library(Path::new("plugin.txt"))); - assert!(!host.is_plugin_library(Path::new("plugin"))); - } - - #[test] - fn test_load_nonexistent() { - let mut host = NativePluginHost::new(); - let result = host.load(Path::new("/nonexistent/plugin.so")); - assert!(result.is_err()); - } -} diff --git a/crates/musicfs-plugins/src/traits.rs b/crates/musicfs-plugins/src/traits.rs deleted file mode 100644 index ca81c15..0000000 --- a/crates/musicfs-plugins/src/traits.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Plugin trait definitions (FR-23.1-23.4) -//! -//! Per architecture.md section 4.3.4: -//! - Plugin trait: Base interface for all plugins -//! - OriginPlugin: Creates Origin instances for storage backends -//! - MetadataPlugin: Provides external metadata lookup -//! - FormatPlugin: Handles custom audio format parsing - -use crate::error::Result; -use async_trait::async_trait; -use musicfs_core::AudioMeta; -use semver::Version; -use serde_json::Value; -use std::io::Read; - -/// Current plugin API version -pub const PLUGIN_API_VERSION: &str = "0.1.0"; - -/// Unique identifier for a loaded plugin -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct PluginId(pub u64); - -impl PluginId { - pub fn new(id: u64) -> Self { - Self(id) - } -} - -/// Plugin metadata returned by plugins -#[derive(Debug, Clone)] -pub struct PluginInfo { - pub id: PluginId, - pub name: String, - pub version: Version, - pub description: String, - pub plugin_type: PluginType, -} - -/// Type of plugin -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PluginType { - Origin, - Metadata, - Format, -} - -/// Base plugin interface (FR-23.1) -/// -/// All plugins must implement this trait. It provides: -/// - Plugin identification (name, version) -/// - Lifecycle management (init, shutdown) -pub trait Plugin: Send + Sync { - /// Unique plugin name (e.g., "s3-origin", "musicbrainz-metadata") - fn name(&self) -> &str; - - /// Plugin version following semver - fn version(&self) -> Version; - - /// Human-readable description - fn description(&self) -> &str { - "" - } - - /// Plugin type for registry categorization - fn plugin_type(&self) -> PluginType; - - /// Initialize plugin with configuration - /// - /// Called once after loading. The config value contains - /// plugin-specific configuration from the main config file. - fn init(&mut self, config: Value) -> Result<()>; - - /// Shutdown plugin and release resources - /// - /// Called before unloading. Plugins should clean up any - /// resources (connections, file handles, etc). - fn shutdown(&mut self) -> Result<()>; -} - -/// Origin plugin interface (FR-23.3) -/// -/// Per architecture.md section 4.3.4: -/// Origin plugins create `Box` instances for custom storage backends. -/// -/// Example use cases: -/// - Google Drive origin -/// - Dropbox origin -/// - Custom NAS protocol -#[async_trait] -pub trait OriginPlugin: Plugin { - /// Origin type identifier (e.g., "gdrive", "dropbox") - fn origin_type(&self) -> &str; - - /// Create a new Origin instance with the given configuration - /// - /// The config contains origin-specific settings (credentials, paths, etc). - /// Returns a boxed Origin that can be used by the OriginRouter. - async fn create_origin(&self, id: &str, config: Value) -> Result>; -} - -/// Instance created by OriginPlugin -/// -/// This is a simplified async interface that maps to the full Origin trait. -/// The plugin host wraps this to provide the full Origin implementation. -#[async_trait] -pub trait OriginInstance: Send + Sync { - /// List directory contents - async fn readdir(&self, path: &str) -> Result>; - - /// Get file/directory stats - async fn stat(&self, path: &str) -> Result; - - /// Read file data - async fn read(&self, path: &str, offset: u64, size: u32) -> Result>; - - /// Check if path exists - async fn exists(&self, path: &str) -> Result; - - /// Health check - async fn health(&self) -> OriginHealth; - - /// Watch path for changes (FR-10.2) - async fn watch( - &self, - path: &str, - callback: Box, - ) -> Result; -} - -pub struct WatchHandle { - _cancel: tokio::sync::oneshot::Sender<()>, -} - -impl WatchHandle { - pub fn new(cancel: tokio::sync::oneshot::Sender<()>) -> Self { - Self { _cancel: cancel } - } -} - -#[derive(Debug, Clone)] -pub enum WatchEvent { - Created(String), - Modified(String), - Deleted(String), -} - -/// Directory entry from plugin origin -#[derive(Debug, Clone)] -pub struct OriginDirEntry { - pub name: String, - pub is_dir: bool, - pub size: u64, - pub mtime_secs: u64, -} - -/// File stats from plugin origin -#[derive(Debug, Clone)] -pub struct OriginStat { - pub size: u64, - pub mtime_secs: u64, - pub is_dir: bool, -} - -/// Origin health status -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OriginHealth { - Healthy, - Degraded, - Unhealthy, -} - -/// Metadata plugin interface (FR-23.3) -/// -/// Metadata plugins provide external metadata lookup from services like -/// MusicBrainz, Discogs, Last.fm, etc. -#[async_trait] -pub trait MetadataPlugin: Plugin { - /// Lookup metadata for a query - /// - /// Returns enriched metadata if found, None otherwise. - async fn lookup(&self, query: &MetadataQuery) -> Result>; - - /// Supported query types - fn supported_queries(&self) -> &[MetadataQueryType] { - &[MetadataQueryType::ByTitleArtist] - } -} - -/// Query for metadata lookup -#[derive(Debug, Clone)] -pub struct MetadataQuery { - pub query_type: MetadataQueryType, - pub title: Option, - pub artist: Option, - pub album: Option, - pub fingerprint: Option, - pub duration_ms: Option, -} - -/// Type of metadata query -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MetadataQueryType { - ByTitleArtist, - ByFingerprint, - ByAlbum, -} - -/// External metadata returned by plugins -#[derive(Debug, Clone, Default)] -pub struct ExternalMetadata { - pub title: Option, - pub artist: Option, - pub album: Option, - pub album_artist: Option, - pub genre: Option, - pub year: Option, - pub track: Option, - pub disc: Option, - pub musicbrainz_id: Option, - pub artwork_url: Option, -} - -/// Format plugin interface (FR-24.1) -/// -/// Format plugins handle custom audio formats not supported by symphonia. -/// -/// Example use cases: -/// - Custom lossless codecs -/// - Proprietary formats -/// - Game audio formats -pub trait FormatPlugin: Plugin { - /// File extensions this plugin handles - fn extensions(&self) -> &[&str]; - - /// Check if plugin can handle a specific extension - fn can_handle(&self, extension: &str) -> bool { - self.extensions() - .iter() - .any(|ext| ext.eq_ignore_ascii_case(extension)) - } - - /// Parse audio metadata from reader - /// - /// The reader provides the raw file bytes. Plugin should parse - /// and return AudioMeta with whatever metadata it can extract. - fn parse(&self, reader: &mut dyn Read) -> Result; - - /// Synthesize file header with updated metadata (FR-5.3) - /// - /// Creates a new file header containing the provided metadata. - /// Used for metadata overlay - serving cached metadata without - /// modifying the original file. - fn synthesize_header(&self, metadata: &AudioMeta) -> Result>; -} - -/// Declaration macro for native plugins -/// -/// Native plugins must export a function with this signature: -/// ```ignore -/// #[no_mangle] -/// pub extern "C" fn musicfs_plugin_create() -> *mut dyn Plugin -/// ``` -#[macro_export] -macro_rules! declare_plugin { - ($plugin_type:ty, $constructor:expr) => { - #[no_mangle] - pub extern "C" fn musicfs_plugin_create() -> *mut dyn $crate::Plugin { - let plugin = $constructor; - let boxed: Box = Box::new(plugin); - Box::into_raw(boxed) - } - - #[no_mangle] - pub extern "C" fn musicfs_plugin_api_version() -> *const std::ffi::c_char { - concat!($crate::PLUGIN_API_VERSION, "\0").as_ptr() as *const std::ffi::c_char - } - }; -} - -#[cfg(test)] -mod tests { - use super::*; - - struct TestPlugin { - name: String, - initialized: bool, - } - - impl Plugin for TestPlugin { - fn name(&self) -> &str { - &self.name - } - - fn version(&self) -> Version { - Version::new(1, 0, 0) - } - - fn plugin_type(&self) -> PluginType { - PluginType::Origin - } - - fn init(&mut self, _config: Value) -> Result<()> { - self.initialized = true; - Ok(()) - } - - fn shutdown(&mut self) -> Result<()> { - self.initialized = false; - Ok(()) - } - } - - #[test] - fn test_plugin_lifecycle() { - let mut plugin = TestPlugin { - name: "test".to_string(), - initialized: false, - }; - - assert_eq!(plugin.name(), "test"); - assert!(!plugin.initialized); - - plugin.init(Value::Null).unwrap(); - assert!(plugin.initialized); - - plugin.shutdown().unwrap(); - assert!(!plugin.initialized); - } - - #[test] - fn test_plugin_id() { - let id1 = PluginId::new(1); - let id2 = PluginId::new(1); - let id3 = PluginId::new(2); - - assert_eq!(id1, id2); - assert_ne!(id1, id3); - } -} diff --git a/crates/musicfs-plugins/src/wasm.rs b/crates/musicfs-plugins/src/wasm.rs deleted file mode 100644 index c84dda3..0000000 --- a/crates/musicfs-plugins/src/wasm.rs +++ /dev/null @@ -1,220 +0,0 @@ -use crate::error::{PluginError, Result}; -use crate::traits::PluginId; - -#[cfg(feature = "wasm")] -use std::sync::atomic::{AtomicU64, Ordering}; - -#[cfg(feature = "wasm")] -static NEXT_WASM_PLUGIN_ID: AtomicU64 = AtomicU64::new(1_000_000); - -#[cfg(feature = "wasm")] -fn next_wasm_plugin_id() -> PluginId { - PluginId::new(NEXT_WASM_PLUGIN_ID.fetch_add(1, Ordering::SeqCst)) -} - -#[derive(Debug, Clone)] -pub struct ResourceLimits { - pub max_memory_mb: u32, - pub max_cpu_time_ms: u32, - pub allow_network: bool, - pub allow_filesystem: bool, -} - -impl Default for ResourceLimits { - fn default() -> Self { - Self { - max_memory_mb: 64, - max_cpu_time_ms: 5000, - allow_network: false, - allow_filesystem: false, - } - } -} - -#[cfg(feature = "wasm")] -mod wasm_impl { - use super::*; - use std::collections::HashMap; - use tracing::info; - use wasmtime::{Config, Engine, Linker, Module, Store}; - - pub struct PluginState { - limits: ResourceLimits, - } - - pub struct WasmPlugin { - id: PluginId, - name: String, - _module: Module, - } - - impl WasmPlugin { - pub fn id(&self) -> PluginId { - self.id - } - - pub fn name(&self) -> &str { - &self.name - } - } - - pub struct WasmPluginHost { - engine: Engine, - linker: Linker, - plugins: HashMap, - limits: ResourceLimits, - } - - impl WasmPluginHost { - pub fn new() -> Result { - let mut config = Config::new(); - config.consume_fuel(true); - config.epoch_interruption(true); - - let engine = Engine::new(&config) - .map_err(|e| PluginError::Wasm(format!("Failed to create WASM engine: {}", e)))?; - - let linker = Linker::new(&engine); - - Ok(Self { - engine, - linker, - plugins: HashMap::new(), - limits: ResourceLimits::default(), - }) - } - - pub fn set_limits(&mut self, limits: ResourceLimits) { - self.limits = limits; - } - - pub fn load(&mut self, wasm_bytes: &[u8]) -> Result { - info!("Loading WASM plugin ({} bytes)", wasm_bytes.len()); - - let module = Module::new(&self.engine, wasm_bytes) - .map_err(|e| PluginError::Wasm(format!("Failed to compile WASM module: {}", e)))?; - - let id = next_wasm_plugin_id(); - let name = module.name().unwrap_or("unnamed").to_string(); - - let plugin = WasmPlugin { - id, - name, - _module: module, - }; - - self.plugins.insert(id, plugin); - - Ok(id) - } - - pub fn unload(&mut self, id: PluginId) -> Result<()> { - self.plugins - .remove(&id) - .ok_or_else(|| PluginError::NotFound(format!("WASM plugin {:?}", id)))?; - Ok(()) - } - - pub fn get(&self, id: PluginId) -> Option<&WasmPlugin> { - self.plugins.get(&id) - } - - pub fn list(&self) -> Vec<(PluginId, &str)> { - self.plugins.iter().map(|(id, p)| (*id, p.name())).collect() - } - - fn create_store(&self) -> Store { - let state = PluginState { - limits: self.limits.clone(), - }; - - let mut store = Store::new(&self.engine, state); - - let fuel = (self.limits.max_cpu_time_ms as u64) * 1_000_000; - store.set_fuel(fuel).ok(); - - store - } - } - - impl Default for WasmPluginHost { - fn default() -> Self { - Self::new().expect("Failed to create WASM host") - } - } -} - -#[cfg(not(feature = "wasm"))] -mod wasm_stub { - use super::*; - - pub struct WasmPluginHost { - limits: ResourceLimits, - } - - impl WasmPluginHost { - pub fn new() -> Result { - Ok(Self { - limits: ResourceLimits::default(), - }) - } - - pub fn set_limits(&mut self, limits: ResourceLimits) { - self.limits = limits; - } - - pub fn load(&mut self, _wasm_bytes: &[u8]) -> Result { - Err(PluginError::Wasm( - "WASM support not enabled. Compile with --features wasm".to_string(), - )) - } - - pub fn unload(&mut self, _id: PluginId) -> Result<()> { - Err(PluginError::Wasm("WASM support not enabled".to_string())) - } - - pub fn list(&self) -> Vec<(PluginId, &str)> { - Vec::new() - } - } - - impl Default for WasmPluginHost { - fn default() -> Self { - Self::new().expect("Failed to create WASM stub host") - } - } -} - -#[cfg(feature = "wasm")] -pub use wasm_impl::*; - -#[cfg(not(feature = "wasm"))] -pub use wasm_stub::*; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_resource_limits_default() { - let limits = ResourceLimits::default(); - assert_eq!(limits.max_memory_mb, 64); - assert_eq!(limits.max_cpu_time_ms, 5000); - assert!(!limits.allow_network); - assert!(!limits.allow_filesystem); - } - - #[test] - fn test_wasm_host_creation() { - let host = WasmPluginHost::new(); - assert!(host.is_ok()); - } - - #[test] - #[cfg(not(feature = "wasm"))] - fn test_wasm_disabled_load_fails() { - let mut host = WasmPluginHost::new().unwrap(); - let result = host.load(&[0x00, 0x61, 0x73, 0x6d]); - assert!(result.is_err()); - } -} diff --git a/crates/musicfs-search/Cargo.toml b/crates/musicfs-search/Cargo.toml deleted file mode 100644 index f774d2b..0000000 --- a/crates/musicfs-search/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "musicfs-search" -version.workspace = true -edition.workspace = true - -[dependencies] -musicfs-core = { path = "../musicfs-core" } - -tantivy.workspace = true -moka.workspace = true -parking_lot.workspace = true -tokio = { workspace = true, features = ["sync", "time"] } -tracing.workspace = true -thiserror.workspace = true -rusqlite.workspace = true -serde.workspace = true -serde_json.workspace = true - -[dev-dependencies] -tempfile.workspace = true -tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/musicfs-search/src/collections.rs b/crates/musicfs-search/src/collections.rs deleted file mode 100644 index a86c9ab..0000000 --- a/crates/musicfs-search/src/collections.rs +++ /dev/null @@ -1,321 +0,0 @@ -use parking_lot::Mutex; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use std::time::{Duration, SystemTime}; -use tracing::{debug, info, warn}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SmartCollection { - pub id: i64, - pub name: String, - pub query: CollectionQuery, - pub created_at: SystemTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum CollectionQuery { - Match { - field: String, - pattern: String, - }, - DateRange { - field: String, - start: i32, - end: i32, - }, - RecentlyAdded { - days: u32, - }, - RecentlyPlayed { - days: u32, - }, - MostPlayed { - limit: u32, - }, - Genre { - genre: String, - }, - Compound { - op: BoolOp, - children: Vec, - }, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub enum BoolOp { - And, - Or, -} - -impl CollectionQuery { - pub fn to_tantivy_query(&self) -> String { - match self { - CollectionQuery::Match { field, pattern } => { - format!("{}:{}", field, pattern) - } - CollectionQuery::DateRange { field, start, end } => { - format!("{}:[{} TO {}]", field, start, end) - } - CollectionQuery::Genre { genre } => { - format!("genre:{}", genre) - } - CollectionQuery::Compound { op, children } => { - let sep = match op { - BoolOp::And => " AND ", - BoolOp::Or => " OR ", - }; - let parts: Vec<_> = children - .iter() - .map(|c| format!("({})", c.to_tantivy_query())) - .collect(); - parts.join(sep) - } - _ => String::new(), - } - } - - pub fn is_dynamic(&self) -> bool { - matches!( - self, - CollectionQuery::RecentlyAdded { .. } - | CollectionQuery::RecentlyPlayed { .. } - | CollectionQuery::MostPlayed { .. } - ) - } -} - -pub struct CollectionStore { - db: Mutex, -} - -impl CollectionStore { - pub fn new(db_path: &Path) -> Result { - let db = rusqlite::Connection::open(db_path)?; - - db.execute( - "CREATE TABLE IF NOT EXISTS collections ( - id INTEGER PRIMARY KEY, - name TEXT UNIQUE NOT NULL, - query_json TEXT NOT NULL, - created_at INTEGER NOT NULL - )", - [], - )?; - - info!(path = ?db_path, "Collection store opened"); - Ok(Self { db: Mutex::new(db) }) - } - - pub fn create( - &self, - name: &str, - query: CollectionQuery, - ) -> Result { - info!(name = %name, "Creating collection"); - let query_json = serde_json::to_string(&query)?; - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - let db = self.db.lock(); - db.execute( - "INSERT INTO collections (name, query_json, created_at) VALUES (?1, ?2, ?3)", - rusqlite::params![name, query_json, now], - )?; - - let id = db.last_insert_rowid(); - debug!(id = id, name = %name, "Collection created"); - - Ok(SmartCollection { - id, - name: name.to_string(), - query, - created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(now as u64), - }) - } - - pub fn list(&self) -> Result, CollectionError> { - let db = self.db.lock(); - let mut stmt = db.prepare("SELECT id, name, query_json, created_at FROM collections")?; - - let collections = stmt.query_map([], |row| { - let query_json: String = row.get(2)?; - let created_secs: i64 = row.get(3)?; - - let query = match serde_json::from_str(&query_json) { - Ok(q) => q, - Err(e) => { - warn!("Failed to parse collection query JSON: {}", e); - CollectionQuery::Match { - field: "title".to_string(), - pattern: "*".to_string(), - } - } - }; - - Ok(SmartCollection { - id: row.get(0)?, - name: row.get(1)?, - query, - created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(created_secs as u64), - }) - })?; - - collections - .collect::, _>>() - .map_err(CollectionError::from) - } - - pub fn get(&self, name: &str) -> Result, CollectionError> { - let db = self.db.lock(); - let mut stmt = - db.prepare("SELECT id, name, query_json, created_at FROM collections WHERE name = ?1")?; - - let result = stmt - .query_row([name], |row| { - let query_json: String = row.get(2)?; - let created_secs: i64 = row.get(3)?; - - let query = match serde_json::from_str(&query_json) { - Ok(q) => q, - Err(e) => { - warn!("Failed to parse collection query JSON: {}", e); - CollectionQuery::Match { - field: "title".to_string(), - pattern: "*".to_string(), - } - } - }; - - Ok(SmartCollection { - id: row.get(0)?, - name: row.get(1)?, - query, - created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(created_secs as u64), - }) - }) - .ok(); - - Ok(result) - } - - pub fn delete(&self, name: &str) -> Result<(), CollectionError> { - info!(name = %name, "Deleting collection"); - let db = self.db.lock(); - db.execute("DELETE FROM collections WHERE name = ?1", [name])?; - Ok(()) - } -} - -pub fn builtin_collections() -> Vec { - vec![ - SmartCollection { - id: -1, - name: "Recently Added".to_string(), - query: CollectionQuery::RecentlyAdded { days: 30 }, - created_at: SystemTime::UNIX_EPOCH, - }, - SmartCollection { - id: -2, - name: "80s Music".to_string(), - query: CollectionQuery::DateRange { - field: "year".to_string(), - start: 1980, - end: 1989, - }, - created_at: SystemTime::UNIX_EPOCH, - }, - SmartCollection { - id: -3, - name: "90s Music".to_string(), - query: CollectionQuery::DateRange { - field: "year".to_string(), - start: 1990, - end: 1999, - }, - created_at: SystemTime::UNIX_EPOCH, - }, - ] -} - -#[derive(Debug, thiserror::Error)] -pub enum CollectionError { - #[error("database error: {0}")] - Database(#[from] rusqlite::Error), - - #[error("serialization error: {0}")] - Serialization(#[from] serde_json::Error), -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_collection_crud() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("collections.db"); - let store = CollectionStore::new(&db_path).unwrap(); - - let collection = store - .create( - "Jazz", - CollectionQuery::Genre { - genre: "Jazz".to_string(), - }, - ) - .unwrap(); - - assert_eq!(collection.name, "Jazz"); - - let collections = store.list().unwrap(); - assert_eq!(collections.len(), 1); - - store.delete("Jazz").unwrap(); - let collections = store.list().unwrap(); - assert_eq!(collections.len(), 0); - } - - #[test] - fn test_compound_query() { - let query = CollectionQuery::Compound { - op: BoolOp::And, - children: vec![ - CollectionQuery::Genre { - genre: "Metal".to_string(), - }, - CollectionQuery::DateRange { - field: "year".to_string(), - start: 1980, - end: 1989, - }, - ], - }; - - let tantivy_query = query.to_tantivy_query(); - assert!(tantivy_query.contains("genre:Metal")); - assert!(tantivy_query.contains("year:[1980 TO 1989]")); - assert!(tantivy_query.contains(" AND ")); - } - - #[test] - fn test_builtin_collections() { - let builtins = builtin_collections(); - assert_eq!(builtins.len(), 3); - assert!(builtins.iter().any(|c| c.name == "Recently Added")); - } - - #[test] - fn test_dynamic_query_detection() { - assert!(CollectionQuery::RecentlyAdded { days: 30 }.is_dynamic()); - assert!(CollectionQuery::RecentlyPlayed { days: 7 }.is_dynamic()); - assert!(CollectionQuery::MostPlayed { limit: 100 }.is_dynamic()); - assert!(!CollectionQuery::Genre { - genre: "Rock".to_string() - } - .is_dynamic()); - } -} diff --git a/crates/musicfs-search/src/index.rs b/crates/musicfs-search/src/index.rs deleted file mode 100644 index f7fa1fd..0000000 --- a/crates/musicfs-search/src/index.rs +++ /dev/null @@ -1,417 +0,0 @@ -use musicfs_core::{AudioMeta, FileId, FileMeta, VirtualPath}; -use parking_lot::RwLock; -use std::path::Path; -use std::sync::Arc; -use tantivy::collector::TopDocs; -use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, Query, QueryParser}; -use tantivy::schema::{Field, Schema, Value, INDEXED, STORED, TEXT}; -use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term}; -use tracing::{debug, info, warn}; - -const SCHEMA_VERSION: u32 = 1; - -pub struct SearchIndex { - index: Index, - reader: IndexReader, - writer: Arc>, - schema: SearchSchema, - pub schema_version: u32, -} - -struct SearchSchema { - schema: Schema, - file_id: Field, - virtual_path: Field, - artist: Field, - album: Field, - album_artist: Field, - title: Field, - genre: Field, - composer: Field, - year: Field, - duration_ms: Field, - bitrate: Field, - sample_rate: Field, -} - -impl SearchSchema { - fn new() -> Self { - let mut builder = Schema::builder(); - - Self { - file_id: builder.add_u64_field("file_id", INDEXED | STORED), - virtual_path: builder.add_text_field("virtual_path", STORED), - artist: builder.add_text_field("artist", TEXT | STORED), - album: builder.add_text_field("album", TEXT | STORED), - album_artist: builder.add_text_field("album_artist", TEXT | STORED), - title: builder.add_text_field("title", TEXT | STORED), - genre: builder.add_text_field("genre", TEXT | STORED), - composer: builder.add_text_field("composer", TEXT | STORED), - year: builder.add_u64_field("year", INDEXED | STORED), - duration_ms: builder.add_u64_field("duration_ms", STORED), - bitrate: builder.add_u64_field("bitrate", STORED), - sample_rate: builder.add_u64_field("sample_rate", STORED), - schema: builder.build(), - } - } -} - -#[derive(Debug, Clone)] -pub struct SearchHit { - pub file_id: FileId, - pub virtual_path: VirtualPath, - pub artist: Option, - pub album: Option, - pub title: Option, - pub score: f32, -} - -impl SearchIndex { - pub fn open(index_path: &Path) -> Result { - let schema_obj = SearchSchema::new(); - - let index = if index_path.exists() && index_path.join("meta.json").exists() { - Index::open_in_dir(index_path)? - } else { - std::fs::create_dir_all(index_path)?; - Index::create_in_dir(index_path, schema_obj.schema.clone())? - }; - - let reader = index - .reader_builder() - .reload_policy(ReloadPolicy::OnCommitWithDelay) - .try_into()?; - - let writer = index.writer(50_000_000)?; - - info!("Search index opened at {:?}", index_path); - - Ok(Self { - index, - reader, - writer: Arc::new(RwLock::new(writer)), - schema: schema_obj, - schema_version: SCHEMA_VERSION, - }) - } - - pub fn open_with_recovery(index_path: &Path) -> Result { - match Self::open(index_path) { - Ok(index) => { - let docs = index.reader.searcher().num_docs(); - info!(docs, "Search index opened successfully"); - Ok(index) - } - Err(e) => { - warn!( - error = %e, - path = ?index_path, - "Search index corrupted, rebuilding from scratch" - ); - if index_path.exists() { - std::fs::remove_dir_all(index_path).map_err(SearchError::Io)?; - } - Self::open(index_path) - } - } - } - - pub fn index_file(&self, file: &FileMeta) -> Result<(), SearchError> { - let mut doc = TantivyDocument::new(); - - doc.add_u64(self.schema.file_id, file.id.0 as u64); - doc.add_text(self.schema.virtual_path, file.virtual_path.as_str()); - - if let Some(ref audio) = file.audio { - Self::add_audio_fields(&mut doc, &self.schema, audio); - } - - self.writer.read().add_document(doc)?; - debug!("Indexed file {:?}", file.id); - Ok(()) - } - - fn add_audio_fields(doc: &mut TantivyDocument, schema: &SearchSchema, audio: &AudioMeta) { - if let Some(ref v) = audio.artist { - doc.add_text(schema.artist, v); - } - if let Some(ref v) = audio.album { - doc.add_text(schema.album, v); - } - if let Some(ref v) = audio.album_artist { - doc.add_text(schema.album_artist, v); - } - if let Some(ref v) = audio.title { - doc.add_text(schema.title, v); - } - if let Some(ref v) = audio.genre { - doc.add_text(schema.genre, v); - } - if let Some(ref v) = audio.year { - doc.add_u64(schema.year, *v as u64); - } - if let Some(v) = audio.duration_ms { - doc.add_u64(schema.duration_ms, v); - } - if let Some(v) = audio.bitrate { - doc.add_u64(schema.bitrate, v as u64); - } - if let Some(v) = audio.sample_rate { - doc.add_u64(schema.sample_rate, v as u64); - } - } - - pub fn remove_file(&self, file_id: FileId) -> Result<(), SearchError> { - let term = tantivy::Term::from_field_u64(self.schema.file_id, file_id.0 as u64); - self.writer.read().delete_term(term); - debug!("Removed file {:?} from index", file_id); - Ok(()) - } - - pub fn remove_by_path(&self, path: &VirtualPath) -> Result { - let searcher = self.reader.searcher(); - let query_parser = QueryParser::for_index(&self.index, vec![self.schema.virtual_path]); - let query = query_parser.parse_query(&format!("\"{}\"", path.as_str()))?; - let top_docs = searcher.search(&query, &TopDocs::with_limit(1))?; - - if let Some((_, doc_address)) = top_docs.first() { - let doc: TantivyDocument = searcher.doc(*doc_address)?; - if let Some(file_id) = doc.get_first(self.schema.file_id).and_then(|v| v.as_u64()) { - self.remove_file(FileId(file_id as i64))?; - debug!("Removed file by path {:?}", path); - return Ok(true); - } - } - Ok(false) - } - - pub fn commit(&self) -> Result<(), SearchError> { - self.writer.write().commit()?; - self.reader.reload()?; - info!("Search index committed"); - Ok(()) - } - - pub fn search(&self, query_str: &str, limit: usize) -> Result, SearchError> { - let searcher = self.reader.searcher(); - - let default_fields = vec![ - self.schema.artist, - self.schema.album, - self.schema.album_artist, - self.schema.title, - self.schema.genre, - self.schema.composer, - ]; - - let query: Box = - if let Some((term, distance)) = Self::parse_fuzzy_query(query_str) { - let subqueries: Vec<(Occur, Box)> = default_fields - .iter() - .map(|&field| { - let term = Term::from_field_text(field, &term); - let fuzzy = FuzzyTermQuery::new(term, distance, true); - (Occur::Should, Box::new(fuzzy) as Box) - }) - .collect(); - Box::new(BooleanQuery::new(subqueries)) - } else { - let query_parser = QueryParser::for_index(&self.index, default_fields); - query_parser.parse_query(query_str)? - }; - - let top_docs = searcher.search(&*query, &TopDocs::with_limit(limit))?; - - let mut results = Vec::with_capacity(top_docs.len()); - for (score, doc_address) in top_docs { - let doc: TantivyDocument = searcher.doc(doc_address)?; - - let file_id = doc - .get_first(self.schema.file_id) - .and_then(|v| v.as_u64()) - .map(|id| FileId(id as i64)) - .ok_or(SearchError::CorruptedIndex)?; - - let virtual_path = doc - .get_first(self.schema.virtual_path) - .and_then(|v| v.as_str()) - .map(|s| VirtualPath::new(s)) - .ok_or(SearchError::CorruptedIndex)?; - - results.push(SearchHit { - file_id, - virtual_path, - artist: doc - .get_first(self.schema.artist) - .and_then(|v| v.as_str()) - .map(String::from), - album: doc - .get_first(self.schema.album) - .and_then(|v| v.as_str()) - .map(String::from), - title: doc - .get_first(self.schema.title) - .and_then(|v| v.as_str()) - .map(String::from), - score, - }); - } - - debug!("Search '{}' returned {} results", query_str, results.len()); - Ok(results) - } - - pub fn count(&self) -> u64 { - self.reader.searcher().num_docs() - } - - fn parse_fuzzy_query(query_str: &str) -> Option<(String, u8)> { - let query_str = query_str.trim(); - if let Some(tilde_pos) = query_str.rfind('~') { - let term = &query_str[..tilde_pos]; - let distance_str = &query_str[tilde_pos + 1..]; - if !term.is_empty() && !term.contains(':') && !term.contains(' ') { - if let Ok(distance) = distance_str.parse::() { - if distance <= 2 { - return Some((term.to_lowercase(), distance)); - } - } - } - } - None - } -} - -#[derive(Debug, thiserror::Error)] -pub enum SearchError { - #[error("tantivy error: {0}")] - Tantivy(#[from] tantivy::TantivyError), - - #[error("query parse error: {0}")] - QueryParse(#[from] tantivy::query::QueryParserError), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("corrupted search index")] - CorruptedIndex, -} - -#[cfg(test)] -mod tests { - use super::*; - use musicfs_core::{AudioFormat, OriginId, RealPath}; - use std::path::PathBuf; - use tempfile::TempDir; - - fn make_file(id: i64, artist: &str, album: &str, title: &str) -> FileMeta { - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(format!("/{}/{}/{}.flac", artist, album, title)), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from("test.flac"), - }, - size: 1000, - mtime: std::time::SystemTime::UNIX_EPOCH, - content_hash: None, - audio: Some(AudioMeta { - artist: Some(artist.to_string()), - album: Some(album.to_string()), - title: Some(title.to_string()), - genre: Some("Metal".to_string()), - format: AudioFormat::Flac, - ..Default::default() - }), - } - } - - #[test] - fn test_search_basic() { - let dir = TempDir::new().unwrap(); - let index = SearchIndex::open(dir.path()).unwrap(); - - index - .index_file(&make_file(1, "Metallica", "Black Album", "Enter Sandman")) - .unwrap(); - index - .index_file(&make_file(2, "Metallica", "Master of Puppets", "Battery")) - .unwrap(); - index - .index_file(&make_file(3, "Iron Maiden", "Powerslave", "Aces High")) - .unwrap(); - index.commit().unwrap(); - - let results = index.search("metallica", 10).unwrap(); - assert_eq!(results.len(), 2); - - let results = index.search("sandman", 10).unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].title.as_deref(), Some("Enter Sandman")); - } - - #[test] - fn test_search_fuzzy() { - let dir = TempDir::new().unwrap(); - let index = SearchIndex::open(dir.path()).unwrap(); - - index - .index_file(&make_file(1, "Metallica", "Black Album", "Enter Sandman")) - .unwrap(); - index.commit().unwrap(); - - let results = index.search("metalica~1", 10).unwrap(); - assert_eq!(results.len(), 1); - } - - #[test] - fn test_search_genre() { - let dir = TempDir::new().unwrap(); - let index = SearchIndex::open(dir.path()).unwrap(); - - index - .index_file(&make_file(1, "Metallica", "Black Album", "Enter Sandman")) - .unwrap(); - index.commit().unwrap(); - - let results = index.search("genre:Metal", 10).unwrap(); - assert_eq!(results.len(), 1); - } - - #[test] - fn test_remove_file() { - let dir = TempDir::new().unwrap(); - let index = SearchIndex::open(dir.path()).unwrap(); - - index - .index_file(&make_file(1, "Test", "Album", "Song")) - .unwrap(); - index.commit().unwrap(); - - assert_eq!(index.search("test", 10).unwrap().len(), 1); - - index.remove_file(FileId(1)).unwrap(); - index.commit().unwrap(); - - assert_eq!(index.search("test", 10).unwrap().len(), 0); - } - - #[test] - fn test_index_persistence() { - let dir = TempDir::new().unwrap(); - - { - let index = SearchIndex::open(dir.path()).unwrap(); - index - .index_file(&make_file(1, "Artist", "Album", "Track")) - .unwrap(); - index.commit().unwrap(); - } - - { - let index = SearchIndex::open(dir.path()).unwrap(); - let results = index.search("artist", 10).unwrap(); - assert_eq!(results.len(), 1); - } - } -} diff --git a/crates/musicfs-search/src/indexer.rs b/crates/musicfs-search/src/indexer.rs deleted file mode 100644 index 75a7345..0000000 --- a/crates/musicfs-search/src/indexer.rs +++ /dev/null @@ -1,236 +0,0 @@ -use crate::index::{SearchError, SearchIndex}; -use musicfs_core::{Event, EventBus, FileMeta}; -use std::sync::Arc; -use tokio::sync::mpsc; -use tracing::{debug, error, info, info_span, warn, Instrument}; - -pub trait MetadataLookup: Send + Sync { - fn lookup(&self, path: &musicfs_core::VirtualPath) -> Option; -} - -pub struct Indexer { - index: Arc, - event_bus: Arc, - metadata_lookup: Arc, -} - -impl Indexer { - pub fn new(index: Arc, event_bus: Arc, metadata_lookup: Arc) -> Self { - Self { - index, - event_bus, - metadata_lookup, - } - } - - pub fn start(self) -> IndexerHandle { - let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1); - let mut event_rx = self.event_bus.subscribe(); - - info!("Search indexer starting"); - - tokio::spawn( - async move { - let mut pending_commit = false; - let mut commit_timer = tokio::time::interval(std::time::Duration::from_secs(5)); - - loop { - tokio::select! { - result = event_rx.recv() => { - match result { - Ok(event) => { - if let Err(e) = self.handle_event(&event) { - error!("Indexer error: {}", e); - } - pending_commit = true; - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!(skipped = n, "Indexer lagged, skipped events"); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - debug!("Event channel closed"); - break; - } - } - } - _ = commit_timer.tick() => { - if pending_commit { - if let Err(e) = self.index.commit() { - error!("Index commit error: {}", e); - } - pending_commit = false; - } - } - _ = stop_rx.recv() => { - info!("Indexer stopping"); - if pending_commit { - let _ = self.index.commit(); - } - break; - } - } - } - } - .instrument(info_span!("search_indexer")), - ); - - IndexerHandle { stop_tx } - } - - fn handle_event(&self, event: &Event) -> Result<(), SearchError> { - match event { - Event::FileAdded { path, .. } => { - debug!("Indexing added file: {:?}", path); - if let Some(meta) = self.metadata_lookup.lookup(path) { - self.index.index_file(&meta)?; - } else { - warn!("No metadata found for added file: {:?}", path); - } - } - Event::FileRemoved { path, file_id } => { - debug!("Removing from index: {:?}", path); - if let Some(id) = file_id { - self.index.remove_file(*id)?; - } else if let Some(meta) = self.metadata_lookup.lookup(path) { - self.index.remove_file(meta.id)?; - } else { - self.index.remove_by_path(path)?; - } - } - Event::FileModified { path } => { - debug!("Re-indexing modified file: {:?}", path); - if let Some(meta) = self.metadata_lookup.lookup(path) { - self.index.remove_file(meta.id)?; - self.index.index_file(&meta)?; - } - } - _ => {} - } - Ok(()) - } - - pub fn index_batch(&self, files: &[FileMeta]) -> Result { - let mut count = 0; - for file in files { - self.index.index_file(file)?; - count += 1; - } - self.index.commit()?; - info!("Indexed {} files", count); - Ok(count) - } -} - -pub struct IndexerHandle { - stop_tx: mpsc::Sender<()>, -} - -impl IndexerHandle { - pub async fn stop(self) { - let _ = self.stop_tx.send(()).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use musicfs_core::{AudioFormat, AudioMeta, FileId, OriginId, RealPath, VirtualPath}; - use std::collections::HashMap; - use std::path::PathBuf; - use std::sync::RwLock; - use tempfile::TempDir; - - struct MockMetadataLookup { - files: RwLock>, - } - - impl MockMetadataLookup { - fn new() -> Self { - Self { - files: RwLock::new(HashMap::new()), - } - } - - fn insert(&self, meta: FileMeta) { - self.files - .write() - .unwrap() - .insert(meta.virtual_path.as_str().to_string(), meta); - } - } - - impl MetadataLookup for MockMetadataLookup { - fn lookup(&self, path: &VirtualPath) -> Option { - self.files.read().unwrap().get(path.as_str()).cloned() - } - } - - fn make_file(id: i64, path: &str, artist: &str, title: &str) -> FileMeta { - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(path), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from("test.flac"), - }, - size: 1000, - mtime: std::time::SystemTime::UNIX_EPOCH, - content_hash: None, - audio: Some(AudioMeta { - artist: Some(artist.to_string()), - title: Some(title.to_string()), - format: AudioFormat::Flac, - ..Default::default() - }), - } - } - - #[tokio::test] - async fn test_indexer_handles_file_added() { - let dir = TempDir::new().unwrap(); - let index = Arc::new(SearchIndex::open(dir.path()).unwrap()); - let event_bus = Arc::new(EventBus::default()); - let metadata = Arc::new(MockMetadataLookup::new()); - - let file = make_file(1, "/Artist/Album/Track.flac", "Artist", "Track"); - metadata.insert(file.clone()); - - let indexer = Indexer::new(index.clone(), event_bus.clone(), metadata); - let handle = indexer.start(); - - event_bus.publish(Event::FileAdded { - path: VirtualPath::new("/Artist/Album/Track.flac"), - origin_id: OriginId::from("test"), - }); - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - index.commit().unwrap(); - - let results = index.search("artist", 10).unwrap(); - assert_eq!(results.len(), 1); - - handle.stop().await; - } - - #[test] - fn test_index_batch() { - let dir = TempDir::new().unwrap(); - let index = Arc::new(SearchIndex::open(dir.path()).unwrap()); - let event_bus = Arc::new(EventBus::default()); - let metadata = Arc::new(MockMetadataLookup::new()); - - let indexer = Indexer::new(index.clone(), event_bus, metadata); - - let files = vec![ - make_file(1, "/a.flac", "Artist1", "Song1"), - make_file(2, "/b.flac", "Artist2", "Song2"), - make_file(3, "/c.flac", "Artist3", "Song3"), - ]; - - let count = indexer.index_batch(&files).unwrap(); - assert_eq!(count, 3); - - let results = index.search("artist1", 10).unwrap(); - assert_eq!(results.len(), 1); - } -} diff --git a/crates/musicfs-search/src/lib.rs b/crates/musicfs-search/src/lib.rs deleted file mode 100644 index d86f608..0000000 --- a/crates/musicfs-search/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod collections; -mod index; -mod indexer; -mod query; - -pub use collections::{ - builtin_collections, BoolOp, CollectionError, CollectionQuery, CollectionStore, SmartCollection, -}; -pub use index::{SearchError, SearchHit, SearchIndex}; -pub use indexer::{Indexer, IndexerHandle, MetadataLookup}; -pub use query::SearchQueryBuilder; diff --git a/crates/musicfs-search/src/query.rs b/crates/musicfs-search/src/query.rs deleted file mode 100644 index d2ed3a4..0000000 --- a/crates/musicfs-search/src/query.rs +++ /dev/null @@ -1,78 +0,0 @@ -use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, Query}; -use tantivy::schema::Field; -use tantivy::Term; - -pub struct SearchQueryBuilder { - fields: Vec, - default_fuzziness: u8, -} - -impl SearchQueryBuilder { - pub fn new(fields: Vec) -> Self { - Self { - fields, - default_fuzziness: 1, - } - } - - pub fn with_fuzziness(mut self, fuzziness: u8) -> Self { - self.default_fuzziness = fuzziness; - self - } - - pub fn build_fuzzy(&self, query_text: &str) -> Box { - let terms: Vec<_> = query_text - .split_whitespace() - .filter(|t| !t.is_empty()) - .collect(); - - if terms.is_empty() { - return Box::new(tantivy::query::AllQuery); - } - - let mut clauses: Vec<(Occur, Box)> = Vec::new(); - - for term in terms { - let mut field_queries: Vec<(Occur, Box)> = Vec::new(); - - for field in &self.fields { - let fuzzy = FuzzyTermQuery::new( - Term::from_field_text(*field, &term.to_lowercase()), - self.default_fuzziness, - true, - ); - field_queries.push((Occur::Should, Box::new(fuzzy))); - } - - let field_union = BooleanQuery::new(field_queries); - clauses.push((Occur::Must, Box::new(field_union))); - } - - Box::new(BooleanQuery::new(clauses)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tantivy::schema::{Schema, TEXT}; - - #[test] - fn test_query_builder() { - let mut schema_builder = Schema::builder(); - let artist = schema_builder.add_text_field("artist", TEXT); - let title = schema_builder.add_text_field("title", TEXT); - - let builder = SearchQueryBuilder::new(vec![artist, title]); - let _query = builder.build_fuzzy("metallica sandman"); - } - - #[test] - fn test_empty_query() { - let mut schema_builder = Schema::builder(); - let artist = schema_builder.add_text_field("artist", TEXT); - - let builder = SearchQueryBuilder::new(vec![artist]); - let _query = builder.build_fuzzy(""); - } -} diff --git a/crates/musicfs-sync/Cargo.toml b/crates/musicfs-sync/Cargo.toml deleted file mode 100644 index af2e6c3..0000000 --- a/crates/musicfs-sync/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "musicfs-sync" -version.workspace = true -edition.workspace = true - -[dependencies] -musicfs-core = { path = "../musicfs-core" } -musicfs-origins = { path = "../musicfs-origins" } - -fastcdc = "3" -xxhash-rust = { version = "0.8", features = ["xxh64"] } -notify = "6" -rmp-serde = "1" - -tokio = { workspace = true } -tracing = { workspace = true } -thiserror = { workspace = true } -serde = { workspace = true } -async-trait = { workspace = true } - -[dev-dependencies] -tempfile = { workspace = true } diff --git a/crates/musicfs-sync/src/cdc.rs b/crates/musicfs-sync/src/cdc.rs deleted file mode 100644 index d269e96..0000000 --- a/crates/musicfs-sync/src/cdc.rs +++ /dev/null @@ -1,232 +0,0 @@ -use fastcdc::v2020::FastCDC; -use musicfs_core::ChunkHash; - -pub struct CdcChunker { - min_size: u32, - avg_size: u32, - max_size: u32, -} - -impl Default for CdcChunker { - fn default() -> Self { - Self { - min_size: 16 * 1024, - avg_size: 64 * 1024, - max_size: 256 * 1024, - } - } -} - -#[derive(Debug, Clone)] -pub struct Chunk { - pub hash: ChunkHash, - pub offset: u64, - pub length: u32, - pub data: Vec, -} - -#[derive(Debug)] -pub struct ChunkRef<'a> { - pub hash: ChunkHash, - pub offset: u64, - pub length: u32, - pub data: &'a [u8], -} - -impl CdcChunker { - pub fn new(min_size: u32, avg_size: u32, max_size: u32) -> Self { - Self { - min_size, - avg_size, - max_size, - } - } - - pub fn chunk(&self, data: &[u8]) -> Vec { - let chunker = FastCDC::new(data, self.min_size, self.avg_size, self.max_size); - - chunker - .map(|c| { - let chunk_data = &data[c.offset..c.offset + c.length]; - Chunk { - hash: ChunkHash::from_bytes(chunk_data), - offset: c.offset as u64, - length: c.length as u32, - data: chunk_data.to_vec(), - } - }) - .collect() - } - - pub fn chunk_refs<'a>(&self, data: &'a [u8]) -> Vec> { - let chunker = FastCDC::new(data, self.min_size, self.avg_size, self.max_size); - - chunker - .map(|c| { - let chunk_data = &data[c.offset..c.offset + c.length]; - ChunkRef { - hash: ChunkHash::from_bytes(chunk_data), - offset: c.offset as u64, - length: c.length as u32, - data: chunk_data, - } - }) - .collect() - } - - pub fn chunk_streaming(&self, data: &[u8], mut processor: F) -> usize - where - F: FnMut(ChunkRef<'_>), - { - let chunker = FastCDC::new(data, self.min_size, self.avg_size, self.max_size); - let mut count = 0; - - for c in chunker { - let chunk_data = &data[c.offset..c.offset + c.length]; - processor(ChunkRef { - hash: ChunkHash::from_bytes(chunk_data), - offset: c.offset as u64, - length: c.length as u32, - data: chunk_data, - }); - count += 1; - } - count - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cdc_basic() { - let chunker = CdcChunker::default(); - let data = vec![0u8; 256 * 1024]; - - let chunks = chunker.chunk(&data); - - assert!(!chunks.is_empty()); - - let total: u64 = chunks.iter().map(|c| c.length as u64).sum(); - assert_eq!(total, data.len() as u64); - - let mut offset = 0u64; - for chunk in &chunks { - assert_eq!(chunk.offset, offset); - offset += chunk.length as u64; - } - } - - #[test] - fn test_cdc_stable_boundaries() { - let chunker = CdcChunker::new(4 * 1024, 16 * 1024, 64 * 1024); - - let mut data1 = vec![0u8; 512 * 1024]; - for (i, b) in data1.iter_mut().enumerate() { - *b = ((i * 17 + 31) % 256) as u8; - } - - let mut data2 = vec![0xFFu8; 1024]; - data2.extend_from_slice(&data1); - - let chunks1 = chunker.chunk(&data1); - let chunks2 = chunker.chunk(&data2); - - let hashes1: std::collections::HashSet<_> = chunks1.iter().map(|c| c.hash).collect(); - let hashes2: std::collections::HashSet<_> = chunks2.iter().map(|c| c.hash).collect(); - - let shared = hashes1.intersection(&hashes2).count(); - - assert!( - shared > 0, - "CDC should produce stable boundaries, got {} chunks in original, {} after prepend", - chunks1.len(), - chunks2.len() - ); - } - - #[test] - fn test_cdc_chunk_sizes() { - let chunker = CdcChunker::default(); - - let data: Vec = (0..1024 * 1024) - .map(|i| ((i * 17 + 31) % 256) as u8) - .collect(); - - let chunks = chunker.chunk(&data); - - for chunk in &chunks { - if chunk.offset + chunk.length as u64 != data.len() as u64 { - assert!( - chunk.length >= chunker.min_size / 2, - "Chunk too small: {}", - chunk.length - ); - assert!( - chunk.length <= chunker.max_size * 2, - "Chunk too large: {}", - chunk.length - ); - } - } - } - - #[test] - fn test_cdc_streaming() { - let chunker = CdcChunker::default(); - let data = vec![0u8; 256 * 1024]; - - let mut streamed = Vec::new(); - let count = chunker.chunk_streaming(&data, |chunk| { - streamed.push((chunk.hash, chunk.offset, chunk.length)); - }); - - let batched = chunker.chunk(&data); - - assert_eq!(count, batched.len()); - for (i, chunk) in batched.iter().enumerate() { - assert_eq!(streamed[i].0, chunk.hash); - assert_eq!(streamed[i].1, chunk.offset); - assert_eq!(streamed[i].2, chunk.length); - } - } - - #[test] - fn test_bandwidth_reduction_metadata_edit() { - let chunker = CdcChunker::new(4 * 1024, 16 * 1024, 64 * 1024); - - let mut state = 12345u64; - let original: Vec = (0..2 * 1024 * 1024) - .map(|_| { - state = state.wrapping_mul(6364136223846793005).wrapping_add(1); - (state >> 56) as u8 - }) - .collect(); - - let chunks1 = chunker.chunk(&original); - let hashes1: std::collections::HashSet<_> = chunks1.iter().map(|c| c.hash).collect(); - - let mut modified = original.clone(); - let mid = modified.len() / 2; - for i in mid..mid + 100 { - modified[i] = 0xFF; - } - - let chunks2 = chunker.chunk(&modified); - let hashes2: std::collections::HashSet<_> = chunks2.iter().map(|c| c.hash).collect(); - - let reused = hashes1.intersection(&hashes2).count(); - let reuse_ratio = reused as f64 / chunks2.len() as f64; - - // NFR-6.4 requires >90% bandwidth reduction for typical edits - assert!( - reuse_ratio > 0.90, - "Expected >90% chunk reuse for mid-file edit (NFR-6.4). Reused {}/{} chunks ({:.1}%, total {} original)", - reused, - chunks2.len(), - reuse_ratio * 100.0, - chunks1.len() - ); - } -} diff --git a/crates/musicfs-sync/src/delta.rs b/crates/musicfs-sync/src/delta.rs deleted file mode 100644 index fdaa708..0000000 --- a/crates/musicfs-sync/src/delta.rs +++ /dev/null @@ -1,338 +0,0 @@ -use crate::cdc::CdcChunker; -use musicfs_core::{ChunkHash, FileId, FileMeta, OriginId}; -use musicfs_origins::Origin; -use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; -use std::time::SystemTime; -use tracing::{debug, info, trace}; - -#[derive(Debug, Clone)] -pub struct ScannedFile { - pub path: PathBuf, - pub origin_id: OriginId, - pub size: u64, - pub mtime: SystemTime, -} - -#[derive(Debug, Default)] -pub struct ChangeSet { - pub added: Vec, - pub removed: Vec, - pub modified: Vec<(FileId, ManifestDiff)>, -} - -impl ChangeSet { - pub fn is_empty(&self) -> bool { - self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty() - } - - pub fn total_changes(&self) -> usize { - self.added.len() + self.removed.len() + self.modified.len() - } -} - -#[derive(Debug, Clone)] -pub struct ManifestChunk { - pub hash: ChunkHash, - pub offset: u64, - pub size: u32, -} - -#[derive(Debug)] -pub struct ManifestDiff { - pub reuse: Vec, - pub fetch: Vec, - pub orphaned: Vec, -} - -pub struct DeltaDetector { - chunker: CdcChunker, -} - -impl DeltaDetector { - pub fn new() -> Self { - Self { - chunker: CdcChunker::default(), - } - } - - pub fn with_chunker(chunker: CdcChunker) -> Self { - Self { chunker } - } - - pub async fn detect_changes( - &self, - origin: &dyn Origin, - cached: &HashMap, - manifests: &HashMap>, - ) -> Result { - let origin_id = origin.id().clone(); - info!(origin_id = %origin_id, "Starting delta detection"); - - let mut changes = ChangeSet::default(); - - let origin_files = self.scan_origin(origin).await?; - trace!(origin_id = %origin_id, scanned_count = origin_files.len(), "Completed origin scan"); - - let cached_by_path: HashMap<_, _> = cached - .values() - .map(|m| (m.real_path.path.clone(), m)) - .collect(); - - for scanned in &origin_files { - if let Some(cached_file) = cached_by_path.get(&scanned.path) { - if self.is_modified_scan(cached_file, scanned) { - debug!(origin_id = %origin_id, path = ?scanned.path, "File modified"); - - if let Some(old_chunks) = manifests.get(&cached_file.id) { - let new_chunks = self.compute_chunks_for_scan(origin, scanned).await?; - let diff = self.compute_diff(old_chunks, &new_chunks); - changes.modified.push((cached_file.id, diff)); - } - } - } else { - debug!(origin_id = %origin_id, path = ?scanned.path, "File added"); - changes.added.push(scanned.clone()); - } - } - - let origin_paths: HashSet<_> = origin_files.iter().map(|f| &f.path).collect(); - - for cached_file in cached.values() { - if !origin_paths.contains(&cached_file.real_path.path) { - debug!(origin_id = %origin_id, path = ?cached_file.real_path.path, "File removed"); - changes.removed.push(cached_file.id); - } - } - - info!( - origin_id = %origin_id, - files_added = changes.added.len(), - files_removed = changes.removed.len(), - files_modified = changes.modified.len(), - "Delta detection complete" - ); - - Ok(changes) - } - - fn is_modified_scan(&self, cached: &FileMeta, scanned: &ScannedFile) -> bool { - cached.size != scanned.size || cached.mtime != scanned.mtime - } - - async fn scan_origin(&self, origin: &dyn Origin) -> Result, DeltaError> { - let mut files = Vec::new(); - let mut dirs_to_scan = vec![PathBuf::from("/")]; - - while let Some(dir) = dirs_to_scan.pop() { - let entries = origin - .readdir(&dir) - .await - .map_err(|e| DeltaError::OriginScan(e.to_string()))?; - - for entry in entries { - let entry_path = dir.join(&entry.name); - - if entry.is_dir { - dirs_to_scan.push(entry_path); - } else if Self::is_audio_file(&entry.name) { - let stat = origin - .stat(&entry_path) - .await - .map_err(|e| DeltaError::OriginScan(e.to_string()))?; - - files.push(ScannedFile { - path: entry_path, - origin_id: origin.id().clone(), - size: stat.size, - mtime: stat.mtime, - }); - } - } - } - - Ok(files) - } - - fn is_audio_file(name: &str) -> bool { - let lower = name.to_lowercase(); - lower.ends_with(".flac") - || lower.ends_with(".mp3") - || lower.ends_with(".ogg") - || lower.ends_with(".wav") - || lower.ends_with(".m4a") - || lower.ends_with(".aac") - || lower.ends_with(".opus") - } - - async fn compute_chunks_for_scan( - &self, - origin: &dyn Origin, - scanned: &ScannedFile, - ) -> Result, DeltaError> { - let data = origin - .read_full(&scanned.path) - .await - .map_err(|e| DeltaError::OriginRead(e.to_string()))?; - - let chunks = self.chunker.chunk_refs(&data); - - Ok(chunks - .into_iter() - .map(|c| ManifestChunk { - hash: c.hash, - offset: c.offset, - size: c.length, - }) - .collect()) - } - - fn compute_diff( - &self, - old_chunks: &[ManifestChunk], - new_chunks: &[ManifestChunk], - ) -> ManifestDiff { - let old_hashes: HashSet<_> = old_chunks.iter().map(|c| c.hash).collect(); - let new_hashes: HashSet<_> = new_chunks.iter().map(|c| c.hash).collect(); - - ManifestDiff { - reuse: new_chunks - .iter() - .filter(|c| old_hashes.contains(&c.hash)) - .cloned() - .collect(), - fetch: new_chunks - .iter() - .filter(|c| !old_hashes.contains(&c.hash)) - .cloned() - .collect(), - orphaned: old_chunks - .iter() - .filter(|c| !new_hashes.contains(&c.hash)) - .map(|c| c.hash) - .collect(), - } - } -} - -impl Default for DeltaDetector { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, thiserror::Error)] -pub enum DeltaError { - #[error("Origin read error: {0}")] - OriginRead(String), - - #[error("Origin scan error: {0}")] - OriginScan(String), -} - -#[cfg(test)] -mod tests { - use super::*; - use musicfs_core::{OriginId, RealPath, VirtualPath}; - use std::time::SystemTime; - - fn make_file_meta(id: i64, path: &str, size: u64) -> FileMeta { - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(format!("/test/{}", path)), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from(path), - }, - size, - mtime: SystemTime::UNIX_EPOCH, - content_hash: None, - audio: None, - } - } - - fn make_scanned_file(path: &str, size: u64) -> ScannedFile { - ScannedFile { - path: PathBuf::from(path), - origin_id: OriginId::from("test"), - size, - mtime: SystemTime::UNIX_EPOCH, - } - } - - #[test] - fn test_is_modified_size_change() { - let detector = DeltaDetector::new(); - - let cached = make_file_meta(1, "test.flac", 1000); - let scanned = make_scanned_file("test.flac", 2000); - - assert!(detector.is_modified_scan(&cached, &scanned)); - } - - #[test] - fn test_is_modified_same() { - let detector = DeltaDetector::new(); - - let cached = make_file_meta(1, "test.flac", 1000); - let scanned = make_scanned_file("test.flac", 1000); - - assert!(!detector.is_modified_scan(&cached, &scanned)); - } - - #[test] - fn test_is_audio_file() { - assert!(DeltaDetector::is_audio_file("track.flac")); - assert!(DeltaDetector::is_audio_file("song.MP3")); - assert!(DeltaDetector::is_audio_file("audio.ogg")); - assert!(!DeltaDetector::is_audio_file("readme.txt")); - assert!(!DeltaDetector::is_audio_file("cover.jpg")); - } - - #[test] - fn test_compute_diff() { - let detector = DeltaDetector::new(); - - let old_chunks = vec![ - ManifestChunk { - hash: ChunkHash::from_bytes(b"A"), - offset: 0, - size: 256, - }, - ManifestChunk { - hash: ChunkHash::from_bytes(b"B"), - offset: 256, - size: 256, - }, - ManifestChunk { - hash: ChunkHash::from_bytes(b"C"), - offset: 512, - size: 256, - }, - ]; - - let new_chunks = vec![ - ManifestChunk { - hash: ChunkHash::from_bytes(b"A"), - offset: 0, - size: 256, - }, - ManifestChunk { - hash: ChunkHash::from_bytes(b"D"), - offset: 256, - size: 256, - }, - ManifestChunk { - hash: ChunkHash::from_bytes(b"C"), - offset: 512, - size: 256, - }, - ]; - - let diff = detector.compute_diff(&old_chunks, &new_chunks); - - assert_eq!(diff.reuse.len(), 2); - assert_eq!(diff.fetch.len(), 1); - assert_eq!(diff.orphaned.len(), 1); - } -} diff --git a/crates/musicfs-sync/src/lib.rs b/crates/musicfs-sync/src/lib.rs deleted file mode 100644 index 0cf3d40..0000000 --- a/crates/musicfs-sync/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod cdc; -pub mod delta; -pub mod watcher; - -pub use cdc::{CdcChunker, Chunk, ChunkRef}; -pub use delta::{ChangeSet, DeltaDetector, DeltaError, ManifestChunk, ManifestDiff}; -pub use watcher::{OriginWatcher, WatchError, WatchHandle}; diff --git a/crates/musicfs-sync/src/watcher.rs b/crates/musicfs-sync/src/watcher.rs deleted file mode 100644 index a1d476d..0000000 --- a/crates/musicfs-sync/src/watcher.rs +++ /dev/null @@ -1,218 +0,0 @@ -use musicfs_core::{Event, EventBus, OriginId, VirtualPath}; -use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::mpsc; -use tracing::{error, info, info_span, trace, Instrument}; - -const DEBOUNCE_MS: u64 = 200; - -pub struct OriginWatcher { - origin_id: OriginId, - root: PathBuf, - event_bus: Arc, -} - -impl OriginWatcher { - pub fn new(origin_id: OriginId, root: PathBuf, event_bus: Arc) -> Self { - Self { - origin_id, - root, - event_bus, - } - } - - pub fn start(self) -> WatchHandle { - let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1); - - let origin_id = self.origin_id.clone(); - let root = self.root.clone(); - let event_bus = self.event_bus.clone(); - - let origin_id_str = origin_id.to_string(); - tokio::spawn( - async move { - if let Err(e) = Self::watch_loop(&origin_id, &root, &event_bus, &mut stop_rx).await - { - error!("Watcher error: {}", e); - } - } - .instrument(info_span!("file_watcher", origin_id = %origin_id_str)), - ); - - WatchHandle { stop_tx } - } - - async fn watch_loop( - origin_id: &OriginId, - root: &Path, - event_bus: &EventBus, - stop_rx: &mut mpsc::Receiver<()>, - ) -> Result<(), WatchError> { - let (tx, mut rx) = mpsc::channel(100); - - let mut watcher = RecommendedWatcher::new( - move |res: Result| { - if let Ok(event) = res { - let _ = tx.blocking_send(event); - } - }, - Config::default(), - ) - .map_err(|e| WatchError::Init(e.to_string()))?; - - watcher - .watch(root, RecursiveMode::Recursive) - .map_err(|e| WatchError::Watch(e.to_string()))?; - - info!(origin_id = %origin_id, path = ?root, "Watcher started"); - - let mut debouncer: HashMap = HashMap::new(); - - loop { - tokio::select! { - Some(event) = rx.recv() => { - Self::handle_notify_event(origin_id, root, event_bus, event, &mut debouncer); - } - _ = stop_rx.recv() => { - info!(origin_id = %origin_id, "Watcher stopped"); - break; - } - } - } - - Ok(()) - } - - fn handle_notify_event( - origin_id: &OriginId, - root: &Path, - event_bus: &EventBus, - event: notify::Event, - debouncer: &mut HashMap, - ) { - use notify::EventKind; - - let now = Instant::now(); - - for path in event.paths { - let relative = match path.strip_prefix(root) { - Ok(p) => p.to_path_buf(), - Err(_) => continue, - }; - - if !Self::is_audio_file(&path) { - continue; - } - - if let Some(last_seen) = debouncer.get(&relative) { - if now.duration_since(*last_seen).as_millis() < DEBOUNCE_MS as u128 { - trace!(origin_id = %origin_id, path = ?relative, "Debouncing event"); - continue; - } - } - debouncer.insert(relative.clone(), now); - - let vpath = VirtualPath::new(format!("/{}", relative.display())); - - match event.kind { - EventKind::Create(_) => { - trace!(origin_id = %origin_id, path = ?relative, "File created"); - event_bus.publish(Event::FileAdded { - path: vpath, - origin_id: origin_id.clone(), - }); - } - EventKind::Remove(_) => { - trace!(origin_id = %origin_id, path = ?relative, "File removed"); - event_bus.publish(Event::FileRemoved { - path: vpath, - file_id: None, - }); - } - EventKind::Modify(_) => { - trace!(origin_id = %origin_id, path = ?relative, "File modified"); - event_bus.publish(Event::FileModified { path: vpath }); - } - _ => {} - } - } - } - - fn is_audio_file(path: &Path) -> bool { - matches!( - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .as_deref(), - Some("flac" | "mp3" | "ogg" | "wav" | "m4a" | "aac" | "opus") - ) - } -} - -pub struct WatchHandle { - stop_tx: mpsc::Sender<()>, -} - -impl WatchHandle { - pub async fn stop(self) { - let _ = self.stop_tx.send(()).await; - } -} - -impl Drop for WatchHandle { - fn drop(&mut self) { - trace!("WatchHandle dropped"); - let _ = self.stop_tx.try_send(()); - } -} - -#[derive(Debug, thiserror::Error)] -pub enum WatchError { - #[error("Failed to initialize watcher: {0}")] - Init(String), - - #[error("Failed to watch path: {0}")] - Watch(String), -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - use tempfile::TempDir; - - #[tokio::test] - async fn test_watcher_detects_create() { - let dir = TempDir::new().unwrap(); - let event_bus = Arc::new(EventBus::default()); - let mut rx = event_bus.subscribe(); - - let watcher = - OriginWatcher::new(OriginId::from("test"), dir.path().to_path_buf(), event_bus); - let handle = watcher.start(); - - tokio::time::sleep(Duration::from_millis(100)).await; - - std::fs::write(dir.path().join("test.flac"), b"audio").unwrap(); - - tokio::time::sleep(Duration::from_millis(300)).await; - - let event = rx.try_recv(); - assert!(matches!(event, Ok(Event::FileAdded { .. }))); - - handle.stop().await; - } - - #[test] - fn test_is_audio_file() { - assert!(OriginWatcher::is_audio_file(Path::new("/music/song.flac"))); - assert!(OriginWatcher::is_audio_file(Path::new("/music/song.MP3"))); - assert!(!OriginWatcher::is_audio_file(Path::new("/music/cover.jpg"))); - assert!(!OriginWatcher::is_audio_file(Path::new( - "/music/readme.txt" - ))); - } -} diff --git a/crates/musicfs-test-utils/Cargo.toml b/crates/musicfs-test-utils/Cargo.toml deleted file mode 100644 index 5354848..0000000 --- a/crates/musicfs-test-utils/Cargo.toml +++ /dev/null @@ -1,43 +0,0 @@ -[package] -name = "musicfs-test-utils" -version.workspace = true -edition.workspace = true -description = "Test utilities and fixtures for MusicFS resilience testing" - -[dependencies] -musicfs-core = { path = "../musicfs-core" } -musicfs-origins = { path = "../musicfs-origins" } -musicfs-cas = { path = "../musicfs-cas" } -musicfs-cache = { path = "../musicfs-cache" } -musicfs-search = { path = "../musicfs-search" } - -async-trait.workspace = true -tokio = { workspace = true, features = ["full", "sync", "time"] } -tracing.workspace = true -thiserror.workspace = true -parking_lot.workspace = true -tempfile.workspace = true -bytes.workspace = true - -# Fault injection -fail = { version = "0.5", optional = true } -rlimit = { version = "0.10", optional = true } -nix = { version = "0.29", optional = true, features = ["signal", "process"] } - -# Docker/network tests -noxious-client = { version = "1.0", optional = true } -reqwest = { version = "0.11", optional = true, default-features = false, features = ["rustls-tls"] } - -[features] -default = [] -failpoints = ["fail/failpoints"] -process-tests = ["nix"] -resource-limits = ["rlimit"] -docker-tests = ["noxious-client", "reqwest"] -full = ["failpoints", "process-tests", "resource-limits", "docker-tests"] - -[dev-dependencies] -tokio-test = "0.4" -tokio-util.workspace = true -sd-notify.workspace = true -libc.workspace = true diff --git a/crates/musicfs-test-utils/src/assertions.rs b/crates/musicfs-test-utils/src/assertions.rs deleted file mode 100644 index e2c1e74..0000000 --- a/crates/musicfs-test-utils/src/assertions.rs +++ /dev/null @@ -1,204 +0,0 @@ -use musicfs_cas::CasError; -use musicfs_core::Error; -use std::time::{Duration, Instant}; - -pub fn assert_error_contains(result: Result, expected_text: &str) { - match result { - Ok(_) => panic!("Expected error containing '{}', but got Ok", expected_text), - Err(e) => { - let error_msg = format!("{:?}", e); - assert!( - error_msg.contains(expected_text), - "Expected error containing '{}', but got: {}", - expected_text, - error_msg - ); - } - } -} - -pub fn assert_io_error(result: Result) { - match result { - Err(Error::Io(_)) => (), - Err(e) => panic!("Expected Io error, got: {:?}", e), - Ok(_) => panic!("Expected Io error, got Ok"), - } -} - -pub fn assert_cas_io_error(result: Result) { - match result { - Err(CasError::Io(_)) => (), - Err(e) => panic!("Expected CasError::Io, got: {:?}", e), - Ok(_) => panic!("Expected CasError::Io, got Ok"), - } -} - -pub fn assert_cas_not_found(result: Result) { - match result { - Err(CasError::NotFound(_)) => (), - Err(e) => panic!("Expected CasError::NotFound, got: {:?}", e), - Ok(_) => panic!("Expected CasError::NotFound, got Ok"), - } -} - -pub fn assert_cas_integrity_error(result: Result) { - match result { - Err(CasError::IntegrityError { .. }) => (), - Err(e) => panic!("Expected CasError::IntegrityError, got: {:?}", e), - Ok(_) => panic!("Expected CasError::IntegrityError, got Ok"), - } -} - -pub fn assert_file_not_found(result: Result) { - match result { - Err(Error::FileNotFound(_)) => (), - Err(e) => panic!("Expected FileNotFound error, got: {:?}", e), - Ok(_) => panic!("Expected FileNotFound error, got Ok"), - } -} - -pub fn assert_origin_error(result: Result) { - match result { - Err(Error::Origin(_)) => (), - Err(e) => panic!("Expected Origin error, got: {:?}", e), - Ok(_) => panic!("Expected Origin error, got Ok"), - } -} - -pub fn assert_timeout_error(result: Result) { - match result { - Err(Error::Timeout(_)) => (), - Err(e) => panic!("Expected Timeout error, got: {:?}", e), - Ok(_) => panic!("Expected Timeout error, got Ok"), - } -} - -pub struct TimedAssertion { - start: Instant, - min_duration: Option, - max_duration: Option, -} - -impl TimedAssertion { - pub fn new() -> Self { - Self { - start: Instant::now(), - min_duration: None, - max_duration: None, - } - } - - pub fn expect_at_least(mut self, duration: Duration) -> Self { - self.min_duration = Some(duration); - self - } - - pub fn expect_at_most(mut self, duration: Duration) -> Self { - self.max_duration = Some(duration); - self - } - - pub fn assert_elapsed(self) { - let elapsed = self.start.elapsed(); - - if let Some(min) = self.min_duration { - assert!( - elapsed >= min, - "Expected at least {:?}, but only {:?} elapsed", - min, - elapsed - ); - } - - if let Some(max) = self.max_duration { - assert!( - elapsed <= max, - "Expected at most {:?}, but {:?} elapsed", - max, - elapsed - ); - } - } -} - -impl Default for TimedAssertion { - fn default() -> Self { - Self::new() - } -} - -pub async fn assert_completes_within(future: F, timeout: Duration) -> T -where - F: std::future::Future, -{ - tokio::time::timeout(timeout, future) - .await - .expect(&format!("Operation did not complete within {:?}", timeout)) -} - -pub async fn assert_times_out(future: F, timeout: Duration) -where - F: std::future::Future, -{ - match tokio::time::timeout(timeout, future).await { - Ok(_) => panic!("Expected operation to time out, but it completed"), - Err(_) => (), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_assert_error_contains() { - let result: Result<(), Error> = Err(Error::Origin("connection refused".into())); - assert_error_contains(result, "connection"); - } - - #[test] - #[should_panic(expected = "Expected error containing")] - fn test_assert_error_contains_failure() { - let result: Result<(), Error> = Err(Error::Origin("something else".into())); - assert_error_contains(result, "connection"); - } - - #[test] - fn test_assert_io_error() { - let result: Result<(), Error> = Err(Error::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "test", - ))); - assert_io_error(result); - } - - #[test] - fn test_timed_assertion_at_least() { - let timer = TimedAssertion::new().expect_at_least(Duration::from_millis(10)); - std::thread::sleep(Duration::from_millis(15)); - timer.assert_elapsed(); - } - - #[test] - fn test_timed_assertion_at_most() { - let timer = TimedAssertion::new().expect_at_most(Duration::from_millis(100)); - timer.assert_elapsed(); - } - - #[tokio::test] - async fn test_assert_completes_within() { - let result = assert_completes_within(async { 42 }, Duration::from_millis(100)).await; - assert_eq!(result, 42); - } - - #[tokio::test] - async fn test_assert_times_out() { - assert_times_out( - async { - tokio::time::sleep(Duration::from_secs(10)).await; - }, - Duration::from_millis(10), - ) - .await; - } -} diff --git a/crates/musicfs-test-utils/src/faulty_cas.rs b/crates/musicfs-test-utils/src/faulty_cas.rs deleted file mode 100644 index 4a5b1e4..0000000 --- a/crates/musicfs-test-utils/src/faulty_cas.rs +++ /dev/null @@ -1,250 +0,0 @@ -use bytes::Bytes; -use musicfs_cas::{CasConfig, CasError, CasStore, DedupStats}; -use musicfs_core::ChunkHash; -use std::io::{self, ErrorKind}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::Arc; - -pub struct FaultyCasStore { - inner: Arc, - inject_enospc: AtomicBool, - inject_eio_on_read: AtomicBool, - inject_eio_on_write: AtomicBool, - inject_corruption: AtomicBool, - fail_after_n_puts: AtomicUsize, - put_count: AtomicUsize, -} - -impl FaultyCasStore { - pub fn new(inner: Arc) -> Self { - Self { - inner, - inject_enospc: AtomicBool::new(false), - inject_eio_on_read: AtomicBool::new(false), - inject_eio_on_write: AtomicBool::new(false), - inject_corruption: AtomicBool::new(false), - fail_after_n_puts: AtomicUsize::new(usize::MAX), - put_count: AtomicUsize::new(0), - } - } - - pub async fn open(config: CasConfig) -> Result { - let store = CasStore::open(config).await?; - Ok(Self::new(Arc::new(store))) - } - - pub fn set_inject_enospc(&self, enabled: bool) { - self.inject_enospc.store(enabled, Ordering::SeqCst); - } - - pub fn set_inject_eio_on_read(&self, enabled: bool) { - self.inject_eio_on_read.store(enabled, Ordering::SeqCst); - } - - pub fn set_inject_eio_on_write(&self, enabled: bool) { - self.inject_eio_on_write.store(enabled, Ordering::SeqCst); - } - - pub fn set_inject_corruption(&self, enabled: bool) { - self.inject_corruption.store(enabled, Ordering::SeqCst); - } - - pub fn set_fail_after_n_puts(&self, n: usize) { - self.fail_after_n_puts.store(n, Ordering::SeqCst); - self.put_count.store(0, Ordering::SeqCst); - } - - pub fn reset_faults(&self) { - self.inject_enospc.store(false, Ordering::SeqCst); - self.inject_eio_on_read.store(false, Ordering::SeqCst); - self.inject_eio_on_write.store(false, Ordering::SeqCst); - self.inject_corruption.store(false, Ordering::SeqCst); - self.fail_after_n_puts.store(usize::MAX, Ordering::SeqCst); - self.put_count.store(0, Ordering::SeqCst); - } - - pub fn put_count(&self) -> usize { - self.put_count.load(Ordering::SeqCst) - } - - pub async fn put(&self, data: &[u8]) -> Result { - let count = self.put_count.fetch_add(1, Ordering::SeqCst); - - if self.inject_enospc.load(Ordering::SeqCst) { - return Err(CasError::Io(io::Error::new( - ErrorKind::Other, - "No space left on device (ENOSPC injected)", - ))); - } - - if self.inject_eio_on_write.load(Ordering::SeqCst) { - return Err(CasError::Io(io::Error::new( - ErrorKind::Other, - "Input/output error (EIO injected)", - ))); - } - - let threshold = self.fail_after_n_puts.load(Ordering::SeqCst); - if count >= threshold { - return Err(CasError::Io(io::Error::new( - ErrorKind::Other, - "Injected failure after N puts", - ))); - } - - self.inner.put(data).await - } - - pub async fn get(&self, hash: &ChunkHash) -> Result { - if self.inject_eio_on_read.load(Ordering::SeqCst) { - return Err(CasError::Io(io::Error::new( - ErrorKind::Other, - "Input/output error (EIO injected)", - ))); - } - - let data = self.inner.get(hash).await?; - - if self.inject_corruption.load(Ordering::SeqCst) { - let mut corrupted = data.to_vec(); - if !corrupted.is_empty() { - corrupted[0] = corrupted[0].wrapping_add(1); - } - return Err(CasError::IntegrityError { - expected: hash.as_hex(), - actual: ChunkHash::from_bytes(&corrupted).as_hex(), - }); - } - - Ok(data) - } - - pub fn exists(&self, hash: &ChunkHash) -> bool { - self.inner.exists(hash) - } - - pub async fn delete(&self, hash: &ChunkHash) -> Result<(), CasError> { - if self.inject_eio_on_write.load(Ordering::SeqCst) { - return Err(CasError::Io(io::Error::new( - ErrorKind::Other, - "Input/output error (EIO injected)", - ))); - } - self.inner.delete(hash).await - } - - pub fn current_size(&self) -> u64 { - self.inner.current_size() - } - - pub fn max_size(&self) -> u64 { - self.inner.max_size() - } - - pub fn list_chunks(&self) -> impl Iterator + '_ { - self.inner.list_chunks() - } - - pub fn dedup_stats(&self) -> DedupStats { - self.inner.dedup_stats() - } - - pub fn inner(&self) -> &Arc { - &self.inner - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - async fn test_store() -> (FaultyCasStore, TempDir) { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - max_size: 1024 * 1024, - shard_levels: 2, - }; - let store = FaultyCasStore::open(config).await.unwrap(); - (store, dir) - } - - #[tokio::test] - async fn test_healthy_passthrough() { - let (store, _dir) = test_store().await; - - let data = b"test data"; - let hash = store.put(data).await.unwrap(); - let retrieved = store.get(&hash).await.unwrap(); - assert_eq!(&retrieved[..], data); - } - - #[tokio::test] - async fn test_inject_enospc() { - let (store, _dir) = test_store().await; - - store.set_inject_enospc(true); - let result = store.put(b"test").await; - assert!(result.is_err()); - - let err = result.unwrap_err(); - assert!(matches!(err, CasError::Io(_))); - - store.set_inject_enospc(false); - assert!(store.put(b"test").await.is_ok()); - } - - #[tokio::test] - async fn test_inject_eio_on_read() { - let (store, _dir) = test_store().await; - - let hash = store.put(b"test").await.unwrap(); - - store.set_inject_eio_on_read(true); - let result = store.get(&hash).await; - assert!(result.is_err()); - - store.set_inject_eio_on_read(false); - assert!(store.get(&hash).await.is_ok()); - } - - #[tokio::test] - async fn test_inject_corruption() { - let (store, _dir) = test_store().await; - - let hash = store.put(b"test data").await.unwrap(); - - store.set_inject_corruption(true); - let result = store.get(&hash).await; - assert!(matches!(result, Err(CasError::IntegrityError { .. }))); - } - - #[tokio::test] - async fn test_fail_after_n_puts() { - let (store, _dir) = test_store().await; - - store.set_fail_after_n_puts(2); - - assert!(store.put(b"data1").await.is_ok()); - assert!(store.put(b"data2").await.is_ok()); - assert!(store.put(b"data3").await.is_err()); - assert!(store.put(b"data4").await.is_err()); - assert_eq!(store.put_count(), 4); - } - - #[tokio::test] - async fn test_reset_faults() { - let (store, _dir) = test_store().await; - - store.set_inject_enospc(true); - store.set_inject_eio_on_read(true); - store.set_fail_after_n_puts(1); - - store.reset_faults(); - - assert!(store.put(b"test").await.is_ok()); - let hash = store.put(b"test2").await.unwrap(); - assert!(store.get(&hash).await.is_ok()); - } -} diff --git a/crates/musicfs-test-utils/src/faulty_origin.rs b/crates/musicfs-test-utils/src/faulty_origin.rs deleted file mode 100644 index 723108c..0000000 --- a/crates/musicfs-test-utils/src/faulty_origin.rs +++ /dev/null @@ -1,328 +0,0 @@ -use async_trait::async_trait; -use musicfs_core::{DirEntry, Error, FileStat, HealthStatus, OriginId, OriginType, Result}; -use musicfs_origins::{Origin, WatchCallback, WatchHandle}; -use parking_lot::RwLock; -use std::io::{self, ErrorKind}; -use std::path::Path; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tokio::io::AsyncRead; - -#[derive(Debug, Clone)] -pub enum FailMode { - Healthy, - FailEveryNth(usize), - FailAfterN(usize), - TimeoutMs(u64), - PartialRead { max_bytes: usize }, - ReturnError(ErrorKind), -} - -impl Default for FailMode { - fn default() -> Self { - FailMode::Healthy - } -} - -pub struct FaultyOrigin { - inner: Arc, - fail_mode: Arc>, - call_count: AtomicUsize, -} - -impl FaultyOrigin { - pub fn new(inner: Arc, mode: FailMode) -> Self { - Self { - inner, - fail_mode: Arc::new(RwLock::new(mode)), - call_count: AtomicUsize::new(0), - } - } - - pub fn wrap(inner: impl Origin + 'static) -> Self { - Self::new(Arc::new(inner), FailMode::Healthy) - } - - pub fn set_mode(&self, mode: FailMode) { - *self.fail_mode.write() = mode; - } - - pub fn call_count(&self) -> usize { - self.call_count.load(Ordering::SeqCst) - } - - pub fn reset_count(&self) { - self.call_count.store(0, Ordering::SeqCst); - } - - fn increment_and_check(&self) -> Option { - let count = self.call_count.fetch_add(1, Ordering::SeqCst) + 1; - let mode = self.fail_mode.read(); - - match *mode { - FailMode::Healthy => None, - FailMode::FailEveryNth(n) if n > 0 && count % n == 0 => { - Some(Error::Origin("Injected failure (every Nth)".into())) - } - FailMode::FailEveryNth(_) => None, - FailMode::FailAfterN(n) if count > n => { - Some(Error::Origin("Injected failure (after N)".into())) - } - FailMode::FailAfterN(_) => None, - FailMode::TimeoutMs(_) => None, - FailMode::PartialRead { .. } => None, - FailMode::ReturnError(kind) => { - Some(Error::Io(io::Error::new(kind, "Injected I/O error"))) - } - } - } - - async fn maybe_timeout(&self) -> Option { - let mode = self.fail_mode.read().clone(); - if let FailMode::TimeoutMs(ms) = mode { - tokio::time::sleep(Duration::from_millis(ms)).await; - Some(Error::Timeout("Injected timeout".into())) - } else { - None - } - } - - fn truncate_if_partial(&self, mut data: Vec) -> Vec { - let mode = self.fail_mode.read(); - if let FailMode::PartialRead { max_bytes } = *mode { - data.truncate(max_bytes); - } - data - } -} - -#[async_trait] -impl Origin for FaultyOrigin { - fn id(&self) -> &OriginId { - self.inner.id() - } - - fn origin_type(&self) -> OriginType { - self.inner.origin_type() - } - - fn display_name(&self) -> &str { - self.inner.display_name() - } - - async fn readdir(&self, path: &Path) -> Result> { - if let Some(err) = self.increment_and_check() { - return Err(err); - } - if let Some(err) = self.maybe_timeout().await { - return Err(err); - } - self.inner.readdir(path).await - } - - async fn stat(&self, path: &Path) -> Result { - if let Some(err) = self.increment_and_check() { - return Err(err); - } - if let Some(err) = self.maybe_timeout().await { - return Err(err); - } - self.inner.stat(path).await - } - - async fn read(&self, path: &Path, offset: u64, size: u32) -> Result> { - if let Some(err) = self.increment_and_check() { - return Err(err); - } - if let Some(err) = self.maybe_timeout().await { - return Err(err); - } - let data = self.inner.read(path, offset, size).await?; - Ok(self.truncate_if_partial(data)) - } - - async fn read_full(&self, path: &Path) -> Result> { - if let Some(err) = self.increment_and_check() { - return Err(err); - } - if let Some(err) = self.maybe_timeout().await { - return Err(err); - } - let data = self.inner.read_full(path).await?; - Ok(self.truncate_if_partial(data)) - } - - async fn exists(&self, path: &Path) -> Result { - if let Some(err) = self.increment_and_check() { - return Err(err); - } - if let Some(err) = self.maybe_timeout().await { - return Err(err); - } - self.inner.exists(path).await - } - - async fn health(&self) -> HealthStatus { - let mode = self.fail_mode.read().clone(); - match mode { - FailMode::Healthy => self.inner.health().await, - FailMode::ReturnError(_) => HealthStatus::Unhealthy, - FailMode::TimeoutMs(ms) => { - tokio::time::sleep(Duration::from_millis(ms)).await; - HealthStatus::Unhealthy - } - FailMode::FailAfterN(n) if self.call_count.load(Ordering::SeqCst) >= n => { - HealthStatus::Unhealthy - } - _ => self.inner.health().await, - } - } - - async fn open_read(&self, path: &Path) -> Result> { - if let Some(err) = self.increment_and_check() { - return Err(err); - } - if let Some(err) = self.maybe_timeout().await { - return Err(err); - } - self.inner.open_read(path).await - } - - async fn watch(&self, path: &Path, callback: WatchCallback) -> Result { - self.inner.watch(path, callback).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::SystemTime; - - struct MockOrigin { - id: OriginId, - } - - impl MockOrigin { - fn new(id: &str) -> Self { - Self { - id: OriginId::from(id), - } - } - } - - #[async_trait] - impl Origin for MockOrigin { - fn id(&self) -> &OriginId { - &self.id - } - - fn origin_type(&self) -> OriginType { - OriginType::Local - } - - fn display_name(&self) -> &str { - "mock" - } - - async fn readdir(&self, _path: &Path) -> Result> { - Ok(vec![]) - } - - async fn stat(&self, _path: &Path) -> Result { - Ok(FileStat { - size: 1000, - mtime: SystemTime::now(), - is_dir: false, - }) - } - - async fn read(&self, _path: &Path, _offset: u64, size: u32) -> Result> { - Ok(vec![0u8; size as usize]) - } - - async fn read_full(&self, _path: &Path) -> Result> { - Ok(vec![0u8; 100]) - } - - async fn exists(&self, _path: &Path) -> Result { - Ok(true) - } - - async fn health(&self) -> HealthStatus { - HealthStatus::Healthy - } - - async fn open_read(&self, _path: &Path) -> Result> { - Err(Error::Origin("Not implemented".into())) - } - - async fn watch(&self, _path: &Path, _callback: WatchCallback) -> Result { - Err(Error::Origin("Not implemented".into())) - } - } - - #[tokio::test] - async fn test_healthy_passthrough() { - let inner = Arc::new(MockOrigin::new("test")); - let faulty = FaultyOrigin::new(inner, FailMode::Healthy); - - let result = faulty.stat(Path::new("/test")).await; - assert!(result.is_ok()); - assert_eq!(faulty.call_count(), 1); - } - - #[tokio::test] - async fn test_fail_every_nth() { - let inner = Arc::new(MockOrigin::new("test")); - let faulty = FaultyOrigin::new(inner, FailMode::FailEveryNth(2)); - - assert!(faulty.stat(Path::new("/test")).await.is_ok()); - assert!(faulty.stat(Path::new("/test")).await.is_err()); - assert!(faulty.stat(Path::new("/test")).await.is_ok()); - assert!(faulty.stat(Path::new("/test")).await.is_err()); - assert_eq!(faulty.call_count(), 4); - } - - #[tokio::test] - async fn test_fail_after_n() { - let inner = Arc::new(MockOrigin::new("test")); - let faulty = FaultyOrigin::new(inner, FailMode::FailAfterN(2)); - - assert!(faulty.stat(Path::new("/test")).await.is_ok()); - assert!(faulty.stat(Path::new("/test")).await.is_ok()); - assert!(faulty.stat(Path::new("/test")).await.is_err()); - assert!(faulty.stat(Path::new("/test")).await.is_err()); - } - - #[tokio::test] - async fn test_partial_read() { - let inner = Arc::new(MockOrigin::new("test")); - let faulty = FaultyOrigin::new(inner, FailMode::PartialRead { max_bytes: 10 }); - - let data = faulty.read(Path::new("/test"), 0, 100).await.unwrap(); - assert_eq!(data.len(), 10); - } - - #[tokio::test] - async fn test_mode_change_mid_test() { - let inner = Arc::new(MockOrigin::new("test")); - let faulty = FaultyOrigin::new(inner, FailMode::ReturnError(ErrorKind::ConnectionRefused)); - - assert!(faulty.stat(Path::new("/test")).await.is_err()); - - faulty.set_mode(FailMode::Healthy); - assert!(faulty.stat(Path::new("/test")).await.is_ok()); - } - - #[tokio::test] - async fn test_health_reflects_mode() { - let inner = Arc::new(MockOrigin::new("test")); - let faulty = FaultyOrigin::new(inner, FailMode::Healthy); - - assert_eq!(faulty.health().await, HealthStatus::Healthy); - - faulty.set_mode(FailMode::ReturnError(ErrorKind::ConnectionRefused)); - assert_eq!(faulty.health().await, HealthStatus::Unhealthy); - } -} diff --git a/crates/musicfs-test-utils/src/fixtures.rs b/crates/musicfs-test-utils/src/fixtures.rs deleted file mode 100644 index c5870c5..0000000 --- a/crates/musicfs-test-utils/src/fixtures.rs +++ /dev/null @@ -1,254 +0,0 @@ -use musicfs_cache::TreeBuilder; -use musicfs_cas::{CasConfig, CasStore}; -use musicfs_core::{AudioFormat, AudioMeta, FileId, FileMeta, OriginId, RealPath, VirtualPath}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; -use std::time::SystemTime; -use tempfile::TempDir; - -pub fn make_file_meta(id: i64, vpath: &str, size: u64) -> FileMeta { - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(vpath), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from(vpath), - }, - size, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - } -} - -pub fn make_file_meta_with_origin(id: i64, vpath: &str, size: u64, origin_id: &str) -> FileMeta { - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(vpath), - real_path: RealPath { - origin_id: OriginId::from(origin_id), - path: PathBuf::from(vpath), - }, - size, - mtime: SystemTime::now(), - content_hash: None, - audio: None, - } -} - -pub fn make_audio_meta(artist: &str, album: &str, title: &str) -> AudioMeta { - AudioMeta { - title: Some(title.to_string()), - artist: Some(artist.to_string()), - album: Some(album.to_string()), - album_artist: None, - genre: None, - year: None, - track: None, - disc: None, - duration_ms: Some(180_000), - bitrate: Some(320), - sample_rate: Some(44100), - format: AudioFormat::Flac, - ..Default::default() - } -} - -pub fn make_audio_file( - id: i64, - vpath: &str, - size: u64, - artist: &str, - album: &str, - title: &str, -) -> FileMeta { - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(vpath), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from(vpath), - }, - size, - mtime: SystemTime::now(), - content_hash: None, - audio: Some(make_audio_meta(artist, album, title)), - } -} - -pub fn make_audio_file_full( - id: i64, - vpath: &str, - size: u64, - artist: &str, - album: &str, - title: &str, - track: u32, - year: u32, -) -> FileMeta { - let mut audio = make_audio_meta(artist, album, title); - audio.track = Some(track); - audio.year = Some(year); - - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(vpath), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from(vpath), - }, - size, - mtime: SystemTime::now(), - content_hash: None, - audio: Some(audio), - } -} - -pub struct TestCasStore { - pub store: Arc, - pub dir: TempDir, -} - -pub async fn setup_test_cas() -> TestCasStore { - let dir = TempDir::new().expect("Failed to create temp dir for CAS"); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - max_size: 100 * 1024 * 1024, - shard_levels: 2, - }; - let store = CasStore::open(config) - .await - .expect("Failed to open CAS store"); - TestCasStore { - store: Arc::new(store), - dir, - } -} - -pub async fn setup_test_cas_with_size(max_size: u64) -> TestCasStore { - let dir = TempDir::new().expect("Failed to create temp dir for CAS"); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - max_size, - shard_levels: 2, - }; - let store = CasStore::open(config) - .await - .expect("Failed to open CAS store"); - TestCasStore { - store: Arc::new(store), - dir, - } -} - -pub fn setup_test_tree(files: &[FileMeta]) -> Arc> { - let mut builder = TreeBuilder::new(); - for file in files { - builder.add_file(file); - } - Arc::new(RwLock::new(builder.build())) -} - -pub fn create_test_file(dir: &Path, relative_path: &str, content: &[u8]) -> PathBuf { - let full_path = dir.join(relative_path); - if let Some(parent) = full_path.parent() { - std::fs::create_dir_all(parent).expect("Failed to create parent directories"); - } - std::fs::write(&full_path, content).expect("Failed to write test file"); - full_path -} - -pub fn create_test_dir_structure(base: &Path, structure: &[&str]) { - for path in structure { - let full_path = base.join(path); - if path.ends_with('/') { - std::fs::create_dir_all(&full_path).expect("Failed to create directory"); - } else { - if let Some(parent) = full_path.parent() { - std::fs::create_dir_all(parent).expect("Failed to create parent"); - } - std::fs::write(&full_path, format!("content of {}", path)) - .expect("Failed to write file"); - } - } -} - -pub struct TestOriginDir { - pub dir: TempDir, -} - -impl TestOriginDir { - pub fn new() -> Self { - Self { - dir: TempDir::new().expect("Failed to create origin temp dir"), - } - } - - pub fn add_file(&self, path: &str, content: &[u8]) -> PathBuf { - create_test_file(self.dir.path(), path, content) - } - - pub fn add_audio_file(&self, path: &str) -> PathBuf { - let fake_audio = b"FAKE_FLAC_HEADER_FOR_TESTING_ONLY"; - self.add_file(path, fake_audio) - } - - pub fn path(&self) -> &Path { - self.dir.path() - } -} - -impl Default for TestOriginDir { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_make_file_meta() { - let meta = make_file_meta(1, "/Artist/Album/Track.flac", 1000); - assert_eq!(meta.id.0, 1); - assert_eq!(meta.virtual_path.as_str(), "/Artist/Album/Track.flac"); - assert_eq!(meta.size, 1000); - assert!(meta.audio.is_none()); - } - - #[test] - fn test_make_audio_file() { - let meta = make_audio_file(1, "/path.flac", 5000, "Artist", "Album", "Title"); - assert!(meta.audio.is_some()); - let audio = meta.audio.unwrap(); - assert_eq!(audio.artist, Some("Artist".to_string())); - assert_eq!(audio.album, Some("Album".to_string())); - assert_eq!(audio.title, Some("Title".to_string())); - } - - #[tokio::test] - async fn test_setup_test_cas() { - let test_cas = setup_test_cas().await; - let hash = test_cas.store.put(b"test data").await.unwrap(); - assert!(test_cas.store.exists(&hash)); - } - - #[test] - fn test_setup_test_tree() { - let files = vec![ - make_file_meta(1, "/A/B/1.flac", 100), - make_file_meta(2, "/A/B/2.flac", 200), - ]; - let tree = setup_test_tree(&files); - let guard = tree.read().unwrap(); - assert!(guard.file_count() > 0); - } - - #[test] - fn test_origin_dir() { - let origin = TestOriginDir::new(); - let path = origin.add_file("artist/album/track.flac", b"content"); - assert!(path.exists()); - } -} diff --git a/crates/musicfs-test-utils/src/lib.rs b/crates/musicfs-test-utils/src/lib.rs deleted file mode 100644 index c59a010..0000000 --- a/crates/musicfs-test-utils/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod assertions; -pub mod faulty_cas; -pub mod faulty_origin; -pub mod fixtures; - -pub use assertions::*; -pub use faulty_cas::FaultyCasStore; -pub use faulty_origin::{FailMode, FaultyOrigin}; -pub use fixtures::*; diff --git a/crates/musicfs-test-utils/tests/docker_network.rs b/crates/musicfs-test-utils/tests/docker_network.rs deleted file mode 100644 index 6e8df32..0000000 --- a/crates/musicfs-test-utils/tests/docker_network.rs +++ /dev/null @@ -1,141 +0,0 @@ -#![cfg(feature = "docker-tests")] - -use musicfs_core::{OriginId, OriginType}; -use musicfs_origins::{HealthMonitor, LocalOrigin, OriginRegistry}; -use noxious_client::{Client, StreamDirection, Toxic, ToxicKind}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; -use tempfile::TempDir; - -const TOXIPROXY_API: &str = "http://localhost:8474"; -const TOXIPROXY_LISTEN: &str = "localhost:18080"; -const UPSTREAM_ADDR: &str = "minio:9000"; - -async fn require_toxiproxy() { - let available = match reqwest::get(format!("{}/version", TOXIPROXY_API)).await { - Ok(resp) => resp.status().is_success(), - Err(_) => false, - }; - assert!( - available, - "Toxiproxy not available at {}. Run: cd tests/integration && docker-compose up -d", - TOXIPROXY_API - ); -} - -#[tokio::test] -#[ignore = "Requires docker-compose up -d (tests/integration/docker-compose.yml)"] -async fn test_toxiproxy_latency_injection() { - require_toxiproxy().await; - - let client = Client::new(TOXIPROXY_API); - let proxy = client - .create_proxy("minio_latency", TOXIPROXY_LISTEN, UPSTREAM_ADDR) - .await - .expect("Failed to create proxy"); - - let toxic = Toxic { - name: "latency_downstream".to_string(), - kind: ToxicKind::Latency { - latency: 500, - jitter: 100, - }, - direction: StreamDirection::Downstream, - toxicity: 1.0, - }; - - proxy.add_toxic(&toxic).await.expect("Failed to add toxic"); - - let start = std::time::Instant::now(); - let _ = reqwest::get(format!("http://{}/minio/health/live", TOXIPROXY_LISTEN)).await; - let elapsed = start.elapsed(); - - assert!( - elapsed >= Duration::from_millis(400), - "Latency should be injected, got {:?}", - elapsed - ); - - proxy.delete().await.expect("Failed to cleanup proxy"); -} - -#[tokio::test] -#[ignore = "Requires docker-compose up -d (tests/integration/docker-compose.yml)"] -async fn test_toxiproxy_timeout_simulates_network_partition() { - require_toxiproxy().await; - - let client = Client::new(TOXIPROXY_API); - let proxy = client - .create_proxy("minio_partition", TOXIPROXY_LISTEN, UPSTREAM_ADDR) - .await - .expect("Failed to create proxy"); - - let result = reqwest::get(format!("http://{}/minio/health/live", TOXIPROXY_LISTEN)).await; - assert!(result.is_ok(), "Should reach MinIO through proxy initially"); - - let toxic = Toxic { - name: "timeout".to_string(), - kind: ToxicKind::Timeout { timeout: 0 }, - direction: StreamDirection::Downstream, - toxicity: 1.0, - }; - - proxy.add_toxic(&toxic).await.expect("Failed to add toxic"); - - let result = tokio::time::timeout( - Duration::from_secs(2), - reqwest::get(format!("http://{}/minio/health/live", TOXIPROXY_LISTEN)), - ) - .await; - - assert!( - result.is_err() || result.unwrap().is_err(), - "Should timeout during partition" - ); - - proxy - .remove_toxic("timeout") - .await - .expect("Failed to remove toxic"); - - tokio::time::sleep(Duration::from_millis(100)).await; - - let result = reqwest::get(format!("http://{}/minio/health/live", TOXIPROXY_LISTEN)).await; - assert!(result.is_ok(), "Should reach MinIO after partition heals"); - - proxy.delete().await.expect("Failed to cleanup proxy"); -} - -#[tokio::test] -#[ignore = "Requires docker-compose up -d (tests/integration/docker-compose.yml)"] -async fn test_toxiproxy_slow_close_throttles_responses() { - require_toxiproxy().await; - - let client = Client::new(TOXIPROXY_API); - let proxy = client - .create_proxy("minio_slow", TOXIPROXY_LISTEN, UPSTREAM_ADDR) - .await - .expect("Failed to create proxy"); - - let toxic = Toxic { - name: "slow_close".to_string(), - kind: ToxicKind::SlowClose { delay: 1000 }, - direction: StreamDirection::Downstream, - toxicity: 1.0, - }; - - proxy.add_toxic(&toxic).await.expect("Failed to add toxic"); - - let start = std::time::Instant::now(); - let _ = reqwest::get(format!("http://{}/minio/health/live", TOXIPROXY_LISTEN)).await; - let elapsed = start.elapsed(); - - assert!( - elapsed >= Duration::from_millis(800), - "Slow close should delay response, got {:?}", - elapsed - ); - - proxy.delete().await.expect("Failed to cleanup proxy"); -} diff --git a/crates/musicfs-test-utils/tests/resilience.rs b/crates/musicfs-test-utils/tests/resilience.rs deleted file mode 100644 index 610198c..0000000 --- a/crates/musicfs-test-utils/tests/resilience.rs +++ /dev/null @@ -1,838 +0,0 @@ -use musicfs_cache::{Database, VirtualTree, ROOT_INODE}; -use musicfs_cas::{CasConfig, CasStore}; -use musicfs_core::supervisor::{TaskStatus, TaskSupervisor}; -use musicfs_core::{ - AudioMeta, FileId, FileMeta, HealthStatus, OriginId, OriginType, RealPath, VirtualPath, -}; -use musicfs_origins::{HealthMonitor, LocalOrigin, OriginRegistry}; -use musicfs_search::SearchIndex; -use musicfs_test_utils::{FailMode, FaultyOrigin}; -use std::collections::HashMap; -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant, UNIX_EPOCH}; -use tempfile::TempDir; -use tokio_util::sync::CancellationToken; - -fn setup_test_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf { - let path = dir.path().join(name); - std::fs::write(&path, content).unwrap(); - path -} - -async fn setup_cas(dir: &Path) -> CasStore { - CasStore::open(CasConfig { - chunks_dir: dir.join("chunks"), - max_size: 100 * 1024 * 1024, - shard_levels: 2, - }) - .await - .unwrap() -} - -fn create_faulty_origin(id: &str, dir: &TempDir, mode: FailMode) -> Arc { - let inner = Arc::new(LocalOrigin::new( - OriginId::from(id), - dir.path().to_path_buf(), - )); - Arc::new(FaultyOrigin::new(inner, mode)) -} - -fn make_file_meta(id: i64, path: &str, size: u64) -> FileMeta { - let name = Path::new(path) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - FileMeta { - id: FileId(id), - virtual_path: VirtualPath::new(path), - real_path: RealPath { - origin_id: OriginId::from("test"), - path: PathBuf::from(path), - }, - size, - mtime: UNIX_EPOCH, - content_hash: None, - audio: Some(AudioMeta { - title: Some(name), - ..Default::default() - }), - } -} - -#[tokio::test] -async fn test_sqlite_integrity_check_detects_corruption() { - let dir = TempDir::new().unwrap(); - let db_path = dir.path().join("test.db"); - - { - let db = Database::open(&db_path).unwrap(); - db.upsert_file( - &OriginId::from("test"), - Path::new("/test.flac"), - &VirtualPath::new("/Test.flac"), - &AudioMeta::default(), - UNIX_EPOCH, - 1000, - ) - .unwrap(); - } - - let mut data = std::fs::read(&db_path).unwrap(); - let mid = data.len() / 2; - data[mid..mid + 100].fill(0xFF); - std::fs::write(&db_path, &data).unwrap(); - - let result = Database::open_with_integrity_check(&db_path); - assert!(result.is_err()); -} - -#[tokio::test] -async fn test_tantivy_corruption_triggers_rebuild() { - let dir = TempDir::new().unwrap(); - let index_path = dir.path().join("search_idx"); - - { - let index = SearchIndex::open(&index_path).unwrap(); - index - .index_file(&make_file_meta(1, "/a.flac", 1000)) - .unwrap(); - index.commit().unwrap(); - } - - std::fs::write(index_path.join("meta.json"), b"corrupted").unwrap(); - - let index = SearchIndex::open_with_recovery(&index_path).unwrap(); - let results = index.search("a", 10).unwrap(); - assert_eq!(results.len(), 0); -} - -#[tokio::test] -async fn test_sled_corruption_triggers_repair() { - let dir = TempDir::new().unwrap(); - let chunks_dir = dir.path().join("chunks"); - let config = CasConfig { - chunks_dir: chunks_dir.clone(), - max_size: 10_000_000, - shard_levels: 2, - }; - - { - let store = CasStore::open(config.clone()).await.unwrap(); - store.put(b"test data").await.unwrap(); - } - - let sled_dir = chunks_dir.join("index.sled"); - if sled_dir.exists() { - for entry in std::fs::read_dir(&sled_dir).unwrap() { - let entry = entry.unwrap(); - if entry.metadata().unwrap().is_file() { - std::fs::write(entry.path(), b"corrupted").unwrap(); - } - } - } - - let result = CasStore::open(config).await; - assert!(result.is_ok(), "sled should recover from corruption"); -} - -#[tokio::test] -async fn test_cas_put_handles_enospc() { - let dir = TempDir::new().unwrap(); - let store = CasStore::open(CasConfig { - chunks_dir: dir.path().join("chunks"), - max_size: 100, - shard_levels: 2, - }) - .await - .unwrap(); - - let large_data = vec![0u8; 1000]; - let result = store.put(&large_data).await; - - assert!( - result.is_err(), - "Issue 2.8: CasStore should pre-check space and reject oversized write" - ); -} - -/// Demonstrates the PROBLEM with std::sync::RwLock: after a writer panic, -/// the lock is poisoned and all subsequent access fails with PoisonError. -/// This is why we use parking_lot::RwLock instead (see test_parking_lot_rwlock_survives_panic). -#[test] -fn test_poisoned_tree_lock_returns_eio_not_panic() { - use std::sync::{Arc, RwLock}; - use std::thread; - - let lock = Arc::new(RwLock::new(42)); - let lock_clone = lock.clone(); - - let handle = thread::spawn(move || { - let _guard = lock_clone.write().unwrap(); - panic!("writer panic"); - }); - - let _ = handle.join(); - - let result = lock.read(); - // std::sync::RwLock poisons after writer panic - this is the problem we fix with parking_lot - assert!(result.is_err(), "Issue 2.9: std::sync::RwLock should poison after writer panic (this demonstrates the problem)"); -} - -#[test] -fn test_parking_lot_rwlock_survives_panic() { - use parking_lot::RwLock; - use std::sync::Arc; - use std::thread; - - let tree = Arc::new(RwLock::new(VirtualTree::new())); - let tree_clone = tree.clone(); - - let handle = thread::spawn(move || { - let _guard = tree_clone.write(); - panic!("writer panic"); - }); - - let _ = handle.join(); - - assert!( - tree.read().get(ROOT_INODE).is_some(), - "parking_lot RwLock should survive writer panic" - ); -} - -#[tokio::test] -async fn test_failover_on_primary_death() { - let primary_dir = TempDir::new().unwrap(); - let backup_dir = TempDir::new().unwrap(); - setup_test_file(&primary_dir, "test.txt", b"primary"); - setup_test_file(&backup_dir, "test.txt", b"backup"); - - let primary = create_faulty_origin( - "primary", - &primary_dir, - FailMode::ReturnError(ErrorKind::ConnectionRefused), - ); - let backup = create_faulty_origin("backup", &backup_dir, FailMode::Healthy); - - let mut thresholds = HashMap::new(); - thresholds.insert(OriginType::Local, 1); - let monitor = - Arc::new(HealthMonitor::new(Duration::from_secs(30)).with_per_type_thresholds(thresholds)); - let registry = Arc::new(OriginRegistry::new(monitor.clone())); - - registry.register(primary.clone(), 1); - registry.register(backup.clone(), 2); - - monitor.check_now(&OriginId::from("primary")).await; - monitor.check_now(&OriginId::from("backup")).await; - - assert!(registry.health().is_unhealthy(&OriginId::from("primary"))); - assert!(registry.health().is_healthy(&OriginId::from("backup"))); - - let path = RealPath { - origin_id: OriginId::from("backup"), - path: PathBuf::from("/test.txt"), - }; - let candidates = registry.route_all(&path); - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].id(), &OriginId::from("backup")); -} - -#[tokio::test] -async fn test_origin_recovery_resumes_routing() { - let dir = TempDir::new().unwrap(); - setup_test_file(&dir, "test.txt", b"content"); - - let faulty = create_faulty_origin( - "recovering", - &dir, - FailMode::ReturnError(ErrorKind::ConnectionRefused), - ); - - let mut thresholds = HashMap::new(); - thresholds.insert(OriginType::Local, 1); - let monitor = - Arc::new(HealthMonitor::new(Duration::from_secs(30)).with_per_type_thresholds(thresholds)); - monitor.add_origin(faulty.clone()); - - monitor.check_now(&OriginId::from("recovering")).await; - assert_eq!( - monitor - .get_state(&OriginId::from("recovering")) - .unwrap() - .status, - HealthStatus::Unhealthy - ); - - faulty.set_mode(FailMode::Healthy); - monitor.check_now(&OriginId::from("recovering")).await; - - assert_eq!( - monitor - .get_state(&OriginId::from("recovering")) - .unwrap() - .status, - HealthStatus::Healthy - ); - assert_eq!( - monitor - .get_state(&OriginId::from("recovering")) - .unwrap() - .consecutive_failures, - 0 - ); -} - -#[tokio::test] -async fn test_local_origin_health_check_has_timeout() { - let dir = TempDir::new().unwrap(); - setup_test_file(&dir, "test.txt", b"content"); - - let slow = create_faulty_origin("slow", &dir, FailMode::TimeoutMs(5_000)); - - let monitor = Arc::new(HealthMonitor::new(Duration::from_secs(30))); - monitor.add_origin(slow.clone()); - - let start = Instant::now(); - monitor.check_now(&OriginId::from("slow")).await; - let elapsed = start.elapsed(); - - assert!( - elapsed < Duration::from_secs(2), - "Issue 4.2.1: Health check should timeout in <2s, took {:?}", - elapsed - ); - - let state = monitor.get_state(&OriginId::from("slow")).unwrap(); - assert_eq!(state.status, HealthStatus::Unhealthy); -} - -#[tokio::test] -async fn test_health_checks_run_in_parallel() { - let slow1_dir = TempDir::new().unwrap(); - let slow2_dir = TempDir::new().unwrap(); - let slow3_dir = TempDir::new().unwrap(); - - let slow1 = create_faulty_origin("slow1", &slow1_dir, FailMode::TimeoutMs(200)); - let slow2 = create_faulty_origin("slow2", &slow2_dir, FailMode::TimeoutMs(200)); - let slow3 = create_faulty_origin("slow3", &slow3_dir, FailMode::TimeoutMs(200)); - - let monitor = Arc::new(HealthMonitor::new(Duration::from_secs(30))); - monitor.add_origin(slow1); - monitor.add_origin(slow2); - monitor.add_origin(slow3); - - let start = Instant::now(); - monitor.check_all().await; - let elapsed = start.elapsed(); - - assert!( - elapsed < Duration::from_millis(350), - "Issue 4.2.2: check_all() should run in parallel (sequential would take ~600ms), took {:?}", - elapsed - ); -} - -#[test] -fn test_tantivy_survives_uncommitted_crash() { - let dir = TempDir::new().unwrap(); - let index_path = dir.path().join("search_idx"); - - { - let index = SearchIndex::open(&index_path).unwrap(); - index - .index_file(&make_file_meta(1, "/a.flac", 1000)) - .unwrap(); - index.commit().unwrap(); - index - .index_file(&make_file_meta(2, "/b.flac", 1000)) - .unwrap(); - } - - let index = SearchIndex::open(&index_path).unwrap(); - let results = index.search("a", 10).unwrap(); - assert_eq!(results.len(), 1); -} - -#[tokio::test] -#[cfg(feature = "resource-limits")] -async fn test_fd_exhaustion_handling() { - use rlimit::{getrlimit, setrlimit, Resource}; - - let (orig_soft, orig_hard) = getrlimit(Resource::NOFILE).unwrap(); - - setrlimit(Resource::NOFILE, 64, 64).unwrap(); - - let dir = TempDir::new().unwrap(); - let result = CasStore::open(CasConfig { - chunks_dir: dir.path().join("chunks"), - max_size: 1_000_000, - shard_levels: 2, - }) - .await; - - match result { - Ok(_store) => {} - Err(e) => { - let msg = format!("{}", e); - assert!(!msg.contains("panic"), "Should not panic on fd exhaustion"); - } - } - - setrlimit(Resource::NOFILE, orig_soft, orig_hard).unwrap(); -} - -#[tokio::test] -#[cfg(not(feature = "resource-limits"))] -async fn test_fd_exhaustion_handling() { - eprintln!("Skipping test_fd_exhaustion_handling: resource-limits feature not enabled"); -} - -#[tokio::test] -async fn test_corrupt_chunk_auto_refetched() { - use musicfs_cas::{ContentFetcher, FileReader}; - use musicfs_origins::LocalOrigin; - - let dir = TempDir::new().unwrap(); - let origin_dir = TempDir::new().unwrap(); - let test_content = b"original audio data for chunk test"; - setup_test_file(&origin_dir, "test.flac", test_content); - - let store = Arc::new(setup_cas(dir.path()).await); - - let origin = Arc::new(LocalOrigin::new( - OriginId::from("local"), - origin_dir.path().to_path_buf(), - )); - let fetcher = Arc::new(ContentFetcher::new(store.clone())); - fetcher.register_origin(origin); - - let file_meta = FileMeta { - id: FileId(1), - virtual_path: VirtualPath::new("/test.flac"), - real_path: RealPath { - origin_id: OriginId::from("local"), - path: PathBuf::from("/test.flac"), - }, - size: test_content.len() as u64, - mtime: UNIX_EPOCH, - content_hash: None, - audio: None, - }; - fetcher.register_file(file_meta); - - let manifest = fetcher.fetch_file(FileId(1)).await.unwrap(); - let chunk_hash = manifest.chunks[0].hash; - let hex = chunk_hash.as_hex(); - let chunk_path = dir - .path() - .join("chunks") - .join(&hex[0..2]) - .join(&hex[2..4]) - .join(&hex); - - let mut corrupted = std::fs::read(&chunk_path).unwrap(); - corrupted[0] = corrupted[0].wrapping_add(1); - std::fs::write(&chunk_path, &corrupted).unwrap(); - - let reader = FileReader::with_fetcher(store, fetcher); - reader.register_manifest(manifest); - - let result = reader.read(FileId(1), 0, test_content.len() as u32).await; - - assert!( - result.is_ok(), - "Issue 6.4: Corrupted chunk should be auto-refetched from origin" - ); - assert_eq!( - &result.unwrap()[..], - test_content, - "Data should match original after re-fetch" - ); -} - -#[tokio::test] -async fn test_missing_chunk_triggers_origin_fetch() { - use musicfs_cas::{ContentFetcher, FileReader}; - use musicfs_origins::LocalOrigin; - - let dir = TempDir::new().unwrap(); - let origin_dir = TempDir::new().unwrap(); - let test_content = b"test data for missing chunk"; - setup_test_file(&origin_dir, "test.flac", test_content); - - let store = Arc::new(setup_cas(dir.path()).await); - - let origin = Arc::new(LocalOrigin::new( - OriginId::from("local"), - origin_dir.path().to_path_buf(), - )); - let fetcher = Arc::new(ContentFetcher::new(store.clone())); - fetcher.register_origin(origin); - - let file_meta = FileMeta { - id: FileId(1), - virtual_path: VirtualPath::new("/test.flac"), - real_path: RealPath { - origin_id: OriginId::from("local"), - path: PathBuf::from("/test.flac"), - }, - size: test_content.len() as u64, - mtime: UNIX_EPOCH, - content_hash: None, - audio: None, - }; - fetcher.register_file(file_meta); - - let manifest = fetcher.fetch_file(FileId(1)).await.unwrap(); - let chunk_hash = manifest.chunks[0].hash; - let hex = chunk_hash.as_hex(); - let chunk_path = dir - .path() - .join("chunks") - .join(&hex[0..2]) - .join(&hex[2..4]) - .join(&hex); - - std::fs::remove_file(&chunk_path).unwrap(); - - let reader = FileReader::with_fetcher(store, fetcher); - reader.register_manifest(manifest); - - let result = reader.read(FileId(1), 0, test_content.len() as u32).await; - - assert!( - result.is_ok(), - "Issue 6.4: Missing chunk should be re-fetched from origin" - ); - assert_eq!( - &result.unwrap()[..], - test_content, - "Data should match original after re-fetch" - ); -} - -#[tokio::test] -async fn test_passthrough_mode_when_cache_disk_dead() { - use musicfs_cas::ContentFetcher; - use musicfs_origins::LocalOrigin; - - let dir = TempDir::new().unwrap(); - let origin_dir = TempDir::new().unwrap(); - let test_content = b"passthrough test data"; - setup_test_file(&origin_dir, "test.flac", test_content); - - let store = Arc::new( - CasStore::open(CasConfig { - chunks_dir: dir.path().join("chunks"), - max_size: 10, - shard_levels: 2, - }) - .await - .unwrap(), - ); - - let origin = Arc::new(LocalOrigin::new( - OriginId::from("local"), - origin_dir.path().to_path_buf(), - )); - let fetcher = Arc::new(ContentFetcher::new(store.clone())); - fetcher.register_origin(origin); - - let file_meta = FileMeta { - id: FileId(1), - virtual_path: VirtualPath::new("/test.flac"), - real_path: RealPath { - origin_id: OriginId::from("local"), - path: PathBuf::from("/test.flac"), - }, - size: test_content.len() as u64, - mtime: UNIX_EPOCH, - content_hash: None, - audio: None, - }; - fetcher.register_file(file_meta); - - let manifest = fetcher.fetch_file(FileId(1)).await.unwrap(); - - assert!( - !manifest.chunks.is_empty(), - "Issue 6.6: Fetch should complete even when CAS write fails (passthrough mode)" - ); -} - -#[tokio::test] -async fn test_cas_size_tracking_is_correct() { - let dir = TempDir::new().unwrap(); - let config = CasConfig { - chunks_dir: dir.path().join("chunks"), - max_size: 10_000_000, - shard_levels: 2, - }; - let store = CasStore::open(config).await.unwrap(); - - let data = vec![0u8; 1000]; - store.put(&data).await.unwrap(); - - assert!( - store.current_size() >= 1000, - "Issue C6: current_size should track chunk data (recursive), got {}", - store.current_size() - ); -} - -#[test] -fn test_pid_file_prevents_concurrent_mount() { - use std::fs::File; - use std::os::unix::io::AsRawFd; - - let dir = TempDir::new().unwrap(); - let lock_path = dir.path().join("musicfs.lock"); - - fn try_lock(path: &Path) -> Result { - let file = File::create(path)?; - let fd = file.as_raw_fd(); - let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) }; - if ret != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(file) - } - - let lock1 = try_lock(&lock_path); - assert!(lock1.is_ok(), "Issue C9: First lock should succeed"); - - let lock2 = try_lock(&lock_path); - assert!( - lock2.is_err(), - "Issue C9: Second lock should fail (already held)" - ); - - drop(lock1); - - let lock3 = try_lock(&lock_path); - assert!( - lock3.is_ok(), - "Issue C9: Third lock should succeed after first released" - ); -} - -#[test] -fn test_panic_hook_logs_to_tracing() { - use std::panic; - - musicfs_core::install_panic_hook(); - - let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { - panic!("test panic message"); - })); - - assert!(result.is_err(), "Panic should have been caught"); -} - -#[test] -fn test_stale_mount_check_function_exists() { - let path = std::path::Path::new("/nonexistent/musicfs/mount"); - assert!( - !path.exists(), - "Test path should not exist for this test to be meaningful" - ); -} - -#[test] -fn test_systemd_service_has_execstoppost() { - let service_path = std::path::Path::new("../../dist/musicfs.service"); - if !service_path.exists() { - panic!( - "Issue 3.7: dist/musicfs.service does not exist at {:?}", - service_path - ); - } - - let content = std::fs::read_to_string(service_path).unwrap(); - assert!( - content.contains("ExecStopPost") && content.contains("fusermount"), - "Issue 3.7: Service file should have ExecStopPost with fusermount for cleanup" - ); -} - -#[test] -fn test_sd_notify_ready_sent() { - use std::os::unix::net::UnixDatagram; - use tempfile::TempDir; - - let dir = TempDir::new().unwrap(); - let socket_path = dir.path().join("notify.sock"); - let socket = UnixDatagram::bind(&socket_path).unwrap(); - socket - .set_read_timeout(Some(Duration::from_secs(1))) - .unwrap(); - - std::env::set_var("NOTIFY_SOCKET", &socket_path); - - let result = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]); - assert!( - result.is_ok(), - "sd_notify should succeed when NOTIFY_SOCKET is set" - ); - - let mut buf = [0u8; 256]; - let len = socket.recv(&mut buf).unwrap(); - let msg = std::str::from_utf8(&buf[..len]).unwrap(); - - assert!( - msg.contains("READY=1"), - "sd_notify should send READY=1, got: {}", - msg - ); - - std::env::remove_var("NOTIFY_SOCKET"); -} - -#[tokio::test] -async fn test_shutdown_cancels_background_tasks() { - let token = CancellationToken::new(); - let stopped = Arc::new(AtomicBool::new(false)); - let stopped_clone = stopped.clone(); - let token_clone = token.clone(); - - tokio::spawn(async move { - token_clone.cancelled().await; - stopped_clone.store(true, Ordering::SeqCst); - }); - - assert!(!stopped.load(Ordering::SeqCst)); - token.cancel(); - tokio::time::sleep(Duration::from_millis(50)).await; - assert!(stopped.load(Ordering::SeqCst)); -} - -#[tokio::test] -async fn test_shutdown_flushes_tantivy() { - let dir = TempDir::new().unwrap(); - let idx_path = dir.path().join("idx"); - - { - let index = SearchIndex::open(&idx_path).unwrap(); - index - .index_file(&make_file_meta(1, "/a.flac", 1000)) - .unwrap(); - index.commit().unwrap(); - } - - let index2 = SearchIndex::open(&idx_path).unwrap(); - assert_eq!(index2.search("a", 10).unwrap().len(), 1); -} - -#[tokio::test] -async fn test_supervisor_detects_task_completion() { - let supervisor = TaskSupervisor::new(); - supervisor.spawn_supervised("fast", async {}); - tokio::time::sleep(Duration::from_millis(50)).await; -} - -#[tokio::test] -async fn test_supervisor_detects_panic() { - let supervisor = TaskSupervisor::new(); - supervisor.spawn_supervised("panicker", async { - panic!("boom"); - }); - tokio::time::sleep(Duration::from_millis(50)).await; - assert!(matches!( - supervisor.task_status("panicker"), - TaskStatus::Failed { .. } - )); -} - -#[tokio::test] -async fn test_supervisor_restarts_critical_task() { - let count = Arc::new(AtomicU32::new(0)); - let c = count.clone(); - - let supervisor = TaskSupervisor::new(); - supervisor.spawn_critical("restartable", move || { - let c = c.clone(); - async move { - let n = c.fetch_add(1, Ordering::SeqCst); - if n == 0 { - panic!("first run fails"); - } - loop { - tokio::time::sleep(Duration::from_secs(60)).await; - } - } - }); - - tokio::time::sleep(Duration::from_secs(2)).await; - assert_eq!(count.load(Ordering::SeqCst), 2); - assert!(matches!( - supervisor.task_status("restartable"), - TaskStatus::Running - )); -} - -#[tokio::test] -async fn test_sigterm_triggers_shutdown() { - use std::process::{Command, Stdio}; - use std::time::Duration; - use tokio::time::timeout; - - let musicfs_bin = std::env::var("CARGO_BIN_EXE_musicfs").ok(); - if musicfs_bin.is_none() { - eprintln!( - "Skipping test_sigterm_triggers_shutdown: musicfs binary not available in test context" - ); - return; - } - - let bin_path = musicfs_bin.unwrap(); - let temp_dir = tempfile::TempDir::new().unwrap(); - let mountpoint = temp_dir.path().join("mount"); - let origin = temp_dir.path().join("origin"); - std::fs::create_dir_all(&mountpoint).unwrap(); - std::fs::create_dir_all(&origin).unwrap(); - - let mut child = Command::new(&bin_path) - .args([ - "mount", - "--origin", - origin.to_str().unwrap(), - mountpoint.to_str().unwrap(), - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - - if child.is_err() { - eprintln!("Skipping test_sigterm_triggers_shutdown: failed to spawn musicfs"); - return; - } - - let mut child = child.unwrap(); - tokio::time::sleep(Duration::from_millis(500)).await; - - unsafe { - libc::kill(child.id() as i32, libc::SIGTERM); - } - - let exit_result = timeout(Duration::from_secs(10), async { - loop { - match child.try_wait() { - Ok(Some(status)) => return status, - Ok(None) => tokio::time::sleep(Duration::from_millis(100)).await, - Err(_) => break, - } - } - child.wait().unwrap() - }) - .await; - - assert!( - exit_result.is_ok(), - "Issue 2.1: Process should exit within 10s after SIGTERM" - ); -} diff --git a/dist/musicfs.service b/dist/musicfs.service deleted file mode 100644 index a0a5440..0000000 --- a/dist/musicfs.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=MusicFS - Virtual FUSE Filesystem for Music -After=network.target - -[Service] -ExecStart=/usr/bin/musicfs mount /mnt/music --origin /path/to/music -ExecStopPost=/usr/bin/fusermount -u /mnt/music -Restart=on-failure - -[Install] -WantedBy=multi-user.target diff --git a/flake.nix b/flake.nix index 1caa32b..3c73ea5 100644 --- a/flake.nix +++ b/flake.nix @@ -8,12 +8,6 @@ }; outputs = { self, nixpkgs, flake-utils, git-hooks }: - { - nixosModules = { - musicfs = import ./nix/module.nix; - default = self.nixosModules.musicfs; - }; - } // flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { @@ -37,15 +31,6 @@ clippy = pkgs.clippy; }; }; - # embedme = { - # enable = true; - # name = "embedme"; - # description = "Keep README code blocks in sync with source files"; - # entry = "${pkgs.embedme}/bin/embedme"; - # args = [ "README.md" ]; - # pass_filenames = false; - # language = "system"; - # }; }; }; in { @@ -53,11 +38,6 @@ inherit pre-commit-check; }; - packages = rec { - musicfs = pkgs.callPackage ./package.nix { }; - default = musicfs; - }; - devShells.default = pkgs.mkShell { inherit (pre-commit-check) shellHook; @@ -67,29 +47,6 @@ just opencode - - pkg-config - fuse3 - sqlite - openssl - - rustc - cargo - cargo-watch - cargo-nextest - cargo-criterion - rust-analyzer - clippy - rustfmt - - clang - lld - crates-lsp - - protobuf - grpcurl - - # embedme ]; }; }); diff --git a/nix/module.nix b/nix/module.nix deleted file mode 100644 index 5770c8c..0000000 --- a/nix/module.nix +++ /dev/null @@ -1,220 +0,0 @@ -{ - config, - lib, - pkgs, - ... -}: - -let - cfg = config.services.musicfs; - toml = pkgs.formats.toml { }; - - originSubmodule = lib.types.submodule { - options = { - id = lib.mkOption { - type = lib.types.str; - description = "Unique identifier for this origin."; - }; - - originType = lib.mkOption { - type = lib.types.enum [ "local" "nfs" "smb" ]; - default = "local"; - description = "Storage backend type."; - }; - - priority = lib.mkOption { - type = lib.types.int; - default = 1; - description = "Failover priority (lower = preferred)."; - }; - - enabled = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether this origin is active."; - }; - - path = lib.mkOption { - type = lib.types.str; - description = "Filesystem path to the music directory."; - }; - - extraSettings = lib.mkOption { - type = lib.types.attrsOf toml.type; - default = { }; - description = "Additional origin-specific settings passed to the config."; - }; - }; - }; - - mkOriginConfig = o: - { - id = o.id; - origin_type = o.originType; - priority = o.priority; - enabled = o.enabled; - path = o.path; - } - // o.extraSettings; - - configFile = toml.generate "musicfs.toml" ( - { - mount_point = cfg.mountPoint; - cache_dir = cfg.cacheDir; - origins = map mkOriginConfig cfg.origins; - } - // lib.optionalAttrs (cfg.cache != { }) { cache = cfg.cache; } - // lib.optionalAttrs (cfg.health != { }) { health = cfg.health; } - // lib.optionalAttrs (cfg.logging != { }) { logging = cfg.logging; } - // cfg.extraConfig - ); - - inherit (builtins) map; -in -{ - options.services.musicfs = { - enable = lib.mkEnableOption "MusicFS virtual FUSE filesystem"; - - package = lib.mkPackageOption pkgs "musicfs" { - default = null; - }; - - mountPoint = lib.mkOption { - type = lib.types.str; - default = "/mnt/music"; - description = "Where to mount the virtual filesystem."; - }; - - cacheDir = lib.mkOption { - type = lib.types.str; - default = "/var/cache/musicfs"; - description = "Directory for cache data (CAS chunks, metadata, search index)."; - }; - - grpcPort = lib.mkOption { - type = lib.types.port; - default = 50052; - description = "Port for the gRPC control API."; - }; - - origins = lib.mkOption { - type = lib.types.listOf originSubmodule; - default = [ ]; - description = "Music storage origins."; - example = lib.literalExpression '' - [ - { - id = "local-music"; - originType = "local"; - path = "/srv/music"; - } - ] - ''; - }; - - cache = lib.mkOption { - type = lib.types.attrsOf toml.type; - default = { }; - description = "Cache settings passed directly to [cache] in config."; - example = { - metadata_cache_mb = 100; - content_cache_gb = 10; - }; - }; - - health = lib.mkOption { - type = lib.types.attrsOf toml.type; - default = { }; - description = "Health monitoring settings passed directly to [health] in config."; - }; - - logging = lib.mkOption { - type = lib.types.attrsOf toml.type; - default = { }; - description = "Logging settings passed directly to [logging] in config."; - }; - - extraConfig = lib.mkOption { - type = lib.types.attrsOf toml.type; - default = { }; - description = "Additional top-level config keys merged into the generated TOML."; - }; - - user = lib.mkOption { - type = lib.types.str; - default = "musicfs"; - description = "User account under which musicfs runs."; - }; - - group = lib.mkOption { - type = lib.types.str; - default = "musicfs"; - description = "Group under which musicfs runs."; - }; - - openFirewall = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Whether to open the gRPC port in the firewall."; - }; - }; - - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = cfg.origins != [ ]; - message = "services.musicfs.origins must have at least one entry."; - } - ]; - - users.users.${cfg.user} = lib.mkIf (cfg.user == "musicfs") { - isSystemUser = true; - group = cfg.group; - home = cfg.cacheDir; - }; - - users.groups.${cfg.group} = lib.mkIf (cfg.group == "musicfs") { }; - - systemd.tmpfiles.rules = [ - "d ${cfg.mountPoint} 0755 ${cfg.user} ${cfg.group} -" - "d ${cfg.cacheDir} 0750 ${cfg.user} ${cfg.group} -" - ]; - - systemd.services.musicfs = { - description = "MusicFS - Virtual FUSE Filesystem for Music"; - after = [ "network.target" "local-fs.target" ]; - wantedBy = [ "multi-user.target" ]; - - serviceConfig = { - Type = "notify"; - ExecStart = "${lib.getExe cfg.package} mount --config ${configFile} --grpc-port ${toString cfg.grpcPort}"; - ExecStopPost = "${pkgs.fuse3}/bin/fusermount3 -u ${cfg.mountPoint}"; - - User = cfg.user; - Group = cfg.group; - - Restart = "on-failure"; - RestartSec = 5; - - # Hardening - ProtectSystem = "strict"; - ReadWritePaths = [ - cfg.mountPoint - cfg.cacheDir - ] ++ map (o: o.path) (builtins.filter (o: o.enabled) cfg.origins); - PrivateTmp = true; - NoNewPrivileges = true; - ProtectHome = "read-only"; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - - # FUSE needs /dev/fuse - DeviceAllow = [ "/dev/fuse rw" ]; - SupplementaryGroups = [ "fuse" ]; - }; - }; - - networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.grpcPort ]; - }; -} diff --git a/package.nix b/package.nix deleted file mode 100644 index d8bc8ef..0000000 --- a/package.nix +++ /dev/null @@ -1,36 +0,0 @@ -{ - lib, - rustPlatform, - pkgs, -}: - -rustPlatform.buildRustPackage (finalAttrs: { - pname = "musicfs"; - version = "0.1.0"; - - src = ./.; - - cargoLock = { - lockFile = ./Cargo.lock; - }; - - nativeBuildInputs = with pkgs; [ - pkg-config - protobuf - ]; - - buildInputs = with pkgs; [ - openssl - fuse3 - sqlite - ]; - - PROTOC = "${pkgs.protobuf}/bin/protoc"; - - meta = { - description = "MusicFS - FUSE filesystem for music with metadata overlay"; - homepage = "https://github.com/LichHunter/MusicFS"; - license = lib.licenses.unlicense; - maintainers = [ ]; - }; -}) diff --git a/tests/e2e/e2e_players.rs b/tests/e2e/e2e_players.rs deleted file mode 100644 index 149ee48..0000000 --- a/tests/e2e/e2e_players.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::process::Command; - -#[test] -#[ignore] -fn test_mpv_playback() { - let mountpoint = setup_test_mount(); - - let output = Command::new("mpv") - .args([ - "--no-video", - "--no-audio", - "--length=2", - "--msg-level=all=debug", - &format!("{}/Artist/Album/01 - Track.flac", mountpoint), - ]) - .output() - .expect("mpv must be installed"); - - assert!( - output.status.success(), - "mpv playback failed: {:?}", - output - ); -} - -#[test] -#[ignore] -fn test_vlc_playback() { - let mountpoint = setup_test_mount(); - - let output = Command::new("cvlc") - .args([ - "--play-and-exit", - "--run-time=2", - &format!("{}/Artist/Album/", mountpoint), - ]) - .output() - .expect("vlc must be installed"); - - assert!(output.status.success(), "VLC playback failed"); -} - -#[test] -#[ignore] -fn test_file_manager_operations() { - let mountpoint = setup_test_mount(); - - let entries: Vec<_> = std::fs::read_dir(&mountpoint) - .expect("read_dir failed") - .collect(); - - assert!(!entries.is_empty(), "mountpoint should have entries"); - - for entry in entries { - let entry = entry.expect("entry should be valid"); - let metadata = entry.metadata().expect("metadata should work"); - assert!(metadata.is_dir() || metadata.is_file()); - } -} - -#[test] -#[ignore] -fn test_concurrent_player_access() { - let mountpoint = setup_test_mount(); - - let handles: Vec<_> = (0..3) - .map(|i| { - let mp = mountpoint.clone(); - std::thread::spawn(move || { - Command::new("mpv") - .args([ - "--no-video", - "--no-audio", - "--length=1", - &format!("{}/Artist/Album/0{} - Track.flac", mp, i + 1), - ]) - .output() - }) - }) - .collect(); - - for handle in handles { - let output = handle.join().unwrap().expect("mpv should run"); - assert!(output.status.success()); - } -} - -fn setup_test_mount() -> String { - std::env::var("MUSICFS_TEST_MOUNT").unwrap_or_else(|_| "/tmp/musicfs-test".to_string()) -} diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml deleted file mode 100644 index 9249a3e..0000000 --- a/tests/integration/docker-compose.yml +++ /dev/null @@ -1,40 +0,0 @@ -services: - toxiproxy: - image: ghcr.io/shopify/toxiproxy:2.9.0 - ports: - - "8474:8474" - - "20000-20010:20000-20010" - healthcheck: - test: ["CMD", "/toxiproxy-cli", "list"] - interval: 5s - timeout: 3s - retries: 3 - - minio: - image: minio/minio:latest - command: server /data --console-address ":9001" - ports: - - "9000:9000" - - "9001:9001" - environment: - MINIO_ROOT_USER: test - MINIO_ROOT_PASSWORD: testtest123 - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - interval: 5s - timeout: 3s - retries: 3 - volumes: - - minio-data:/data - - sftp: - image: atmoz/sftp:latest - ports: - - "2222:22" - command: test:test:::music - volumes: - - sftp-data:/home/test/music - -volumes: - minio-data: - sftp-data: