From f55edcdedd54c05e6d39a07b62c44d08f05522be Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 22 Mar 2026 17:33:57 +0300 Subject: [PATCH 01/22] meta: set up Just for general maintenance tasks Signed-off-by: NotAShelf Change-Id: I16a6121ab52fbc732a56720f622321496a6a6964 --- justfile | 40 ++++++++++++++++++++++++++++++++++++++++ nix/shell.nix | 11 ++++++----- 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 justfile diff --git a/justfile b/justfile new file mode 100644 index 0000000..563e545 --- /dev/null +++ b/justfile @@ -0,0 +1,40 @@ +# Default recipe to show help +@default: + just --list + +# Build all crates +build-all: build-server build-tui build-ui + +# Build the server crate +build-server: + cargo build -p pinakes-server + +# Build the TUI crate +build-tui: + cargo build -p pinakes-tui + +# Build the UI using Dioxus CLI. The UI *has* to be built with `dx`, because CSS +# is not correctly embedded otherwise. +build-ui: + dx build -p pinakes-ui + +# Generate REST API documentation using cargo xtask +@docs: + cargo xtask docs + +# Run all tests +@test: + cargo nextest run --workspace + +# Format code +@fmt: + cargo fmt + +# Run clippy linting +@lint: + cargo clippy --workspace + +# Clean build artifacts +@clean: + cargo clean + rm -rf target/dx/ diff --git a/nix/shell.nix b/nix/shell.nix index dcff21d..6e706fc 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -26,21 +26,22 @@ in name = "pinakes-dev"; packages = [ - # Build tools # We use the rust-overlay to get the stable Rust toolchain for various targets. # This is not exactly necessary, but it allows for compiling for various targets - # with the least amount of friction. + # with the least amount of friction. The extensions are to make sure all tooling + # uses the same Rust version and the general surrounding tooling. (rust-bin.nightly.latest.default.override { extensions = ["rustfmt" "rust-src" "rust-analyzer" "clippy" "rust-analyzer"]; targets = ["wasm32-unknown-unknown" "wasm32-wasip1"]; # web + plugins }) - # Modern, LLVM based linking pipeline + # Modern, LLVM based linking pipeline. Kind of sucks on Windows, though. llvmPackages.lld llvmPackages.clang - # Handy CLI for packaging Dioxus apps and such - pkgs.dioxus-cli + # CLI helpers + pkgs.dioxus-cli # for packaging Dioxus apps and such + pkgs.just # general command runner for everything # Additional Cargo Tooling pkgs.cargo-nextest From aa9c55277cb346d9cb8b3af1da64f3375fac20f9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 22 Mar 2026 23:40:03 +0300 Subject: [PATCH 02/22] docs: document usage for Just intrumentation Signed-off-by: NotAShelf Change-Id: I11f818ca94867d483caf89c1753e28876a6a6964 --- docs/README.md | 67 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/docs/README.md b/docs/README.md index 69145fe..9d8a072 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,19 +11,41 @@ PostgreSQL (production deployments) as available database backends. ## Building +This project uses [Just](https://just.systems/) as its command runner to help +run cargo with a consistent interface. You are recommended to get it with Nix, +using the default devshell which provides Just. + ```bash -# Build all compilable crates +# Build everything (core crates + UI) +$ just build + +# Build only core crates (cargo) +$ just build-core + +# Build only the UI (uses dx - see note below) +$ just build-ui +``` + +> [!IMPORTANT] +> The Dioxus UI (`pinakes-ui`) must be built with `dx` (Dioxus CLI) to compile +> SCSS stylesheets. Using `cargo build -p pinakes-ui` will not work correctly as +> it skips the SCSS compilation step. This was previously "remedied" with an +> intermediate build wrapper, which turned out to be fragile. It is highly +> recommended that you prefer `just` to build. + +Manual build commands if not using Just: + +```bash +# Core crates (standard cargo) $ cargo build -p pinakes-core -p pinakes-server -p pinakes-tui -# The Dioxus UI requires GTK3 and libsoup system libraries: +# UI (requires Dioxus CLI for SCSS compilation) +$ dx build -p pinakes-ui + +# System dependencies for the UI: # On Debian/Ubuntu: apt install libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev # On Fedora: dnf install gtk3-devel libsoup3-devel webkit2gtk4.1-devel # On Nix: Use the dev shell, everything is provided :) -$ cargo build -p pinakes-ui - -# Alternatively, while app deps are in PATH, you may simply build the entire -# workspace. -$ cargo build --workspace ``` ## Configuration @@ -53,17 +75,20 @@ Key settings: ## Running +All commands are available via Just. Run `just --list` to see all available +recipes. + ### Server To use Pinakes, you will need the server to be running. The GUI on its own will work, but it will not be functional without the server. -```sh -# Start the server first -$ cargo run -p pinakes-server -- pinakes.toml +```bash +# Using Just +$ just run-server -# or: -$ cargo run -p pinakes-server -- --config pinakes.toml +# Or manually: +$ cargo run -p pinakes-server -- pinakes.toml ``` The server starts on the configured host:port (default `127.0.0.1:3000`). In a @@ -77,11 +102,11 @@ terminal. While the server is running you may connect to it using the `--server` flag. ```bash -# Using defaults -$ cargo run -p pinakes-tui +# Using Just +$ just run-tui -# or with a custom server URL: -$ cargo run -p pinakes-tui -- --server http://localhost:3000 +# Or manually: +$ cargo run -p pinakes-tui ``` #### Keybindings @@ -120,9 +145,15 @@ Pinakes features a fully fledged Desktop and Web UI powered by Dioxus. Those two components are meant as a GUI frontend for the Pinakes server, and are interchangeable in terms of usage. +> [!IMPORTANT] +> The UI must be run with `dx` (Dioxus CLI), not `cargo run`. + ```bash -# Build the UI -$ cargo run -p pinakes-ui +# Using Just +$ just run-ui + +# Or manually with dx: +$ dx serve -p pinakes-ui ``` > [!TIP] From 9e5eb41d392454d87e9097dcee974bd293b0fb73 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 22 Mar 2026 23:42:02 +0300 Subject: [PATCH 03/22] nix: set up project-wide formatter Signed-off-by: NotAShelf Change-Id: I4806c58aa0a17f504c9312723ad770166a6a6964 --- crates/pinakes-ui/assets/css/main.css | 4628 ++++++++++++++++- docs/api/analytics.md | 85 +- docs/api/audit.md | 19 +- docs/api/auth.md | 68 +- docs/api/backup.md | 21 +- docs/api/books.md | 147 +- docs/api/collections.md | 123 +- docs/api/config.md | 73 +- docs/api/database.md | 37 +- docs/api/duplicates.md | 11 +- docs/api/enrichment.md | 53 +- docs/api/export.md | 25 +- docs/api/health.md | 35 +- docs/api/integrity.md | 61 +- docs/api/jobs.md | 47 +- docs/api/media.md | 489 +- docs/api/notes.md | 93 +- docs/api/photos.md | 47 +- docs/api/playlists.md | 171 +- docs/api/plugins.md | 125 +- docs/api/saved_searches.md | 41 +- docs/api/scan.md | 21 +- docs/api/scheduled_tasks.md | 47 +- docs/api/search.md | 37 +- docs/api/shares.md | 217 +- docs/api/social.md | 135 +- docs/api/statistics.md | 11 +- docs/api/streaming.md | 99 +- docs/api/subtitles.md | 85 +- docs/api/sync.md | 307 +- docs/api/tags.md | 123 +- docs/api/transcode.md | 59 +- docs/api/upload.md | 71 +- docs/api/users.md | 133 +- docs/api/webhooks.md | 21 +- examples/plugins/heif-support/README.md | 22 +- examples/plugins/markdown-metadata/README.md | 11 +- flake.lock | Bin 1108 -> 1108 bytes flake.nix | 33 + migrations/postgres/V10__incremental_scan.sql | 20 +- .../postgres/V11__session_persistence.sql | 19 +- migrations/postgres/V12__book_management.sql | 73 +- migrations/postgres/V13__photo_metadata.sql | 45 +- migrations/postgres/V14__perceptual_hash.sql | 8 +- migrations/postgres/V15__managed_storage.sql | 29 +- migrations/postgres/V16__sync_system.sql | 156 +- migrations/postgres/V17__enhanced_sharing.sql | 115 +- migrations/postgres/V18__file_management.sql | 10 +- migrations/postgres/V19__markdown_links.sql | 36 +- migrations/postgres/V1__initial_schema.sql | 98 +- migrations/postgres/V2__fts_indexes.sql | 21 +- migrations/postgres/V3__audit_indexes.sql | 23 +- migrations/postgres/V4__thumbnail_path.sql | 3 +- .../V5__integrity_and_saved_searches.sql | 17 +- migrations/postgres/V6__plugin_system.sql | 21 +- migrations/postgres/V7__user_management.sql | 46 +- .../postgres/V8__media_server_features.sql | 173 +- .../V9__fix_indexes_and_constraints.sql | 23 +- migrations/sqlite/V10__incremental_scan.sql | 20 +- .../sqlite/V11__session_persistence.sql | 19 +- migrations/sqlite/V12__book_management.sql | 74 +- migrations/sqlite/V13__photo_metadata.sql | 45 +- migrations/sqlite/V14__perceptual_hash.sql | 8 +- migrations/sqlite/V15__managed_storage.sql | 29 +- migrations/sqlite/V16__sync_system.sql | 170 +- migrations/sqlite/V17__enhanced_sharing.sql | 178 +- migrations/sqlite/V18__file_management.sql | 10 +- migrations/sqlite/V19__markdown_links.sql | 36 +- migrations/sqlite/V1__initial_schema.sql | 108 +- migrations/sqlite/V2__fts5_indexes.sql | 119 +- migrations/sqlite/V3__audit_indexes.sql | 17 +- migrations/sqlite/V4__thumbnail_path.sql | 3 +- .../V5__integrity_and_saved_searches.sql | 17 +- migrations/sqlite/V6__plugin_system.sql | 21 +- migrations/sqlite/V7__user_management.sql | 46 +- .../sqlite/V8__media_server_features.sql | 197 +- .../V9__fix_indexes_and_constraints.sql | 23 +- nix/shell.nix | 3 - 78 files changed, 7406 insertions(+), 2504 deletions(-) diff --git a/crates/pinakes-ui/assets/css/main.css b/crates/pinakes-ui/assets/css/main.css index d25d383..149dd65 100644 --- a/crates/pinakes-ui/assets/css/main.css +++ b/crates/pinakes-ui/assets/css/main.css @@ -1 +1,4627 @@ -@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}.mt-16{margin-top:16px}.mt-8{margin-top:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.card-body{padding-top:8px}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:50%;font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}.badge-neutral{background:rgba(255,255,255,.04);color:#a0a0b8}.badge-success{background:rgba(62,201,122,.08);color:#3ec97a}.badge-warning{background:rgba(212,160,55,.06);color:#d4a037}.badge-danger{background:rgba(228,88,88,.06);color:#d47070}.tab-bar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;border-bottom:1px solid rgba(255,255,255,.09);margin-bottom:12px}.tab-btn{background:rgba(0,0,0,0);border:none;padding:6px 8px;font-size:13px;color:#a0a0b8;border-bottom:2px solid rgba(0,0,0,0);margin-bottom:-1px;cursor:pointer;transition:color .1s,border-color .1s}.tab-btn:hover{color:#dcdce4}.tab-btn.active{color:#9698f7;border-bottom-color:#7c7ef5}.input-sm{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit;padding:3px 8px;font-size:12px}.input-sm::placeholder{color:#6c6c84}.input-sm:focus{border-color:#7c7ef5}.input-suffix{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch}.input-suffix input{border-radius:3px 0 0 3px;border-right:none;flex:1}.input-suffix .btn{border-radius:0 3px 3px 0}.field-error{color:#d47070;font-size:11px;margin-top:2px}.comments-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px}.comment-item{padding:8px;background:#18181f;border-radius:5px;border:1px solid rgba(255,255,255,.06)}.comment-text{font-size:13px;color:#dcdce4;line-height:1.5;margin-top:4px}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-native-video{width:100%;display:block}.player-native-audio{display:none}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)}.plugin-page{padding:16px 24px;max-width:100%;overflow-x:hidden}.plugin-page-title{font-size:14px;font-weight:600;color:#dcdce4;margin:0 0 16px}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden}.plugin-card-header{padding:12px 16px;font-size:12px;font-weight:600;color:#dcdce4;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.plugin-card-content{padding:16px}.plugin-card-footer{padding:12px 16px;border-top:1px solid rgba(255,255,255,.09);background:#18181f}.plugin-heading{color:#dcdce4;margin:0;line-height:1.2}.plugin-heading.level-1{font-size:28px;font-weight:700}.plugin-heading.level-2{font-size:18px;font-weight:600}.plugin-heading.level-3{font-size:16px;font-weight:600}.plugin-heading.level-4{font-size:14px;font-weight:500}.plugin-heading.level-5{font-size:13px;font-weight:500}.plugin-heading.level-6{font-size:12px;font-weight:500}.plugin-text{margin:0;font-size:12px;color:#dcdce4;line-height:1.4}.plugin-text.text-secondary{color:#a0a0b8}.plugin-text.text-error{color:#d47070}.plugin-text.text-success{color:#3ec97a}.plugin-text.text-warning{color:#d4a037}.plugin-text.text-bold{font-weight:600}.plugin-text.text-italic{font-style:italic}.plugin-text.text-small{font-size:10px}.plugin-text.text-large{font-size:15px}.plugin-code{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px 24px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#dcdce4;overflow-x:auto;white-space:pre}.plugin-code code{font-family:inherit;font-size:inherit;color:inherit}.plugin-tabs{display:flex;flex-direction:column}.plugin-tab-list{display:flex;gap:2px;border-bottom:1px solid rgba(255,255,255,.09);margin-bottom:16px}.plugin-tab{padding:8px 20px;font-size:12px;font-weight:500;color:#a0a0b8;background:rgba(0,0,0,0);border:none;border-bottom:2px solid rgba(0,0,0,0);cursor:pointer;transition:color .1s,border-color .1s}.plugin-tab:hover{color:#dcdce4}.plugin-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.plugin-tab .tab-icon{margin-right:4px}.plugin-tab-panel:not(.active){display:none}.plugin-description-list-wrapper{width:100%}.plugin-description-list{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:0;padding:0}.plugin-description-list dt{font-size:10px;font-weight:500;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;padding:6px 0;white-space:nowrap}.plugin-description-list dd{font-size:12px;color:#dcdce4;padding:6px 0;margin:0;word-break:break-word}.plugin-description-list.horizontal{display:flex;flex-wrap:wrap;gap:16px 24px;display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr))}.plugin-description-list.horizontal dt{width:auto;padding:0}.plugin-description-list.horizontal dd{width:auto;padding:0}.plugin-description-list.horizontal dt,.plugin-description-list.horizontal dd{display:inline}.plugin-description-list.horizontal dt{font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:#6c6c84;margin-bottom:2px}.plugin-description-list.horizontal dd{font-size:13px;font-weight:600;color:#dcdce4}.plugin-data-table-wrapper{overflow-x:auto}.plugin-data-table{width:100%;border-collapse:collapse;font-size:12px}.plugin-data-table thead tr{border-bottom:1px solid rgba(255,255,255,.14)}.plugin-data-table thead th{padding:8px 12px;text-align:left;font-size:10px;font-weight:600;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}.plugin-data-table tbody tr{border-bottom:1px solid rgba(255,255,255,.06);transition:background .08s}.plugin-data-table tbody tr:hover{background:rgba(255,255,255,.03)}.plugin-data-table tbody tr:last-child{border-bottom:none}.plugin-data-table tbody td{padding:8px 12px;color:#dcdce4;vertical-align:middle}.plugin-col-constrained{width:var(--plugin-col-width)}.table-filter{margin-bottom:12px}.table-filter input{width:240px;padding:6px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px}.table-filter input::placeholder{color:#6c6c84}.table-filter input:focus{outline:none;border-color:#7c7ef5}.table-pagination{display:flex;align-items:center;gap:12px;padding:8px 0;font-size:12px;color:#a0a0b8}.row-actions{white-space:nowrap;width:1%}.row-actions .plugin-button{padding:4px 8px;font-size:10px;margin-right:4px}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.media-grid-item{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden;display:flex;flex-direction:column}.media-grid-img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block}.media-grid-no-img{width:100%;aspect-ratio:16/9;background:#26263a;display:flex;align-items:center;justify-content:center;font-size:10px;color:#6c6c84}.media-grid-caption{padding:8px 12px;font-size:10px;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.plugin-list{list-style:none;margin:0;padding:0}.plugin-list-item{padding:8px 0}.plugin-list-divider{border:none;border-top:1px solid rgba(255,255,255,.06);margin:0}.plugin-list-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px}.plugin-button{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border:1px solid rgba(255,255,255,.09);border-radius:5px;font-size:12px;font-weight:500;cursor:pointer;transition:background .08s,border-color .08s,color .08s;background:#1f1f28;color:#dcdce4}.plugin-button:disabled{opacity:.45;cursor:not-allowed}.plugin-button.btn-primary{background:#7c7ef5;border-color:#7c7ef5;color:#fff}.plugin-button.btn-primary:hover:not(:disabled){background:#8b8df7}.plugin-button.btn-secondary{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.plugin-button.btn-secondary:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-button.btn-tertiary{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#9698f7}.plugin-button.btn-tertiary:hover:not(:disabled){background:rgba(124,126,245,.15)}.plugin-button.btn-danger{background:rgba(0,0,0,0);border-color:rgba(228,88,88,.2);color:#d47070}.plugin-button.btn-danger:hover:not(:disabled){background:rgba(228,88,88,.06)}.plugin-button.btn-success{background:rgba(0,0,0,0);border-color:rgba(62,201,122,.2);color:#3ec97a}.plugin-button.btn-success:hover:not(:disabled){background:rgba(62,201,122,.08)}.plugin-button.btn-ghost{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#a0a0b8}.plugin-button.btn-ghost:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:50%;font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}.plugin-badge.badge-default,.plugin-badge.badge-neutral{background:rgba(255,255,255,.04);color:#a0a0b8}.plugin-badge.badge-primary{background:rgba(124,126,245,.15);color:#9698f7}.plugin-badge.badge-secondary{background:rgba(255,255,255,.03);color:#dcdce4}.plugin-badge.badge-success{background:rgba(62,201,122,.08);color:#3ec97a}.plugin-badge.badge-warning{background:rgba(212,160,55,.06);color:#d4a037}.plugin-badge.badge-error{background:rgba(228,88,88,.06);color:#d47070}.plugin-badge.badge-info{background:rgba(99,102,241,.08);color:#9698f7}.plugin-form{display:flex;flex-direction:column;gap:16px}.form-field{display:flex;flex-direction:column;gap:6px}.form-field label{font-size:12px;font-weight:500;color:#dcdce4}.form-field input,.form-field textarea,.form-field select{padding:8px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px;font-family:inherit}.form-field input::placeholder,.form-field textarea::placeholder,.form-field select::placeholder{color:#6c6c84}.form-field input:focus,.form-field textarea:focus,.form-field select:focus{outline:none;border-color:#7c7ef5;box-shadow:0 0 0 2px rgba(124,126,245,.15)}.form-field textarea{min-height:80px;resize:vertical}.form-field select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}.form-help{margin:0;font-size:10px;color:#6c6c84}.form-actions{display:flex;gap:12px;padding-top:8px}.required{color:#e45858}.plugin-link{color:#9698f7;text-decoration:none}.plugin-link:hover{text-decoration:underline}.plugin-link-blocked{color:#6c6c84;text-decoration:line-through;cursor:not-allowed}.plugin-progress{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;height:8px;overflow:hidden;display:flex;align-items:center;gap:8px}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-progress-label{font-size:10px;color:#a0a0b8;white-space:nowrap;flex-shrink:0}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)}.plugin-chart .chart-title{font-size:13px;font-weight:600;color:#dcdce4;margin-bottom:8px}.plugin-chart .chart-x-label,.plugin-chart .chart-y-label{font-size:10px;color:#6c6c84;margin-bottom:4px}.plugin-chart .chart-data-table{overflow-x:auto}.plugin-chart .chart-no-data{padding:24px;text-align:center;color:#6c6c84;font-size:12px}.plugin-loading{padding:16px;color:#a0a0b8;font-size:12px;font-style:italic}.plugin-error{padding:12px 16px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:5px;color:#d47070;font-size:12px}.plugin-feedback{position:sticky;bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;border-radius:7px;font-size:12px;z-index:300;box-shadow:0 4px 20px rgba(0,0,0,.45)}.plugin-feedback.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.plugin-feedback.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#d47070}.plugin-feedback-dismiss{background:rgba(0,0,0,0);border:none;color:inherit;font-size:14px;cursor:pointer;line-height:1;padding:0;opacity:.7}.plugin-feedback-dismiss:hover{opacity:1}.plugin-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;z-index:100}.plugin-modal{position:relative;background:#1f1f28;border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:32px;min-width:380px;max-width:640px;max-height:80vh;overflow-y:auto;box-shadow:0 4px 20px rgba(0,0,0,.45);z-index:200}.plugin-modal-close{position:absolute;top:16px;right:16px;background:rgba(0,0,0,0);border:none;color:#a0a0b8;font-size:14px;cursor:pointer;line-height:1;padding:4px;border-radius:5px}.plugin-modal-close:hover{background:rgba(255,255,255,.04);color:#dcdce4} \ No newline at end of file +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} +* { + margin: 0; + padding: 0; + box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.06) rgba(0, 0, 0, 0); +} +*::-webkit-scrollbar { + width: 5px; + height: 5px; +} +*::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0); +} +*::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.06); + border-radius: 3px; +} +*::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.14); +} +:root { + --bg-0: #111118; + --bg-1: #18181f; + --bg-2: #1f1f28; + --bg-3: #26263a; + --border-subtle: rgba(255, 255, 255, 0.06); + --border: rgba(255, 255, 255, 0.09); + --border-strong: rgba(255, 255, 255, 0.14); + --text-0: #dcdce4; + --text-1: #a0a0b8; + --text-2: #6c6c84; + --accent: #7c7ef5; + --accent-dim: rgba(124, 126, 245, 0.15); + --accent-text: #9698f7; + --success: #3ec97a; + --error: #e45858; + --warning: #d4a037; + --radius-sm: 3px; + --radius: 5px; + --radius-md: 7px; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.45); +} +body { + font-family: + "Inter", + -apple-system, + "Segoe UI", + system-ui, + sans-serif; + background: var(--bg-0); + color: var(--text-0); + font-size: 13px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} +:focus-visible:focus-visible { + outline: 2px solid #7c7ef5; + outline-offset: 2px; +} +::selection { + background: rgba(124, 126, 245, 0.15); + color: #9698f7; +} +a { + color: #9698f7; + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +code { + padding: 1px 5px; + border-radius: 3px; + background: #111118; + color: #9698f7; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-size: 11px; +} +ul { + list-style: none; + padding: 0; +} +ul li { + padding: 3px 0; + font-size: 12px; + color: #a0a0b8; +} +.text-muted { + color: #a0a0b8; +} +.text-sm { + font-size: 11px; +} +.mono { + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-size: 12px; +} +.flex-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; +} +.flex-between { + display: flex; + justify-content: space-between; + align-items: center; +} +.mb-16 { + margin-bottom: 16px; +} +.mb-8 { + margin-bottom: 12px; +} +.mt-16 { + margin-top: 16px; +} +.mt-8 { + margin-top: 12px; +} +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@keyframes skeleton-pulse { + 0% { + opacity: 0.6; + } + 50% { + opacity: 0.3; + } + 100% { + opacity: 0.6; + } +} +@keyframes indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} +.app { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: stretch; + height: 100vh; + overflow: hidden; +} +.sidebar { + width: 220px; + min-width: 220px; + max-width: 220px; + background: #18181f; + border-right: 1px solid rgba(255, 255, 255, 0.09); + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + flex-shrink: 0; + user-select: none; + overflow-y: auto; + overflow-x: hidden; + z-index: 10; + transition: + width 0.15s, + min-width 0.15s, + max-width 0.15s; +} +.sidebar.collapsed { + width: 48px; + min-width: 48px; + max-width: 48px; +} +.sidebar.collapsed .nav-label, +.sidebar.collapsed .sidebar-header .logo, +.sidebar.collapsed .sidebar-header .version, +.sidebar.collapsed .nav-badge, +.sidebar.collapsed .nav-item-text, +.sidebar.collapsed .sidebar-footer .status-text, +.sidebar.collapsed .user-name, +.sidebar.collapsed .role-badge, +.sidebar.collapsed .user-info .btn, +.sidebar.collapsed .sidebar-import-header span, +.sidebar.collapsed .sidebar-import-file { + display: none; +} +.sidebar.collapsed .nav-item { + justify-content: center; + padding: 8px; + border-left: none; + border-radius: 3px; +} +.sidebar.collapsed .nav-item.active { + border-left: none; +} +.sidebar.collapsed .nav-icon { + width: auto; + margin: 0; +} +.sidebar.collapsed .sidebar-header { + padding: 12px 8px; + justify-content: center; +} +.sidebar.collapsed .nav-section { + padding: 0 4px; +} +.sidebar.collapsed .sidebar-footer { + padding: 8px; +} +.sidebar.collapsed .sidebar-footer .user-info { + justify-content: center; + padding: 4px; +} +.sidebar.collapsed .sidebar-import-progress { + padding: 6px; +} +.sidebar-header { + padding: 16px 16px 20px; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: baseline; + gap: 8px; +} +.sidebar-header .logo { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.4px; + color: #dcdce4; +} +.sidebar-header .version { + font-size: 10px; + color: #6c6c84; +} +.sidebar-toggle { + background: rgba(0, 0, 0, 0); + border: none; + color: #6c6c84; + padding: 8px; + font-size: 18px; + width: 100%; + text-align: center; +} +.sidebar-toggle:hover { + color: #dcdce4; +} +.sidebar-spacer { + flex: 1; +} +.sidebar-footer { + padding: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + overflow: visible; + min-width: 0; +} +.nav-section { + padding: 0 8px; + margin-bottom: 2px; +} +.nav-label { + padding: 8px 8px 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6c6c84; +} +.nav-item { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 3px; + cursor: pointer; + color: #a0a0b8; + font-size: 13px; + font-weight: 450; + transition: + color 0.1s, + background 0.1s; + border: none; + background: none; + width: 100%; + text-align: left; + border-left: 2px solid rgba(0, 0, 0, 0); + margin-left: 0; +} +.nav-item:hover { + color: #dcdce4; + background: rgba(255, 255, 255, 0.03); +} +.nav-item.active { + color: #9698f7; + border-left-color: #7c7ef5; + background: rgba(124, 126, 245, 0.15); +} +.nav-item-text { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.sidebar:not(.collapsed) .nav-item-text { + overflow: visible; +} +.nav-icon { + width: 18px; + text-align: center; + font-size: 14px; + opacity: 0.7; +} +.nav-badge { + margin-left: auto; + font-size: 10px; + font-weight: 600; + color: #6c6c84; + background: #26263a; + padding: 1px 6px; + border-radius: 12px; + min-width: 20px; + text-align: center; + font-variant-numeric: tabular-nums; +} +.status-indicator { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 500; + min-width: 0; + overflow: visible; +} +.sidebar:not(.collapsed) .status-indicator { + justify-content: flex-start; +} +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.status-dot.connected { + background: #3ec97a; +} +.status-dot.disconnected { + background: #e45858; +} +.status-dot.checking { + background: #d4a037; + animation: pulse 1.5s infinite; +} +.status-text { + color: #6c6c84; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.sidebar:not(.collapsed) .status-text { + overflow: visible; +} +.main { + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + overflow: hidden; + min-width: 0; +} +.header { + height: 48px; + min-height: 48px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 12px; + padding: 0 20px; + background: #18181f; +} +.page-title { + font-size: 14px; + font-weight: 600; + color: #dcdce4; +} +.header-spacer { + flex: 1; +} +.content { + flex: 1; + overflow-y: auto; + padding: 20px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.06) rgba(0, 0, 0, 0); +} +.sidebar-import-progress { + padding: 10px 12px; + background: #1f1f28; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 11px; +} +.sidebar-import-header { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; + margin-bottom: 4px; + color: #a0a0b8; +} +.sidebar-import-file { + color: #6c6c84; + font-size: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} +.sidebar-import-progress .progress-bar { + height: 3px; +} +.user-info { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; + font-size: 12px; + overflow: hidden; + min-width: 0; +} +.user-name { + font-weight: 500; + color: #dcdce4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 90px; + flex-shrink: 1; +} +.role-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} +.role-badge.role-admin { + background: rgba(139, 92, 246, 0.1); + color: #9d8be0; +} +.role-badge.role-editor { + background: rgba(34, 160, 80, 0.1); + color: #5cb97a; +} +.role-badge.role-viewer { + background: rgba(59, 120, 200, 0.1); + color: #6ca0d4; +} +.btn { + padding: 5px 12px; + border-radius: 3px; + border: none; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.1s; + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + line-height: 1.5; +} +.btn-primary { + background: #7c7ef5; + color: #fff; +} +.btn-primary:hover { + background: #8b8df7; +} +.btn-secondary { + background: #26263a; + color: #dcdce4; + border: 1px solid rgba(255, 255, 255, 0.09); +} +.btn-secondary:hover { + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.06); +} +.btn-danger { + background: rgba(0, 0, 0, 0); + color: #e45858; + border: 1px solid rgba(228, 88, 88, 0.25); +} +.btn-danger:hover { + background: rgba(228, 88, 88, 0.08); +} +.btn-ghost { + background: rgba(0, 0, 0, 0); + border: none; + color: #a0a0b8; + padding: 5px 8px; +} +.btn-ghost:hover { + color: #dcdce4; + background: rgba(255, 255, 255, 0.04); +} +.btn-sm { + padding: 3px 8px; + font-size: 11px; +} +.btn-icon { + padding: 4px; + border-radius: 3px; + background: rgba(0, 0, 0, 0); + border: none; + color: #6c6c84; + cursor: pointer; + transition: color 0.1s; + font-size: 13px; +} +.btn-icon:hover { + color: #dcdce4; +} +.btn:disabled, +.btn[disabled] { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} +.btn.btn-disabled-hint:disabled { + opacity: 0.6; + border-style: dashed; + pointer-events: auto; + cursor: help; +} +.card { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + padding: 16px; +} +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} +.card-title { + font-size: 14px; + font-weight: 600; +} +.card-body { + padding-top: 8px; +} +.data-table { + width: 100%; + border-collapse: collapse; + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + overflow: hidden; +} +.data-table thead th { + padding: 8px 14px; + text-align: left; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #6c6c84; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); + background: #26263a; +} +.data-table tbody td { + padding: 8px 14px; + font-size: 13px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.data-table tbody tr { + cursor: pointer; + transition: background 0.08s; +} +.data-table tbody tr:hover { + background: rgba(255, 255, 255, 0.02); +} +.data-table tbody tr.row-selected { + background: rgba(99, 102, 241, 0.12); +} +.data-table tbody tr:last-child td { + border-bottom: none; +} +.sortable-header { + cursor: pointer; + user-select: none; + transition: color 0.1s; +} +.sortable-header:hover { + color: #9698f7; +} +input[type="text"], +textarea, +select { + padding: 6px 10px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.09); + background: #111118; + color: #dcdce4; + font-size: 13px; + outline: none; + transition: border-color 0.15s; + font-family: inherit; +} +input[type="text"]::placeholder, +textarea::placeholder, +select::placeholder { + color: #6c6c84; +} +input[type="text"]:focus, +textarea:focus, +select:focus { + border-color: #7c7ef5; +} +input[type="text"][type="number"], +textarea[type="number"], +select[type="number"] { + width: 80px; + padding: 6px 8px; + -moz-appearance: textfield; +} +input[type="text"][type="number"]::-webkit-outer-spin-button, +input[type="text"][type="number"]::-webkit-inner-spin-button, +textarea[type="number"]::-webkit-outer-spin-button, +textarea[type="number"]::-webkit-inner-spin-button, +select[type="number"]::-webkit-outer-spin-button, +select[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +textarea { + min-height: 64px; + resize: vertical; +} +select { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 26px; + min-width: 100px; +} +.form-group { + margin-bottom: 12px; +} +.form-label { + display: block; + font-size: 11px; + font-weight: 600; + color: #a0a0b8; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.form-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-end; + gap: 8px; +} +.form-row input[type="text"] { + flex: 1; +} +.form-label-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 2px; + margin-bottom: 4px; +} +.form-label-row .form-label { + margin-bottom: 0; +} +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 50%; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; +} +.badge-neutral { + background: rgba(255, 255, 255, 0.04); + color: #a0a0b8; +} +.badge-success { + background: rgba(62, 201, 122, 0.08); + color: #3ec97a; +} +.badge-warning { + background: rgba(212, 160, 55, 0.06); + color: #d4a037; +} +.badge-danger { + background: rgba(228, 88, 88, 0.06); + color: #d47070; +} +.tab-bar { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); + margin-bottom: 12px; +} +.tab-btn { + background: rgba(0, 0, 0, 0); + border: none; + padding: 6px 8px; + font-size: 13px; + color: #a0a0b8; + border-bottom: 2px solid rgba(0, 0, 0, 0); + margin-bottom: -1px; + cursor: pointer; + transition: + color 0.1s, + border-color 0.1s; +} +.tab-btn:hover { + color: #dcdce4; +} +.tab-btn.active { + color: #9698f7; + border-bottom-color: #7c7ef5; +} +.input-sm { + padding: 6px 10px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.09); + background: #111118; + color: #dcdce4; + font-size: 13px; + outline: none; + transition: border-color 0.15s; + font-family: inherit; + padding: 3px 8px; + font-size: 12px; +} +.input-sm::placeholder { + color: #6c6c84; +} +.input-sm:focus { + border-color: #7c7ef5; +} +.input-suffix { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: stretch; +} +.input-suffix input { + border-radius: 3px 0 0 3px; + border-right: none; + flex: 1; +} +.input-suffix .btn { + border-radius: 0 3px 3px 0; +} +.field-error { + color: #d47070; + font-size: 11px; + margin-top: 2px; +} +.comments-list { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 8px; +} +.comment-item { + padding: 8px; + background: #18181f; + border-radius: 5px; + border: 1px solid rgba(255, 255, 255, 0.06); +} +.comment-text { + font-size: 13px; + color: #dcdce4; + line-height: 1.5; + margin-top: 4px; +} +input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 16px; + height: 16px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 3px; + background: #1f1f28; + cursor: pointer; + position: relative; + flex-shrink: 0; + transition: all 0.15s ease; +} +input[type="checkbox"]:hover { + border-color: #7c7ef5; + background: #26263a; +} +input[type="checkbox"]:checked { + background: #7c7ef5; + border-color: #7c7ef5; +} +input[type="checkbox"]:checked::after { + content: ""; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 8px; + border: solid #111118; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +input[type="checkbox"]:focus-visible:focus-visible { + outline: 2px solid #7c7ef5; + outline-offset: 2px; +} +.checkbox-label { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + color: #a0a0b8; + user-select: none; +} +.checkbox-label:hover { + color: #dcdce4; +} +.checkbox-label input[type="checkbox"] { + margin: 0; +} +.toggle { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + color: #dcdce4; +} +.toggle.disabled { + opacity: 0.4; + cursor: not-allowed; +} +.toggle-track { + width: 32px; + height: 18px; + border-radius: 9px; + background: #26263a; + border: 1px solid rgba(255, 255, 255, 0.09); + position: relative; + transition: background 0.15s; + flex-shrink: 0; +} +.toggle-track.active { + background: #7c7ef5; + border-color: #7c7ef5; +} +.toggle-track.active .toggle-thumb { + transform: translateX(14px); +} +.toggle-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: #dcdce4; + position: absolute; + top: 1px; + left: 1px; + transition: transform 0.15s; +} +.filter-bar { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 12px; + padding: 12px; + background: #111118; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 5px; + margin-bottom: 12px; +} +.filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.filter-label { + font-size: 11px; + font-weight: 500; + color: #6c6c84; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-right: 4px; +} +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 14px; + cursor: pointer; + font-size: 11px; + color: #a0a0b8; + transition: all 0.15s ease; + user-select: none; +} +.filter-chip:hover { + background: #26263a; + border-color: rgba(255, 255, 255, 0.14); + color: #dcdce4; +} +.filter-chip.active { + background: rgba(124, 126, 245, 0.15); + border-color: #7c7ef5; + color: #9698f7; +} +.filter-chip input[type="checkbox"] { + width: 12px; + height: 12px; + margin: 0; +} +.filter-chip input[type="checkbox"]:checked::after { + left: 3px; + top: 1px; + width: 3px; + height: 6px; +} +.filter-group { + display: flex; + align-items: center; + gap: 6px; +} +.filter-group label { + display: flex; + align-items: center; + gap: 3px; + cursor: pointer; + color: #a0a0b8; + font-size: 11px; + white-space: nowrap; +} +.filter-group label:hover { + color: #dcdce4; +} +.filter-separator { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.09); + flex-shrink: 0; +} +.view-toggle { + display: flex; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 3px; + overflow: hidden; +} +.view-btn { + padding: 4px 10px; + background: #1f1f28; + border: none; + color: #6c6c84; + cursor: pointer; + font-size: 18px; + line-height: 1; + transition: + background 0.1s, + color 0.1s; +} +.view-btn:first-child { + border-right: 1px solid rgba(255, 255, 255, 0.09); +} +.view-btn:hover { + color: #dcdce4; + background: #26263a; +} +.view-btn.active { + background: rgba(124, 126, 245, 0.15); + color: #9698f7; +} +.breadcrumb { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 4px; + padding: 10px 16px; + font-size: 0.85rem; + color: #6c6c84; +} +.breadcrumb-sep { + color: #6c6c84; + opacity: 0.5; +} +.breadcrumb-link { + color: #9698f7; + text-decoration: none; + cursor: pointer; +} +.breadcrumb-link:hover { + text-decoration: underline; +} +.breadcrumb-current { + color: #dcdce4; + font-weight: 500; +} +.progress-bar { + width: 100%; + height: 8px; + background: #26263a; + border-radius: 4px; + overflow: hidden; + margin-bottom: 6px; +} +.progress-fill { + height: 100%; + background: #7c7ef5; + border-radius: 4px; + transition: width 0.3s ease; +} +.progress-fill.indeterminate { + width: 30%; + animation: indeterminate 1.5s ease-in-out infinite; +} +.loading-overlay { + display: flex; + align-items: center; + justify-content: center; + padding: 48px 16px; + color: #6c6c84; + font-size: 13px; + gap: 10px; +} +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.09); + border-top-color: #7c7ef5; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +.spinner-small { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.09); + border-top-color: #7c7ef5; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +.spinner-tiny { + width: 10px; + height: 10px; + border: 1.5px solid rgba(255, 255, 255, 0.09); + border-top-color: #7c7ef5; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: fade-in 0.1s ease-out; +} +.modal { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + padding: 20px; + min-width: 360px; + max-width: 480px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); +} +.modal.wide { + max-width: 600px; + max-height: 70vh; + overflow-y: auto; +} +.modal-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 6px; +} +.modal-body { + font-size: 12px; + color: #a0a0b8; + margin-bottom: 16px; + line-height: 1.5; +} +.modal-actions { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + gap: 6px; +} +.tooltip-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + background: #26263a; + color: #6c6c84; + font-size: 9px; + font-weight: 700; + cursor: help; + position: relative; + flex-shrink: 0; + margin-left: 4px; +} +.tooltip-trigger:hover { + background: rgba(124, 126, 245, 0.15); + color: #9698f7; +} +.tooltip-trigger:hover .tooltip-text { + display: block; +} +.tooltip-text { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + background: #26263a; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 3px; + color: #dcdce4; + font-size: 11px; + font-weight: 400; + line-height: 1.4; + white-space: normal; + width: 220px; + text-transform: none; + letter-spacing: normal; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + z-index: 100; + pointer-events: none; +} +.media-player { + position: relative; + background: #111118; + border-radius: 5px; + overflow: hidden; +} +.media-player:focus { + outline: none; +} +.media-player-audio .player-artwork { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + padding: 24px 16px 8px; +} +.player-native-video { + width: 100%; + display: block; +} +.player-native-audio { + display: none; +} +.player-artwork img { + max-width: 200px; + max-height: 200px; + border-radius: 5px; + object-fit: cover; +} +.player-artwork-placeholder { + width: 120px; + height: 120px; + display: flex; + align-items: center; + justify-content: center; + background: #1f1f28; + border-radius: 5px; + font-size: 48px; + opacity: 0.3; +} +.player-title { + font-size: 13px; + font-weight: 500; + color: #dcdce4; + text-align: center; +} +.player-controls { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: #1f1f28; +} +.media-player-video .player-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.7); + opacity: 0; + transition: opacity 0.2s; +} +.media-player-video:hover .player-controls { + opacity: 1; +} +.play-btn, +.mute-btn, +.fullscreen-btn { + background: none; + border: none; + color: #dcdce4; + cursor: pointer; + font-size: 18px; + padding: 4px; + line-height: 1; + transition: color 0.1s; +} +.play-btn:hover, +.mute-btn:hover, +.fullscreen-btn:hover { + color: #9698f7; +} +.player-time { + font-size: 11px; + color: #6c6c84; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + min-width: 36px; + text-align: center; + user-select: none; +} +.seek-bar { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 4px; + background: #26263a; + outline: none; + cursor: pointer; +} +.seek-bar::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: #7c7ef5; + cursor: pointer; + border: none; +} +.seek-bar::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: #7c7ef5; + cursor: pointer; + border: none; +} +.volume-slider { + width: 70px; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 4px; + background: #26263a; + outline: none; + cursor: pointer; +} +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: #a0a0b8; + cursor: pointer; + border: none; +} +.volume-slider::-moz-range-thumb { + width: 10px; + height: 10px; + border-radius: 50%; + background: #a0a0b8; + cursor: pointer; + border: none; +} +.image-viewer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.92); + z-index: 150; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + animation: fade-in 0.15s ease-out; +} +.image-viewer-overlay:focus { + outline: none; +} +.image-viewer-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + background: rgba(0, 0, 0, 0.5); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + z-index: 2; + user-select: none; +} +.image-viewer-toolbar-left, +.image-viewer-toolbar-center, +.image-viewer-toolbar-right { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; +} +.iv-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #dcdce4; + border-radius: 3px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + transition: background 0.1s; +} +.iv-btn:hover { + background: rgba(255, 255, 255, 0.12); +} +.iv-btn.iv-close { + color: #e45858; + font-weight: 600; +} +.iv-zoom-label { + font-size: 11px; + color: #a0a0b8; + min-width: 40px; + text-align: center; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; +} +.image-viewer-canvas { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} +.image-viewer-canvas img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} +.pdf-viewer { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + height: 100%; + min-height: 500px; + background: #111118; + border-radius: 5px; + overflow: hidden; +} +.pdf-toolbar { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: #18181f; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); +} +.pdf-toolbar-group { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 4px; +} +.pdf-toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 3px; + color: #a0a0b8; + font-size: 14px; + cursor: pointer; + transition: all 0.15s; +} +.pdf-toolbar-btn:hover:not(:disabled) { + background: #26263a; + color: #dcdce4; +} +.pdf-toolbar-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.pdf-zoom-label { + min-width: 45px; + text-align: center; + font-size: 12px; + color: #a0a0b8; +} +.pdf-container { + flex: 1; + position: relative; + overflow: hidden; + background: #1f1f28; +} +.pdf-object { + width: 100%; + height: 100%; + border: none; +} +.pdf-loading, +.pdf-error { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 12px; + background: #18181f; + color: #a0a0b8; +} +.pdf-error { + padding: 12px; + text-align: center; +} +.pdf-fallback { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 16px; + padding: 48px 12px; + text-align: center; + color: #6c6c84; +} +.markdown-viewer { + padding: 16px; + text-align: left; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 12px; +} +.markdown-toolbar { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + padding: 8px; + background: #1f1f28; + border-radius: 5px; + border: 1px solid rgba(255, 255, 255, 0.09); +} +.toolbar-btn { + padding: 6px 12px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 3px; + background: #18181f; + color: #a0a0b8; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} +.toolbar-btn:hover { + background: #26263a; + border-color: rgba(255, 255, 255, 0.14); +} +.toolbar-btn.active { + background: #7c7ef5; + color: #fff; + border-color: #7c7ef5; +} +.markdown-source { + max-width: 100%; + background: #26263a; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + padding: 16px; + overflow-x: auto; + font-family: "Menlo", "Monaco", "Courier New", monospace; + font-size: 13px; + line-height: 1.7; + color: #dcdce4; + white-space: pre-wrap; + word-wrap: break-word; +} +.markdown-source code { + font-family: inherit; + background: none; + padding: 0; + border: none; +} +.markdown-content { + max-width: 800px; + color: #dcdce4; + line-height: 1.7; + font-size: 14px; + text-align: left; +} +.markdown-content h1 { + font-size: 1.8em; + font-weight: 700; + margin: 1em 0 0.5em; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding-bottom: 0.3em; +} +.markdown-content h2 { + font-size: 1.5em; + font-weight: 600; + margin: 0.8em 0 0.4em; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding-bottom: 0.2em; +} +.markdown-content h3 { + font-size: 1.25em; + font-weight: 600; + margin: 0.6em 0 0.3em; +} +.markdown-content h4 { + font-size: 1.1em; + font-weight: 600; + margin: 0.5em 0 0.25em; +} +.markdown-content h5, +.markdown-content h6 { + font-size: 1em; + font-weight: 600; + margin: 0.4em 0 0.2em; + color: #a0a0b8; +} +.markdown-content p { + margin: 0 0 1em; +} +.markdown-content a { + color: #7c7ef5; + text-decoration: none; +} +.markdown-content a:hover { + text-decoration: underline; +} +.markdown-content pre { + background: #26263a; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 3px; + padding: 12px 16px; + overflow-x: auto; + margin: 0 0 1em; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-size: 12px; + line-height: 1.5; +} +.markdown-content code { + background: #26263a; + padding: 1px 5px; + border-radius: 3px; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-size: 0.9em; +} +.markdown-content pre code { + background: none; + padding: 0; +} +.markdown-content blockquote { + border-left: 3px solid #7c7ef5; + padding: 4px 16px; + margin: 0 0 1em; + color: #a0a0b8; + background: rgba(124, 126, 245, 0.04); +} +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin: 0 0 1em; +} +.markdown-content th, +.markdown-content td { + padding: 6px 12px; + border: 1px solid rgba(255, 255, 255, 0.09); + font-size: 13px; +} +.markdown-content th { + background: #26263a; + font-weight: 600; + text-align: left; +} +.markdown-content tr:nth-child(even) { + background: #1f1f28; +} +.markdown-content ul, +.markdown-content ol { + margin: 0 0 1em; + padding-left: 16px; +} +.markdown-content ul { + list-style: disc; +} +.markdown-content ol { + list-style: decimal; +} +.markdown-content li { + padding: 2px 0; + font-size: 14px; + color: #dcdce4; +} +.markdown-content hr { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.09); + margin: 1.5em 0; +} +.markdown-content img { + max-width: 100%; + border-radius: 5px; +} +.markdown-content .footnote-definition { + font-size: 0.85em; + color: #a0a0b8; + margin-top: 0.5em; + padding-left: 1.5em; +} +.markdown-content .footnote-definition sup { + color: #7c7ef5; + margin-right: 4px; +} +.markdown-content sup a { + color: #7c7ef5; + text-decoration: none; + font-size: 0.8em; +} +.wikilink { + color: #9698f7; + text-decoration: none; + border-bottom: 1px dashed #7c7ef5; + cursor: pointer; + transition: + border-color 0.1s, + color 0.1s; +} +.wikilink:hover { + color: #7c7ef5; + border-bottom-style: solid; +} +.wikilink-embed { + display: inline-block; + padding: 2px 8px; + background: rgba(139, 92, 246, 0.08); + border: 1px dashed rgba(139, 92, 246, 0.3); + border-radius: 3px; + color: #9d8be0; + font-size: 12px; + cursor: default; +} +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} +.media-card { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + overflow: hidden; + cursor: pointer; + transition: + border-color 0.12s, + box-shadow 0.12s; + position: relative; +} +.media-card:hover { + border-color: rgba(255, 255, 255, 0.14); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} +.media-card.selected { + border-color: #7c7ef5; + box-shadow: 0 0 0 1px #7c7ef5; +} +.card-checkbox { + position: absolute; + top: 6px; + left: 6px; + z-index: 2; + opacity: 0; + transition: opacity 0.1s; +} +.card-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); +} +.media-card:hover .card-checkbox, +.media-card.selected .card-checkbox { + opacity: 1; +} +.card-thumbnail { + width: 100%; + aspect-ratio: 1; + background: #111118; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} +.card-thumbnail img, +.card-thumbnail .card-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + z-index: 1; +} +.card-type-icon { + font-size: 32px; + opacity: 0.4; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 0; +} +.card-info { + padding: 8px 10px; +} +.card-name { + font-size: 12px; + font-weight: 500; + color: #dcdce4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} +.card-title, +.card-artist { + font-size: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} +.card-meta { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; + font-size: 10px; +} +.card-size { + color: #6c6c84; + font-size: 10px; +} +.table-thumb-cell { + width: 36px; + padding: 4px 6px !important; + position: relative; +} +.table-thumb { + width: 28px; + height: 28px; + object-fit: cover; + border-radius: 3px; + display: block; +} +.table-thumb-overlay { + position: absolute; + top: 4px; + left: 6px; + z-index: 1; +} +.table-type-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + font-size: 14px; + opacity: 0.5; + border-radius: 3px; + background: #111118; + z-index: 0; +} +.type-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.type-badge.type-audio { + background: rgba(139, 92, 246, 0.1); + color: #9d8be0; +} +.type-badge.type-video { + background: rgba(200, 72, 130, 0.1); + color: #d07eaa; +} +.type-badge.type-image { + background: rgba(34, 160, 80, 0.1); + color: #5cb97a; +} +.type-badge.type-document { + background: rgba(59, 120, 200, 0.1); + color: #6ca0d4; +} +.type-badge.type-text { + background: rgba(200, 160, 36, 0.1); + color: #c4a840; +} +.type-badge.type-other { + background: rgba(128, 128, 160, 0.08); + color: #6c6c84; +} +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.tag-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + background: rgba(124, 126, 245, 0.15); + color: #9698f7; + border-radius: 12px; + font-size: 11px; + font-weight: 500; +} +.tag-badge.selected { + background: #7c7ef5; + color: #fff; + cursor: pointer; +} +.tag-badge:not(.selected) { + cursor: pointer; +} +.tag-badge .tag-remove { + cursor: pointer; + opacity: 0.4; + font-size: 13px; + line-height: 1; + transition: opacity 0.1s; +} +.tag-badge .tag-remove:hover { + opacity: 1; +} +.tag-group { + margin-bottom: 6px; +} +.tag-children { + margin-left: 16px; + margin-top: 4px; + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.tag-confirm-delete { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: #a0a0b8; +} +.tag-confirm-yes { + cursor: pointer; + color: #e45858; + font-weight: 600; +} +.tag-confirm-yes:hover { + text-decoration: underline; +} +.tag-confirm-no { + cursor: pointer; + color: #6c6c84; + font-weight: 500; +} +.tag-confirm-no:hover { + text-decoration: underline; +} +.detail-actions { + display: flex; + gap: 6px; + margin-bottom: 16px; +} +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.detail-field { + padding: 10px 12px; + background: #111118; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.06); +} +.detail-field.full-width { + grid-column: 1/-1; +} +.detail-field input[type="text"], +.detail-field textarea, +.detail-field select { + width: 100%; + margin-top: 4px; +} +.detail-field textarea { + min-height: 64px; + resize: vertical; +} +.detail-label { + font-size: 10px; + font-weight: 600; + color: #6c6c84; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 2px; +} +.detail-value { + font-size: 13px; + color: #dcdce4; + word-break: break-all; +} +.detail-value.mono { + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-size: 11px; + color: #a0a0b8; +} +.detail-preview { + margin-bottom: 16px; + background: #111118; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 5px; + overflow: hidden; + text-align: center; +} +.detail-preview:has(.markdown-viewer) { + max-height: none; + overflow-y: auto; + text-align: left; +} +.detail-preview:not(:has(.markdown-viewer)) { + max-height: 450px; +} +.detail-preview img { + max-width: 100%; + max-height: 400px; + object-fit: contain; + display: block; + margin: 0 auto; +} +.detail-preview audio { + width: 100%; + padding: 16px; +} +.detail-preview video { + max-width: 100%; + max-height: 400px; + display: block; + margin: 0 auto; +} +.detail-no-preview { + padding: 16px 16px; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; +} +.frontmatter-card { + max-width: 800px; + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + padding: 12px 16px; + margin-bottom: 16px; +} +.frontmatter-fields { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + margin: 0; +} +.frontmatter-fields dt { + font-weight: 600; + font-size: 12px; + color: #a0a0b8; + text-transform: capitalize; +} +.frontmatter-fields dd { + font-size: 13px; + color: #dcdce4; + margin: 0; +} +.empty-state { + text-align: center; + padding: 48px 12px; + color: #6c6c84; +} +.empty-state .empty-icon { + font-size: 32px; + margin-bottom: 12px; + opacity: 0.3; +} +.empty-title { + font-size: 15px; + font-weight: 600; + color: #a0a0b8; + margin-bottom: 4px; +} +.empty-subtitle { + font-size: 12px; + max-width: 320px; + margin: 0 auto; + line-height: 1.5; +} +.toast-container { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 300; + display: flex; + flex-direction: column-reverse; + gap: 6px; + align-items: flex-end; +} +.toast-container .toast { + position: static; + transform: none; +} +.toast { + position: fixed; + bottom: 16px; + right: 16px; + padding: 10px 16px; + border-radius: 5px; + background: #26263a; + border: 1px solid rgba(255, 255, 255, 0.09); + color: #dcdce4; + font-size: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + z-index: 300; + animation: slide-up 0.15s ease-out; + max-width: 420px; +} +.toast.success { + border-left: 3px solid #3ec97a; +} +.toast.error { + border-left: 3px solid #e45858; +} +.offline-banner, +.error-banner { + background: rgba(228, 88, 88, 0.06); + border: 1px solid rgba(228, 88, 88, 0.2); + border-radius: 3px; + padding: 10px 12px; + margin-bottom: 12px; + font-size: 12px; + color: #d47070; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; +} +.offline-banner .offline-icon, +.offline-banner .error-icon, +.error-banner .offline-icon, +.error-banner .error-icon { + font-size: 14px; + flex-shrink: 0; +} +.error-banner { + padding: 10px 14px; +} +.readonly-banner { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: rgba(212, 160, 55, 0.06); + border: 1px solid rgba(212, 160, 55, 0.15); + border-radius: 3px; + margin-bottom: 16px; + font-size: 12px; + color: #d4a037; +} +.batch-actions { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: rgba(124, 126, 245, 0.15); + border: 1px solid rgba(124, 126, 245, 0.2); + border-radius: 3px; + margin-bottom: 12px; + font-size: 12px; + font-weight: 500; + color: #9698f7; +} +.select-all-banner { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: rgba(99, 102, 241, 0.08); + border-radius: 6px; + margin-bottom: 8px; + font-size: 0.85rem; + color: #a0a0b8; +} +.select-all-banner button { + background: none; + border: none; + color: #7c7ef5; + cursor: pointer; + font-weight: 600; + text-decoration: underline; + font-size: 0.85rem; + padding: 0; +} +.select-all-banner button:hover { + color: #dcdce4; +} +.import-status-panel { + background: #1f1f28; + border: 1px solid #7c7ef5; + border-radius: 5px; + padding: 12px 16px; + margin-bottom: 16px; +} +.import-status-header { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 13px; + color: #dcdce4; +} +.import-current-file { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 2px; + margin-bottom: 6px; + font-size: 12px; + overflow: hidden; +} +.import-file-label { + color: #6c6c84; + flex-shrink: 0; +} +.import-file-name { + color: #dcdce4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: monospace; + font-size: 11px; +} +.import-queue-indicator { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 2px; + margin-bottom: 8px; + font-size: 11px; +} +.import-queue-badge { + display: flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 6px; + background: rgba(124, 126, 245, 0.15); + color: #9698f7; + border-radius: 9px; + font-weight: 600; + font-size: 10px; +} +.import-queue-text { + color: #6c6c84; +} +.import-tabs { + display: flex; + gap: 0; + margin-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); +} +.import-tab { + padding: 10px 16px; + background: none; + border: none; + border-bottom: 2px solid rgba(0, 0, 0, 0); + color: #6c6c84; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + color 0.1s, + border-color 0.1s; +} +.import-tab:hover { + color: #dcdce4; +} +.import-tab.active { + color: #9698f7; + border-bottom-color: #7c7ef5; +} +.queue-panel { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + border-left: 1px solid rgba(255, 255, 255, 0.09); + background: #18181f; + min-width: 280px; + max-width: 320px; +} +.queue-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.queue-header h3 { + margin: 0; + font-size: 0.9rem; + color: #dcdce4; +} +.queue-controls { + display: flex; + gap: 2px; +} +.queue-list { + overflow-y: auto; + flex: 1; +} +.queue-item { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + padding: 8px 16px; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + transition: background 0.15s; +} +.queue-item:hover { + background: #1f1f28; +} +.queue-item:hover .queue-item-remove { + opacity: 1; +} +.queue-item-active { + background: rgba(124, 126, 245, 0.15); + border-left: 3px solid #7c7ef5; +} +.queue-item-info { + flex: 1; + min-width: 0; +} +.queue-item-title { + display: block; + font-size: 0.85rem; + color: #dcdce4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.queue-item-artist { + display: block; + font-size: 0.75rem; + color: #6c6c84; +} +.queue-item-remove { + opacity: 0; + transition: opacity 0.15s; +} +.queue-empty { + padding: 16px 16px; + text-align: center; + color: #6c6c84; + font-size: 0.85rem; +} +.statistics-page { + padding: 20px; +} +.stats-overview, +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 24px; +} +@media (max-width: 768px) { + .stats-overview, + .stats-grid { + grid-template-columns: 1fr; + } +} +.stat-card { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + padding: 20px; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 16px; +} +.stat-card.stat-primary { + border-left: 3px solid #7c7ef5; +} +.stat-card.stat-success { + border-left: 3px solid #3ec97a; +} +.stat-card.stat-info { + border-left: 3px solid #6ca0d4; +} +.stat-card.stat-warning { + border-left: 3px solid #d4a037; +} +.stat-card.stat-purple { + border-left: 3px solid #9d8be0; +} +.stat-card.stat-danger { + border-left: 3px solid #e45858; +} +.stat-icon { + flex-shrink: 0; + color: #6c6c84; +} +.stat-content { + flex: 1; +} +.stat-value { + font-size: 28px; + font-weight: 700; + color: #dcdce4; + line-height: 1.2; + font-variant-numeric: tabular-nums; +} +.stat-label { + font-size: 12px; + color: #6c6c84; + margin-top: 4px; + font-weight: 500; +} +.stats-section { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + padding: 16px; + margin-bottom: 20px; +} +.section-title { + font-size: 16px; + font-weight: 600; + color: #dcdce4; + margin-bottom: 20px; +} +.section-title.small { + font-size: 14px; + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.chart-bars { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 16px; +} +.bar-item { + display: grid; + grid-template-columns: 120px 1fr 80px; + align-items: center; + gap: 16px; +} +.bar-label { + font-size: 13px; + font-weight: 500; + color: #a0a0b8; + text-align: right; +} +.bar-track { + height: 28px; + background: #26263a; + border-radius: 3px; + overflow: hidden; + position: relative; +} +.bar-fill { + height: 100%; + transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 3px; +} +.bar-fill.bar-primary { + background: linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%); +} +.bar-fill.bar-success { + background: linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%); +} +.bar-value { + font-size: 13px; + font-weight: 600; + color: #a0a0b8; + text-align: right; + font-variant-numeric: tabular-nums; +} +.settings-section { + margin-bottom: 16px; +} +.settings-card { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + padding: 20px; + margin-bottom: 16px; +} +.settings-card.danger-card { + border: 1px solid rgba(228, 88, 88, 0.25); +} +.settings-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.settings-card-title { + font-size: 14px; + font-weight: 600; +} +.settings-card-body { + padding-top: 2px; +} +.settings-field { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.settings-field:last-child { + border-bottom: none; +} +.settings-field select { + min-width: 120px; +} +.config-path { + font-size: 11px; + color: #6c6c84; + margin-bottom: 12px; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + padding: 6px 10px; + background: #111118; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.06); +} +.config-status { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; +} +.config-status.writable { + background: rgba(62, 201, 122, 0.1); + color: #3ec97a; +} +.config-status.readonly { + background: rgba(228, 88, 88, 0.1); + color: #e45858; +} +.root-list { + list-style: none; +} +.root-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: #111118; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 3px; + margin-bottom: 4px; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-size: 12px; + color: #a0a0b8; +} +.info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + font-size: 13px; +} +.info-row:last-child { + border-bottom: none; +} +.info-label { + color: #a0a0b8; + font-weight: 500; +} +.info-value { + color: #dcdce4; +} +.tasks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + gap: 16px; + padding: 12px; +} +.task-card { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + overflow: hidden; + transition: all 0.2s; +} +.task-card:hover { + border-color: rgba(255, 255, 255, 0.14); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); +} +.task-card-enabled { + border-left: 3px solid #3ec97a; +} +.task-card-disabled { + border-left: 3px solid #4a4a5e; + opacity: 0.7; +} +.task-card-header { + display: flex; + justify-content: space-between; + align-items: center; + align-items: flex-start; + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.task-header-left { + flex: 1; + min-width: 0; +} +.task-name { + font-size: 16px; + font-weight: 600; + color: #dcdce4; + margin-bottom: 2px; +} +.task-schedule { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; + font-size: 12px; + color: #6c6c84; + font-family: "Menlo", "Monaco", "Courier New", monospace; +} +.schedule-icon { + font-size: 14px; +} +.task-status-badge { + flex-shrink: 0; +} +.status-badge { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.status-badge.status-enabled { + background: rgba(76, 175, 80, 0.12); + color: #3ec97a; +} +.status-badge.status-enabled .status-dot { + animation: pulse 1.5s infinite; +} +.status-badge.status-disabled { + background: #26263a; + color: #6c6c84; +} +.status-badge .status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: currentColor; +} +.task-info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; + padding: 16px; +} +.task-info-item { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + gap: 10px; +} +.task-info-icon { + font-size: 18px; + color: #6c6c84; + flex-shrink: 0; +} +.task-info-content { + flex: 1; + min-width: 0; +} +.task-info-label { + font-size: 10px; + color: #6c6c84; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-bottom: 2px; +} +.task-info-value { + font-size: 12px; + color: #a0a0b8; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.task-card-actions { + display: flex; + gap: 8px; + padding: 10px 16px; + background: #18181f; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} +.task-card-actions button { + flex: 1; +} +.db-actions { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 16px; + padding: 10px; +} +.db-action-row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 10px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.06); +} +.db-action-info { + flex: 1; +} +.db-action-info h4 { + font-size: 0.95rem; + font-weight: 600; + color: #dcdce4; + margin-bottom: 2px; +} +.db-action-confirm { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.library-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + margin-bottom: 12px; + gap: 12px; + flex-wrap: wrap; +} +.toolbar-left { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 10px; +} +.toolbar-right { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 10px; +} +.sort-control select, +.page-size-control select { + padding: 4px 24px 4px 8px; + font-size: 11px; + background: #1f1f28; +} +.page-size-control { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 4px; +} +.library-stats { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 0 6px 0; + font-size: 11px; +} +.type-filter-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; + padding: 4px 0; + margin-bottom: 6px; + flex-wrap: wrap; +} +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-top: 16px; + padding: 8px 0; +} +.page-btn { + min-width: 28px; + text-align: center; + font-variant-numeric: tabular-nums; +} +.page-ellipsis { + color: #6c6c84; + padding: 0 4px; + font-size: 12px; + user-select: none; +} +.audit-controls { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} +.filter-select { + padding: 4px 24px 4px 8px; + font-size: 11px; + background: #1f1f28; +} +.action-danger { + background: rgba(228, 88, 88, 0.1); + color: #d47070; +} +.action-updated { + background: rgba(59, 120, 200, 0.1); + color: #6ca0d4; +} +.action-collection { + background: rgba(34, 160, 80, 0.1); + color: #5cb97a; +} +.action-collection-remove { + background: rgba(212, 160, 55, 0.1); + color: #c4a840; +} +.action-opened { + background: rgba(139, 92, 246, 0.1); + color: #9d8be0; +} +.action-scanned { + background: rgba(128, 128, 160, 0.08); + color: #6c6c84; +} +.clickable { + cursor: pointer; + color: #9698f7; +} +.clickable:hover { + text-decoration: underline; +} +.clickable-row { + cursor: pointer; +} +.clickable-row:hover { + background: rgba(255, 255, 255, 0.03); +} +.duplicates-view { + padding: 0; +} +.duplicates-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} +.duplicates-header h3 { + margin: 0; +} +.duplicates-summary { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 12px; +} +.duplicate-group { + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + margin-bottom: 8px; + overflow: hidden; +} +.duplicate-group-header { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 14px; + background: #1f1f28; + border: none; + cursor: pointer; + text-align: left; + color: #dcdce4; + font-size: 13px; +} +.duplicate-group-header:hover { + background: #26263a; +} +.expand-icon { + font-size: 10px; + width: 14px; + flex-shrink: 0; +} +.group-name { + font-weight: 600; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.group-badge { + background: #7c7ef5; + color: #fff; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} +.group-size { + flex-shrink: 0; + font-size: 12px; +} +.group-hash { + font-size: 11px; + flex-shrink: 0; +} +.duplicate-items { + border-top: 1px solid rgba(255, 255, 255, 0.09); +} +.duplicate-item { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.duplicate-item:last-child { + border-bottom: none; +} +.duplicate-item-keep { + background: rgba(76, 175, 80, 0.06); +} +.dup-thumb { + width: 48px; + height: 48px; + flex-shrink: 0; + border-radius: 3px; + overflow: hidden; +} +.dup-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; +} +.dup-thumb-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: #26263a; + font-size: 20px; + color: #6c6c84; +} +.dup-info { + flex: 1; + min-width: 0; +} +.dup-filename { + font-weight: 600; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dup-path { + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dup-meta { + font-size: 12px; + margin-top: 2px; +} +.dup-actions { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.keep-badge { + background: rgba(76, 175, 80, 0.12); + color: #4caf50; + padding: 2px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} +.saved-searches-list { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 4px; + max-height: 300px; + overflow-y: auto; +} +.saved-search-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: #18181f; + border-radius: 3px; + cursor: pointer; + transition: background 0.15s ease; +} +.saved-search-item:hover { + background: #1f1f28; +} +.saved-search-info { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 2px; + flex: 1; + min-width: 0; +} +.saved-search-name { + font-weight: 500; + color: #dcdce4; +} +.saved-search-query { + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.backlinks-panel, +.outgoing-links-panel { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + margin-top: 16px; + overflow: hidden; +} +.backlinks-header, +.outgoing-links-header { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: #26263a; + cursor: pointer; + user-select: none; + transition: background 0.1s; +} +.backlinks-header:hover, +.outgoing-links-header:hover { + background: rgba(255, 255, 255, 0.04); +} +.backlinks-toggle, +.outgoing-links-toggle { + font-size: 10px; + color: #6c6c84; + width: 12px; + text-align: center; +} +.backlinks-title, +.outgoing-links-title { + font-size: 12px; + font-weight: 600; + color: #dcdce4; + flex: 1; +} +.backlinks-count, +.outgoing-links-count { + font-size: 11px; + color: #6c6c84; +} +.backlinks-reindex-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + margin-left: auto; + background: rgba(0, 0, 0, 0); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 3px; + color: #6c6c84; + font-size: 12px; + cursor: pointer; + transition: + background 0.1s, + color 0.1s, + border-color 0.1s; +} +.backlinks-reindex-btn:hover:not(:disabled) { + background: #1f1f28; + color: #dcdce4; + border-color: rgba(255, 255, 255, 0.14); +} +.backlinks-reindex-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.backlinks-content, +.outgoing-links-content { + padding: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} +.backlinks-loading, +.outgoing-links-loading { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + padding: 12px; + color: #6c6c84; + font-size: 12px; +} +.backlinks-error, +.outgoing-links-error { + padding: 8px 12px; + background: rgba(228, 88, 88, 0.06); + border: 1px solid rgba(228, 88, 88, 0.2); + border-radius: 3px; + font-size: 12px; + color: #e45858; +} +.backlinks-empty, +.outgoing-links-empty { + padding: 16px; + text-align: center; + color: #6c6c84; + font-size: 12px; + font-style: italic; +} +.backlinks-list, +.outgoing-links-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 6px; +} +.backlink-item, +.outgoing-link-item { + padding: 10px 12px; + background: #111118; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 3px; + cursor: pointer; + transition: + background 0.1s, + border-color 0.1s; +} +.backlink-item:hover, +.outgoing-link-item:hover { + background: #18181f; + border-color: rgba(255, 255, 255, 0.09); +} +.backlink-item.unresolved, +.outgoing-link-item.unresolved { + opacity: 0.7; + border-style: dashed; +} +.backlink-source, +.outgoing-link-target { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + margin-bottom: 2px; +} +.backlink-title, +.outgoing-link-text { + font-size: 13px; + font-weight: 500; + color: #dcdce4; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.backlink-type-badge, +.outgoing-link-type-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 12px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.backlink-type-badge.backlink-type-wikilink, +.backlink-type-badge.link-type-wikilink, +.outgoing-link-type-badge.backlink-type-wikilink, +.outgoing-link-type-badge.link-type-wikilink { + background: rgba(124, 126, 245, 0.15); + color: #9698f7; +} +.backlink-type-badge.backlink-type-embed, +.backlink-type-badge.link-type-embed, +.outgoing-link-type-badge.backlink-type-embed, +.outgoing-link-type-badge.link-type-embed { + background: rgba(139, 92, 246, 0.1); + color: #9d8be0; +} +.backlink-type-badge.backlink-type-markdown_link, +.backlink-type-badge.link-type-markdown_link, +.outgoing-link-type-badge.backlink-type-markdown_link, +.outgoing-link-type-badge.link-type-markdown_link { + background: rgba(59, 120, 200, 0.1); + color: #6ca0d4; +} +.backlink-context { + font-size: 11px; + color: #6c6c84; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.backlink-line { + color: #a0a0b8; + font-weight: 500; +} +.unresolved-badge { + padding: 1px 6px; + border-radius: 12px; + font-size: 9px; + font-weight: 600; + background: rgba(212, 160, 55, 0.1); + color: #d4a037; +} +.outgoing-links-unresolved-badge { + margin-left: 8px; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 500; + background: rgba(212, 160, 55, 0.12); + color: #d4a037; +} +.outgoing-links-global-unresolved { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 6px; + margin-top: 12px; + padding: 10px 12px; + background: rgba(212, 160, 55, 0.06); + border: 1px solid rgba(212, 160, 55, 0.15); + border-radius: 3px; + font-size: 11px; + color: #6c6c84; +} +.outgoing-links-global-unresolved .unresolved-icon { + color: #d4a037; +} +.backlinks-message { + padding: 8px 10px; + margin-bottom: 10px; + border-radius: 3px; + font-size: 11px; +} +.backlinks-message.success { + background: rgba(62, 201, 122, 0.08); + border: 1px solid rgba(62, 201, 122, 0.2); + color: #3ec97a; +} +.backlinks-message.error { + background: rgba(228, 88, 88, 0.06); + border: 1px solid rgba(228, 88, 88, 0.2); + color: #e45858; +} +.graph-view { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + height: 100%; + background: #18181f; + border-radius: 5px; + overflow: hidden; +} +.graph-toolbar { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 16px; + padding: 12px 16px; + background: #1f1f28; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); +} +.graph-title { + font-size: 14px; + font-weight: 600; + color: #dcdce4; +} +.graph-controls { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + font-size: 12px; + color: #a0a0b8; +} +.graph-controls select { + padding: 4px 20px 4px 8px; + font-size: 11px; + background: #26263a; +} +.graph-stats { + margin-left: auto; + font-size: 11px; + color: #6c6c84; +} +.graph-container { + flex: 1; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: #111118; +} +.graph-loading, +.graph-error, +.graph-empty { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + padding: 48px; + color: #6c6c84; + font-size: 13px; + text-align: center; +} +.graph-svg { + max-width: 100%; + max-height: 100%; + cursor: grab; +} +.graph-svg-container { + position: relative; + width: 100%; + height: 100%; +} +.graph-zoom-controls { + position: absolute; + top: 16px; + left: 16px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 8px; + z-index: 5; +} +.zoom-btn { + width: 36px; + height: 36px; + border-radius: 6px; + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + color: #dcdce4; + font-size: 18px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} +.zoom-btn:hover { + background: #26263a; + border-color: rgba(255, 255, 255, 0.14); + transform: scale(1.05); +} +.zoom-btn:active { + transform: scale(0.95); +} +.graph-edges line { + stroke: rgba(255, 255, 255, 0.14); + stroke-width: 1; + opacity: 0.6; +} +.graph-edges line.edge-type-wikilink { + stroke: #7c7ef5; +} +.graph-edges line.edge-type-embed { + stroke: #9d8be0; + stroke-dasharray: 4 2; +} +.graph-nodes .graph-node { + cursor: pointer; +} +.graph-nodes .graph-node circle { + fill: #4caf50; + stroke: #388e3c; + stroke-width: 2; + transition: + fill 0.15s, + stroke 0.15s; +} +.graph-nodes .graph-node:hover circle { + fill: #66bb6a; +} +.graph-nodes .graph-node.selected circle { + fill: #7c7ef5; + stroke: #5456d6; +} +.graph-nodes .graph-node text { + fill: #a0a0b8; + font-size: 11px; + pointer-events: none; + text-anchor: middle; + dominant-baseline: central; + transform: translateY(16px); +} +.node-details-panel { + position: absolute; + top: 16px; + right: 16px; + width: 280px; + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + z-index: 10; +} +.node-details-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.node-details-header h3 { + font-size: 13px; + font-weight: 600; + color: #dcdce4; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.node-details-header .close-btn { + background: none; + border: none; + color: #6c6c84; + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + line-height: 1; +} +.node-details-header .close-btn:hover { + color: #dcdce4; +} +.node-details-content { + padding: 14px; +} +.node-details-content .node-title { + font-size: 12px; + color: #a0a0b8; + margin-bottom: 12px; +} +.node-stats { + display: flex; + gap: 16px; + margin-bottom: 12px; +} +.node-stats .stat { + font-size: 12px; + color: #6c6c84; +} +.node-stats .stat strong { + color: #dcdce4; +} +.physics-controls-panel { + position: absolute; + top: 16px; + right: 16px; + width: 300px; + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); + padding: 16px; + z-index: 10; +} +.physics-controls-panel h4 { + font-size: 13px; + font-weight: 600; + color: #dcdce4; + margin: 0 0 16px 0; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.physics-controls-panel .btn { + width: 100%; + margin-top: 8px; +} +.control-group { + margin-bottom: 14px; +} +.control-group label { + display: block; + font-size: 11px; + font-weight: 500; + color: #a0a0b8; + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.control-group input[type="range"] { + width: 100%; + height: 4px; + border-radius: 4px; + background: #26263a; + outline: none; + -webkit-appearance: none; +} +.control-group input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #7c7ef5; + cursor: pointer; + transition: transform 0.1s; +} +.control-group input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.15); +} +.control-group input[type="range"]::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: #7c7ef5; + cursor: pointer; + border: none; + transition: transform 0.1s; +} +.control-group input[type="range"]::-moz-range-thumb:hover { + transform: scale(1.15); +} +.control-value { + display: inline-block; + margin-top: 2px; + font-size: 11px; + color: #6c6c84; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; +} +.theme-light { + --bg-0: #f5f5f7; + --bg-1: #eeeef0; + --bg-2: #fff; + --bg-3: #e8e8ec; + --border-subtle: rgba(0, 0, 0, 0.06); + --border: rgba(0, 0, 0, 0.1); + --border-strong: rgba(0, 0, 0, 0.16); + --text-0: #1a1a2e; + --text-1: #555570; + --text-2: #8888a0; + --accent: #6366f1; + --accent-dim: rgba(99, 102, 241, 0.1); + --accent-text: #4f52e8; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.12); +} +.theme-light ::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.12); +} +.theme-light ::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.08); +} +.theme-light ::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.06); +} +.theme-light .graph-nodes .graph-node text { + fill: #1a1a2e; +} +.theme-light .graph-edges line { + stroke: rgba(0, 0, 0, 0.12); +} +.theme-light .pdf-container { + background: #e8e8ec; +} +.skeleton-pulse { + animation: skeleton-pulse 1.5s ease-in-out infinite; + background: #26263a; + border-radius: 4px; +} +.skeleton-card { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 8px; + padding: 8px; +} +.skeleton-thumb { + width: 100%; + aspect-ratio: 1; + border-radius: 6px; +} +.skeleton-text { + height: 14px; + width: 80%; +} +.skeleton-text-short { + width: 50%; +} +.skeleton-row { + display: flex; + gap: 12px; + padding: 10px 16px; + align-items: center; +} +.skeleton-cell { + height: 14px; + flex: 1; + border-radius: 4px; +} +.skeleton-cell-icon { + width: 32px; + height: 32px; + flex: none; + border-radius: 4px; +} +.skeleton-cell-wide { + flex: 3; +} +.loading-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + background: rgba(0, 0, 0, 0.3); + z-index: 100; + border-radius: 8px; +} +.loading-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.09); + border-top-color: #7c7ef5; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +.loading-message { + color: #a0a0b8; + font-size: 0.9rem; +} +.login-container { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + background: #111118; +} +.login-card { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + padding: 24px; + width: 360px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); +} +.login-title { + font-size: 20px; + font-weight: 700; + color: #dcdce4; + text-align: center; + margin-bottom: 2px; +} +.login-subtitle { + font-size: 13px; + color: #6c6c84; + text-align: center; + margin-bottom: 20px; +} +.login-error { + background: rgba(228, 88, 88, 0.08); + border: 1px solid rgba(228, 88, 88, 0.2); + border-radius: 3px; + padding: 8px 12px; + margin-bottom: 12px; + font-size: 12px; + color: #e45858; +} +.login-form input[type="text"], +.login-form input[type="password"] { + width: 100%; +} +.login-btn { + width: 100%; + padding: 8px 16px; + font-size: 13px; + margin-top: 2px; +} +.pagination { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 2px; + margin-top: 16px; + padding: 8px 0; +} +.page-btn { + min-width: 28px; + text-align: center; + font-variant-numeric: tabular-nums; +} +.page-ellipsis { + color: #6c6c84; + padding: 0 4px; + font-size: 12px; + user-select: none; +} +.help-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: fade-in 0.1s ease-out; +} +.help-dialog { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + padding: 16px; + min-width: 300px; + max-width: 400px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); +} +.help-dialog h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; +} +.help-shortcuts { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 8px; + margin-bottom: 16px; +} +.shortcut-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 12px; +} +.shortcut-row kbd { + display: inline-block; + padding: 2px 8px; + background: #111118; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 3px; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-size: 11px; + color: #dcdce4; + min-width: 32px; + text-align: center; +} +.shortcut-row span { + font-size: 13px; + color: #a0a0b8; +} +.help-close { + display: block; + width: 100%; + padding: 6px 12px; + background: #26263a; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 3px; + color: #dcdce4; + font-size: 12px; + cursor: pointer; + text-align: center; +} +.help-close:hover { + background: rgba(255, 255, 255, 0.06); +} +.plugin-page { + padding: 16px 24px; + max-width: 100%; + overflow-x: hidden; +} +.plugin-page-title { + font-size: 14px; + font-weight: 600; + color: #dcdce4; + margin: 0 0 16px; +} +.plugin-container { + display: flex; + flex-direction: column; + gap: var(--plugin-gap, 0px); + padding: var(--plugin-padding, 0); +} +.plugin-grid { + display: grid; + grid-template-columns: repeat(var(--plugin-columns, 1), 1fr); + gap: var(--plugin-gap, 0px); +} +.plugin-flex { + display: flex; + gap: var(--plugin-gap, 0px); +} +.plugin-flex[data-direction="row"] { + flex-direction: row; +} +.plugin-flex[data-direction="column"] { + flex-direction: column; +} +.plugin-flex[data-justify="flex-start"] { + justify-content: flex-start; +} +.plugin-flex[data-justify="flex-end"] { + justify-content: flex-end; +} +.plugin-flex[data-justify="center"] { + justify-content: center; +} +.plugin-flex[data-justify="space-between"] { + justify-content: space-between; +} +.plugin-flex[data-justify="space-around"] { + justify-content: space-around; +} +.plugin-flex[data-justify="space-evenly"] { + justify-content: space-evenly; +} +.plugin-flex[data-align="flex-start"] { + align-items: flex-start; +} +.plugin-flex[data-align="flex-end"] { + align-items: flex-end; +} +.plugin-flex[data-align="center"] { + align-items: center; +} +.plugin-flex[data-align="stretch"] { + align-items: stretch; +} +.plugin-flex[data-align="baseline"] { + align-items: baseline; +} +.plugin-flex[data-wrap="wrap"] { + flex-wrap: wrap; +} +.plugin-flex[data-wrap="nowrap"] { + flex-wrap: nowrap; +} +.plugin-split { + display: flex; +} +.plugin-split-sidebar { + width: var(--plugin-sidebar-width, 200px); + flex-shrink: 0; +} +.plugin-split-main { + flex: 1; + min-width: 0; +} +.plugin-card { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + overflow: hidden; +} +.plugin-card-header { + padding: 12px 16px; + font-size: 12px; + font-weight: 600; + color: #dcdce4; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); + background: #26263a; +} +.plugin-card-content { + padding: 16px; +} +.plugin-card-footer { + padding: 12px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.09); + background: #18181f; +} +.plugin-heading { + color: #dcdce4; + margin: 0; + line-height: 1.2; +} +.plugin-heading.level-1 { + font-size: 28px; + font-weight: 700; +} +.plugin-heading.level-2 { + font-size: 18px; + font-weight: 600; +} +.plugin-heading.level-3 { + font-size: 16px; + font-weight: 600; +} +.plugin-heading.level-4 { + font-size: 14px; + font-weight: 500; +} +.plugin-heading.level-5 { + font-size: 13px; + font-weight: 500; +} +.plugin-heading.level-6 { + font-size: 12px; + font-weight: 500; +} +.plugin-text { + margin: 0; + font-size: 12px; + color: #dcdce4; + line-height: 1.4; +} +.plugin-text.text-secondary { + color: #a0a0b8; +} +.plugin-text.text-error { + color: #d47070; +} +.plugin-text.text-success { + color: #3ec97a; +} +.plugin-text.text-warning { + color: #d4a037; +} +.plugin-text.text-bold { + font-weight: 600; +} +.plugin-text.text-italic { + font-style: italic; +} +.plugin-text.text-small { + font-size: 10px; +} +.plugin-text.text-large { + font-size: 15px; +} +.plugin-code { + background: #18181f; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + padding: 16px 24px; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + font-size: 12px; + color: #dcdce4; + overflow-x: auto; + white-space: pre; +} +.plugin-code code { + font-family: inherit; + font-size: inherit; + color: inherit; +} +.plugin-tabs { + display: flex; + flex-direction: column; +} +.plugin-tab-list { + display: flex; + gap: 2px; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); + margin-bottom: 16px; +} +.plugin-tab { + padding: 8px 20px; + font-size: 12px; + font-weight: 500; + color: #a0a0b8; + background: rgba(0, 0, 0, 0); + border: none; + border-bottom: 2px solid rgba(0, 0, 0, 0); + cursor: pointer; + transition: + color 0.1s, + border-color 0.1s; +} +.plugin-tab:hover { + color: #dcdce4; +} +.plugin-tab.active { + color: #9698f7; + border-bottom-color: #7c7ef5; +} +.plugin-tab .tab-icon { + margin-right: 4px; +} +.plugin-tab-panel:not(.active) { + display: none; +} +.plugin-description-list-wrapper { + width: 100%; +} +.plugin-description-list { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px 16px; + margin: 0; + padding: 0; +} +.plugin-description-list dt { + font-size: 10px; + font-weight: 500; + color: #a0a0b8; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 6px 0; + white-space: nowrap; +} +.plugin-description-list dd { + font-size: 12px; + color: #dcdce4; + padding: 6px 0; + margin: 0; + word-break: break-word; +} +.plugin-description-list.horizontal { + display: flex; + flex-wrap: wrap; + gap: 16px 24px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); +} +.plugin-description-list.horizontal dt { + width: auto; + padding: 0; +} +.plugin-description-list.horizontal dd { + width: auto; + padding: 0; +} +.plugin-description-list.horizontal dt, +.plugin-description-list.horizontal dd { + display: inline; +} +.plugin-description-list.horizontal dt { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6c6c84; + margin-bottom: 2px; +} +.plugin-description-list.horizontal dd { + font-size: 13px; + font-weight: 600; + color: #dcdce4; +} +.plugin-data-table-wrapper { + overflow-x: auto; +} +.plugin-data-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.plugin-data-table thead tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.14); +} +.plugin-data-table thead th { + padding: 8px 12px; + text-align: left; + font-size: 10px; + font-weight: 600; + color: #a0a0b8; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} +.plugin-data-table tbody tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + transition: background 0.08s; +} +.plugin-data-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} +.plugin-data-table tbody tr:last-child { + border-bottom: none; +} +.plugin-data-table tbody td { + padding: 8px 12px; + color: #dcdce4; + vertical-align: middle; +} +.plugin-col-constrained { + width: var(--plugin-col-width); +} +.table-filter { + margin-bottom: 12px; +} +.table-filter input { + width: 240px; + padding: 6px 12px; + background: #18181f; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + color: #dcdce4; + font-size: 12px; +} +.table-filter input::placeholder { + color: #6c6c84; +} +.table-filter input:focus { + outline: none; + border-color: #7c7ef5; +} +.table-pagination { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + font-size: 12px; + color: #a0a0b8; +} +.row-actions { + white-space: nowrap; + width: 1%; +} +.row-actions .plugin-button { + padding: 4px 8px; + font-size: 10px; + margin-right: 4px; +} +.plugin-media-grid { + display: grid; + grid-template-columns: repeat(var(--plugin-columns, 2), 1fr); + gap: var(--plugin-gap, 8px); +} +.media-grid-item { + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + overflow: hidden; + display: flex; + flex-direction: column; +} +.media-grid-img { + width: 100%; + aspect-ratio: 16/9; + object-fit: cover; + display: block; +} +.media-grid-no-img { + width: 100%; + aspect-ratio: 16/9; + background: #26263a; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: #6c6c84; +} +.media-grid-caption { + padding: 8px 12px; + font-size: 10px; + color: #dcdce4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.plugin-list { + list-style: none; + margin: 0; + padding: 0; +} +.plugin-list-item { + padding: 8px 0; +} +.plugin-list-divider { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.06); + margin: 0; +} +.plugin-list-empty { + padding: 16px; + text-align: center; + color: #6c6c84; + font-size: 12px; +} +.plugin-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + background 0.08s, + border-color 0.08s, + color 0.08s; + background: #1f1f28; + color: #dcdce4; +} +.plugin-button:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.plugin-button.btn-primary { + background: #7c7ef5; + border-color: #7c7ef5; + color: #fff; +} +.plugin-button.btn-primary:hover:not(:disabled) { + background: #8b8df7; +} +.plugin-button.btn-secondary { + background: #26263a; + border-color: rgba(255, 255, 255, 0.14); + color: #dcdce4; +} +.plugin-button.btn-secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.04); +} +.plugin-button.btn-tertiary { + background: rgba(0, 0, 0, 0); + border-color: rgba(0, 0, 0, 0); + color: #9698f7; +} +.plugin-button.btn-tertiary:hover:not(:disabled) { + background: rgba(124, 126, 245, 0.15); +} +.plugin-button.btn-danger { + background: rgba(0, 0, 0, 0); + border-color: rgba(228, 88, 88, 0.2); + color: #d47070; +} +.plugin-button.btn-danger:hover:not(:disabled) { + background: rgba(228, 88, 88, 0.06); +} +.plugin-button.btn-success { + background: rgba(0, 0, 0, 0); + border-color: rgba(62, 201, 122, 0.2); + color: #3ec97a; +} +.plugin-button.btn-success:hover:not(:disabled) { + background: rgba(62, 201, 122, 0.08); +} +.plugin-button.btn-ghost { + background: rgba(0, 0, 0, 0); + border-color: rgba(0, 0, 0, 0); + color: #a0a0b8; +} +.plugin-button.btn-ghost:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.04); +} +.plugin-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 50%; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; +} +.plugin-badge.badge-default, +.plugin-badge.badge-neutral { + background: rgba(255, 255, 255, 0.04); + color: #a0a0b8; +} +.plugin-badge.badge-primary { + background: rgba(124, 126, 245, 0.15); + color: #9698f7; +} +.plugin-badge.badge-secondary { + background: rgba(255, 255, 255, 0.03); + color: #dcdce4; +} +.plugin-badge.badge-success { + background: rgba(62, 201, 122, 0.08); + color: #3ec97a; +} +.plugin-badge.badge-warning { + background: rgba(212, 160, 55, 0.06); + color: #d4a037; +} +.plugin-badge.badge-error { + background: rgba(228, 88, 88, 0.06); + color: #d47070; +} +.plugin-badge.badge-info { + background: rgba(99, 102, 241, 0.08); + color: #9698f7; +} +.plugin-form { + display: flex; + flex-direction: column; + gap: 16px; +} +.form-field { + display: flex; + flex-direction: column; + gap: 6px; +} +.form-field label { + font-size: 12px; + font-weight: 500; + color: #dcdce4; +} +.form-field input, +.form-field textarea, +.form-field select { + padding: 8px 12px; + background: #18181f; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + color: #dcdce4; + font-size: 12px; + font-family: inherit; +} +.form-field input::placeholder, +.form-field textarea::placeholder, +.form-field select::placeholder { + color: #6c6c84; +} +.form-field input:focus, +.form-field textarea:focus, +.form-field select:focus { + outline: none; + border-color: #7c7ef5; + box-shadow: 0 0 0 2px rgba(124, 126, 245, 0.15); +} +.form-field textarea { + min-height: 80px; + resize: vertical; +} +.form-field select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 32px; +} +.form-help { + margin: 0; + font-size: 10px; + color: #6c6c84; +} +.form-actions { + display: flex; + gap: 12px; + padding-top: 8px; +} +.required { + color: #e45858; +} +.plugin-link { + color: #9698f7; + text-decoration: none; +} +.plugin-link:hover { + text-decoration: underline; +} +.plugin-link-blocked { + color: #6c6c84; + text-decoration: line-through; + cursor: not-allowed; +} +.plugin-progress { + background: #18181f; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 5px; + height: 8px; + overflow: hidden; + display: flex; + align-items: center; + gap: 8px; +} +.plugin-progress-bar { + height: 100%; + background: #7c7ef5; + border-radius: 4px; + transition: width 0.3s ease; + width: var(--plugin-progress, 0%); +} +.plugin-progress-label { + font-size: 10px; + color: #a0a0b8; + white-space: nowrap; + flex-shrink: 0; +} +.plugin-chart { + overflow: auto; + height: var(--plugin-chart-height, 200px); +} +.plugin-chart .chart-title { + font-size: 13px; + font-weight: 600; + color: #dcdce4; + margin-bottom: 8px; +} +.plugin-chart .chart-x-label, +.plugin-chart .chart-y-label { + font-size: 10px; + color: #6c6c84; + margin-bottom: 4px; +} +.plugin-chart .chart-data-table { + overflow-x: auto; +} +.plugin-chart .chart-no-data { + padding: 24px; + text-align: center; + color: #6c6c84; + font-size: 12px; +} +.plugin-loading { + padding: 16px; + color: #a0a0b8; + font-size: 12px; + font-style: italic; +} +.plugin-error { + padding: 12px 16px; + background: rgba(228, 88, 88, 0.06); + border: 1px solid rgba(228, 88, 88, 0.2); + border-radius: 5px; + color: #d47070; + font-size: 12px; +} +.plugin-feedback { + position: sticky; + bottom: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 16px; + border-radius: 7px; + font-size: 12px; + z-index: 300; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); +} +.plugin-feedback.success { + background: rgba(62, 201, 122, 0.08); + border: 1px solid rgba(62, 201, 122, 0.2); + color: #3ec97a; +} +.plugin-feedback.error { + background: rgba(228, 88, 88, 0.06); + border: 1px solid rgba(228, 88, 88, 0.2); + color: #d47070; +} +.plugin-feedback-dismiss { + background: rgba(0, 0, 0, 0); + border: none; + color: inherit; + font-size: 14px; + cursor: pointer; + line-height: 1; + padding: 0; + opacity: 0.7; +} +.plugin-feedback-dismiss:hover { + opacity: 1; +} +.plugin-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} +.plugin-modal { + position: relative; + background: #1f1f28; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 12px; + padding: 32px; + min-width: 380px; + max-width: 640px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); + z-index: 200; +} +.plugin-modal-close { + position: absolute; + top: 16px; + right: 16px; + background: rgba(0, 0, 0, 0); + border: none; + color: #a0a0b8; + font-size: 14px; + cursor: pointer; + line-height: 1; + padding: 4px; + border-radius: 5px; +} +.plugin-modal-close:hover { + background: rgba(255, 255, 255, 0.04); + color: #dcdce4; +} diff --git a/docs/api/analytics.md b/docs/api/analytics.md index f706d2f..086e07e 100644 --- a/docs/api/analytics.md +++ b/docs/api/analytics.md @@ -16,11 +16,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Event recorded | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Event recorded | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,18 +30,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `limit` | query | No | Maximum number of results | -| `offset` | query | No | Pagination offset | +| Name | In | Required | Description | +| -------- | ----- | -------- | ------------------------- | +| `limit` | query | No | Maximum number of results | +| `offset` | query | No | Pagination offset | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Most viewed media | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Most viewed media | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -51,18 +51,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `limit` | query | No | Maximum number of results | -| `offset` | query | No | Pagination offset | +| Name | In | Required | Description | +| -------- | ----- | -------- | ------------------------- | +| `limit` | query | No | Maximum number of results | +| `offset` | query | No | Pagination offset | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Recently viewed media | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Recently viewed media | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -72,18 +72,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Watch progress | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Watch progress | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -93,9 +93,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -105,13 +105,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Progress updated | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Progress updated | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- - diff --git a/docs/api/audit.md b/docs/api/audit.md index adcf493..5385ca8 100644 --- a/docs/api/audit.md +++ b/docs/api/audit.md @@ -10,18 +10,17 @@ Audit log entries #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Page size | +| Name | In | Required | Description | +| -------- | ----- | -------- | ----------------- | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Page size | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Audit log entries | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Audit log entries | +| 401 | Unauthorized | +| 500 | Internal server error | --- - diff --git a/docs/api/auth.md b/docs/api/auth.md index f62c099..edbd3bd 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -16,12 +16,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Login successful | -| 400 | Bad request | -| 401 | Invalid credentials | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Login successful | +| 400 | Bad request | +| 401 | Invalid credentials | +| 500 | Internal server error | --- @@ -31,11 +31,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Logged out | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Logged out | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -45,28 +45,27 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Current user info | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Current user info | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### POST /api/v1/auth/refresh -Refresh the current session, extending its expiry by the configured -duration. +Refresh the current session, extending its expiry by the configured duration. **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | Session refreshed | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Session refreshed | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -78,11 +77,11 @@ Revoke all sessions for the current user #### Responses -| Status | Description | -|--------|-------------| -| 200 | All sessions revoked | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | All sessions revoked | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -92,12 +91,11 @@ Revoke all sessions for the current user #### Responses -| Status | Description | -|--------|-------------| -| 200 | Active sessions | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Active sessions | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- - diff --git a/docs/api/backup.md b/docs/api/backup.md index e7c7884..86a184f 100644 --- a/docs/api/backup.md +++ b/docs/api/backup.md @@ -6,22 +6,21 @@ Database backup ### POST /api/v1/admin/backup -Create a database backup and return it as a downloadable file. -POST /api/v1/admin/backup +Create a database backup and return it as a downloadable file. POST +/api/v1/admin/backup -For `SQLite`: creates a backup via VACUUM INTO and returns the file. -For `PostgreSQL`: returns unsupported error (use `pg_dump` instead). +For `SQLite`: creates a backup via VACUUM INTO and returns the file. For +`PostgreSQL`: returns unsupported error (use `pg_dump` instead). **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | Backup file download | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Backup file download | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- - diff --git a/docs/api/books.md b/docs/api/books.md index 6af55ad..3ddf863 100644 --- a/docs/api/books.md +++ b/docs/api/books.md @@ -12,23 +12,23 @@ List all books with optional search filters #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `isbn` | query | No | Filter by ISBN | -| `author` | query | No | Filter by author | -| `series` | query | No | Filter by series | -| `publisher` | query | No | Filter by publisher | -| `language` | query | No | Filter by language | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +| ----------- | ----- | -------- | ------------------- | +| `isbn` | query | No | Filter by ISBN | +| `author` | query | No | Filter by author | +| `series` | query | No | Filter by series | +| `publisher` | query | No | Filter by publisher | +| `language` | query | No | Filter by language | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of books | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | List of books | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -40,17 +40,17 @@ List all authors with book counts #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +| -------- | ----- | -------- | ----------------- | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Authors with book counts | -| 401 | Unauthorized | +| Status | Description | +| ------ | ------------------------ | +| 200 | Authors with book counts | +| 401 | Unauthorized | --- @@ -62,18 +62,18 @@ Get books by a specific author #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `name` | path | Yes | Author name | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +| -------- | ----- | -------- | ----------------- | +| `name` | path | Yes | Author name | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Books by author | -| 401 | Unauthorized | +| Status | Description | +| ------ | --------------- | +| 200 | Books by author | +| 401 | Unauthorized | --- @@ -85,16 +85,16 @@ Get user's reading list #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `status` | query | No | Filter by reading status | +| Name | In | Required | Description | +| -------- | ----- | -------- | ------------------------ | +| `status` | query | No | Filter by reading status | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Reading list | -| 401 | Unauthorized | +| Status | Description | +| ------ | ------------ | +| 200 | Reading list | +| 401 | Unauthorized | --- @@ -106,10 +106,10 @@ List all series with book counts #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of series with counts | -| 401 | Unauthorized | +| Status | Description | +| ------ | -------------------------- | +| 200 | List of series with counts | +| 401 | Unauthorized | --- @@ -121,16 +121,16 @@ Get books in a specific series #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `name` | path | Yes | Series name | +| Name | In | Required | Description | +| ------ | ---- | -------- | ----------- | +| `name` | path | Yes | Series name | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Books in series | -| 401 | Unauthorized | +| Status | Description | +| ------ | --------------- | +| 200 | Books in series | +| 401 | Unauthorized | --- @@ -142,17 +142,17 @@ Get book metadata by media ID #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Book metadata | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ------------- | +| 200 | Book metadata | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -164,17 +164,17 @@ Get reading progress for a book #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Reading progress | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ---------------- | +| 200 | Reading progress | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -186,9 +186,9 @@ Update reading progress for a book #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -198,11 +198,10 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 204 | Progress updated | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +| ------ | ---------------- | +| 204 | Progress updated | +| 400 | Bad request | +| 401 | Unauthorized | --- - diff --git a/docs/api/collections.md b/docs/api/collections.md index f86b5e0..9db76ba 100644 --- a/docs/api/collections.md +++ b/docs/api/collections.md @@ -10,11 +10,11 @@ Media collections #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of collections | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | List of collections | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,13 +30,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Collection created | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Collection created | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -46,18 +46,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Collection ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Collection ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Collection | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Collection | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -67,19 +67,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Collection ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Collection ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Collection deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Collection deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -89,18 +89,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Collection ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Collection ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Collection members | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Collection members | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -110,9 +110,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Collection ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Collection ID | #### Request Body @@ -122,13 +122,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Member added | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Member added | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -138,20 +138,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Collection ID | -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---------- | ---- | -------- | ------------- | +| `id` | path | Yes | Collection ID | +| `media_id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Member removed | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Member removed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- - diff --git a/docs/api/config.md b/docs/api/config.md index f88299f..a05fcd0 100644 --- a/docs/api/config.md +++ b/docs/api/config.md @@ -10,12 +10,12 @@ Server configuration #### Responses -| Status | Description | -|--------|-------------| -| 200 | Current server configuration | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | ---------------------------- | +| 200 | Current server configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -31,13 +31,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Updated configuration | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Updated configuration | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -53,12 +53,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Updated configuration | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Updated configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -74,12 +74,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Updated configuration | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Updated configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -89,11 +89,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | UI configuration | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | UI configuration | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -109,12 +109,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Updated UI configuration | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | ------------------------ | +| 200 | Updated UI configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- - diff --git a/docs/api/database.md b/docs/api/database.md index 373df31..bed7ff6 100644 --- a/docs/api/database.md +++ b/docs/api/database.md @@ -10,12 +10,12 @@ Database administration #### Responses -| Status | Description | -|--------|-------------| -| 200 | Database cleared | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Database cleared | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -25,12 +25,12 @@ Database administration #### Responses -| Status | Description | -|--------|-------------| -| 200 | Database statistics | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Database statistics | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -40,12 +40,11 @@ Database administration #### Responses -| Status | Description | -|--------|-------------| -| 200 | Database vacuumed | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Database vacuumed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- - diff --git a/docs/api/duplicates.md b/docs/api/duplicates.md index b1005b7..63a200d 100644 --- a/docs/api/duplicates.md +++ b/docs/api/duplicates.md @@ -10,11 +10,10 @@ Duplicate media detection #### Responses -| Status | Description | -|--------|-------------| -| 200 | Duplicate groups | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Duplicate groups | +| 401 | Unauthorized | +| 500 | Internal server error | --- - diff --git a/docs/api/enrichment.md b/docs/api/enrichment.md index 5012641..f4ec48e 100644 --- a/docs/api/enrichment.md +++ b/docs/api/enrichment.md @@ -16,13 +16,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Enrichment job submitted | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | ------------------------ | +| 200 | Enrichment job submitted | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -32,19 +32,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Enrichment job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | ------------------------ | +| 200 | Enrichment job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -54,18 +54,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | External metadata | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | External metadata | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- - diff --git a/docs/api/export.md b/docs/api/export.md index 3410a9f..593c1aa 100644 --- a/docs/api/export.md +++ b/docs/api/export.md @@ -10,12 +10,12 @@ Media library export #### Responses -| Status | Description | -|--------|-------------| -| 200 | Export job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Export job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -31,12 +31,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Export job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Export job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- - diff --git a/docs/api/health.md b/docs/api/health.md index 44f6d4a..3ed9cf6 100644 --- a/docs/api/health.md +++ b/docs/api/health.md @@ -12,9 +12,9 @@ Comprehensive health check - includes database, filesystem, and cache status #### Responses -| Status | Description | -|--------|-------------| -| 200 | Health status | +| Status | Description | +| ------ | ------------- | +| 200 | Health status | --- @@ -24,40 +24,39 @@ Comprehensive health check - includes database, filesystem, and cache status #### Responses -| Status | Description | -|--------|-------------| -| 200 | Detailed health status | +| Status | Description | +| ------ | ---------------------- | +| 200 | Detailed health status | --- ### GET /api/v1/health/live -Liveness probe - just checks if the server is running -Returns 200 OK if the server process is alive +Liveness probe - just checks if the server is running Returns 200 OK if the +server process is alive **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | Server is alive | +| Status | Description | +| ------ | --------------- | +| 200 | Server is alive | --- ### GET /api/v1/health/ready -Readiness probe - checks if the server can serve requests -Returns 200 OK if database is accessible +Readiness probe - checks if the server can serve requests Returns 200 OK if +database is accessible **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | Server is ready | -| 503 | Server not ready | +| Status | Description | +| ------ | ---------------- | +| 200 | Server is ready | +| 503 | Server not ready | --- - diff --git a/docs/api/integrity.md b/docs/api/integrity.md index fd22c23..5c2b4c8 100644 --- a/docs/api/integrity.md +++ b/docs/api/integrity.md @@ -10,12 +10,12 @@ Library integrity checks and repairs #### Responses -| Status | Description | -|--------|-------------| -| 200 | Orphan detection job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | ------------------------------ | +| 200 | Orphan detection job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -31,12 +31,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Orphans resolved | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Orphans resolved | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -46,12 +46,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Thumbnail cleanup job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | ------------------------------- | +| 200 | Thumbnail cleanup job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -67,12 +67,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Thumbnail generation job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | ---------------------------------- | +| 200 | Thumbnail generation job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -88,12 +88,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Integrity verification job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | ------------------------------------ | +| 200 | Integrity verification job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- - diff --git a/docs/api/jobs.md b/docs/api/jobs.md index a46b7fd..4cf6a2d 100644 --- a/docs/api/jobs.md +++ b/docs/api/jobs.md @@ -10,11 +10,11 @@ Background job management #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of jobs | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | ------------ | +| 200 | List of jobs | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -24,18 +24,18 @@ Background job management #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Job ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Job ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Job details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | Job details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -45,18 +45,17 @@ Background job management #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Job ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Job ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Job cancelled | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------- | +| 200 | Job cancelled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- - diff --git a/docs/api/media.md b/docs/api/media.md index b612729..bdd1dd7 100644 --- a/docs/api/media.md +++ b/docs/api/media.md @@ -10,19 +10,19 @@ Media item management #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Page size | -| `sort` | query | No | Sort field | +| Name | In | Required | Description | +| -------- | ----- | -------- | ----------------- | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Page size | +| `sort` | query | No | Sort field | #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of media items | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | List of media items | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -32,12 +32,12 @@ Media item management #### Responses -| Status | Description | -|--------|-------------| -| 200 | All media deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | All media deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -53,13 +53,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Batch collection result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | ----------------------- | +| 200 | Batch collection result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -75,13 +75,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Batch delete result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Batch delete result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -97,13 +97,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Batch move result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Batch move result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -119,13 +119,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Batch tag result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Batch tag result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -141,13 +141,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Batch update result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Batch update result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -157,11 +157,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media count | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media count | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -177,13 +177,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media imported | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media imported | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -199,13 +199,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Batch import results | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Batch import results | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -221,13 +221,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Directory import results | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | ------------------------ | +| 200 | Directory import results | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -243,13 +243,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media imported | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media imported | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -265,13 +265,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Directory preview | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Directory preview | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -281,18 +281,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Page size | +| Name | In | Required | Description | +| -------- | ----- | -------- | ----------------- | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Page size | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Trashed media items | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Trashed media items | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -302,12 +302,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Trash emptied | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Trash emptied | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -317,11 +317,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Trash info | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Trash info | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -331,18 +331,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media item | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media item | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -352,9 +352,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -364,14 +364,14 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Updated media item | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Updated media item | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -381,19 +381,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -403,9 +403,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -415,14 +415,14 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Custom field set | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Custom field set | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -432,20 +432,20 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | -| `name` | path | Yes | Custom field name | +| Name | In | Required | Description | +| ------ | ---- | -------- | ----------------- | +| `id` | path | Yes | Media item ID | +| `name` | path | Yes | Custom field name | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Custom field deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Custom field deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -455,9 +455,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -467,14 +467,14 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Moved media item | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Moved media item | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -484,18 +484,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media opened | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media opened | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -505,20 +505,20 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | -| `permanent` | query | No | Set to 'true' for permanent deletion | +| Name | In | Required | Description | +| ----------- | ----- | -------- | ------------------------------------ | +| `id` | path | Yes | Media item ID | +| `permanent` | query | No | Set to 'true' for permanent deletion | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -528,9 +528,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -540,14 +540,14 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Renamed media item | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Renamed media item | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -557,19 +557,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media restored | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media restored | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -579,19 +579,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media stream | -| 206 | Partial content | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media stream | +| 206 | Partial content | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -601,18 +601,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Thumbnail image | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Thumbnail image | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -622,19 +622,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media moved to trash | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media moved to trash | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- - diff --git a/docs/api/notes.md b/docs/api/notes.md index 330c8f3..0ffc1fe 100644 --- a/docs/api/notes.md +++ b/docs/api/notes.md @@ -14,18 +14,18 @@ GET /api/v1/media/{id}/backlinks #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Backlinks | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Backlinks | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -39,18 +39,18 @@ GET /api/v1/media/{id}/outgoing-links #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Outgoing links | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Outgoing links | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -64,18 +64,18 @@ POST /api/v1/media/{id}/reindex-links #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Links reindexed | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Links reindexed | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -89,18 +89,18 @@ GET /api/v1/notes/graph?center={uuid}&depth={n} #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `center` | query | No | Center node ID | -| `depth` | query | No | Traversal depth (max 5, default 2) | +| Name | In | Required | Description | +| -------- | ----- | -------- | ---------------------------------- | +| `center` | query | No | Center node ID | +| `depth` | query | No | Traversal depth (max 5, default 2) | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Graph data | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Graph data | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -114,11 +114,11 @@ POST /api/v1/notes/resolve-links #### Responses -| Status | Description | -|--------|-------------| -| 200 | Links resolved | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Links resolved | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -132,11 +132,10 @@ GET /api/v1/notes/unresolved-count #### Responses -| Status | Description | -|--------|-------------| -| 200 | Unresolved link count | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Unresolved link count | +| 401 | Unauthorized | +| 500 | Internal server error | --- - diff --git a/docs/api/photos.md b/docs/api/photos.md index 5afdba3..3613838 100644 --- a/docs/api/photos.md +++ b/docs/api/photos.md @@ -12,21 +12,21 @@ Get photos in a bounding box for map view #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `lat1` | query | Yes | Bounding box latitude 1 | -| `lon1` | query | Yes | Bounding box longitude 1 | -| `lat2` | query | Yes | Bounding box latitude 2 | -| `lon2` | query | Yes | Bounding box longitude 2 | +| Name | In | Required | Description | +| ------ | ----- | -------- | ------------------------ | +| `lat1` | query | Yes | Bounding box latitude 1 | +| `lon1` | query | Yes | Bounding box longitude 1 | +| `lat2` | query | Yes | Bounding box latitude 2 | +| `lon2` | query | Yes | Bounding box longitude 2 | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Map markers | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Map markers | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -38,20 +38,19 @@ Get timeline of photos grouped by date #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `group_by` | query | No | Grouping: day, month, year | -| `year` | query | No | Filter by year | -| `month` | query | No | Filter by month | -| `limit` | query | No | Max items (default 10000) | +| Name | In | Required | Description | +| ---------- | ----- | -------- | -------------------------- | +| `group_by` | query | No | Grouping: day, month, year | +| `year` | query | No | Filter by year | +| `month` | query | No | Filter by month | +| `limit` | query | No | Max items (default 10000) | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Photo timeline groups | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Photo timeline groups | +| 401 | Unauthorized | +| 500 | Internal server error | --- - diff --git a/docs/api/playlists.md b/docs/api/playlists.md index 2f97cde..fdcbf35 100644 --- a/docs/api/playlists.md +++ b/docs/api/playlists.md @@ -10,11 +10,11 @@ Media playlists #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of playlists | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | List of playlists | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,12 +30,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Playlist created | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Playlist created | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -45,18 +45,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Playlist ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Playlist details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ---------------- | +| 200 | Playlist details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -66,9 +66,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Playlist ID | #### Request Body @@ -78,13 +78,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Playlist updated | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ---------------- | +| 200 | Playlist updated | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -94,18 +94,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Playlist ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Playlist deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ---------------- | +| 200 | Playlist deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -115,18 +115,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Playlist ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Playlist items | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Playlist items | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -136,9 +136,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Playlist ID | #### Request Body @@ -148,12 +148,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Item added | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | Item added | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -163,9 +163,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Playlist ID | #### Request Body @@ -175,12 +175,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Item reordered | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Item reordered | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -190,19 +190,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Playlist ID | -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---------- | ---- | -------- | ------------- | +| `id` | path | Yes | Playlist ID | +| `media_id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Item removed | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | Item removed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -212,18 +212,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Playlist ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Shuffled playlist items | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ----------------------- | +| 200 | Shuffled playlist items | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- - diff --git a/docs/api/plugins.md b/docs/api/plugins.md index eaab41e..683de6c 100644 --- a/docs/api/plugins.md +++ b/docs/api/plugins.md @@ -12,11 +12,11 @@ List all installed plugins #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of plugins | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | List of plugins | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -34,12 +34,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Plugin installed | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | ---------------- | +| 200 | Plugin installed | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -58,10 +58,10 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Event received | -| 401 | Unauthorized | +| Status | Description | +| ------ | -------------- | +| 200 | Event received | +| 401 | Unauthorized | --- @@ -73,10 +73,10 @@ List all UI pages provided by loaded plugins #### Responses -| Status | Description | -|--------|-------------| -| 200 | Plugin UI pages | -| 401 | Unauthorized | +| Status | Description | +| ------ | --------------- | +| 200 | Plugin UI pages | +| 401 | Unauthorized | --- @@ -88,10 +88,10 @@ List merged CSS custom property overrides from all enabled plugins #### Responses -| Status | Description | -|--------|-------------| -| 200 | Plugin UI theme extensions | -| 401 | Unauthorized | +| Status | Description | +| ------ | -------------------------- | +| 200 | Plugin UI theme extensions | +| 401 | Unauthorized | --- @@ -103,10 +103,10 @@ List all UI widgets provided by loaded plugins #### Responses -| Status | Description | -|--------|-------------| -| 200 | Plugin UI widgets | -| 401 | Unauthorized | +| Status | Description | +| ------ | ----------------- | +| 200 | Plugin UI widgets | +| 401 | Unauthorized | --- @@ -118,17 +118,17 @@ Get a specific plugin by ID #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Plugin ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Plugin ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Plugin details | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Plugin details | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -140,18 +140,18 @@ Uninstall a plugin #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Plugin ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Plugin ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Plugin uninstalled | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------------ | +| 200 | Plugin uninstalled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -163,18 +163,18 @@ Reload a plugin (for development) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Plugin ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Plugin ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Plugin reloaded | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | --------------- | +| 200 | Plugin reloaded | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -186,9 +186,9 @@ Enable or disable a plugin #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Plugin ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Plugin ID | #### Request Body @@ -198,12 +198,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Plugin toggled | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Plugin toggled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- - diff --git a/docs/api/saved_searches.md b/docs/api/saved_searches.md index 12e374d..3889deb 100644 --- a/docs/api/saved_searches.md +++ b/docs/api/saved_searches.md @@ -10,11 +10,11 @@ Saved search queries #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of saved searches | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | ---------------------- | +| 200 | List of saved searches | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,12 +30,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Search saved | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Search saved | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -45,18 +45,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Saved search ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | --------------- | +| `id` | path | Yes | Saved search ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Saved search deleted | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Saved search deleted | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- - diff --git a/docs/api/scan.md b/docs/api/scan.md index 9c2af4b..3101bca 100644 --- a/docs/api/scan.md +++ b/docs/api/scan.md @@ -18,12 +18,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Scan job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Scan job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -33,10 +33,9 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Scan status | -| 401 | Unauthorized | +| Status | Description | +| ------ | ------------ | +| 200 | Scan status | +| 401 | Unauthorized | --- - diff --git a/docs/api/scheduled_tasks.md b/docs/api/scheduled_tasks.md index 2367493..d357c66 100644 --- a/docs/api/scheduled_tasks.md +++ b/docs/api/scheduled_tasks.md @@ -10,11 +10,11 @@ Scheduled background tasks #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of scheduled tasks | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | ----------------------- | +| 200 | List of scheduled tasks | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -24,18 +24,18 @@ Scheduled background tasks #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Task ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Task ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Task triggered | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Task triggered | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -45,18 +45,17 @@ Scheduled background tasks #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Task ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Task ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Task toggled | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | Task toggled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- - diff --git a/docs/api/search.md b/docs/api/search.md index 102d2fb..2ca9429 100644 --- a/docs/api/search.md +++ b/docs/api/search.md @@ -10,21 +10,21 @@ Full-text media search #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `q` | query | Yes | Search query | -| `sort` | query | No | Sort order | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +| -------- | ----- | -------- | ----------------- | +| `q` | query | Yes | Search query | +| `sort` | query | No | Sort order | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Search results | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Search results | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -40,12 +40,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Search results | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Search results | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- - diff --git a/docs/api/shares.md b/docs/api/shares.md index 9702f41..cd62b74 100644 --- a/docs/api/shares.md +++ b/docs/api/shares.md @@ -6,86 +6,81 @@ Media sharing and notifications ### GET /api/v1/notifications/shares -Get unread share notifications -GET /api/notifications/shares +Get unread share notifications GET /api/notifications/shares **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | Unread notifications | -| 401 | Unauthorized | +| Status | Description | +| ------ | -------------------- | +| 200 | Unread notifications | +| 401 | Unauthorized | --- ### POST /api/v1/notifications/shares/read-all -Mark all notifications as read -POST /api/notifications/shares/read-all +Mark all notifications as read POST /api/notifications/shares/read-all **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | All notifications marked as read | -| 401 | Unauthorized | +| Status | Description | +| ------ | -------------------------------- | +| 200 | All notifications marked as read | +| 401 | Unauthorized | --- ### POST /api/v1/notifications/shares/{id}/read -Mark a notification as read -POST /api/notifications/shares/{id}/read +Mark a notification as read POST /api/notifications/shares/{id}/read **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Notification ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | --------------- | +| `id` | path | Yes | Notification ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Notification marked as read | -| 401 | Unauthorized | +| Status | Description | +| ------ | --------------------------- | +| 200 | Notification marked as read | +| 401 | Unauthorized | --- ### GET /api/v1/shared/{token} -Access a public shared resource -GET /api/shared/{token} +Access a public shared resource GET /api/shared/{token} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `token` | path | Yes | Share token | -| `password` | query | No | Share password if required | +| Name | In | Required | Description | +| ---------- | ----- | -------- | -------------------------- | +| `token` | path | Yes | Share token | +| `password` | query | No | Share password if required | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Shared content | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Shared content | +| 401 | Unauthorized | +| 404 | Not found | --- ### POST /api/v1/shares -Create a new share -POST /api/shares +Create a new share POST /api/shares **Authentication:** Required (Bearer JWT) @@ -97,19 +92,18 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Share created | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Share created | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### POST /api/v1/shares/batch/delete -Batch delete shares -POST /api/shares/batch/delete +Batch delete shares POST /api/shares/batch/delete **Authentication:** Required (Bearer JWT) @@ -121,97 +115,93 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Shares deleted | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | -------------- | +| 200 | Shares deleted | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | --- ### GET /api/v1/shares/incoming -List incoming shares (shares shared with me) -GET /api/shares/incoming +List incoming shares (shares shared with me) GET /api/shares/incoming **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +| -------- | ----- | -------- | ----------------- | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Incoming shares | -| 401 | Unauthorized | +| Status | Description | +| ------ | --------------- | +| 200 | Incoming shares | +| 401 | Unauthorized | --- ### GET /api/v1/shares/outgoing -List outgoing shares (shares I created) -GET /api/shares/outgoing +List outgoing shares (shares I created) GET /api/shares/outgoing **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +| -------- | ----- | -------- | ----------------- | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Outgoing shares | -| 401 | Unauthorized | +| Status | Description | +| ------ | --------------- | +| 200 | Outgoing shares | +| 401 | Unauthorized | --- ### GET /api/v1/shares/{id} -Get share details -GET /api/shares/{id} +Get share details GET /api/shares/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Share ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Share ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Share details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------- | +| 200 | Share details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### PATCH /api/v1/shares/{id} -Update a share -PATCH /api/shares/{id} +Update a share PATCH /api/shares/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Share ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Share ID | #### Request Body @@ -221,62 +211,59 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Share updated | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------- | +| 200 | Share updated | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### DELETE /api/v1/shares/{id} -Delete (revoke) a share -DELETE /api/shares/{id} +Delete (revoke) a share DELETE /api/shares/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Share ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Share ID | #### Responses -| Status | Description | -|--------|-------------| -| 204 | Share deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------- | +| 204 | Share deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### GET /api/v1/shares/{id}/activity -Get share activity log -GET /api/shares/{id}/activity +Get share activity log GET /api/shares/{id}/activity **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Share ID | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +| -------- | ----- | -------- | ----------------- | +| `id` | path | Yes | Share ID | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Share activity | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Share activity | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- - diff --git a/docs/api/social.md b/docs/api/social.md index e706183..097a627 100644 --- a/docs/api/social.md +++ b/docs/api/social.md @@ -10,11 +10,11 @@ Ratings, comments, favorites, and share links #### Responses -| Status | Description | -|--------|-------------| -| 200 | User favorites | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | User favorites | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,11 +30,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Added to favorites | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Added to favorites | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -44,17 +44,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---------- | ---- | -------- | ------------- | +| `media_id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Removed from favorites | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | ---------------------- | +| 200 | Removed from favorites | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -70,12 +70,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Share link created | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Share link created | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -85,17 +85,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media comments | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media comments | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -105,9 +105,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -117,12 +117,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Comment added | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Comment added | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -132,9 +132,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -144,12 +144,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Rating saved | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Rating saved | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -159,17 +159,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media ratings | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media ratings | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -179,18 +179,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `token` | path | Yes | Share token | -| `password` | query | No | Share password | +| Name | In | Required | Description | +| ---------- | ----- | -------- | -------------- | +| `token` | path | Yes | Share token | +| `password` | query | No | Share password | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Shared media | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | Shared media | +| 401 | Unauthorized | +| 404 | Not found | --- - diff --git a/docs/api/statistics.md b/docs/api/statistics.md index 270ad62..b74f02f 100644 --- a/docs/api/statistics.md +++ b/docs/api/statistics.md @@ -10,11 +10,10 @@ Library statistics #### Responses -| Status | Description | -|--------|-------------| -| 200 | Library statistics | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Library statistics | +| 401 | Unauthorized | +| 500 | Internal server error | --- - diff --git a/docs/api/streaming.md b/docs/api/streaming.md index 11a3352..0bd0a35 100644 --- a/docs/api/streaming.md +++ b/docs/api/streaming.md @@ -10,18 +10,18 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | DASH manifest | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ------------- | +| 200 | DASH manifest | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -31,20 +31,20 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | -| `profile` | path | Yes | Transcode profile name | -| `segment` | path | Yes | Segment filename | +| Name | In | Required | Description | +| --------- | ---- | -------- | ---------------------- | +| `id` | path | Yes | Media item ID | +| `profile` | path | Yes | Transcode profile name | +| `segment` | path | Yes | Segment filename | #### Responses -| Status | Description | -|--------|-------------| -| 200 | DASH segment data | -| 202 | Segment not yet available | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +| ------ | ------------------------- | +| 200 | DASH segment data | +| 202 | Segment not yet available | +| 400 | Bad request | +| 401 | Unauthorized | --- @@ -54,17 +54,17 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | HLS master playlist | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ------------------- | +| 200 | HLS master playlist | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -74,19 +74,19 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | -| `profile` | path | Yes | Transcode profile name | +| Name | In | Required | Description | +| --------- | ---- | -------- | ---------------------- | +| `id` | path | Yes | Media item ID | +| `profile` | path | Yes | Transcode profile name | #### Responses -| Status | Description | -|--------|-------------| -| 200 | HLS variant playlist | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | -------------------- | +| 200 | HLS variant playlist | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -96,20 +96,19 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | -| `profile` | path | Yes | Transcode profile name | -| `segment` | path | Yes | Segment filename | +| Name | In | Required | Description | +| --------- | ---- | -------- | ---------------------- | +| `id` | path | Yes | Media item ID | +| `profile` | path | Yes | Transcode profile name | +| `segment` | path | Yes | Segment filename | #### Responses -| Status | Description | -|--------|-------------| -| 200 | HLS segment data | -| 202 | Segment not yet available | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +| ------ | ------------------------- | +| 200 | HLS segment data | +| 202 | Segment not yet available | +| 400 | Bad request | +| 401 | Unauthorized | --- - diff --git a/docs/api/subtitles.md b/docs/api/subtitles.md index ce36e05..d170681 100644 --- a/docs/api/subtitles.md +++ b/docs/api/subtitles.md @@ -10,17 +10,17 @@ Media subtitle management #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Subtitles | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | Subtitles | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -30,9 +30,9 @@ Media subtitle management #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -42,12 +42,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Subtitle added | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Subtitle added | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -57,18 +57,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `media_id` | path | Yes | Media item ID | -| `subtitle_id` | path | Yes | Subtitle ID | +| Name | In | Required | Description | +| ------------- | ---- | -------- | ------------- | +| `media_id` | path | Yes | Media item ID | +| `subtitle_id` | path | Yes | Subtitle ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Subtitle content | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ---------------- | +| 200 | Subtitle content | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -78,17 +78,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Subtitle ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Subtitle ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Subtitle deleted | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ---------------- | +| 200 | Subtitle deleted | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -98,9 +98,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Subtitle ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Subtitle ID | #### Request Body @@ -110,11 +110,10 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Offset updated | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Offset updated | +| 401 | Unauthorized | +| 404 | Not found | --- - diff --git a/docs/api/sync.md b/docs/api/sync.md index 165d4c0..fd1aa5b 100644 --- a/docs/api/sync.md +++ b/docs/api/sync.md @@ -6,8 +6,7 @@ Multi-device library synchronization ### POST /api/v1/sync/ack -Acknowledge processed changes -POST /api/sync/ack +Acknowledge processed changes POST /api/sync/ack **Authentication:** Required (Bearer JWT) @@ -19,66 +18,63 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Changes acknowledged | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +| ------ | -------------------- | +| 200 | Changes acknowledged | +| 400 | Bad request | +| 401 | Unauthorized | --- ### GET /api/v1/sync/changes -Get changes since cursor -GET /api/sync/changes +Get changes since cursor GET /api/sync/changes **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `cursor` | query | No | Sync cursor | -| `limit` | query | No | Max changes (max 1000) | +| Name | In | Required | Description | +| -------- | ----- | -------- | ---------------------- | +| `cursor` | query | No | Sync cursor | +| `limit` | query | No | Max changes (max 1000) | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Changes since cursor | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +| ------ | -------------------- | +| 200 | Changes since cursor | +| 400 | Bad request | +| 401 | Unauthorized | --- ### GET /api/v1/sync/conflicts -List unresolved conflicts -GET /api/sync/conflicts +List unresolved conflicts GET /api/sync/conflicts **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | Unresolved conflicts | -| 401 | Unauthorized | +| Status | Description | +| ------ | -------------------- | +| 200 | Unresolved conflicts | +| 401 | Unauthorized | --- ### POST /api/v1/sync/conflicts/{id}/resolve -Resolve a sync conflict -POST /api/sync/conflicts/{id}/resolve +Resolve a sync conflict POST /api/sync/conflicts/{id}/resolve **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Conflict ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Conflict ID | #### Request Body @@ -88,34 +84,32 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Conflict resolved | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +| ------ | ----------------- | +| 200 | Conflict resolved | +| 400 | Bad request | +| 401 | Unauthorized | --- ### GET /api/v1/sync/devices -List user's sync devices -GET /api/sync/devices +List user's sync devices GET /api/sync/devices **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of devices | -| 401 | Unauthorized | +| Status | Description | +| ------ | --------------- | +| 200 | List of devices | +| 401 | Unauthorized | --- ### POST /api/v1/sync/devices -Register a new sync device -POST /api/sync/devices +Register a new sync device POST /api/sync/devices **Authentication:** Required (Bearer JWT) @@ -127,51 +121,49 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Device registered | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Device registered | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### GET /api/v1/sync/devices/{id} -Get device details -GET /api/sync/devices/{id} +Get device details GET /api/sync/devices/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Device ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Device ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Device details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Device details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### PUT /api/v1/sync/devices/{id} -Update a device -PUT /api/sync/devices/{id} +Update a device PUT /api/sync/devices/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Device ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Device ID | #### Request Body @@ -181,91 +173,87 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Device updated | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Device updated | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### DELETE /api/v1/sync/devices/{id} -Delete a device -DELETE /api/sync/devices/{id} +Delete a device DELETE /api/sync/devices/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Device ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Device ID | #### Responses -| Status | Description | -|--------|-------------| -| 204 | Device deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 204 | Device deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### POST /api/v1/sync/devices/{id}/token -Regenerate device token -POST /api/sync/devices/{id}/token +Regenerate device token POST /api/sync/devices/{id}/token **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Device ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Device ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Token regenerated | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ----------------- | +| 200 | Token regenerated | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### GET /api/v1/sync/download/{path} -Download a file for sync (supports Range header) -GET /api/sync/download/{*path} +Download a file for sync (supports Range header) GET /api/sync/download/{*path} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `path` | path | Yes | File path | +| Name | In | Required | Description | +| ------ | ---- | -------- | ----------- | +| `path` | path | Yes | File path | #### Responses -| Status | Description | -|--------|-------------| -| 200 | File content | -| 206 | Partial content | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | --------------- | +| 200 | File content | +| 206 | Partial content | +| 401 | Unauthorized | +| 404 | Not found | --- ### POST /api/v1/sync/report -Report local changes from client -POST /api/sync/report +Report local changes from client POST /api/sync/report **Authentication:** Required (Bearer JWT) @@ -277,18 +265,17 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Changes processed | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +| ------ | ----------------- | +| 200 | Changes processed | +| 400 | Bad request | +| 401 | Unauthorized | --- ### POST /api/v1/sync/upload -Create an upload session for chunked upload -POST /api/sync/upload +Create an upload session for chunked upload POST /api/sync/upload **Authentication:** Required (Bearer JWT) @@ -300,113 +287,107 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Upload session created | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +| ------ | ---------------------- | +| 200 | Upload session created | +| 400 | Bad request | +| 401 | Unauthorized | --- ### GET /api/v1/sync/upload/{id} -Get upload session status -GET /api/sync/upload/{id} +Get upload session status GET /api/sync/upload/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Upload session ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------------- | +| `id` | path | Yes | Upload session ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Upload session status | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | --------------------- | +| 200 | Upload session status | +| 401 | Unauthorized | +| 404 | Not found | --- ### DELETE /api/v1/sync/upload/{id} -Cancel an upload session -DELETE /api/sync/upload/{id} +Cancel an upload session DELETE /api/sync/upload/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Upload session ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------------- | +| `id` | path | Yes | Upload session ID | #### Responses -| Status | Description | -|--------|-------------| -| 204 | Upload cancelled | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ---------------- | +| 204 | Upload cancelled | +| 401 | Unauthorized | +| 404 | Not found | --- ### PUT /api/v1/sync/upload/{id}/chunks/{index} -Upload a chunk -PUT /api/sync/upload/{id}/chunks/{index} +Upload a chunk PUT /api/sync/upload/{id}/chunks/{index} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Upload session ID | -| `index` | path | Yes | Chunk index | +| Name | In | Required | Description | +| ------- | ---- | -------- | ----------------- | +| `id` | path | Yes | Upload session ID | +| `index` | path | Yes | Chunk index | #### Request Body -Chunk binary data -`Content-Type: application/octet-stream` +Chunk binary data `Content-Type: application/octet-stream` See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Chunk received | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | -------------- | +| 200 | Chunk received | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- ### POST /api/v1/sync/upload/{id}/complete -Complete an upload session -POST /api/sync/upload/{id}/complete +Complete an upload session POST /api/sync/upload/{id}/complete **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Upload session ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------------- | +| `id` | path | Yes | Upload session ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Upload completed | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ---------------- | +| 200 | Upload completed | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- - diff --git a/docs/api/tags.md b/docs/api/tags.md index a9a71c0..6bdefc4 100644 --- a/docs/api/tags.md +++ b/docs/api/tags.md @@ -10,18 +10,18 @@ Media tag management #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---------- | ---- | -------- | ------------- | +| `media_id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Media tags | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Media tags | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -31,9 +31,9 @@ Media tag management #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---------- | ---- | -------- | ------------- | +| `media_id` | path | Yes | Media item ID | #### Request Body @@ -43,13 +43,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Tag applied | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Tag applied | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -59,20 +59,20 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `media_id` | path | Yes | Media item ID | -| `tag_id` | path | Yes | Tag ID | +| Name | In | Required | Description | +| ---------- | ---- | -------- | ------------- | +| `media_id` | path | Yes | Media item ID | +| `tag_id` | path | Yes | Tag ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Tag removed | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Tag removed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -82,11 +82,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of tags | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | List of tags | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -102,13 +102,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Tag created | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Tag created | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -118,18 +118,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Tag ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Tag ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Tag | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Tag | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -139,19 +139,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Tag ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | Tag ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Tag deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | Tag deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- - diff --git a/docs/api/transcode.md b/docs/api/transcode.md index 126135e..3cbb7f9 100644 --- a/docs/api/transcode.md +++ b/docs/api/transcode.md @@ -10,9 +10,9 @@ Video transcoding sessions #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Request Body @@ -22,12 +22,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Transcode job submitted | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | ----------------------- | +| 200 | Transcode job submitted | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -37,10 +37,10 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of transcode sessions | -| 401 | Unauthorized | +| Status | Description | +| ------ | -------------------------- | +| 200 | List of transcode sessions | +| 401 | Unauthorized | --- @@ -50,17 +50,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Transcode session ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | -------------------- | +| `id` | path | Yes | Transcode session ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Transcode session details | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ------------------------- | +| 200 | Transcode session details | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -70,17 +70,16 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Transcode session ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | -------------------- | +| `id` | path | Yes | Transcode session ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | Transcode session cancelled | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | --------------------------- | +| 200 | Transcode session cancelled | +| 401 | Unauthorized | +| 404 | Not found | --- - diff --git a/docs/api/upload.md b/docs/api/upload.md index da8a61b..698b963 100644 --- a/docs/api/upload.md +++ b/docs/api/upload.md @@ -6,84 +6,79 @@ File upload and managed storage ### GET /api/v1/managed/stats -Get managed storage statistics -GET /api/managed/stats +Get managed storage statistics GET /api/managed/stats **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | Managed storage statistics | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | -------------------------- | +| 200 | Managed storage statistics | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### GET /api/v1/media/{id}/download -Download a managed file -GET /api/media/{id}/download +Download a managed file GET /api/media/{id}/download **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | File content | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | File content | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- ### POST /api/v1/media/{id}/move-to-managed -Migrate an external file to managed storage -POST /api/media/{id}/move-to-managed +Migrate an external file to managed storage POST /api/media/{id}/move-to-managed **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ------------- | +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -|--------|-------------| -| 204 | File migrated | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 204 | File migrated | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### POST /api/v1/upload -Upload a file to managed storage -POST /api/upload +Upload a file to managed storage POST /api/upload **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -|--------|-------------| -| 200 | File uploaded | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | File uploaded | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- - diff --git a/docs/api/users.md b/docs/api/users.md index 0cd7087..c76a411 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -12,11 +12,11 @@ List all users (admin only) #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of users | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | ------------- | +| 200 | List of users | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -35,13 +35,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | User created | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +| ------ | --------------------- | +| 200 | User created | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -53,18 +53,18 @@ Get a specific user by ID #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | User ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | User details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | User details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -76,9 +76,9 @@ Update a user #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | User ID | #### Request Body @@ -89,13 +89,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | User updated | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | User updated | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -107,18 +107,18 @@ Delete a user (admin only) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | User ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | User deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +| ------ | ------------ | +| 200 | User deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -130,17 +130,17 @@ Get user's accessible libraries #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | User ID | #### Responses -| Status | Description | -|--------|-------------| -| 200 | User libraries | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | -------------- | +| 200 | User libraries | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -152,9 +152,9 @@ Grant library access to a user (admin only) #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | User ID | #### Request Body @@ -164,12 +164,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Access granted | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | -------------- | +| 200 | Access granted | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -184,9 +184,9 @@ slashes that conflict with URL routing. #### Parameters -| Name | In | Required | Description | -|------|----|----------|-------------| -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +| ---- | ---- | -------- | ----------- | +| `id` | path | Yes | User ID | #### Request Body @@ -196,12 +196,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -|--------|-------------| -| 200 | Access revoked | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | -------------- | +| 200 | Access revoked | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | --- - diff --git a/docs/api/webhooks.md b/docs/api/webhooks.md index 9005323..da0e1f4 100644 --- a/docs/api/webhooks.md +++ b/docs/api/webhooks.md @@ -10,11 +10,11 @@ Webhook configuration #### Responses -| Status | Description | -|--------|-------------| -| 200 | List of configured webhooks | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | --------------------------- | +| 200 | List of configured webhooks | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -24,11 +24,10 @@ Webhook configuration #### Responses -| Status | Description | -|--------|-------------| -| 200 | Test webhook sent | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +| ------ | ----------------- | +| 200 | Test webhook sent | +| 401 | Unauthorized | +| 403 | Forbidden | --- - diff --git a/examples/plugins/heif-support/README.md b/examples/plugins/heif-support/README.md index b64a002..d3e866d 100644 --- a/examples/plugins/heif-support/README.md +++ b/examples/plugins/heif-support/README.md @@ -1,18 +1,23 @@ # HEIF/HEIC Support Plugin -This example plugin adds support for HEIF (High Efficiency Image Format) and HEIC (HEIF Container) to Pinakes. +This example plugin adds support for HEIF (High Efficiency Image Format) and +HEIC (HEIF Container) to Pinakes. ## Overview -HEIF is a modern image format that provides better compression than JPEG while maintaining higher quality. This plugin enables Pinakes to: +HEIF is a modern image format that provides better compression than JPEG while +maintaining higher quality. This plugin enables Pinakes to: + - Recognize HEIF/HEIC files as a media type - Extract metadata from HEIF images - Generate thumbnails from HEIF images ## Features -- **Media Type Registration**: Registers `.heif`, `.heic`, `.hif` extensions as image media types -- **EXIF Extraction**: Extracts EXIF metadata including camera info, GPS coordinates, timestamps +- **Media Type Registration**: Registers `.heif`, `.heic`, `.hif` extensions as + image media types +- **EXIF Extraction**: Extracts EXIF metadata including camera info, GPS + coordinates, timestamps - **Thumbnail Generation**: Generates thumbnails in JPEG, PNG, or WebP format - **Resource Limits**: Configurable memory and CPU limits for safe processing - **Large Image Support**: Handles images up to 8192x8192 pixels @@ -95,6 +100,7 @@ impl ThumbnailGenerator for HeifPlugin { ## Dependencies The plugin uses the following Rust crates (compiled to WASM): + - `libheif-rs`: HEIF decoding and encoding - `image`: Image processing and thumbnail generation - `kamadak-exif`: EXIF metadata parsing @@ -159,15 +165,19 @@ pinakes plugin install /path/to/heif-support The plugin can be configured through the `config` section in `plugin.toml`: ### EXIF Extraction + - `extract_exif`: Enable EXIF metadata extraction (default: true) ### Thumbnail Generation + - `generate_thumbnails`: Enable thumbnail generation (default: true) - `thumbnail_quality`: JPEG quality for thumbnails, 1-100 (default: 85) - `thumbnail_format`: Output format - "jpeg", "png", or "webp" (default: "jpeg") ### Resource Limits -- `max_memory_mb`: Maximum memory the plugin can use in megabytes (default: 256, set in `[capabilities]`) + +- `max_memory_mb`: Maximum memory the plugin can use in megabytes (default: 256, + set in `[capabilities]`) - `max_width`: Maximum image width to process (default: 8192) - `max_height`: Maximum image height to process (default: 8192) @@ -188,6 +198,7 @@ The plugin can be configured through the `config` section in `plugin.toml`: ### Sandboxing The plugin runs in a WASM sandbox with: + - No access to host filesystem beyond granted paths - No network access - No arbitrary code execution @@ -210,6 +221,7 @@ The plugin runs in a WASM sandbox with: ## Error Handling The plugin handles: + - **Corrupted Files**: Returns descriptive error - **Unsupported Variants**: Gracefully skips unsupported HEIF features - **Memory Limits**: Fails safely if image too large diff --git a/examples/plugins/markdown-metadata/README.md b/examples/plugins/markdown-metadata/README.md index 91f2f4c..39044a4 100644 --- a/examples/plugins/markdown-metadata/README.md +++ b/examples/plugins/markdown-metadata/README.md @@ -1,10 +1,12 @@ # Markdown Metadata Extractor Plugin -This example plugin demonstrates how to create a metadata extractor plugin for Pinakes. +This example plugin demonstrates how to create a metadata extractor plugin for +Pinakes. ## Overview The Markdown Metadata Extractor enhances Pinakes' built-in markdown support by: + - Parsing YAML and TOML frontmatter - Extracting metadata from frontmatter fields - Converting frontmatter tags to Pinakes media tags @@ -12,8 +14,10 @@ The Markdown Metadata Extractor enhances Pinakes' built-in markdown support by: ## Features -- **Frontmatter Parsing**: Supports both YAML (`---`) and TOML (`+++`) frontmatter formats -- **Tag Extraction**: Automatically extracts tags from frontmatter and applies them to media items +- **Frontmatter Parsing**: Supports both YAML (`---`) and TOML (`+++`) + frontmatter formats +- **Tag Extraction**: Automatically extracts tags from frontmatter and applies + them to media items - **Custom Fields**: Preserves all frontmatter fields as custom metadata - **Configuration**: Configurable via `plugin.toml` config section @@ -88,6 +92,7 @@ The plugin can be configured through the `config` section in `plugin.toml`: ## Security This plugin has minimal capabilities: + - **Filesystem**: No write access, read access only to files being processed - **Network**: Disabled - **Environment**: No access diff --git a/flake.lock b/flake.lock index 52fc03b07293feaff826ce0f494e0286e3b33041..c1c549c0e85f6f19922b770e30104398546b1878 100644 GIT binary patch delta 219 zcmWN~K~94}6adh)O|(aFqw6N!%%2&C`AcFN6k{pHR9Mb1A5m#7MpDZuYS?-Uui#!6 zJ&?W&yyN&dKHQ!N%|VO)XLvEai$t9!Z})7*qt|W|uGYGWlW?`oo}zu0^M^Kge{Ok< zf&BQqo><8gM=Hs4iVCTn11hai9xHtGowH7$DTwul(al#jXa=VeBr+fR-{JW*&9{2K z?{?*7EW)-~w^%KfS4*gNv$RegjklS1qRwA<6ZB`J#Y7n<0F@$Rg)NN%w1mVJ5IN6k N<72_31^vH${{i-(MKu5b delta 220 zcmW;FO-{l<6ae5L3pj!s_at2~Z~ppbYqdZM4gL(2?q*&)kr-lXqamIG(=E5~3LL-% z2a=e4`|mtF4-bn0k7L2WbiY?O(LMP6bf0J8?(M;Te7*+PF6+*s-*$x1sbk3c=E_DFy=-mp-Fd#e)d!Jn%;n;;@qhJOr*Et_1C<7 z+FHIK#S+t)RbgTNXiMK(ZKv6;oIn!2*zUT&91$>N5R@T}HAoT+UF|S9?U@%9LL~)h Is4v^;58MYqrT_o{ diff --git a/flake.nix b/flake.nix index 5e4017d..d9ca783 100644 --- a/flake.nix +++ b/flake.nix @@ -21,5 +21,38 @@ in { default = pkgs.callPackage ./nix/shell.nix {}; }); + + formatter = forEachSystem (system: let + pkgs = nixpkgs.legacyPackages.${system}; + in + pkgs.writeShellApplication { + name = "nix3-fmt-wrapper"; + + runtimeInputs = [ + pkgs.alejandra + pkgs.fd + pkgs.prettier + pkgs.deno + pkgs.taplo + pkgs.sql-formatter + ]; + + text = '' + # Format Nix with Alejandra + fd "$@" -t f -e nix -x alejandra -q '{}' + + # Format TOML with Taplo + fd "$@" -t f -e toml -x taplo fmt '{}' + + # Format CSS with Prettier + fd "$@" -t f -e css -x prettier --write '{}' + + # Format SQL with sql-format + fd "$@" -t f -e sql -x sql-formatter --fix '{}' -l postgresql + + # Format Markdown with Deno + fd "$@" -t f -e md -x deno fmt -q '{}' + ''; + }); }; } diff --git a/migrations/postgres/V10__incremental_scan.sql b/migrations/postgres/V10__incremental_scan.sql index 8fdc2cb..85b196e 100644 --- a/migrations/postgres/V10__incremental_scan.sql +++ b/migrations/postgres/V10__incremental_scan.sql @@ -1,19 +1,19 @@ -- Add file_mtime column to media_items table for incremental scanning -- Stores Unix timestamp in seconds of the file's modification time - -ALTER TABLE media_items ADD COLUMN file_mtime BIGINT; +ALTER TABLE media_items +ADD COLUMN file_mtime BIGINT; -- Create index for quick mtime lookups -CREATE INDEX IF NOT EXISTS idx_media_items_file_mtime ON media_items(file_mtime); +CREATE INDEX IF NOT EXISTS idx_media_items_file_mtime ON media_items (file_mtime); -- Create a scan_history table to track when each directory was last scanned CREATE TABLE IF NOT EXISTS scan_history ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - directory TEXT NOT NULL UNIQUE, - last_scan_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - files_scanned INTEGER NOT NULL DEFAULT 0, - files_changed INTEGER NOT NULL DEFAULT 0, - scan_duration_ms INTEGER + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directory TEXT NOT NULL UNIQUE, + last_scan_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + files_scanned INTEGER NOT NULL DEFAULT 0, + files_changed INTEGER NOT NULL DEFAULT 0, + scan_duration_ms INTEGER ); -CREATE INDEX IF NOT EXISTS idx_scan_history_directory ON scan_history(directory); +CREATE INDEX IF NOT EXISTS idx_scan_history_directory ON scan_history (directory); diff --git a/migrations/postgres/V11__session_persistence.sql b/migrations/postgres/V11__session_persistence.sql index 8603d0b..d9b5f69 100644 --- a/migrations/postgres/V11__session_persistence.sql +++ b/migrations/postgres/V11__session_persistence.sql @@ -1,18 +1,17 @@ -- Session persistence for database-backed sessions -- Replaces in-memory session storage - CREATE TABLE IF NOT EXISTS sessions ( - session_token TEXT PRIMARY KEY NOT NULL, - user_id TEXT, - username TEXT NOT NULL, - role TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - last_accessed TIMESTAMPTZ NOT NULL + session_token TEXT PRIMARY KEY NOT NULL, + user_id TEXT, + username TEXT NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + last_accessed TIMESTAMPTZ NOT NULL ); -- Index for efficient cleanup of expired sessions -CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at); -- Index for listing sessions by username -CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions(username); +CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions (username); diff --git a/migrations/postgres/V12__book_management.sql b/migrations/postgres/V12__book_management.sql index 2452032..71e29f5 100644 --- a/migrations/postgres/V12__book_management.sql +++ b/migrations/postgres/V12__book_management.sql @@ -1,60 +1,61 @@ -- V12: Book Management Schema (PostgreSQL) -- Adds comprehensive book metadata tracking, authors, and identifiers - -- Book metadata (supplements media_items for EPUB/PDF/MOBI) CREATE TABLE book_metadata ( - media_id UUID PRIMARY KEY REFERENCES media_items(id) ON DELETE CASCADE, - isbn TEXT, - isbn13 TEXT, -- Normalized ISBN-13 for lookups - publisher TEXT, - language TEXT, -- ISO 639-1 code - page_count INTEGER, - publication_date DATE, - series_name TEXT, - series_index DOUBLE PRECISION, -- Supports 1.5, etc. - format TEXT, -- 'epub', 'pdf', 'mobi', 'azw3' - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + media_id UUID PRIMARY KEY REFERENCES media_items (id) ON DELETE CASCADE, + isbn TEXT, + isbn13 TEXT, -- Normalized ISBN-13 for lookups + publisher TEXT, + language TEXT, -- ISO 639-1 code + page_count INTEGER, + publication_date DATE, + series_name TEXT, + series_index DOUBLE PRECISION, -- Supports 1.5, etc. + format TEXT, -- 'epub', 'pdf', 'mobi', 'azw3' + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX idx_book_isbn13 ON book_metadata(isbn13); -CREATE INDEX idx_book_series ON book_metadata(series_name, series_index); -CREATE INDEX idx_book_publisher ON book_metadata(publisher); -CREATE INDEX idx_book_language ON book_metadata(language); +CREATE INDEX idx_book_isbn13 ON book_metadata (isbn13); + +CREATE INDEX idx_book_series ON book_metadata (series_name, series_index); + +CREATE INDEX idx_book_publisher ON book_metadata (publisher); + +CREATE INDEX idx_book_language ON book_metadata (language); -- Multiple authors per book (many-to-many) CREATE TABLE book_authors ( - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - author_name TEXT NOT NULL, - author_sort TEXT, -- "Last, First" for sorting - role TEXT NOT NULL DEFAULT 'author', -- author, translator, editor, illustrator - position INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (media_id, author_name, role) + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + author_name TEXT NOT NULL, + author_sort TEXT, -- "Last, First" for sorting + role TEXT NOT NULL DEFAULT 'author', -- author, translator, editor, illustrator + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (media_id, author_name, role) ); -CREATE INDEX idx_book_authors_name ON book_authors(author_name); -CREATE INDEX idx_book_authors_sort ON book_authors(author_sort); +CREATE INDEX idx_book_authors_name ON book_authors (author_name); + +CREATE INDEX idx_book_authors_sort ON book_authors (author_sort); -- Multiple identifiers (ISBN variants, ASIN, DOI, etc.) CREATE TABLE book_identifiers ( - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - identifier_type TEXT NOT NULL, -- isbn, isbn13, asin, doi, lccn, oclc - identifier_value TEXT NOT NULL, - PRIMARY KEY (media_id, identifier_type, identifier_value) + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + identifier_type TEXT NOT NULL, -- isbn, isbn13, asin, doi, lccn, oclc + identifier_value TEXT NOT NULL, + PRIMARY KEY (media_id, identifier_type, identifier_value) ); -CREATE INDEX idx_book_identifiers ON book_identifiers(identifier_type, identifier_value); +CREATE INDEX idx_book_identifiers ON book_identifiers (identifier_type, identifier_value); -- Trigger to update updated_at on book_metadata changes -CREATE OR REPLACE FUNCTION update_book_metadata_timestamp() -RETURNS TRIGGER AS $$ +CREATE OR REPLACE FUNCTION update_book_metadata_timestamp () RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -CREATE TRIGGER update_book_metadata_timestamp - BEFORE UPDATE ON book_metadata - FOR EACH ROW - EXECUTE FUNCTION update_book_metadata_timestamp(); +CREATE TRIGGER update_book_metadata_timestamp BEFORE +UPDATE ON book_metadata FOR EACH ROW +EXECUTE FUNCTION update_book_metadata_timestamp (); diff --git a/migrations/postgres/V13__photo_metadata.sql b/migrations/postgres/V13__photo_metadata.sql index f1365cd..7c66bb8 100644 --- a/migrations/postgres/V13__photo_metadata.sql +++ b/migrations/postgres/V13__photo_metadata.sql @@ -1,15 +1,40 @@ -- V13: Enhanced photo metadata support -- Add photo-specific fields to media_items table +ALTER TABLE media_items +ADD COLUMN date_taken TIMESTAMPTZ; -ALTER TABLE media_items ADD COLUMN date_taken TIMESTAMPTZ; -ALTER TABLE media_items ADD COLUMN latitude DOUBLE PRECISION; -ALTER TABLE media_items ADD COLUMN longitude DOUBLE PRECISION; -ALTER TABLE media_items ADD COLUMN camera_make TEXT; -ALTER TABLE media_items ADD COLUMN camera_model TEXT; -ALTER TABLE media_items ADD COLUMN rating INTEGER CHECK (rating >= 0 AND rating <= 5); +ALTER TABLE media_items +ADD COLUMN latitude DOUBLE PRECISION; + +ALTER TABLE media_items +ADD COLUMN longitude DOUBLE PRECISION; + +ALTER TABLE media_items +ADD COLUMN camera_make TEXT; + +ALTER TABLE media_items +ADD COLUMN camera_model TEXT; + +ALTER TABLE media_items +ADD COLUMN rating INTEGER CHECK ( + rating >= 0 + AND rating <= 5 +); -- Indexes for photo queries -CREATE INDEX idx_media_date_taken ON media_items(date_taken) WHERE date_taken IS NOT NULL; -CREATE INDEX idx_media_location ON media_items(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; -CREATE INDEX idx_media_camera ON media_items(camera_make) WHERE camera_make IS NOT NULL; -CREATE INDEX idx_media_rating ON media_items(rating) WHERE rating IS NOT NULL; +CREATE INDEX idx_media_date_taken ON media_items (date_taken) +WHERE + date_taken IS NOT NULL; + +CREATE INDEX idx_media_location ON media_items (latitude, longitude) +WHERE + latitude IS NOT NULL + AND longitude IS NOT NULL; + +CREATE INDEX idx_media_camera ON media_items (camera_make) +WHERE + camera_make IS NOT NULL; + +CREATE INDEX idx_media_rating ON media_items (rating) +WHERE + rating IS NOT NULL; diff --git a/migrations/postgres/V14__perceptual_hash.sql b/migrations/postgres/V14__perceptual_hash.sql index 4bdc677..1d3c634 100644 --- a/migrations/postgres/V14__perceptual_hash.sql +++ b/migrations/postgres/V14__perceptual_hash.sql @@ -1,7 +1,9 @@ -- V14: Perceptual hash for duplicate detection -- Add perceptual hash column for image similarity detection - -ALTER TABLE media_items ADD COLUMN perceptual_hash TEXT; +ALTER TABLE media_items +ADD COLUMN perceptual_hash TEXT; -- Index for perceptual hash lookups -CREATE INDEX idx_media_phash ON media_items(perceptual_hash) WHERE perceptual_hash IS NOT NULL; +CREATE INDEX idx_media_phash ON media_items (perceptual_hash) +WHERE + perceptual_hash IS NOT NULL; diff --git a/migrations/postgres/V15__managed_storage.sql b/migrations/postgres/V15__managed_storage.sql index 56ef8f4..e3fb615 100644 --- a/migrations/postgres/V15__managed_storage.sql +++ b/migrations/postgres/V15__managed_storage.sql @@ -1,30 +1,33 @@ -- V15: Managed File Storage -- Adds server-side content-addressable storage for uploaded files - -- Add storage mode to media_items (external = file on disk, managed = in content-addressable storage) -ALTER TABLE media_items ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'external'; +ALTER TABLE media_items +ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'external'; -- Original filename for managed uploads (preserved separately from file_name which may be normalized) -ALTER TABLE media_items ADD COLUMN original_filename TEXT; +ALTER TABLE media_items +ADD COLUMN original_filename TEXT; -- When the file was uploaded to managed storage -ALTER TABLE media_items ADD COLUMN uploaded_at TIMESTAMPTZ; +ALTER TABLE media_items +ADD COLUMN uploaded_at TIMESTAMPTZ; -- Storage key for looking up the blob (usually same as content_hash for deduplication) -ALTER TABLE media_items ADD COLUMN storage_key TEXT; +ALTER TABLE media_items +ADD COLUMN storage_key TEXT; -- Managed blobs table - tracks deduplicated file storage CREATE TABLE managed_blobs ( - content_hash TEXT PRIMARY KEY NOT NULL, - file_size BIGINT NOT NULL, - mime_type TEXT NOT NULL, - reference_count INTEGER NOT NULL DEFAULT 1, - stored_at TIMESTAMPTZ NOT NULL, - last_verified TIMESTAMPTZ + content_hash TEXT PRIMARY KEY NOT NULL, + file_size BIGINT NOT NULL, + mime_type TEXT NOT NULL, + reference_count INTEGER NOT NULL DEFAULT 1, + stored_at TIMESTAMPTZ NOT NULL, + last_verified TIMESTAMPTZ ); -- Index for finding managed media items -CREATE INDEX idx_media_storage_mode ON media_items(storage_mode); +CREATE INDEX idx_media_storage_mode ON media_items (storage_mode); -- Index for finding orphaned blobs (reference_count = 0) -CREATE INDEX idx_blobs_reference_count ON managed_blobs(reference_count); +CREATE INDEX idx_blobs_reference_count ON managed_blobs (reference_count); diff --git a/migrations/postgres/V16__sync_system.sql b/migrations/postgres/V16__sync_system.sql index 8a87823..8647e12 100644 --- a/migrations/postgres/V16__sync_system.sql +++ b/migrations/postgres/V16__sync_system.sql @@ -1,91 +1,100 @@ -- V16: Cross-Device Sync System -- Adds device registration, change tracking, and chunked upload support - -- Sync devices table CREATE TABLE sync_devices ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - device_type TEXT NOT NULL, - client_version TEXT NOT NULL, - os_info TEXT, - device_token_hash TEXT NOT NULL UNIQUE, - last_sync_at TIMESTAMPTZ, - last_seen_at TIMESTAMPTZ NOT NULL, - sync_cursor BIGINT DEFAULT 0, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL, + device_type TEXT NOT NULL, + client_version TEXT NOT NULL, + os_info TEXT, + device_token_hash TEXT NOT NULL UNIQUE, + last_sync_at TIMESTAMPTZ, + last_seen_at TIMESTAMPTZ NOT NULL, + sync_cursor BIGINT DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL ); -CREATE INDEX idx_sync_devices_user ON sync_devices(user_id); -CREATE INDEX idx_sync_devices_token ON sync_devices(device_token_hash); +CREATE INDEX idx_sync_devices_user ON sync_devices (user_id); + +CREATE INDEX idx_sync_devices_token ON sync_devices (device_token_hash); -- Sync log table - tracks all changes for sync CREATE TABLE sync_log ( - id TEXT PRIMARY KEY NOT NULL, - sequence BIGSERIAL UNIQUE NOT NULL, - change_type TEXT NOT NULL, - media_id TEXT REFERENCES media_items(id) ON DELETE SET NULL, - path TEXT NOT NULL, - content_hash TEXT, - file_size BIGINT, - metadata_json TEXT, - changed_by_device TEXT REFERENCES sync_devices(id) ON DELETE SET NULL, - timestamp TIMESTAMPTZ NOT NULL + id TEXT PRIMARY KEY NOT NULL, + sequence BIGSERIAL UNIQUE NOT NULL, + change_type TEXT NOT NULL, + media_id TEXT REFERENCES media_items (id) ON DELETE SET NULL, + path TEXT NOT NULL, + content_hash TEXT, + file_size BIGINT, + metadata_json TEXT, + changed_by_device TEXT REFERENCES sync_devices (id) ON DELETE SET NULL, + timestamp TIMESTAMPTZ NOT NULL ); -CREATE INDEX idx_sync_log_sequence ON sync_log(sequence); -CREATE INDEX idx_sync_log_path ON sync_log(path); -CREATE INDEX idx_sync_log_timestamp ON sync_log(timestamp); +CREATE INDEX idx_sync_log_sequence ON sync_log (sequence); + +CREATE INDEX idx_sync_log_path ON sync_log (path); + +CREATE INDEX idx_sync_log_timestamp ON sync_log (timestamp); -- Sequence counter for sync log CREATE TABLE sync_sequence ( - id INTEGER PRIMARY KEY CHECK (id = 1), - current_value BIGINT NOT NULL DEFAULT 0 + id INTEGER PRIMARY KEY CHECK (id = 1), + current_value BIGINT NOT NULL DEFAULT 0 ); -INSERT INTO sync_sequence (id, current_value) VALUES (1, 0); + +INSERT INTO + sync_sequence (id, current_value) +VALUES + (1, 0); -- Device sync state - tracks sync status per device per file CREATE TABLE device_sync_state ( - device_id TEXT NOT NULL REFERENCES sync_devices(id) ON DELETE CASCADE, - path TEXT NOT NULL, - local_hash TEXT, - server_hash TEXT, - local_mtime BIGINT, - server_mtime BIGINT, - sync_status TEXT NOT NULL, - last_synced_at TIMESTAMPTZ, - conflict_info_json TEXT, - PRIMARY KEY (device_id, path) + device_id TEXT NOT NULL REFERENCES sync_devices (id) ON DELETE CASCADE, + path TEXT NOT NULL, + local_hash TEXT, + server_hash TEXT, + local_mtime BIGINT, + server_mtime BIGINT, + sync_status TEXT NOT NULL, + last_synced_at TIMESTAMPTZ, + conflict_info_json TEXT, + PRIMARY KEY (device_id, path) ); -CREATE INDEX idx_device_sync_status ON device_sync_state(device_id, sync_status); +CREATE INDEX idx_device_sync_status ON device_sync_state (device_id, sync_status); -- Upload sessions for chunked uploads CREATE TABLE upload_sessions ( - id TEXT PRIMARY KEY NOT NULL, - device_id TEXT NOT NULL REFERENCES sync_devices(id) ON DELETE CASCADE, - target_path TEXT NOT NULL, - expected_hash TEXT NOT NULL, - expected_size BIGINT NOT NULL, - chunk_size BIGINT NOT NULL, - chunk_count BIGINT NOT NULL, - status TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - last_activity TIMESTAMPTZ NOT NULL + id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL REFERENCES sync_devices (id) ON DELETE CASCADE, + target_path TEXT NOT NULL, + expected_hash TEXT NOT NULL, + expected_size BIGINT NOT NULL, + chunk_size BIGINT NOT NULL, + chunk_count BIGINT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + last_activity TIMESTAMPTZ NOT NULL ); -CREATE INDEX idx_upload_sessions_device ON upload_sessions(device_id); -CREATE INDEX idx_upload_sessions_status ON upload_sessions(status); -CREATE INDEX idx_upload_sessions_expires ON upload_sessions(expires_at); +CREATE INDEX idx_upload_sessions_device ON upload_sessions (device_id); + +CREATE INDEX idx_upload_sessions_status ON upload_sessions (status); + +CREATE INDEX idx_upload_sessions_expires ON upload_sessions (expires_at); -- Upload chunks - tracks received chunks CREATE TABLE upload_chunks ( - upload_id TEXT NOT NULL REFERENCES upload_sessions(id) ON DELETE CASCADE, - chunk_index BIGINT NOT NULL, - offset BIGINT NOT NULL, + upload_id TEXT NOT NULL REFERENCES upload_sessions (id) ON DELETE CASCADE, + chunk_index BIGINT NOT NULL, + offset + BIGINT NOT NULL, size BIGINT NOT NULL, hash TEXT NOT NULL, received_at TIMESTAMPTZ NOT NULL, @@ -94,17 +103,20 @@ CREATE TABLE upload_chunks ( -- Sync conflicts CREATE TABLE sync_conflicts ( - id TEXT PRIMARY KEY NOT NULL, - device_id TEXT NOT NULL REFERENCES sync_devices(id) ON DELETE CASCADE, - path TEXT NOT NULL, - local_hash TEXT NOT NULL, - local_mtime BIGINT NOT NULL, - server_hash TEXT NOT NULL, - server_mtime BIGINT NOT NULL, - detected_at TIMESTAMPTZ NOT NULL, - resolved_at TIMESTAMPTZ, - resolution TEXT + id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL REFERENCES sync_devices (id) ON DELETE CASCADE, + path TEXT NOT NULL, + local_hash TEXT NOT NULL, + local_mtime BIGINT NOT NULL, + server_hash TEXT NOT NULL, + server_mtime BIGINT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + resolution TEXT ); -CREATE INDEX idx_sync_conflicts_device ON sync_conflicts(device_id); -CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts(device_id) WHERE resolved_at IS NULL; +CREATE INDEX idx_sync_conflicts_device ON sync_conflicts (device_id); + +CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts (device_id) +WHERE + resolved_at IS NULL; diff --git a/migrations/postgres/V17__enhanced_sharing.sql b/migrations/postgres/V17__enhanced_sharing.sql index 2107b5c..b068ae7 100644 --- a/migrations/postgres/V17__enhanced_sharing.sql +++ b/migrations/postgres/V17__enhanced_sharing.sql @@ -1,68 +1,85 @@ -- V17: Enhanced Sharing System -- Replaces simple share_links with comprehensive sharing capabilities - -- Enhanced shares table CREATE TABLE shares ( - id TEXT PRIMARY KEY NOT NULL, - target_type TEXT NOT NULL CHECK (target_type IN ('media', 'collection', 'tag', 'saved_search')), - target_id TEXT NOT NULL, - owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - recipient_type TEXT NOT NULL CHECK (recipient_type IN ('public_link', 'user', 'group', 'federated')), - recipient_user_id TEXT REFERENCES users(id) ON DELETE CASCADE, - recipient_group_id TEXT, - recipient_federated_handle TEXT, - recipient_federated_server TEXT, - public_token TEXT UNIQUE, - public_password_hash TEXT, - perm_view BOOLEAN NOT NULL DEFAULT TRUE, - perm_download BOOLEAN NOT NULL DEFAULT FALSE, - perm_edit BOOLEAN NOT NULL DEFAULT FALSE, - perm_delete BOOLEAN NOT NULL DEFAULT FALSE, - perm_reshare BOOLEAN NOT NULL DEFAULT FALSE, - perm_add BOOLEAN NOT NULL DEFAULT FALSE, - note TEXT, - expires_at TIMESTAMPTZ, - access_count BIGINT NOT NULL DEFAULT 0, - last_accessed TIMESTAMPTZ, - inherit_to_children BOOLEAN NOT NULL DEFAULT TRUE, - parent_share_id TEXT REFERENCES shares(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE(owner_id, target_type, target_id, recipient_type, recipient_user_id) + id TEXT PRIMARY KEY NOT NULL, + target_type TEXT NOT NULL CHECK ( + target_type IN ('media', 'collection', 'tag', 'saved_search') + ), + target_id TEXT NOT NULL, + owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + recipient_type TEXT NOT NULL CHECK ( + recipient_type IN ('public_link', 'user', 'group', 'federated') + ), + recipient_user_id TEXT REFERENCES users (id) ON DELETE CASCADE, + recipient_group_id TEXT, + recipient_federated_handle TEXT, + recipient_federated_server TEXT, + public_token TEXT UNIQUE, + public_password_hash TEXT, + perm_view BOOLEAN NOT NULL DEFAULT TRUE, + perm_download BOOLEAN NOT NULL DEFAULT FALSE, + perm_edit BOOLEAN NOT NULL DEFAULT FALSE, + perm_delete BOOLEAN NOT NULL DEFAULT FALSE, + perm_reshare BOOLEAN NOT NULL DEFAULT FALSE, + perm_add BOOLEAN NOT NULL DEFAULT FALSE, + note TEXT, + expires_at TIMESTAMPTZ, + access_count BIGINT NOT NULL DEFAULT 0, + last_accessed TIMESTAMPTZ, + inherit_to_children BOOLEAN NOT NULL DEFAULT TRUE, + parent_share_id TEXT REFERENCES shares (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE ( + owner_id, + target_type, + target_id, + recipient_type, + recipient_user_id + ) ); -CREATE INDEX idx_shares_owner ON shares(owner_id); -CREATE INDEX idx_shares_recipient_user ON shares(recipient_user_id); -CREATE INDEX idx_shares_target ON shares(target_type, target_id); -CREATE INDEX idx_shares_token ON shares(public_token); -CREATE INDEX idx_shares_expires ON shares(expires_at); +CREATE INDEX idx_shares_owner ON shares (owner_id); + +CREATE INDEX idx_shares_recipient_user ON shares (recipient_user_id); + +CREATE INDEX idx_shares_target ON shares (target_type, target_id); + +CREATE INDEX idx_shares_token ON shares (public_token); + +CREATE INDEX idx_shares_expires ON shares (expires_at); -- Share activity log CREATE TABLE share_activity ( - id TEXT PRIMARY KEY NOT NULL, - share_id TEXT NOT NULL REFERENCES shares(id) ON DELETE CASCADE, - actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, - actor_ip TEXT, - action TEXT NOT NULL, - details TEXT, - timestamp TIMESTAMPTZ NOT NULL + id TEXT PRIMARY KEY NOT NULL, + share_id TEXT NOT NULL REFERENCES shares (id) ON DELETE CASCADE, + actor_id TEXT REFERENCES users (id) ON DELETE SET NULL, + actor_ip TEXT, + action TEXT NOT NULL, + details TEXT, + timestamp TIMESTAMPTZ NOT NULL ); -CREATE INDEX idx_share_activity_share ON share_activity(share_id); -CREATE INDEX idx_share_activity_timestamp ON share_activity(timestamp); +CREATE INDEX idx_share_activity_share ON share_activity (share_id); + +CREATE INDEX idx_share_activity_timestamp ON share_activity (timestamp); -- Share notifications CREATE TABLE share_notifications ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - share_id TEXT NOT NULL REFERENCES shares(id) ON DELETE CASCADE, - notification_type TEXT NOT NULL, - is_read BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + share_id TEXT NOT NULL REFERENCES shares (id) ON DELETE CASCADE, + notification_type TEXT NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL ); -CREATE INDEX idx_share_notifications_user ON share_notifications(user_id); -CREATE INDEX idx_share_notifications_unread ON share_notifications(user_id) WHERE is_read = FALSE; +CREATE INDEX idx_share_notifications_user ON share_notifications (user_id); + +CREATE INDEX idx_share_notifications_unread ON share_notifications (user_id) +WHERE + is_read = FALSE; -- Migrate existing share_links to new shares table DO $$ diff --git a/migrations/postgres/V18__file_management.sql b/migrations/postgres/V18__file_management.sql index 30403c3..6059e65 100644 --- a/migrations/postgres/V18__file_management.sql +++ b/migrations/postgres/V18__file_management.sql @@ -1,11 +1,13 @@ -- V18: File Management (Rename, Move, Trash) -- Adds soft delete support for trash/recycle bin functionality - -- Add deleted_at column for soft delete (trash) -ALTER TABLE media_items ADD COLUMN deleted_at TIMESTAMPTZ; +ALTER TABLE media_items +ADD COLUMN deleted_at TIMESTAMPTZ; -- Index for efficient trash queries -CREATE INDEX idx_media_deleted_at ON media_items(deleted_at); +CREATE INDEX idx_media_deleted_at ON media_items (deleted_at); -- Partial index for listing non-deleted items (most common query pattern) -CREATE INDEX idx_media_not_deleted ON media_items(id) WHERE deleted_at IS NULL; +CREATE INDEX idx_media_not_deleted ON media_items (id) +WHERE + deleted_at IS NULL; diff --git a/migrations/postgres/V19__markdown_links.sql b/migrations/postgres/V19__markdown_links.sql index b0d5475..084726e 100644 --- a/migrations/postgres/V19__markdown_links.sql +++ b/migrations/postgres/V19__markdown_links.sql @@ -1,35 +1,35 @@ -- V19: Markdown Links (Obsidian-style bidirectional links) -- Adds support for wikilinks, markdown links, embeds, and backlink tracking - -- Table for storing extracted markdown links CREATE TABLE IF NOT EXISTS markdown_links ( - id TEXT PRIMARY KEY NOT NULL, - source_media_id TEXT NOT NULL, - target_path TEXT NOT NULL, -- raw link target (wikilink or path) - target_media_id TEXT, -- resolved media_id (nullable if unresolved) - link_type TEXT NOT NULL, -- 'wikilink', 'markdown_link', 'embed' - link_text TEXT, -- display text for the link - line_number INTEGER, -- line number in source file - context TEXT, -- surrounding text for preview - created_at TIMESTAMPTZ NOT NULL, - FOREIGN KEY (source_media_id) REFERENCES media_items(id) ON DELETE CASCADE, - FOREIGN KEY (target_media_id) REFERENCES media_items(id) ON DELETE SET NULL + id TEXT PRIMARY KEY NOT NULL, + source_media_id TEXT NOT NULL, + target_path TEXT NOT NULL, -- raw link target (wikilink or path) + target_media_id TEXT, -- resolved media_id (nullable if unresolved) + link_type TEXT NOT NULL, -- 'wikilink', 'markdown_link', 'embed' + link_text TEXT, -- display text for the link + line_number INTEGER, -- line number in source file + context TEXT, -- surrounding text for preview + created_at TIMESTAMPTZ NOT NULL, + FOREIGN KEY (source_media_id) REFERENCES media_items (id) ON DELETE CASCADE, + FOREIGN KEY (target_media_id) REFERENCES media_items (id) ON DELETE SET NULL ); -- Index for efficient outgoing link queries (what does this note link to?) -CREATE INDEX idx_links_source ON markdown_links(source_media_id); +CREATE INDEX idx_links_source ON markdown_links (source_media_id); -- Index for efficient backlink queries (what links to this note?) -CREATE INDEX idx_links_target ON markdown_links(target_media_id); +CREATE INDEX idx_links_target ON markdown_links (target_media_id); -- Index for path-based resolution (finding unresolved links) -CREATE INDEX idx_links_target_path ON markdown_links(target_path); +CREATE INDEX idx_links_target_path ON markdown_links (target_path); -- Index for link type filtering -CREATE INDEX idx_links_type ON markdown_links(link_type); +CREATE INDEX idx_links_type ON markdown_links (link_type); -- Track when links were last extracted from a media item -ALTER TABLE media_items ADD COLUMN links_extracted_at TIMESTAMPTZ; +ALTER TABLE media_items +ADD COLUMN links_extracted_at TIMESTAMPTZ; -- Index for finding media items that need link extraction -CREATE INDEX idx_media_links_extracted ON media_items(links_extracted_at); +CREATE INDEX idx_media_links_extracted ON media_items (links_extracted_at); diff --git a/migrations/postgres/V1__initial_schema.sql b/migrations/postgres/V1__initial_schema.sql index cd6a0c8..c1c49af 100644 --- a/migrations/postgres/V1__initial_schema.sql +++ b/migrations/postgres/V1__initial_schema.sql @@ -1,73 +1,75 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE TABLE IF NOT EXISTS root_dirs ( - path TEXT PRIMARY KEY NOT NULL -); +CREATE TABLE IF NOT EXISTS root_dirs (path TEXT PRIMARY KEY NOT NULL); CREATE TABLE IF NOT EXISTS media_items ( - id UUID PRIMARY KEY NOT NULL, - path TEXT NOT NULL UNIQUE, - file_name TEXT NOT NULL, - media_type TEXT NOT NULL, - content_hash TEXT NOT NULL UNIQUE, - file_size BIGINT NOT NULL, - title TEXT, - artist TEXT, - album TEXT, - genre TEXT, - year INTEGER, - duration_secs DOUBLE PRECISION, - description TEXT, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL + id UUID PRIMARY KEY NOT NULL, + path TEXT NOT NULL UNIQUE, + file_name TEXT NOT NULL, + media_type TEXT NOT NULL, + content_hash TEXT NOT NULL UNIQUE, + file_size BIGINT NOT NULL, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + year INTEGER, + duration_secs DOUBLE PRECISION, + description TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS tags ( - id UUID PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - parent_id UUID REFERENCES tags(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL + id UUID PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + parent_id UUID REFERENCES tags (id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags(name, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000')); +CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags ( + name, + COALESCE(parent_id, '00000000-0000-0000-0000-000000000000') +); CREATE TABLE IF NOT EXISTS media_tags ( - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (media_id, tag_id) + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags (id) ON DELETE CASCADE, + PRIMARY KEY (media_id, tag_id) ); CREATE TABLE IF NOT EXISTS collections ( - id UUID PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - description TEXT, - kind TEXT NOT NULL, - filter_query TEXT, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL + id UUID PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL, + filter_query TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS collection_members ( - collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - position INTEGER NOT NULL DEFAULT 0, - added_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (collection_id, media_id) + collection_id UUID NOT NULL REFERENCES collections (id) ON DELETE CASCADE, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + added_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (collection_id, media_id) ); CREATE TABLE IF NOT EXISTS audit_log ( - id UUID PRIMARY KEY NOT NULL, - media_id UUID REFERENCES media_items(id) ON DELETE SET NULL, - action TEXT NOT NULL, - details TEXT, - timestamp TIMESTAMPTZ NOT NULL + id UUID PRIMARY KEY NOT NULL, + media_id UUID REFERENCES media_items (id) ON DELETE SET NULL, + action TEXT NOT NULL, + details TEXT, + timestamp TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS custom_fields ( - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - field_name TEXT NOT NULL, - field_type TEXT NOT NULL, - field_value TEXT NOT NULL, - PRIMARY KEY (media_id, field_name) + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + field_type TEXT NOT NULL, + field_value TEXT NOT NULL, + PRIMARY KEY (media_id, field_name) ); diff --git a/migrations/postgres/V2__fts_indexes.sql b/migrations/postgres/V2__fts_indexes.sql index 510fce6..543fdce 100644 --- a/migrations/postgres/V2__fts_indexes.sql +++ b/migrations/postgres/V2__fts_indexes.sql @@ -1,11 +1,12 @@ -ALTER TABLE media_items ADD COLUMN IF NOT EXISTS search_vector tsvector - GENERATED ALWAYS AS ( - setweight(to_tsvector('english', COALESCE(title, '')), 'A') || - setweight(to_tsvector('english', COALESCE(artist, '')), 'B') || - setweight(to_tsvector('english', COALESCE(album, '')), 'B') || - setweight(to_tsvector('english', COALESCE(genre, '')), 'C') || - setweight(to_tsvector('english', COALESCE(description, '')), 'C') || - setweight(to_tsvector('english', COALESCE(file_name, '')), 'D') - ) STORED; +ALTER TABLE media_items +ADD COLUMN IF NOT EXISTS search_vector tsvector GENERATED ALWAYS AS ( + setweight(to_tsvector('english', COALESCE(title, '')), 'A') || setweight(to_tsvector('english', COALESCE(artist, '')), 'B') || setweight(to_tsvector('english', COALESCE(album, '')), 'B') || setweight(to_tsvector('english', COALESCE(genre, '')), 'C') || setweight( + to_tsvector('english', COALESCE(description, '')), + 'C' + ) || setweight( + to_tsvector('english', COALESCE(file_name, '')), + 'D' + ) +) STORED; -CREATE INDEX IF NOT EXISTS idx_media_search ON media_items USING GIN(search_vector); +CREATE INDEX IF NOT EXISTS idx_media_search ON media_items USING GIN (search_vector); diff --git a/migrations/postgres/V3__audit_indexes.sql b/migrations/postgres/V3__audit_indexes.sql index d8c423a..85ffa06 100644 --- a/migrations/postgres/V3__audit_indexes.sql +++ b/migrations/postgres/V3__audit_indexes.sql @@ -1,8 +1,15 @@ -CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log(media_id); -CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); -CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); -CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items(content_hash); -CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items(media_type); -CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items(created_at); -CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media_items USING GIN(title gin_trgm_ops); -CREATE INDEX IF NOT EXISTS idx_media_artist_trgm ON media_items USING GIN(artist gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log (media_id); + +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log (timestamp); + +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action); + +CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items (content_hash); + +CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items (media_type); + +CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items (created_at); + +CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media_items USING GIN (title gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_media_artist_trgm ON media_items USING GIN (artist gin_trgm_ops); diff --git a/migrations/postgres/V4__thumbnail_path.sql b/migrations/postgres/V4__thumbnail_path.sql index 9021884..4c23b5b 100644 --- a/migrations/postgres/V4__thumbnail_path.sql +++ b/migrations/postgres/V4__thumbnail_path.sql @@ -1 +1,2 @@ -ALTER TABLE media_items ADD COLUMN thumbnail_path TEXT; +ALTER TABLE media_items +ADD COLUMN thumbnail_path TEXT; diff --git a/migrations/postgres/V5__integrity_and_saved_searches.sql b/migrations/postgres/V5__integrity_and_saved_searches.sql index f2807f4..fd62baf 100644 --- a/migrations/postgres/V5__integrity_and_saved_searches.sql +++ b/migrations/postgres/V5__integrity_and_saved_searches.sql @@ -1,12 +1,15 @@ -- Integrity tracking columns -ALTER TABLE media_items ADD COLUMN last_verified_at TIMESTAMPTZ; -ALTER TABLE media_items ADD COLUMN integrity_status TEXT DEFAULT 'unverified'; +ALTER TABLE media_items +ADD COLUMN last_verified_at TIMESTAMPTZ; + +ALTER TABLE media_items +ADD COLUMN integrity_status TEXT DEFAULT 'unverified'; -- Saved searches CREATE TABLE IF NOT EXISTS saved_searches ( - id UUID PRIMARY KEY, - name TEXT NOT NULL, - query TEXT NOT NULL, - sort_order TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY, + name TEXT NOT NULL, + query TEXT NOT NULL, + sort_order TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/migrations/postgres/V6__plugin_system.sql b/migrations/postgres/V6__plugin_system.sql index bd52a1a..b40cf41 100644 --- a/migrations/postgres/V6__plugin_system.sql +++ b/migrations/postgres/V6__plugin_system.sql @@ -1,15 +1,16 @@ -- Plugin registry table CREATE TABLE plugin_registry ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - version TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - config_json TEXT, - manifest_json TEXT, - installed_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + config_json TEXT, + manifest_json TEXT, + installed_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL ); -- Index for quick lookups -CREATE INDEX idx_plugin_registry_enabled ON plugin_registry(enabled); -CREATE INDEX idx_plugin_registry_name ON plugin_registry(name); +CREATE INDEX idx_plugin_registry_enabled ON plugin_registry (enabled); + +CREATE INDEX idx_plugin_registry_name ON plugin_registry (name); diff --git a/migrations/postgres/V7__user_management.sql b/migrations/postgres/V7__user_management.sql index a9eb12f..c405c80 100644 --- a/migrations/postgres/V7__user_management.sql +++ b/migrations/postgres/V7__user_management.sql @@ -1,35 +1,37 @@ -- Users table CREATE TABLE users ( - id UUID PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - role JSONB NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL + id UUID PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL ); -- User profiles table CREATE TABLE user_profiles ( - user_id UUID PRIMARY KEY, - avatar_path TEXT, - bio TEXT, - preferences_json JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) + user_id UUID PRIMARY KEY, + avatar_path TEXT, + bio TEXT, + preferences_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ); -- User library access table CREATE TABLE user_libraries ( - user_id UUID NOT NULL, - root_path TEXT NOT NULL, - permission JSONB NOT NULL, - granted_at TIMESTAMP WITH TIME ZONE NOT NULL, - PRIMARY KEY (user_id, root_path), - FOREIGN KEY (user_id) REFERENCES users(id) + user_id UUID NOT NULL, + root_path TEXT NOT NULL, + permission JSONB NOT NULL, + granted_at TIMESTAMP WITH TIME ZONE NOT NULL, + PRIMARY KEY (user_id, root_path), + FOREIGN KEY (user_id) REFERENCES users (id) ); -- Indexes for efficient lookups -CREATE INDEX idx_users_username ON users(username); -CREATE INDEX idx_user_libraries_user_id ON user_libraries(user_id); -CREATE INDEX idx_user_libraries_root_path ON user_libraries(root_path); +CREATE INDEX idx_users_username ON users (username); + +CREATE INDEX idx_user_libraries_user_id ON user_libraries (user_id); + +CREATE INDEX idx_user_libraries_root_path ON user_libraries (root_path); diff --git a/migrations/postgres/V8__media_server_features.sql b/migrations/postgres/V8__media_server_features.sql index 7d22838..2594bd4 100644 --- a/migrations/postgres/V8__media_server_features.sql +++ b/migrations/postgres/V8__media_server_features.sql @@ -1,131 +1,136 @@ -- Ratings CREATE TABLE IF NOT EXISTS ratings ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - stars INTEGER NOT NULL CHECK (stars >= 1 AND stars <= 5), - review_text TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, media_id) + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + stars INTEGER NOT NULL CHECK ( + stars >= 1 + AND stars <= 5 + ), + review_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, media_id) ); -- Comments CREATE TABLE IF NOT EXISTS comments ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE, - text TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + parent_comment_id UUID REFERENCES comments (id) ON DELETE CASCADE, + text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Favorites CREATE TABLE IF NOT EXISTS favorites ( - user_id UUID NOT NULL, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id, media_id) + user_id UUID NOT NULL, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, media_id) ); -- Share links CREATE TABLE IF NOT EXISTS share_links ( - id UUID PRIMARY KEY, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - created_by UUID NOT NULL, - token TEXT NOT NULL UNIQUE, - password_hash TEXT, - expires_at TIMESTAMPTZ, - view_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + created_by UUID NOT NULL, + token TEXT NOT NULL UNIQUE, + password_hash TEXT, + expires_at TIMESTAMPTZ, + view_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Playlists CREATE TABLE IF NOT EXISTS playlists ( - id UUID PRIMARY KEY, - owner_id UUID NOT NULL, - name TEXT NOT NULL, - description TEXT, - is_public BOOLEAN NOT NULL DEFAULT FALSE, - is_smart BOOLEAN NOT NULL DEFAULT FALSE, - filter_query TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY, + owner_id UUID NOT NULL, + name TEXT NOT NULL, + description TEXT, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + is_smart BOOLEAN NOT NULL DEFAULT FALSE, + filter_query TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Playlist items CREATE TABLE IF NOT EXISTS playlist_items ( - playlist_id UUID NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - position INTEGER NOT NULL DEFAULT 0, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (playlist_id, media_id) + playlist_id UUID NOT NULL REFERENCES playlists (id) ON DELETE CASCADE, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (playlist_id, media_id) ); -- Usage events CREATE TABLE IF NOT EXISTS usage_events ( - id UUID PRIMARY KEY, - media_id UUID REFERENCES media_items(id) ON DELETE SET NULL, - user_id UUID, - event_type TEXT NOT NULL, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - duration_secs DOUBLE PRECISION, - context_json JSONB + id UUID PRIMARY KEY, + media_id UUID REFERENCES media_items (id) ON DELETE SET NULL, + user_id UUID, + event_type TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + duration_secs DOUBLE PRECISION, + context_json JSONB ); -CREATE INDEX IF NOT EXISTS idx_usage_events_media ON usage_events(media_id); -CREATE INDEX IF NOT EXISTS idx_usage_events_user ON usage_events(user_id); -CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp ON usage_events(timestamp); +CREATE INDEX IF NOT EXISTS idx_usage_events_media ON usage_events (media_id); + +CREATE INDEX IF NOT EXISTS idx_usage_events_user ON usage_events (user_id); + +CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp ON usage_events (timestamp); -- Watch history / progress CREATE TABLE IF NOT EXISTS watch_history ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - progress_secs DOUBLE PRECISION NOT NULL DEFAULT 0, - last_watched TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, media_id) + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + progress_secs DOUBLE PRECISION NOT NULL DEFAULT 0, + last_watched TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, media_id) ); -- Subtitles CREATE TABLE IF NOT EXISTS subtitles ( - id UUID PRIMARY KEY, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - language TEXT, - format TEXT NOT NULL, - file_path TEXT, - is_embedded BOOLEAN NOT NULL DEFAULT FALSE, - track_index INTEGER, - offset_ms INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + language TEXT, + format TEXT NOT NULL, + file_path TEXT, + is_embedded BOOLEAN NOT NULL DEFAULT FALSE, + track_index INTEGER, + offset_ms INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX IF NOT EXISTS idx_subtitles_media ON subtitles(media_id); +CREATE INDEX IF NOT EXISTS idx_subtitles_media ON subtitles (media_id); -- External metadata (enrichment) CREATE TABLE IF NOT EXISTS external_metadata ( - id UUID PRIMARY KEY, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - source TEXT NOT NULL, - external_id TEXT, - metadata_json JSONB NOT NULL DEFAULT '{}', - confidence DOUBLE PRECISION NOT NULL DEFAULT 0.0, - last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + source TEXT NOT NULL, + external_id TEXT, + metadata_json JSONB NOT NULL DEFAULT '{}', + confidence DOUBLE PRECISION NOT NULL DEFAULT 0.0, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX IF NOT EXISTS idx_external_metadata_media ON external_metadata(media_id); +CREATE INDEX IF NOT EXISTS idx_external_metadata_media ON external_metadata (media_id); -- Transcode sessions CREATE TABLE IF NOT EXISTS transcode_sessions ( - id UUID PRIMARY KEY, - media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - user_id UUID, - profile TEXT NOT NULL, - cache_path TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - progress DOUBLE PRECISION NOT NULL DEFAULT 0.0, - error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ + id UUID PRIMARY KEY, + media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + user_id UUID, + profile TEXT NOT NULL, + cache_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + progress DOUBLE PRECISION NOT NULL DEFAULT 0.0, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ ); -CREATE INDEX IF NOT EXISTS idx_transcode_sessions_media ON transcode_sessions(media_id); +CREATE INDEX IF NOT EXISTS idx_transcode_sessions_media ON transcode_sessions (media_id); diff --git a/migrations/postgres/V9__fix_indexes_and_constraints.sql b/migrations/postgres/V9__fix_indexes_and_constraints.sql index a65fda3..9446a94 100644 --- a/migrations/postgres/V9__fix_indexes_and_constraints.sql +++ b/migrations/postgres/V9__fix_indexes_and_constraints.sql @@ -1,19 +1,26 @@ -- Drop redundant indexes (already covered by UNIQUE constraints) DROP INDEX IF EXISTS idx_users_username; + DROP INDEX IF EXISTS idx_user_libraries_user_id; -- Add missing indexes for comments table -CREATE INDEX IF NOT EXISTS idx_comments_media ON comments(media_id); -CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_comment_id); +CREATE INDEX IF NOT EXISTS idx_comments_media ON comments (media_id); + +CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments (parent_comment_id); -- Remove duplicates before adding unique constraint DELETE FROM external_metadata e1 -WHERE EXISTS ( - SELECT 1 FROM external_metadata e2 - WHERE e1.media_id = e2.media_id - AND e1.source = e2.source - AND e1.ctid < e2.ctid -); +WHERE + EXISTS ( + SELECT + 1 + FROM + external_metadata e2 + WHERE + e1.media_id = e2.media_id + AND e1.source = e2.source + AND e1.ctid < e2.ctid + ); -- Add unique constraint for external_metadata (idempotent) DO $$ diff --git a/migrations/sqlite/V10__incremental_scan.sql b/migrations/sqlite/V10__incremental_scan.sql index 76db5c9..a070c7f 100644 --- a/migrations/sqlite/V10__incremental_scan.sql +++ b/migrations/sqlite/V10__incremental_scan.sql @@ -1,19 +1,19 @@ -- Add file_mtime column to media_items table for incremental scanning -- Stores Unix timestamp in seconds of the file's modification time - -ALTER TABLE media_items ADD COLUMN file_mtime INTEGER; +ALTER TABLE media_items +ADD COLUMN file_mtime INTEGER; -- Create index for quick mtime lookups -CREATE INDEX IF NOT EXISTS idx_media_items_file_mtime ON media_items(file_mtime); +CREATE INDEX IF NOT EXISTS idx_media_items_file_mtime ON media_items (file_mtime); -- Create a scan_history table to track when each directory was last scanned CREATE TABLE IF NOT EXISTS scan_history ( - id TEXT PRIMARY KEY, - directory TEXT NOT NULL UNIQUE, - last_scan_at TEXT NOT NULL, - files_scanned INTEGER NOT NULL DEFAULT 0, - files_changed INTEGER NOT NULL DEFAULT 0, - scan_duration_ms INTEGER + id TEXT PRIMARY KEY, + directory TEXT NOT NULL UNIQUE, + last_scan_at TEXT NOT NULL, + files_scanned INTEGER NOT NULL DEFAULT 0, + files_changed INTEGER NOT NULL DEFAULT 0, + scan_duration_ms INTEGER ); -CREATE INDEX IF NOT EXISTS idx_scan_history_directory ON scan_history(directory); +CREATE INDEX IF NOT EXISTS idx_scan_history_directory ON scan_history (directory); diff --git a/migrations/sqlite/V11__session_persistence.sql b/migrations/sqlite/V11__session_persistence.sql index b4e2753..e5e7a94 100644 --- a/migrations/sqlite/V11__session_persistence.sql +++ b/migrations/sqlite/V11__session_persistence.sql @@ -1,18 +1,17 @@ -- Session persistence for database-backed sessions -- Replaces in-memory session storage - CREATE TABLE IF NOT EXISTS sessions ( - session_token TEXT PRIMARY KEY NOT NULL, - user_id TEXT, - username TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - last_accessed TEXT NOT NULL + session_token TEXT PRIMARY KEY NOT NULL, + user_id TEXT, + username TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_accessed TEXT NOT NULL ); -- Index for efficient cleanup of expired sessions -CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at); -- Index for listing sessions by username -CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions(username); +CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions (username); diff --git a/migrations/sqlite/V12__book_management.sql b/migrations/sqlite/V12__book_management.sql index 9823b87..9b18100 100644 --- a/migrations/sqlite/V12__book_management.sql +++ b/migrations/sqlite/V12__book_management.sql @@ -1,54 +1,62 @@ -- V12: Book Management Schema -- Adds comprehensive book metadata tracking, authors, and identifiers - -- Book metadata (supplements media_items for EPUB/PDF/MOBI) CREATE TABLE book_metadata ( - media_id TEXT PRIMARY KEY REFERENCES media_items(id) ON DELETE CASCADE, - isbn TEXT, - isbn13 TEXT, -- Normalized ISBN-13 for lookups - publisher TEXT, - language TEXT, -- ISO 639-1 code - page_count INTEGER, - publication_date TEXT, -- ISO 8601 date string - series_name TEXT, - series_index REAL, -- Supports 1.5, etc. - format TEXT, -- 'epub', 'pdf', 'mobi', 'azw3' - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + media_id TEXT PRIMARY KEY REFERENCES media_items (id) ON DELETE CASCADE, + isbn TEXT, + isbn13 TEXT, -- Normalized ISBN-13 for lookups + publisher TEXT, + language TEXT, -- ISO 639-1 code + page_count INTEGER, + publication_date TEXT, -- ISO 8601 date string + series_name TEXT, + series_index REAL, -- Supports 1.5, etc. + format TEXT, -- 'epub', 'pdf', 'mobi', 'azw3' + created_at TEXT NOT NULL DEFAULT (datetime ('now')), + updated_at TEXT NOT NULL DEFAULT (datetime ('now')) ) STRICT; -CREATE INDEX idx_book_isbn13 ON book_metadata(isbn13); -CREATE INDEX idx_book_series ON book_metadata(series_name, series_index); -CREATE INDEX idx_book_publisher ON book_metadata(publisher); -CREATE INDEX idx_book_language ON book_metadata(language); +CREATE INDEX idx_book_isbn13 ON book_metadata (isbn13); + +CREATE INDEX idx_book_series ON book_metadata (series_name, series_index); + +CREATE INDEX idx_book_publisher ON book_metadata (publisher); + +CREATE INDEX idx_book_language ON book_metadata (language); -- Multiple authors per book (many-to-many) CREATE TABLE book_authors ( - media_id TEXT NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - author_name TEXT NOT NULL, - author_sort TEXT, -- "Last, First" for sorting - role TEXT NOT NULL DEFAULT 'author', -- author, translator, editor, illustrator - position INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (media_id, author_name, role) + media_id TEXT NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + author_name TEXT NOT NULL, + author_sort TEXT, -- "Last, First" for sorting + role TEXT NOT NULL DEFAULT 'author', -- author, translator, editor, illustrator + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (media_id, author_name, role) ) STRICT; -CREATE INDEX idx_book_authors_name ON book_authors(author_name); -CREATE INDEX idx_book_authors_sort ON book_authors(author_sort); +CREATE INDEX idx_book_authors_name ON book_authors (author_name); + +CREATE INDEX idx_book_authors_sort ON book_authors (author_sort); -- Multiple identifiers (ISBN variants, ASIN, DOI, etc.) CREATE TABLE book_identifiers ( - media_id TEXT NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, - identifier_type TEXT NOT NULL, -- isbn, isbn13, asin, doi, lccn, oclc - identifier_value TEXT NOT NULL, - PRIMARY KEY (media_id, identifier_type, identifier_value) + media_id TEXT NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, + identifier_type TEXT NOT NULL, -- isbn, isbn13, asin, doi, lccn, oclc + identifier_value TEXT NOT NULL, + PRIMARY KEY (media_id, identifier_type, identifier_value) ) STRICT; -CREATE INDEX idx_book_identifiers ON book_identifiers(identifier_type, identifier_value); +CREATE INDEX idx_book_identifiers ON book_identifiers (identifier_type, identifier_value); -- Trigger to update updated_at on book_metadata changes CREATE TRIGGER update_book_metadata_timestamp - AFTER UPDATE ON book_metadata - FOR EACH ROW +AFTER +UPDATE ON book_metadata FOR EACH ROW BEGIN - UPDATE book_metadata SET updated_at = datetime('now') WHERE media_id = NEW.media_id; +UPDATE book_metadata +SET + updated_at = datetime ('now') +WHERE + media_id = NEW.media_id; + END; diff --git a/migrations/sqlite/V13__photo_metadata.sql b/migrations/sqlite/V13__photo_metadata.sql index 616b2fa..640374f 100644 --- a/migrations/sqlite/V13__photo_metadata.sql +++ b/migrations/sqlite/V13__photo_metadata.sql @@ -1,15 +1,40 @@ -- V13: Enhanced photo metadata support -- Add photo-specific fields to media_items table +ALTER TABLE media_items +ADD COLUMN date_taken TIMESTAMP; -ALTER TABLE media_items ADD COLUMN date_taken TIMESTAMP; -ALTER TABLE media_items ADD COLUMN latitude REAL; -ALTER TABLE media_items ADD COLUMN longitude REAL; -ALTER TABLE media_items ADD COLUMN camera_make TEXT; -ALTER TABLE media_items ADD COLUMN camera_model TEXT; -ALTER TABLE media_items ADD COLUMN rating INTEGER CHECK (rating >= 0 AND rating <= 5); +ALTER TABLE media_items +ADD COLUMN latitude REAL; + +ALTER TABLE media_items +ADD COLUMN longitude REAL; + +ALTER TABLE media_items +ADD COLUMN camera_make TEXT; + +ALTER TABLE media_items +ADD COLUMN camera_model TEXT; + +ALTER TABLE media_items +ADD COLUMN rating INTEGER CHECK ( + rating >= 0 + AND rating <= 5 +); -- Indexes for photo queries -CREATE INDEX idx_media_date_taken ON media_items(date_taken) WHERE date_taken IS NOT NULL; -CREATE INDEX idx_media_location ON media_items(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; -CREATE INDEX idx_media_camera ON media_items(camera_make) WHERE camera_make IS NOT NULL; -CREATE INDEX idx_media_rating ON media_items(rating) WHERE rating IS NOT NULL; +CREATE INDEX idx_media_date_taken ON media_items (date_taken) +WHERE + date_taken IS NOT NULL; + +CREATE INDEX idx_media_location ON media_items (latitude, longitude) +WHERE + latitude IS NOT NULL + AND longitude IS NOT NULL; + +CREATE INDEX idx_media_camera ON media_items (camera_make) +WHERE + camera_make IS NOT NULL; + +CREATE INDEX idx_media_rating ON media_items (rating) +WHERE + rating IS NOT NULL; diff --git a/migrations/sqlite/V14__perceptual_hash.sql b/migrations/sqlite/V14__perceptual_hash.sql index 4bdc677..1d3c634 100644 --- a/migrations/sqlite/V14__perceptual_hash.sql +++ b/migrations/sqlite/V14__perceptual_hash.sql @@ -1,7 +1,9 @@ -- V14: Perceptual hash for duplicate detection -- Add perceptual hash column for image similarity detection - -ALTER TABLE media_items ADD COLUMN perceptual_hash TEXT; +ALTER TABLE media_items +ADD COLUMN perceptual_hash TEXT; -- Index for perceptual hash lookups -CREATE INDEX idx_media_phash ON media_items(perceptual_hash) WHERE perceptual_hash IS NOT NULL; +CREATE INDEX idx_media_phash ON media_items (perceptual_hash) +WHERE + perceptual_hash IS NOT NULL; diff --git a/migrations/sqlite/V15__managed_storage.sql b/migrations/sqlite/V15__managed_storage.sql index b7f2a9d..1f10c7b 100644 --- a/migrations/sqlite/V15__managed_storage.sql +++ b/migrations/sqlite/V15__managed_storage.sql @@ -1,30 +1,33 @@ -- V15: Managed File Storage -- Adds server-side content-addressable storage for uploaded files - -- Add storage mode to media_items (external = file on disk, managed = in content-addressable storage) -ALTER TABLE media_items ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'external'; +ALTER TABLE media_items +ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'external'; -- Original filename for managed uploads (preserved separately from file_name which may be normalized) -ALTER TABLE media_items ADD COLUMN original_filename TEXT; +ALTER TABLE media_items +ADD COLUMN original_filename TEXT; -- When the file was uploaded to managed storage -ALTER TABLE media_items ADD COLUMN uploaded_at TEXT; +ALTER TABLE media_items +ADD COLUMN uploaded_at TEXT; -- Storage key for looking up the blob (usually same as content_hash for deduplication) -ALTER TABLE media_items ADD COLUMN storage_key TEXT; +ALTER TABLE media_items +ADD COLUMN storage_key TEXT; -- Managed blobs table - tracks deduplicated file storage CREATE TABLE managed_blobs ( - content_hash TEXT PRIMARY KEY NOT NULL, - file_size INTEGER NOT NULL, - mime_type TEXT NOT NULL, - reference_count INTEGER NOT NULL DEFAULT 1, - stored_at TEXT NOT NULL, - last_verified TEXT + content_hash TEXT PRIMARY KEY NOT NULL, + file_size INTEGER NOT NULL, + mime_type TEXT NOT NULL, + reference_count INTEGER NOT NULL DEFAULT 1, + stored_at TEXT NOT NULL, + last_verified TEXT ); -- Index for finding managed media items -CREATE INDEX idx_media_storage_mode ON media_items(storage_mode); +CREATE INDEX idx_media_storage_mode ON media_items (storage_mode); -- Index for finding orphaned blobs (reference_count = 0) -CREATE INDEX idx_blobs_reference_count ON managed_blobs(reference_count); +CREATE INDEX idx_blobs_reference_count ON managed_blobs (reference_count); diff --git a/migrations/sqlite/V16__sync_system.sql b/migrations/sqlite/V16__sync_system.sql index 8941500..6787034 100644 --- a/migrations/sqlite/V16__sync_system.sql +++ b/migrations/sqlite/V16__sync_system.sql @@ -1,117 +1,129 @@ -- V16: Cross-Device Sync System -- Adds device registration, change tracking, and chunked upload support - -- Sync devices table CREATE TABLE sync_devices ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - device_type TEXT NOT NULL, - client_version TEXT NOT NULL, - os_info TEXT, - device_token_hash TEXT NOT NULL UNIQUE, - last_sync_at TEXT, - last_seen_at TEXT NOT NULL, - sync_cursor INTEGER DEFAULT 0, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + device_type TEXT NOT NULL, + client_version TEXT NOT NULL, + os_info TEXT, + device_token_hash TEXT NOT NULL UNIQUE, + last_sync_at TEXT, + last_seen_at TEXT NOT NULL, + sync_cursor INTEGER DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); -CREATE INDEX idx_sync_devices_user ON sync_devices(user_id); -CREATE INDEX idx_sync_devices_token ON sync_devices(device_token_hash); +CREATE INDEX idx_sync_devices_user ON sync_devices (user_id); + +CREATE INDEX idx_sync_devices_token ON sync_devices (device_token_hash); -- Sync log table - tracks all changes for sync CREATE TABLE sync_log ( - id TEXT PRIMARY KEY NOT NULL, - sequence INTEGER NOT NULL UNIQUE, - change_type TEXT NOT NULL, - media_id TEXT, - path TEXT NOT NULL, - content_hash TEXT, - file_size INTEGER, - metadata_json TEXT, - changed_by_device TEXT, - timestamp TEXT NOT NULL, - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE SET NULL, - FOREIGN KEY (changed_by_device) REFERENCES sync_devices(id) ON DELETE SET NULL + id TEXT PRIMARY KEY NOT NULL, + sequence INTEGER NOT NULL UNIQUE, + change_type TEXT NOT NULL, + media_id TEXT, + path TEXT NOT NULL, + content_hash TEXT, + file_size INTEGER, + metadata_json TEXT, + changed_by_device TEXT, + timestamp TEXT NOT NULL, + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE SET NULL, + FOREIGN KEY (changed_by_device) REFERENCES sync_devices (id) ON DELETE SET NULL ); -CREATE INDEX idx_sync_log_sequence ON sync_log(sequence); -CREATE INDEX idx_sync_log_path ON sync_log(path); -CREATE INDEX idx_sync_log_timestamp ON sync_log(timestamp); +CREATE INDEX idx_sync_log_sequence ON sync_log (sequence); + +CREATE INDEX idx_sync_log_path ON sync_log (path); + +CREATE INDEX idx_sync_log_timestamp ON sync_log (timestamp); -- Sequence counter for sync log CREATE TABLE sync_sequence ( - id INTEGER PRIMARY KEY CHECK (id = 1), - current_value INTEGER NOT NULL DEFAULT 0 + id INTEGER PRIMARY KEY CHECK (id = 1), + current_value INTEGER NOT NULL DEFAULT 0 ); -INSERT INTO sync_sequence (id, current_value) VALUES (1, 0); + +INSERT INTO + sync_sequence (id, current_value) +VALUES + (1, 0); -- Device sync state - tracks sync status per device per file CREATE TABLE device_sync_state ( - device_id TEXT NOT NULL, - path TEXT NOT NULL, - local_hash TEXT, - server_hash TEXT, - local_mtime INTEGER, - server_mtime INTEGER, - sync_status TEXT NOT NULL, - last_synced_at TEXT, - conflict_info_json TEXT, - PRIMARY KEY (device_id, path), - FOREIGN KEY (device_id) REFERENCES sync_devices(id) ON DELETE CASCADE + device_id TEXT NOT NULL, + path TEXT NOT NULL, + local_hash TEXT, + server_hash TEXT, + local_mtime INTEGER, + server_mtime INTEGER, + sync_status TEXT NOT NULL, + last_synced_at TEXT, + conflict_info_json TEXT, + PRIMARY KEY (device_id, path), + FOREIGN KEY (device_id) REFERENCES sync_devices (id) ON DELETE CASCADE ); -CREATE INDEX idx_device_sync_status ON device_sync_state(device_id, sync_status); +CREATE INDEX idx_device_sync_status ON device_sync_state (device_id, sync_status); -- Upload sessions for chunked uploads CREATE TABLE upload_sessions ( - id TEXT PRIMARY KEY NOT NULL, - device_id TEXT NOT NULL, - target_path TEXT NOT NULL, - expected_hash TEXT NOT NULL, - expected_size INTEGER NOT NULL, - chunk_size INTEGER NOT NULL, - chunk_count INTEGER NOT NULL, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - last_activity TEXT NOT NULL, - FOREIGN KEY (device_id) REFERENCES sync_devices(id) ON DELETE CASCADE + id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL, + target_path TEXT NOT NULL, + expected_hash TEXT NOT NULL, + expected_size INTEGER NOT NULL, + chunk_size INTEGER NOT NULL, + chunk_count INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_activity TEXT NOT NULL, + FOREIGN KEY (device_id) REFERENCES sync_devices (id) ON DELETE CASCADE ); -CREATE INDEX idx_upload_sessions_device ON upload_sessions(device_id); -CREATE INDEX idx_upload_sessions_status ON upload_sessions(status); -CREATE INDEX idx_upload_sessions_expires ON upload_sessions(expires_at); +CREATE INDEX idx_upload_sessions_device ON upload_sessions (device_id); + +CREATE INDEX idx_upload_sessions_status ON upload_sessions (status); + +CREATE INDEX idx_upload_sessions_expires ON upload_sessions (expires_at); -- Upload chunks - tracks received chunks CREATE TABLE upload_chunks ( - upload_id TEXT NOT NULL, - chunk_index INTEGER NOT NULL, - offset INTEGER NOT NULL, + upload_id TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + offset + INTEGER NOT NULL, size INTEGER NOT NULL, hash TEXT NOT NULL, received_at TEXT NOT NULL, PRIMARY KEY (upload_id, chunk_index), - FOREIGN KEY (upload_id) REFERENCES upload_sessions(id) ON DELETE CASCADE + FOREIGN KEY (upload_id) REFERENCES upload_sessions (id) ON DELETE CASCADE ); -- Sync conflicts CREATE TABLE sync_conflicts ( - id TEXT PRIMARY KEY NOT NULL, - device_id TEXT NOT NULL, - path TEXT NOT NULL, - local_hash TEXT NOT NULL, - local_mtime INTEGER NOT NULL, - server_hash TEXT NOT NULL, - server_mtime INTEGER NOT NULL, - detected_at TEXT NOT NULL, - resolved_at TEXT, - resolution TEXT, - FOREIGN KEY (device_id) REFERENCES sync_devices(id) ON DELETE CASCADE + id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL, + path TEXT NOT NULL, + local_hash TEXT NOT NULL, + local_mtime INTEGER NOT NULL, + server_hash TEXT NOT NULL, + server_mtime INTEGER NOT NULL, + detected_at TEXT NOT NULL, + resolved_at TEXT, + resolution TEXT, + FOREIGN KEY (device_id) REFERENCES sync_devices (id) ON DELETE CASCADE ); -CREATE INDEX idx_sync_conflicts_device ON sync_conflicts(device_id); -CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts(device_id, resolved_at) WHERE resolved_at IS NULL; +CREATE INDEX idx_sync_conflicts_device ON sync_conflicts (device_id); + +CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts (device_id, resolved_at) +WHERE + resolved_at IS NULL; diff --git a/migrations/sqlite/V17__enhanced_sharing.sql b/migrations/sqlite/V17__enhanced_sharing.sql index 1cd17f3..ac7d93e 100644 --- a/migrations/sqlite/V17__enhanced_sharing.sql +++ b/migrations/sqlite/V17__enhanced_sharing.sql @@ -1,85 +1,133 @@ -- V17: Enhanced Sharing System -- Replaces simple share_links with comprehensive sharing capabilities - -- Enhanced shares table CREATE TABLE shares ( - id TEXT PRIMARY KEY NOT NULL, - target_type TEXT NOT NULL CHECK (target_type IN ('media', 'collection', 'tag', 'saved_search')), - target_id TEXT NOT NULL, - owner_id TEXT NOT NULL, - recipient_type TEXT NOT NULL CHECK (recipient_type IN ('public_link', 'user', 'group', 'federated')), - recipient_user_id TEXT, - recipient_group_id TEXT, - recipient_federated_handle TEXT, - recipient_federated_server TEXT, - public_token TEXT UNIQUE, - public_password_hash TEXT, - perm_view INTEGER NOT NULL DEFAULT 1, - perm_download INTEGER NOT NULL DEFAULT 0, - perm_edit INTEGER NOT NULL DEFAULT 0, - perm_delete INTEGER NOT NULL DEFAULT 0, - perm_reshare INTEGER NOT NULL DEFAULT 0, - perm_add INTEGER NOT NULL DEFAULT 0, - note TEXT, - expires_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - last_accessed TEXT, - inherit_to_children INTEGER NOT NULL DEFAULT 1, - parent_share_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (recipient_user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (parent_share_id) REFERENCES shares(id) ON DELETE CASCADE, - UNIQUE(owner_id, target_type, target_id, recipient_type, recipient_user_id) + id TEXT PRIMARY KEY NOT NULL, + target_type TEXT NOT NULL CHECK ( + target_type IN ('media', 'collection', 'tag', 'saved_search') + ), + target_id TEXT NOT NULL, + owner_id TEXT NOT NULL, + recipient_type TEXT NOT NULL CHECK ( + recipient_type IN ('public_link', 'user', 'group', 'federated') + ), + recipient_user_id TEXT, + recipient_group_id TEXT, + recipient_federated_handle TEXT, + recipient_federated_server TEXT, + public_token TEXT UNIQUE, + public_password_hash TEXT, + perm_view INTEGER NOT NULL DEFAULT 1, + perm_download INTEGER NOT NULL DEFAULT 0, + perm_edit INTEGER NOT NULL DEFAULT 0, + perm_delete INTEGER NOT NULL DEFAULT 0, + perm_reshare INTEGER NOT NULL DEFAULT 0, + perm_add INTEGER NOT NULL DEFAULT 0, + note TEXT, + expires_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + last_accessed TEXT, + inherit_to_children INTEGER NOT NULL DEFAULT 1, + parent_share_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (recipient_user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (parent_share_id) REFERENCES shares (id) ON DELETE CASCADE, + UNIQUE ( + owner_id, + target_type, + target_id, + recipient_type, + recipient_user_id + ) ); -CREATE INDEX idx_shares_owner ON shares(owner_id); -CREATE INDEX idx_shares_recipient_user ON shares(recipient_user_id); -CREATE INDEX idx_shares_target ON shares(target_type, target_id); -CREATE INDEX idx_shares_token ON shares(public_token); -CREATE INDEX idx_shares_expires ON shares(expires_at); +CREATE INDEX idx_shares_owner ON shares (owner_id); + +CREATE INDEX idx_shares_recipient_user ON shares (recipient_user_id); + +CREATE INDEX idx_shares_target ON shares (target_type, target_id); + +CREATE INDEX idx_shares_token ON shares (public_token); + +CREATE INDEX idx_shares_expires ON shares (expires_at); -- Share activity log CREATE TABLE share_activity ( - id TEXT PRIMARY KEY NOT NULL, - share_id TEXT NOT NULL, - actor_id TEXT, - actor_ip TEXT, - action TEXT NOT NULL, - details TEXT, - timestamp TEXT NOT NULL, - FOREIGN KEY (share_id) REFERENCES shares(id) ON DELETE CASCADE, - FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL + id TEXT PRIMARY KEY NOT NULL, + share_id TEXT NOT NULL, + actor_id TEXT, + actor_ip TEXT, + action TEXT NOT NULL, + details TEXT, + timestamp TEXT NOT NULL, + FOREIGN KEY (share_id) REFERENCES shares (id) ON DELETE CASCADE, + FOREIGN KEY (actor_id) REFERENCES users (id) ON DELETE SET NULL ); -CREATE INDEX idx_share_activity_share ON share_activity(share_id); -CREATE INDEX idx_share_activity_timestamp ON share_activity(timestamp); +CREATE INDEX idx_share_activity_share ON share_activity (share_id); + +CREATE INDEX idx_share_activity_timestamp ON share_activity (timestamp); -- Share notifications CREATE TABLE share_notifications ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, - share_id TEXT NOT NULL, - notification_type TEXT NOT NULL, - is_read INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (share_id) REFERENCES shares(id) ON DELETE CASCADE + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + share_id TEXT NOT NULL, + notification_type TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (share_id) REFERENCES shares (id) ON DELETE CASCADE ); -CREATE INDEX idx_share_notifications_user ON share_notifications(user_id); -CREATE INDEX idx_share_notifications_unread ON share_notifications(user_id, is_read) WHERE is_read = 0; +CREATE INDEX idx_share_notifications_user ON share_notifications (user_id); + +CREATE INDEX idx_share_notifications_unread ON share_notifications (user_id, is_read) +WHERE + is_read = 0; -- Migrate existing share_links to new shares table (if share_links exists) -INSERT OR IGNORE INTO shares ( - id, target_type, target_id, owner_id, recipient_type, - public_token, public_password_hash, perm_view, perm_download, - access_count, expires_at, created_at, updated_at +INSERT +OR IGNORE INTO shares ( + id, + target_type, + target_id, + owner_id, + recipient_type, + public_token, + public_password_hash, + perm_view, + perm_download, + access_count, + expires_at, + created_at, + updated_at ) SELECT - id, 'media', media_id, created_by, 'public_link', - token, password_hash, 1, 1, - view_count, expires_at, created_at, created_at -FROM share_links -WHERE EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name='share_links'); + id, + 'media', + media_id, + created_by, + 'public_link', + token, + password_hash, + 1, + 1, + view_count, + expires_at, + created_at, + created_at +FROM + share_links +WHERE + EXISTS ( + SELECT + 1 + FROM + sqlite_master + WHERE + type = 'table' + AND name = 'share_links' + ); diff --git a/migrations/sqlite/V18__file_management.sql b/migrations/sqlite/V18__file_management.sql index 0af0738..08599dd 100644 --- a/migrations/sqlite/V18__file_management.sql +++ b/migrations/sqlite/V18__file_management.sql @@ -1,11 +1,13 @@ -- V18: File Management (Rename, Move, Trash) -- Adds soft delete support for trash/recycle bin functionality - -- Add deleted_at column for soft delete (trash) -ALTER TABLE media_items ADD COLUMN deleted_at TEXT; +ALTER TABLE media_items +ADD COLUMN deleted_at TEXT; -- Index for efficient trash queries -CREATE INDEX idx_media_deleted_at ON media_items(deleted_at); +CREATE INDEX idx_media_deleted_at ON media_items (deleted_at); -- Index for listing non-deleted items (most common query pattern) -CREATE INDEX idx_media_not_deleted ON media_items(id) WHERE deleted_at IS NULL; +CREATE INDEX idx_media_not_deleted ON media_items (id) +WHERE + deleted_at IS NULL; diff --git a/migrations/sqlite/V19__markdown_links.sql b/migrations/sqlite/V19__markdown_links.sql index 7cbdda3..214e40a 100644 --- a/migrations/sqlite/V19__markdown_links.sql +++ b/migrations/sqlite/V19__markdown_links.sql @@ -1,35 +1,35 @@ -- V19: Markdown Links (Obsidian-style bidirectional links) -- Adds support for wikilinks, markdown links, embeds, and backlink tracking - -- Table for storing extracted markdown links CREATE TABLE IF NOT EXISTS markdown_links ( - id TEXT PRIMARY KEY NOT NULL, - source_media_id TEXT NOT NULL, - target_path TEXT NOT NULL, -- raw link target (wikilink or path) - target_media_id TEXT, -- resolved media_id (nullable if unresolved) - link_type TEXT NOT NULL, -- 'wikilink', 'markdown_link', 'embed' - link_text TEXT, -- display text for the link - line_number INTEGER, -- line number in source file - context TEXT, -- surrounding text for preview - created_at TEXT NOT NULL, - FOREIGN KEY (source_media_id) REFERENCES media_items(id) ON DELETE CASCADE, - FOREIGN KEY (target_media_id) REFERENCES media_items(id) ON DELETE SET NULL + id TEXT PRIMARY KEY NOT NULL, + source_media_id TEXT NOT NULL, + target_path TEXT NOT NULL, -- raw link target (wikilink or path) + target_media_id TEXT, -- resolved media_id (nullable if unresolved) + link_type TEXT NOT NULL, -- 'wikilink', 'markdown_link', 'embed' + link_text TEXT, -- display text for the link + line_number INTEGER, -- line number in source file + context TEXT, -- surrounding text for preview + created_at TEXT NOT NULL, + FOREIGN KEY (source_media_id) REFERENCES media_items (id) ON DELETE CASCADE, + FOREIGN KEY (target_media_id) REFERENCES media_items (id) ON DELETE SET NULL ); -- Index for efficient outgoing link queries (what does this note link to?) -CREATE INDEX idx_links_source ON markdown_links(source_media_id); +CREATE INDEX idx_links_source ON markdown_links (source_media_id); -- Index for efficient backlink queries (what links to this note?) -CREATE INDEX idx_links_target ON markdown_links(target_media_id); +CREATE INDEX idx_links_target ON markdown_links (target_media_id); -- Index for path-based resolution (finding unresolved links) -CREATE INDEX idx_links_target_path ON markdown_links(target_path); +CREATE INDEX idx_links_target_path ON markdown_links (target_path); -- Index for link type filtering -CREATE INDEX idx_links_type ON markdown_links(link_type); +CREATE INDEX idx_links_type ON markdown_links (link_type); -- Track when links were last extracted from a media item -ALTER TABLE media_items ADD COLUMN links_extracted_at TEXT; +ALTER TABLE media_items +ADD COLUMN links_extracted_at TEXT; -- Index for finding media items that need link extraction -CREATE INDEX idx_media_links_extracted ON media_items(links_extracted_at); +CREATE INDEX idx_media_links_extracted ON media_items (links_extracted_at); diff --git a/migrations/sqlite/V1__initial_schema.sql b/migrations/sqlite/V1__initial_schema.sql index 5b16abf..b6ccd0e 100644 --- a/migrations/sqlite/V1__initial_schema.sql +++ b/migrations/sqlite/V1__initial_schema.sql @@ -1,77 +1,75 @@ -CREATE TABLE IF NOT EXISTS root_dirs ( - path TEXT PRIMARY KEY NOT NULL -); +CREATE TABLE IF NOT EXISTS root_dirs (path TEXT PRIMARY KEY NOT NULL); CREATE TABLE IF NOT EXISTS media_items ( - id TEXT PRIMARY KEY NOT NULL, - path TEXT NOT NULL UNIQUE, - file_name TEXT NOT NULL, - media_type TEXT NOT NULL, - content_hash TEXT NOT NULL UNIQUE, - file_size INTEGER NOT NULL, - title TEXT, - artist TEXT, - album TEXT, - genre TEXT, - year INTEGER, - duration_secs REAL, - description TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + id TEXT PRIMARY KEY NOT NULL, + path TEXT NOT NULL UNIQUE, + file_name TEXT NOT NULL, + media_type TEXT NOT NULL, + content_hash TEXT NOT NULL UNIQUE, + file_size INTEGER NOT NULL, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + year INTEGER, + duration_secs REAL, + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS tags ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - parent_id TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE SET NULL + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + parent_id TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (parent_id) REFERENCES tags (id) ON DELETE SET NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags(name, parent_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags (name, parent_id); CREATE TABLE IF NOT EXISTS media_tags ( - media_id TEXT NOT NULL, - tag_id TEXT NOT NULL, - PRIMARY KEY (media_id, tag_id), - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE, - FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + media_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (media_id, tag_id), + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS collections ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - description TEXT, - kind TEXT NOT NULL, - filter_query TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL, + filter_query TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS collection_members ( - collection_id TEXT NOT NULL, - media_id TEXT NOT NULL, - position INTEGER NOT NULL DEFAULT 0, - added_at TEXT NOT NULL, - PRIMARY KEY (collection_id, media_id), - FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE, - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + collection_id TEXT NOT NULL, + media_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL, + PRIMARY KEY (collection_id, media_id), + FOREIGN KEY (collection_id) REFERENCES collections (id) ON DELETE CASCADE, + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS audit_log ( - id TEXT PRIMARY KEY NOT NULL, - media_id TEXT, - action TEXT NOT NULL, - details TEXT, - timestamp TEXT NOT NULL, - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE SET NULL + id TEXT PRIMARY KEY NOT NULL, + media_id TEXT, + action TEXT NOT NULL, + details TEXT, + timestamp TEXT NOT NULL, + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS custom_fields ( - media_id TEXT NOT NULL, - field_name TEXT NOT NULL, - field_type TEXT NOT NULL, - field_value TEXT NOT NULL, - PRIMARY KEY (media_id, field_name), - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + media_id TEXT NOT NULL, + field_name TEXT NOT NULL, + field_type TEXT NOT NULL, + field_value TEXT NOT NULL, + PRIMARY KEY (media_id, field_name), + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); diff --git a/migrations/sqlite/V2__fts5_indexes.sql b/migrations/sqlite/V2__fts5_indexes.sql index 00c5597..01270a0 100644 --- a/migrations/sqlite/V2__fts5_indexes.sql +++ b/migrations/sqlite/V2__fts5_indexes.sql @@ -1,27 +1,114 @@ -CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5( +CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5 ( + title, + artist, + album, + genre, + description, + file_name, + content = 'media_items', + content_rowid = 'rowid' +); + +CREATE TRIGGER IF NOT EXISTS media_fts_insert +AFTER INSERT ON media_items +BEGIN +INSERT INTO + media_fts ( + rowid, title, artist, album, genre, description, - file_name, - content='media_items', - content_rowid='rowid' -); + file_name + ) +VALUES + ( + new.rowid, + new.title, + new.artist, + new.album, + new.genre, + new.description, + new.file_name + ); -CREATE TRIGGER IF NOT EXISTS media_fts_insert AFTER INSERT ON media_items BEGIN - INSERT INTO media_fts(rowid, title, artist, album, genre, description, file_name) - VALUES (new.rowid, new.title, new.artist, new.album, new.genre, new.description, new.file_name); END; -CREATE TRIGGER IF NOT EXISTS media_fts_update AFTER UPDATE ON media_items BEGIN - INSERT INTO media_fts(media_fts, rowid, title, artist, album, genre, description, file_name) - VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.genre, old.description, old.file_name); - INSERT INTO media_fts(rowid, title, artist, album, genre, description, file_name) - VALUES (new.rowid, new.title, new.artist, new.album, new.genre, new.description, new.file_name); +CREATE TRIGGER IF NOT EXISTS media_fts_update +AFTER +UPDATE ON media_items +BEGIN +INSERT INTO + media_fts ( + media_fts, + rowid, + title, + artist, + album, + genre, + description, + file_name + ) +VALUES + ( + 'delete', + old.rowid, + old.title, + old.artist, + old.album, + old.genre, + old.description, + old.file_name + ); + +INSERT INTO + media_fts ( + rowid, + title, + artist, + album, + genre, + description, + file_name + ) +VALUES + ( + new.rowid, + new.title, + new.artist, + new.album, + new.genre, + new.description, + new.file_name + ); + END; -CREATE TRIGGER IF NOT EXISTS media_fts_delete AFTER DELETE ON media_items BEGIN - INSERT INTO media_fts(media_fts, rowid, title, artist, album, genre, description, file_name) - VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.genre, old.description, old.file_name); +CREATE TRIGGER IF NOT EXISTS media_fts_delete +AFTER DELETE ON media_items +BEGIN +INSERT INTO + media_fts ( + media_fts, + rowid, + title, + artist, + album, + genre, + description, + file_name + ) +VALUES + ( + 'delete', + old.rowid, + old.title, + old.artist, + old.album, + old.genre, + old.description, + old.file_name + ); + END; diff --git a/migrations/sqlite/V3__audit_indexes.sql b/migrations/sqlite/V3__audit_indexes.sql index 1c741fe..5372307 100644 --- a/migrations/sqlite/V3__audit_indexes.sql +++ b/migrations/sqlite/V3__audit_indexes.sql @@ -1,6 +1,11 @@ -CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log(media_id); -CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); -CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); -CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items(content_hash); -CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items(media_type); -CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log (media_id); + +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log (timestamp); + +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action); + +CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items (content_hash); + +CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items (media_type); + +CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items (created_at); diff --git a/migrations/sqlite/V4__thumbnail_path.sql b/migrations/sqlite/V4__thumbnail_path.sql index 9021884..4c23b5b 100644 --- a/migrations/sqlite/V4__thumbnail_path.sql +++ b/migrations/sqlite/V4__thumbnail_path.sql @@ -1 +1,2 @@ -ALTER TABLE media_items ADD COLUMN thumbnail_path TEXT; +ALTER TABLE media_items +ADD COLUMN thumbnail_path TEXT; diff --git a/migrations/sqlite/V5__integrity_and_saved_searches.sql b/migrations/sqlite/V5__integrity_and_saved_searches.sql index 650da16..a8b05ea 100644 --- a/migrations/sqlite/V5__integrity_and_saved_searches.sql +++ b/migrations/sqlite/V5__integrity_and_saved_searches.sql @@ -1,12 +1,15 @@ -- Integrity tracking columns -ALTER TABLE media_items ADD COLUMN last_verified_at TEXT; -ALTER TABLE media_items ADD COLUMN integrity_status TEXT DEFAULT 'unverified'; +ALTER TABLE media_items +ADD COLUMN last_verified_at TEXT; + +ALTER TABLE media_items +ADD COLUMN integrity_status TEXT DEFAULT 'unverified'; -- Saved searches CREATE TABLE IF NOT EXISTS saved_searches ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - query TEXT NOT NULL, - sort_order TEXT, - created_at TEXT NOT NULL + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + query TEXT NOT NULL, + sort_order TEXT, + created_at TEXT NOT NULL ); diff --git a/migrations/sqlite/V6__plugin_system.sql b/migrations/sqlite/V6__plugin_system.sql index f4e7790..c675177 100644 --- a/migrations/sqlite/V6__plugin_system.sql +++ b/migrations/sqlite/V6__plugin_system.sql @@ -1,15 +1,16 @@ -- Plugin registry table CREATE TABLE plugin_registry ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - version TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - config_json TEXT, - manifest_json TEXT, - installed_at TEXT NOT NULL, - updated_at TEXT NOT NULL + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + config_json TEXT, + manifest_json TEXT, + installed_at TEXT NOT NULL, + updated_at TEXT NOT NULL ); -- Index for quick lookups -CREATE INDEX idx_plugin_registry_enabled ON plugin_registry(enabled); -CREATE INDEX idx_plugin_registry_name ON plugin_registry(name); +CREATE INDEX idx_plugin_registry_enabled ON plugin_registry (enabled); + +CREATE INDEX idx_plugin_registry_name ON plugin_registry (name); diff --git a/migrations/sqlite/V7__user_management.sql b/migrations/sqlite/V7__user_management.sql index 6584f03..8042e10 100644 --- a/migrations/sqlite/V7__user_management.sql +++ b/migrations/sqlite/V7__user_management.sql @@ -1,35 +1,37 @@ -- Users table CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL ); -- User profiles table CREATE TABLE user_profiles ( - user_id TEXT PRIMARY KEY, - avatar_path TEXT, - bio TEXT, - preferences_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) + user_id TEXT PRIMARY KEY, + avatar_path TEXT, + bio TEXT, + preferences_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ); -- User library access table CREATE TABLE user_libraries ( - user_id TEXT NOT NULL, - root_path TEXT NOT NULL, - permission TEXT NOT NULL, - granted_at TEXT NOT NULL, - PRIMARY KEY (user_id, root_path), - FOREIGN KEY (user_id) REFERENCES users(id) + user_id TEXT NOT NULL, + root_path TEXT NOT NULL, + permission TEXT NOT NULL, + granted_at TEXT NOT NULL, + PRIMARY KEY (user_id, root_path), + FOREIGN KEY (user_id) REFERENCES users (id) ); -- Indexes for efficient lookups -CREATE INDEX idx_users_username ON users(username); -CREATE INDEX idx_user_libraries_user_id ON user_libraries(user_id); -CREATE INDEX idx_user_libraries_root_path ON user_libraries(root_path); +CREATE INDEX idx_users_username ON users (username); + +CREATE INDEX idx_user_libraries_user_id ON user_libraries (user_id); + +CREATE INDEX idx_user_libraries_root_path ON user_libraries (root_path); diff --git a/migrations/sqlite/V8__media_server_features.sql b/migrations/sqlite/V8__media_server_features.sql index 50040c3..ee3dc04 100644 --- a/migrations/sqlite/V8__media_server_features.sql +++ b/migrations/sqlite/V8__media_server_features.sql @@ -1,143 +1,148 @@ -- Ratings CREATE TABLE IF NOT EXISTS ratings ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - media_id TEXT NOT NULL, - stars INTEGER NOT NULL CHECK (stars >= 1 AND stars <= 5), - review_text TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(user_id, media_id), - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + media_id TEXT NOT NULL, + stars INTEGER NOT NULL CHECK ( + stars >= 1 + AND stars <= 5 + ), + review_text TEXT, + created_at TEXT NOT NULL DEFAULT (datetime ('now')), + UNIQUE (user_id, media_id), + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); -- Comments CREATE TABLE IF NOT EXISTS comments ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - media_id TEXT NOT NULL, - parent_comment_id TEXT, - text TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE, - FOREIGN KEY (parent_comment_id) REFERENCES comments(id) ON DELETE CASCADE + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + media_id TEXT NOT NULL, + parent_comment_id TEXT, + text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime ('now')), + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE, + FOREIGN KEY (parent_comment_id) REFERENCES comments (id) ON DELETE CASCADE ); -- Favorites CREATE TABLE IF NOT EXISTS favorites ( - user_id TEXT NOT NULL, - media_id TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (user_id, media_id), - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + user_id TEXT NOT NULL, + media_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime ('now')), + PRIMARY KEY (user_id, media_id), + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); -- Share links CREATE TABLE IF NOT EXISTS share_links ( - id TEXT PRIMARY KEY, - media_id TEXT NOT NULL, - created_by TEXT NOT NULL, - token TEXT NOT NULL UNIQUE, - password_hash TEXT, - expires_at TEXT, - view_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + id TEXT PRIMARY KEY, + media_id TEXT NOT NULL, + created_by TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + password_hash TEXT, + expires_at TEXT, + view_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime ('now')), + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); -- Playlists CREATE TABLE IF NOT EXISTS playlists ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - is_public INTEGER NOT NULL DEFAULT 0, - is_smart INTEGER NOT NULL DEFAULT 0, - filter_query TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + is_public INTEGER NOT NULL DEFAULT 0, + is_smart INTEGER NOT NULL DEFAULT 0, + filter_query TEXT, + created_at TEXT NOT NULL DEFAULT (datetime ('now')), + updated_at TEXT NOT NULL DEFAULT (datetime ('now')) ); -- Playlist items CREATE TABLE IF NOT EXISTS playlist_items ( - playlist_id TEXT NOT NULL, - media_id TEXT NOT NULL, - position INTEGER NOT NULL DEFAULT 0, - added_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (playlist_id, media_id), - FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + playlist_id TEXT NOT NULL, + media_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT (datetime ('now')), + PRIMARY KEY (playlist_id, media_id), + FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE, + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); -- Usage events CREATE TABLE IF NOT EXISTS usage_events ( - id TEXT PRIMARY KEY, - media_id TEXT, - user_id TEXT, - event_type TEXT NOT NULL, - timestamp TEXT NOT NULL DEFAULT (datetime('now')), - duration_secs REAL, - context_json TEXT, - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE SET NULL + id TEXT PRIMARY KEY, + media_id TEXT, + user_id TEXT, + event_type TEXT NOT NULL, + timestamp TEXT NOT NULL DEFAULT (datetime ('now')), + duration_secs REAL, + context_json TEXT, + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE SET NULL ); -CREATE INDEX IF NOT EXISTS idx_usage_events_media ON usage_events(media_id); -CREATE INDEX IF NOT EXISTS idx_usage_events_user ON usage_events(user_id); -CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp ON usage_events(timestamp); +CREATE INDEX IF NOT EXISTS idx_usage_events_media ON usage_events (media_id); + +CREATE INDEX IF NOT EXISTS idx_usage_events_user ON usage_events (user_id); + +CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp ON usage_events (timestamp); -- Watch history / progress CREATE TABLE IF NOT EXISTS watch_history ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - media_id TEXT NOT NULL, - progress_secs REAL NOT NULL DEFAULT 0, - last_watched TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(user_id, media_id), - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + media_id TEXT NOT NULL, + progress_secs REAL NOT NULL DEFAULT 0, + last_watched TEXT NOT NULL DEFAULT (datetime ('now')), + UNIQUE (user_id, media_id), + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); -- Subtitles CREATE TABLE IF NOT EXISTS subtitles ( - id TEXT PRIMARY KEY, - media_id TEXT NOT NULL, - language TEXT, - format TEXT NOT NULL, - file_path TEXT, - is_embedded INTEGER NOT NULL DEFAULT 0, - track_index INTEGER, - offset_ms INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + id TEXT PRIMARY KEY, + media_id TEXT NOT NULL, + language TEXT, + format TEXT NOT NULL, + file_path TEXT, + is_embedded INTEGER NOT NULL DEFAULT 0, + track_index INTEGER, + offset_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime ('now')), + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_subtitles_media ON subtitles(media_id); +CREATE INDEX IF NOT EXISTS idx_subtitles_media ON subtitles (media_id); -- External metadata (enrichment) CREATE TABLE IF NOT EXISTS external_metadata ( - id TEXT PRIMARY KEY, - media_id TEXT NOT NULL, - source TEXT NOT NULL, - external_id TEXT, - metadata_json TEXT NOT NULL DEFAULT '{}', - confidence REAL NOT NULL DEFAULT 0.0, - last_updated TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + id TEXT PRIMARY KEY, + media_id TEXT NOT NULL, + source TEXT NOT NULL, + external_id TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + confidence REAL NOT NULL DEFAULT 0.0, + last_updated TEXT NOT NULL DEFAULT (datetime ('now')), + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_external_metadata_media ON external_metadata(media_id); +CREATE INDEX IF NOT EXISTS idx_external_metadata_media ON external_metadata (media_id); -- Transcode sessions CREATE TABLE IF NOT EXISTS transcode_sessions ( - id TEXT PRIMARY KEY, - media_id TEXT NOT NULL, - user_id TEXT, - profile TEXT NOT NULL, - cache_path TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - progress REAL NOT NULL DEFAULT 0.0, - error_message TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - expires_at TEXT, - FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE + id TEXT PRIMARY KEY, + media_id TEXT NOT NULL, + user_id TEXT, + profile TEXT NOT NULL, + cache_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + progress REAL NOT NULL DEFAULT 0.0, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime ('now')), + expires_at TEXT, + FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_transcode_sessions_media ON transcode_sessions(media_id); +CREATE INDEX IF NOT EXISTS idx_transcode_sessions_media ON transcode_sessions (media_id); diff --git a/migrations/sqlite/V9__fix_indexes_and_constraints.sql b/migrations/sqlite/V9__fix_indexes_and_constraints.sql index 432f35a..dd1bfa0 100644 --- a/migrations/sqlite/V9__fix_indexes_and_constraints.sql +++ b/migrations/sqlite/V9__fix_indexes_and_constraints.sql @@ -1,18 +1,25 @@ -- Drop redundant indexes (already covered by UNIQUE constraints) DROP INDEX IF EXISTS idx_users_username; + DROP INDEX IF EXISTS idx_user_libraries_user_id; -- Add missing indexes for comments table -CREATE INDEX IF NOT EXISTS idx_comments_media ON comments(media_id); -CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_comment_id); +CREATE INDEX IF NOT EXISTS idx_comments_media ON comments (media_id); + +CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments (parent_comment_id); -- Remove duplicates before adding unique index (keep the first row) DELETE FROM external_metadata -WHERE rowid NOT IN ( - SELECT MIN(rowid) - FROM external_metadata - GROUP BY media_id, source -); +WHERE + rowid NOT IN ( + SELECT + MIN(rowid) + FROM + external_metadata + GROUP BY + media_id, + source + ); -- Add unique index for external_metadata to prevent duplicates -CREATE UNIQUE INDEX IF NOT EXISTS uq_external_metadata_source ON external_metadata(media_id, source); +CREATE UNIQUE INDEX IF NOT EXISTS uq_external_metadata_source ON external_metadata (media_id, source); diff --git a/nix/shell.nix b/nix/shell.nix index 6e706fc..4eab514 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -68,9 +68,6 @@ in echo "sccache setup complete!" fi - - # Start the daemon early for slightly faster startup. - "$SCCACHE_BIN" --start-server >/dev/null 2>&1 || true ''; env = { From 6900984e46c73289ed5040f0d26f1fc1c8a8ac51 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 22 Mar 2026 23:57:19 +0300 Subject: [PATCH 04/22] meta: set up editorconfig Signed-off-by: NotAShelf Change-Id: Ie44e5a7239a053d674e6aa06e14c3ce56a6a6964 --- .editorconfig | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..473fe3a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,32 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.rs] +indent_style = space +indent_size = 2 + +[justfile] +indent_style = space +indent_size = 2 + +[*.toml] +indent_style = space +indent_size = 2 + +[*.md] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = false + +[*.nix] +indent_style = space +indent_size = 2 + +[*.scss] +indent_style = space +indent_size = 2 From e7e9ea6036b46993d3590b9fbab5f58e9c65a3f7 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 22 Mar 2026 23:57:30 +0300 Subject: [PATCH 05/22] nix: drop sccache Did not work. Signed-off-by: NotAShelf Change-Id: I09802cd4835347115ba51bdffd0af1096a6a6964 --- nix/shell.nix | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/nix/shell.nix b/nix/shell.nix index 4eab514..a2a53c9 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -50,26 +50,9 @@ in # Other tools pkgs.taplo # TOML formatter pkgs.lldb # debugger - pkgs.sccache # distributed Rust cache ] ++ runtimeDeps; - # We could do this in the NixOS configuration via ~/.config/cargo.toml - # or something, but this is better because it lets everyone benefit - # from sccache while working with Pinakes. The reason those variables - # are not in 'env' are that we have to use Bash, which doesn't mix well - # with Nix strings. - shellHook = '' - if [ -n "$SCCACHE_BIN" ]; then - export RUSTC_WRAPPER="$SCCACHE_BIN" - export SCCACHE_DIR="''${HOME}/.cache/sccache" - export SCCACHE_CACHE_SIZE="50G" - mkdir -p "$SCCACHE_DIR" - - echo "sccache setup complete!" - fi - ''; - env = { # Allow Cargo to use lld and clang properly LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; @@ -82,8 +65,5 @@ in # Runtime library path for GTK/WebKit/xdotool LD_LIBRARY_PATH = "${lib.makeLibraryPath runtimeDeps}"; - - # Enable sccache for local nix develop shell - SCCACHE_BIN = "${pkgs.sccache}/bin/sccache"; }; } From d18317b49b35c65633f869c958e4ee5df1ca14f3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 23 Mar 2026 00:02:03 +0300 Subject: [PATCH 06/22] meta: drop plugin stubs for now Signed-off-by: NotAShelf Change-Id: Iedf1d829183c258d0f6ef8a313053d4d6a6a6964 --- examples/plugins/README.md | 518 ------------------ examples/plugins/heif-support/README.md | 269 --------- examples/plugins/heif-support/plugin.toml | 29 - examples/plugins/markdown-metadata/README.md | 108 ---- .../plugins/markdown-metadata/plugin.toml | 25 - examples/plugins/media-stats-ui/Cargo.lock | Bin 1246 -> 0 bytes examples/plugins/media-stats-ui/Cargo.toml | 20 - .../plugins/media-stats-ui/pages/stats.json | 132 ----- .../media-stats-ui/pages/tag-manager.json | 126 ----- examples/plugins/media-stats-ui/plugin.toml | 39 -- examples/plugins/media-stats-ui/src/lib.rs | 101 ---- 11 files changed, 1367 deletions(-) delete mode 100644 examples/plugins/README.md delete mode 100644 examples/plugins/heif-support/README.md delete mode 100644 examples/plugins/heif-support/plugin.toml delete mode 100644 examples/plugins/markdown-metadata/README.md delete mode 100644 examples/plugins/markdown-metadata/plugin.toml delete mode 100644 examples/plugins/media-stats-ui/Cargo.lock delete mode 100644 examples/plugins/media-stats-ui/Cargo.toml delete mode 100644 examples/plugins/media-stats-ui/pages/stats.json delete mode 100644 examples/plugins/media-stats-ui/pages/tag-manager.json delete mode 100644 examples/plugins/media-stats-ui/plugin.toml delete mode 100644 examples/plugins/media-stats-ui/src/lib.rs diff --git a/examples/plugins/README.md b/examples/plugins/README.md deleted file mode 100644 index 12c5217..0000000 --- a/examples/plugins/README.md +++ /dev/null @@ -1,518 +0,0 @@ -# Pinakes Plugin Examples - -This directory contains example plugins demonstrating the Pinakes plugin system. - -## Overview - -Pinakes supports extensibility through a WASM-based plugin system. Plugins can -extend Pinakes functionality by: - -- **Media Type Providers**: Add support for new file formats -- **Metadata Extractors**: Extract metadata from files -- **Thumbnail Generators**: Generate thumbnails for media types -- **Search Backends**: Implement custom search algorithms -- **Event Handlers**: React to system events -- **Theme Providers**: Provide custom UI themes - -## Example Plugins - -### 1. Markdown Metadata Extractor - -**Directory**: `markdown-metadata/` - -Enhances markdown file support with advanced frontmatter parsing. - -**Demonstrates**: - -- Metadata extraction from files -- Plugin configuration via `plugin.toml` -- Minimal capability requirements - -**Plugin Kind**: `metadata_extractor` - -### 2. HEIF/HEIC Support - -**Directory**: `heif-support/` - -Adds support for HEIF and HEIC image formats. - -**Demonstrates**: - -- Media type registration -- Metadata extraction from binary formats -- Thumbnail generation -- Resource limits (memory, CPU time) - -**Plugin Kinds**: `media_type`, `metadata_extractor`, `thumbnail_generator` - -## Plugin Architecture - -### Plugin Structure - -``` -my-plugin/ -├── plugin.toml # Plugin manifest -├── Cargo.toml # Rust project configuration -├── src/ -│ └── lib.rs # Plugin implementation -└── README.md # Plugin documentation -``` - -### Plugin Manifest (plugin.toml) - -```toml -[plugin] -name = "my-plugin" -version = "1.0.0" -api_version = "1.0" -author = "Your Name" -description = "Description of your plugin" -kind = ["metadata_extractor"] - -[plugin.binary] -wasm = "my_plugin.wasm" - -[capabilities] -network = false - -[capabilities.filesystem] -read = ["/path/to/read"] -write = ["/path/to/write"] - -[config] -# Plugin-specific configuration -option1 = "value1" -option2 = 42 -``` - -### Manifest Fields - -#### [plugin] Section - -- `name`: Plugin identifier (must be unique) -- `version`: Semantic version (e.g., "1.0.0") -- `api_version`: Pinakes Plugin API version (currently "1.0") -- `author`: Plugin author (optional) -- `description`: Short description (optional) -- `homepage`: Plugin homepage URL (optional) -- `license`: License identifier (optional) -- `kind`: Array of plugin kinds -- `dependencies`: Array of plugin names this plugin depends on (optional) - -#### [plugin.binary] Section - -- `wasm`: Path to WASM binary (relative to manifest) -- `entrypoint`: Custom entrypoint function name (optional, default: "_start") - -#### [capabilities] Section - -Capabilities define what the plugin can access: - -**Filesystem**: - -```toml -[capabilities.filesystem] -read = ["/tmp/cache", "/var/data"] -write = ["/tmp/output"] -``` - -**Network**: - -```toml -[capabilities] -network = true # or false -``` - -**Environment**: - -```toml -[capabilities] -environment = ["PATH", "HOME"] # or omit for no access -``` - -**Resource Limits**: - -```toml -[capabilities] -max_memory_mb = 128 -max_cpu_time_secs = 10 -``` - -### Plugin Kinds - -#### media_type - -Register new media types with file extensions and MIME types. - -**Trait**: `MediaTypeProvider` - -**Methods**: - -- `supported_media_types()`: Returns list of media type definitions -- `can_handle(path, mime_type)`: Check if plugin can handle a file - -#### metadata_extractor - -Extract metadata from files. - -**Trait**: `MetadataExtractor` - -**Methods**: - -- `extract_metadata(path)`: Extract metadata from file -- `supported_types()`: Returns list of supported media type IDs - -#### thumbnail_generator - -Generate thumbnails for media files. - -**Trait**: `ThumbnailGenerator` - -**Methods**: - -- `generate_thumbnail(path, output_path, options)`: Generate thumbnail -- `supported_types()`: Returns list of supported media type IDs - -#### search_backend - -Implement custom search algorithms. - -**Trait**: `SearchBackend` - -**Methods**: - -- `index_item(item)`: Index a media item -- `remove_item(item_id)`: Remove item from index -- `search(query)`: Perform search -- `get_stats()`: Get index statistics - -#### event_handler - -React to system events. - -**Trait**: `EventHandler` - -**Methods**: - -- `handle_event(event)`: Handle an event -- `interested_events()`: Returns list of event types to receive - -#### theme_provider - -Provide UI themes. - -**Trait**: `ThemeProvider` - -**Methods**: - -- `get_themes()`: List available themes -- `load_theme(theme_id)`: Load theme data - -## Creating a Plugin - -### Step 1: Set Up Project - -```bash -# Create new Rust library project -cargo new --lib my-plugin -cd my-plugin - -# Add dependencies -cat >> Cargo.toml <, -} - -#[async_trait] -impl Plugin for MyPlugin { - fn metadata(&self) -> &PluginMetadata { - // Return plugin metadata - } - - async fn initialize(&mut self, context: PluginContext) -> PluginResult<()> { - self.context = Some(context); - Ok(()) - } - - async fn shutdown(&mut self) -> PluginResult<()> { - Ok(()) - } - - async fn health_check(&self) -> PluginResult { - Ok(HealthStatus { - healthy: true, - message: None, - metrics: Default::default(), - }) - } -} - -#[async_trait] -impl MetadataExtractor for MyPlugin { - async fn extract_metadata(&self, path: &PathBuf) -> PluginResult { - // Extract metadata from file - } - - fn supported_types(&self) -> Vec { - vec!["my_type".to_string()] - } -} -``` - -### Step 3: Build to WASM - -```bash -# Install WASM target -rustup target add wasm32-wasi - -# Build -cargo build --target wasm32-wasi --release - -# Optimize (optional, wasm-tools provides wasm-strip functionality) -cargo install wasm-tools -wasm-tools strip target/wasm32-wasi/release/my_plugin.wasm -o target/wasm32-wasi/release/my_plugin.wasm - -# Copy to plugin directory -cp target/wasm32-wasi/release/my_plugin.wasm . -``` - -### Step 4: Create Manifest - -Create `plugin.toml` with appropriate configuration (see examples above). - -### Step 5: Install Plugin - -```bash -# Copy to plugins directory -cp -r my-plugin ~/.config/pinakes/plugins/ - -# Or use API -curl -X POST http://localhost:3000/api/v1/plugins/install \ - -d '{"source": "/path/to/my-plugin"}' -``` - -## Testing Plugins - -### Unit Tests - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_metadata_extraction() { - let mut plugin = MyPlugin::default(); - let context = PluginContext { - data_dir: PathBuf::from("/tmp/data"), - cache_dir: PathBuf::from("/tmp/cache"), - config: Default::default(), - capabilities: Default::default(), - }; - - plugin.initialize(context).await.unwrap(); - - let metadata = plugin - .extract_metadata(&PathBuf::from("test.txt")) - .await - .unwrap(); - - assert!(metadata.title.is_some()); - } -} -``` - -### Integration Tests - -```bash -# Load plugin in test Pinakes instance -pinakes --config test-config.toml plugin load /path/to/plugin - -# Verify plugin is loaded -pinakes plugin list - -# Test plugin functionality -pinakes scan /path/to/test/files -``` - -## Security Considerations - -### Capability-Based Security - -Plugins operate in a sandbox with explicit capabilities. Only request the -minimum capabilities needed: - -**Good**: - -```toml -[capabilities.filesystem] -read = ["/tmp/cache"] -``` - -**Bad**: - -```toml -[capabilities.filesystem] -read = ["/", "/home", "/etc"] # Too broad! -``` - -### Resource Limits - -Always set appropriate resource limits: - -```toml -[capabilities] -max_memory_mb = 128 # Reasonable for image processing -max_cpu_time_secs = 10 # Prevent runaway operations -``` - -### Input Validation - -Always validate input in your plugin: - -```rust -async fn extract_metadata(&self, path: &PathBuf) -> PluginResult { - // Check file exists - if !path.exists() { - return Err(PluginError::InvalidInput("File not found".to_string())); - } - - // Check file size - let metadata = std::fs::metadata(path) - .map_err(|e| PluginError::IoError(e.to_string()))?; - if metadata.len() > 10_000_000 { // 10MB limit - return Err(PluginError::InvalidInput("File too large".to_string())); - } - - // Process file... -} -``` - -## Best Practices - -### Error Handling - -- Use descriptive error messages -- Return appropriate `PluginError` variants -- Don't panic - return errors instead - -### Performance - -- Avoid blocking operations in async functions -- Use streaming for large files -- Implement timeouts for external operations -- Cache results when appropriate - -### Configuration - -- Provide sensible defaults -- Document all configuration options -- Validate configuration during initialization - -### Documentation - -- Write clear README with examples -- Document all configuration options -- Include troubleshooting section -- Provide integration examples - -## API Reference - -See the -[pinakes-plugin-api documentation](../../crates/pinakes-plugin-api/README.md) -for detailed API reference. - -## Plugin Distribution - -### Plugin Registry (Future) - -A centralized plugin registry is planned for future releases: - -```bash -# Install from registry -pinakes plugin install markdown-metadata - -# Search plugins -pinakes plugin search heif - -# Update all plugins -pinakes plugin update --all -``` - -### Manual Distribution - -Currently, plugins are distributed as directories containing: - -- `plugin.toml` manifest -- WASM binary -- README and documentation - -## Troubleshooting - -### Plugin Won't Load - -**Check manifest syntax**: - -```bash -# Validate TOML syntax -taplo check plugin.toml -``` - -**Check API version**: Ensure `api_version = "1.0"` in manifest. - -**Check binary path**: Verify WASM binary exists at path specified in -`plugin.binary.wasm`. - -### Plugin Crashes - -**Check resource limits**: Increase `max_memory_mb` or `max_cpu_time_secs` if -operations are timing out. - -**Check capabilities**: Ensure plugin has necessary filesystem/network -capabilities. - -**Check logs**: - -```bash -# View plugin logs -tail -f ~/.local/share/pinakes/logs/plugin.log -``` - -### Permission Denied Errors - -**Filesystem capabilities**: Add required paths to -`capabilities.filesystem.read` or `.write`. - -**Network capabilities**: Set `capabilities.network = true` if plugin needs -network access. - -## Support - -- **Issues**: https://github.com/notashelf/pinakes/issues -- **Discussions**: https://github.com/notashelf/pinakes/discussions -- **Documentation**: https://pinakes.readthedocs.io - -## License - -All example plugins are licensed under MIT. diff --git a/examples/plugins/heif-support/README.md b/examples/plugins/heif-support/README.md deleted file mode 100644 index d3e866d..0000000 --- a/examples/plugins/heif-support/README.md +++ /dev/null @@ -1,269 +0,0 @@ -# HEIF/HEIC Support Plugin - -This example plugin adds support for HEIF (High Efficiency Image Format) and -HEIC (HEIF Container) to Pinakes. - -## Overview - -HEIF is a modern image format that provides better compression than JPEG while -maintaining higher quality. This plugin enables Pinakes to: - -- Recognize HEIF/HEIC files as a media type -- Extract metadata from HEIF images -- Generate thumbnails from HEIF images - -## Features - -- **Media Type Registration**: Registers `.heif`, `.heic`, `.hif` extensions as - image media types -- **EXIF Extraction**: Extracts EXIF metadata including camera info, GPS - coordinates, timestamps -- **Thumbnail Generation**: Generates thumbnails in JPEG, PNG, or WebP format -- **Resource Limits**: Configurable memory and CPU limits for safe processing -- **Large Image Support**: Handles images up to 8192x8192 pixels - -## Supported Formats - -- **HEIF**: High Efficiency Image Format (`.heif`, `.hif`) -- **HEIC**: HEIF Container format used by Apple devices (`.heic`) -- **HEIF Sequences**: Multi-image HEIF files -- **HEIF with Alpha**: HEIF images with transparency - -## Implementation - -The plugin implements three traits: - -### MediaTypeProvider - -```rust -#[async_trait] -impl MediaTypeProvider for HeifPlugin { - fn supported_media_types(&self) -> Vec { - vec![MediaTypeDefinition { - id: "heif".to_string(), - name: "HEIF Image".to_string(), - category: "image".to_string(), - extensions: vec!["heif".to_string(), "heic".to_string(), "hif".to_string()], - mime_types: vec!["image/heif".to_string(), "image/heic".to_string()], - icon: Some("image".to_string()), - }] - } - - async fn can_handle(&self, path: &PathBuf, mime_type: Option<&str>) -> PluginResult { - // Check file extension and/or MIME type - } -} -``` - -### MetadataExtractor - -```rust -#[async_trait] -impl MetadataExtractor for HeifPlugin { - async fn extract_metadata(&self, path: &PathBuf) -> PluginResult { - // 1. Parse HEIF file structure - // 2. Extract EXIF metadata - // 3. Get image dimensions - // 4. Return ExtractedMetadata - } - - fn supported_types(&self) -> Vec { - vec!["heif".to_string()] - } -} -``` - -### ThumbnailGenerator - -```rust -#[async_trait] -impl ThumbnailGenerator for HeifPlugin { - async fn generate_thumbnail( - &self, - path: &PathBuf, - output_path: &PathBuf, - options: ThumbnailOptions, - ) -> PluginResult { - // 1. Decode HEIF image - // 2. Resize to thumbnail dimensions - // 3. Encode to output format - // 4. Save to output_path - // 5. Return ThumbnailInfo - } - - fn supported_types(&self) -> Vec { - vec!["heif".to_string()] - } -} -``` - -## Dependencies - -The plugin uses the following Rust crates (compiled to WASM): - -- `libheif-rs`: HEIF decoding and encoding -- `image`: Image processing and thumbnail generation -- `kamadak-exif`: EXIF metadata parsing - -## Building - -### Prerequisites - -```bash -# Install WASM target -rustup target add wasm32-wasi - -# Install wasm-tools for optimization (provides strip functionality) -cargo install wasm-tools -``` - -### Build Process - -```bash -# Build the plugin -cargo build --target wasm32-wasi --release - -# Strip debug symbols to reduce size -wasm-tools strip target/wasm32-wasi/release/heif_support.wasm -o target/wasm32-wasi/release/heif_support.wasm - -# Copy to plugin directory -cp target/wasm32-wasi/release/heif_support.wasm . -``` - -### Size Optimization - -```bash -# Use wasm-opt for further optimization -wasm-opt -Oz heif_support.wasm -o heif_support_optimized.wasm -``` - -## Installation - -### Manual Installation - -```bash -# Copy plugin directory to Pinakes plugins directory -cp -r examples/plugins/heif-support ~/.config/pinakes/plugins/ -``` - -### Via API - -```bash -curl -X POST http://localhost:3000/api/v1/plugins/install \ - -H "Content-Type: application/json" \ - -d '{"source": "/path/to/heif-support"}' -``` - -### Via Plugin Manager - -```bash -pinakes plugin install /path/to/heif-support -``` - -## Configuration - -The plugin can be configured through the `config` section in `plugin.toml`: - -### EXIF Extraction - -- `extract_exif`: Enable EXIF metadata extraction (default: true) - -### Thumbnail Generation - -- `generate_thumbnails`: Enable thumbnail generation (default: true) -- `thumbnail_quality`: JPEG quality for thumbnails, 1-100 (default: 85) -- `thumbnail_format`: Output format - "jpeg", "png", or "webp" (default: "jpeg") - -### Resource Limits - -- `max_memory_mb`: Maximum memory the plugin can use in megabytes (default: 256, - set in `[capabilities]`) -- `max_width`: Maximum image width to process (default: 8192) -- `max_height`: Maximum image height to process (default: 8192) - -## Security - -### Capabilities - -- **Filesystem Read**: Only files being processed (via Pinakes) -- **Filesystem Write**: Thumbnail directory only -- **Network**: Disabled -- **Environment**: No access - -### Resource Limits - -- **Memory**: 256 MB maximum -- **CPU Time**: 30 seconds maximum per operation - -### Sandboxing - -The plugin runs in a WASM sandbox with: - -- No access to host filesystem beyond granted paths -- No network access -- No arbitrary code execution -- Memory and CPU time limits enforced by runtime - -## Performance - -### Typical Performance - -- **Metadata Extraction**: ~50-100ms for typical HEIF files -- **Thumbnail Generation**: ~200-500ms depending on source image size -- **Memory Usage**: 50-150 MB typical, 256 MB maximum - -### Optimization Tips - -1. Keep source images below 8192x8192 for best performance -2. Use JPEG thumbnail format for smaller file sizes -3. Adjust thumbnail quality vs file size tradeoff with `thumbnail_quality` - -## Error Handling - -The plugin handles: - -- **Corrupted Files**: Returns descriptive error -- **Unsupported Variants**: Gracefully skips unsupported HEIF features -- **Memory Limits**: Fails safely if image too large -- **Timeout**: Returns error if processing exceeds CPU time limit - -## Testing - -```bash -# Run unit tests -cargo test - -# Test with sample HEIF files -cargo test --test integration -- --nocapture -``` - -## Troubleshooting - -### Plugin Fails to Load - -- Check that `heif_support.wasm` exists in plugin directory -- Verify `plugin.toml` is valid TOML -- Check Pinakes logs for detailed error messages - -### Thumbnails Not Generated - -- Verify `generate_thumbnails = true` in config -- Check filesystem write permissions for thumbnail directory -- Ensure source image is below size limits - -### Out of Memory Errors - -- Reduce `max_width` and `max_height` in config -- Increase `max_memory_mb` if source images are large -- Check that source files aren't corrupted - -## Future Enhancements - -- Support for HEIF image sequences (burst photos) -- HDR metadata extraction -- Live Photo support -- AVIF format support (similar to HEIF) - -## License - -MIT diff --git a/examples/plugins/heif-support/plugin.toml b/examples/plugins/heif-support/plugin.toml deleted file mode 100644 index 6f30eb1..0000000 --- a/examples/plugins/heif-support/plugin.toml +++ /dev/null @@ -1,29 +0,0 @@ -[plugin] -name = "heif-support" -version = "1.0.0" -api_version = "1.0" -author = "Pinakes Contributors" -description = "HEIF/HEIC image format support for Pinakes" -homepage = "https://github.com/notashelf/pinakes" -license = "MIT" -kind = ["media_type", "metadata_extractor", "thumbnail_generator"] - -[plugin.binary] -wasm = "heif_support.wasm" - -[capabilities] -network = false -max_memory_mb = 256 -max_cpu_time_secs = 30 - -[capabilities.filesystem] -read = ["/media"] -write = ["/tmp/pinakes"] - -[config] -extract_exif = true -generate_thumbnails = true -thumbnail_quality = 85 -thumbnail_format = "jpeg" -max_width = 8192 -max_height = 8192 diff --git a/examples/plugins/markdown-metadata/README.md b/examples/plugins/markdown-metadata/README.md deleted file mode 100644 index 39044a4..0000000 --- a/examples/plugins/markdown-metadata/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# Markdown Metadata Extractor Plugin - -This example plugin demonstrates how to create a metadata extractor plugin for -Pinakes. - -## Overview - -The Markdown Metadata Extractor enhances Pinakes' built-in markdown support by: - -- Parsing YAML and TOML frontmatter -- Extracting metadata from frontmatter fields -- Converting frontmatter tags to Pinakes media tags -- Extracting custom fields from frontmatter - -## Features - -- **Frontmatter Parsing**: Supports both YAML (`---`) and TOML (`+++`) - frontmatter formats -- **Tag Extraction**: Automatically extracts tags from frontmatter and applies - them to media items -- **Custom Fields**: Preserves all frontmatter fields as custom metadata -- **Configuration**: Configurable via `plugin.toml` config section - -## Frontmatter Example - -```markdown ---- -title: "My Document" -author: "John Doe" -date: "2024-01-15" -tags: ["documentation", "example", "markdown"] -category: "tutorials" -draft: false ---- - -# My Document - -Content goes here... -``` - -## Implementation - -The plugin implements the `MetadataExtractor` trait from `pinakes-plugin-api`: - -```rust -#[async_trait] -impl MetadataExtractor for MarkdownMetadataPlugin { - async fn extract_metadata(&self, path: &PathBuf) -> PluginResult { - // 1. Read the file - // 2. Parse frontmatter - // 3. Extract metadata fields - // 4. Return ExtractedMetadata - } - - fn supported_types(&self) -> Vec { - vec!["markdown".to_string()] - } -} -``` - -## Building - -The plugin is compiled to WebAssembly: - -```bash -cargo build --target wasm32-wasi --release -wasm-strip target/wasm32-wasi/release/markdown_metadata.wasm -cp target/wasm32-wasi/release/markdown_metadata.wasm . -``` - -## Installation - -```bash -# Copy plugin directory to Pinakes plugins directory -cp -r examples/plugins/markdown-metadata ~/.config/pinakes/plugins/ - -# Or via API -curl -X POST http://localhost:3000/api/v1/plugins/install \ - -H "Content-Type: application/json" \ - -d '{"source": "/path/to/markdown-metadata"}' -``` - -## Configuration - -The plugin can be configured through the `config` section in `plugin.toml`: - -- `extract_tags`: Extract tags from frontmatter (default: true) -- `parse_yaml`: Parse YAML frontmatter (default: true) -- `parse_toml`: Parse TOML frontmatter (default: true) -- `max_file_size`: Maximum file size to process in bytes (default: 10MB) - -## Security - -This plugin has minimal capabilities: - -- **Filesystem**: No write access, read access only to files being processed -- **Network**: Disabled -- **Environment**: No access - -## Testing - -```bash -cargo test -``` - -## License - -MIT diff --git a/examples/plugins/markdown-metadata/plugin.toml b/examples/plugins/markdown-metadata/plugin.toml deleted file mode 100644 index a558095..0000000 --- a/examples/plugins/markdown-metadata/plugin.toml +++ /dev/null @@ -1,25 +0,0 @@ -[plugin] -name = "markdown-metadata" -version = "1.0.0" -api_version = "1.0" -author = "Pinakes Contributors" -description = "Extract metadata from Markdown files with YAML frontmatter" -homepage = "https://github.com/notashelf/pinakes" -license = "MIT" -kind = ["metadata_extractor"] - -[plugin.binary] -wasm = "markdown_metadata.wasm" - -[capabilities] -network = false - -[capabilities.filesystem] -read = [] -write = [] - -[config] -extract_tags = true -parse_yaml = true -parse_toml = true -max_file_size = 10485760 diff --git a/examples/plugins/media-stats-ui/Cargo.lock b/examples/plugins/media-stats-ui/Cargo.lock deleted file mode 100644 index 882e3efca9f97ef32b8359035bfb45e6f38366dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1246 zcmb`GJ8#=C6oq&F3PBlL%jD%dK!F0Cx_1jQ@Np><(IXJ4rvCS(3?zsFXY(Rb)P;1; zca9#~Kc|%2m_}^tYPYgXwj^)I@!b9#aK_aZ46Qr2zwA0Jee=-%F8k=b)MYL>hx#}! zYdhI_vtx@Pl{621^B32gmbv}W@}_xudbR$=4*2}s%yzwIHX+G^S4teulezK98x)LuJ_AyT(?{fqn(GN-$mv=Er(R){OcNjLjRJtY5$C2 zj704qM#>`Nf)mF%8ZI$1K`GTB6e!1ZU<`CUHCj>*k_rk`6WZNYAI7OdTKtuM(nC+- z&IY8a+5nA_pg3}4oL7znX_(hSJEed);{yoJERprXh*l`QBbG-?W!D6J-E*3K!d#I& zH6QT5*X@{`@Al1GrMJB0Lq6wv|9so`W%_ew7tfmA*|ZZ*aw!G4C}qvU7^J~!L#SiU z2mozlBoq~s&IS<`u!zbs3o2@3!WXGcwVw8ni!J%ErK{}r?vb0UKBE2-$~(C+%`aCk zy}-FMnn*OM004!GN&pSgNU^wV~Dr4iJr^0V5g}Bu7QUUI-Llpn3POr+=NV zr2V}+ZILnsP^;{M^dXX{oF ! { - core::arch::wasm32::unreachable() -} - -// Host functions provided by the Pinakes runtime. -unsafe extern "C" { - // Write a result value back to the host (ptr + byte length). - fn host_set_result(ptr: i32, len: i32); - - // Emit a structured log message to the host logger. - // `level` mirrors tracing severity: 0=trace 1=debug 2=info 3=warn 4=error - fn host_log(level: i32, ptr: i32, len: i32); -} - -/// # Safety -/// -/// `json` is a valid slice; the host copies the bytes before -/// returning so there are no lifetime concerns. -fn set_response(json: &[u8]) { - unsafe { host_set_result(json.as_ptr() as i32, json.len() as i32) } -} - -/// # Safety -/// -/// Same as [`set_response`] -fn log_info(msg: &[u8]) { - unsafe { host_log(2, msg.as_ptr() as i32, msg.len() as i32) } -} - -/// Allocate a buffer for the host to write request data into. -/// -/// # Returns -/// -/// The byte offset of the allocation, or -1 on failure. -/// -/// # Safety -/// -/// Size is positive; Layout construction cannot fail for align=1. -#[unsafe(no_mangle)] -pub extern "C" fn alloc(size: i32) -> i32 { - if size <= 0 { - return 0; - } - unsafe { - let layout = Layout::from_size_align_unchecked(size as usize, 1); - let ptr = alloc::alloc::alloc(layout); - if ptr.is_null() { -1 } else { ptr as i32 } - } -} - -/// Called once after the plugin is loaded. Returns 0 on success. -#[unsafe(no_mangle)] -pub extern "C" fn initialize() -> i32 { - log_info(b"media-stats-ui: initialized"); - 0 -} - -/// Called before the plugin is unloaded. Returns 0 on success. -#[unsafe(no_mangle)] -pub extern "C" fn shutdown() -> i32 { - log_info(b"media-stats-ui: shutdown"); - 0 -} - -/// # Returns -/// -/// an empty JSON array; this plugin adds no custom media types. -#[unsafe(no_mangle)] -pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) { - set_response(b"[]"); -} - -/// # Returns -/// -/// An empty JSON array; this plugin handles no event types. -#[unsafe(no_mangle)] -pub extern "C" fn interested_events(_ptr: i32, _len: i32) { - set_response(b"[]"); -} From 70b0113d8a60c6a3213df1597ad35df5cc22960f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 23 Mar 2026 02:17:49 +0300 Subject: [PATCH 07/22] meta: release under EUPL v1.2 Signed-off-by: NotAShelf Change-Id: I81153bc119c58300fc2b1e7efab871496a6a6964 --- LICENSE | Bin 0 -> 13829 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..9ba5d422c802f13adf4c9e1bd50da00cc7025b58 GIT binary patch literal 13829 zcmche-*OvAcE<1LDdsL@1;UEj*v=|fG)+6&q9v^*m1-{sg9b77Ug{>&o@{(Aw%B+wQ0Q zqUiIr+fc zdd!QwtM0c$+_Y@>kN$&gTT5P((hGCBl*atezvX1;!ZmhqJ8Iu{QdkxgmIsAxIo`f5 zO2m@NH*Jj`tL8p$+5ubS)o$n3RWZ2wG5L~5vsvcw=!PA%hBWvxn_R^2o~RDrz^Yls zNkX@`ZM0KaTKBE-2jxXTcL+9<9l)(=%@{VAT}0oj*M3@`D_ellJXq zI24`B*Hu4s)oPTl@Ojzpc4JeOXxJ1W&E|eTSu7qhJx1|RRCTecom}%>J9e1qrd{)E zv(AgUZ}s`@Cs&Sxo?;66uME0hz}d zVOgtl=j3*7k+0Z>;M)`W+Vw~|T5;E|tBsB=Tj`O`wVP#b&=9nZ;Omx<$Gt+B+fpI0 zh>&y*t+{r4N3;t+&TY$jo%@fmQV3QJFBmYid07{IUu_hWW-YW=;7Lb1`8T%424U~2 zmuyZRB=C{r&e2d82ai^z*@&G??d{kM-DAE36gaXM;kqNnu;bMi(G>4P{7fczqPS}4 zPlfB#w-bk$7TJ!wqIrg~)+Q2xx=RseMome&Yx{xYm2LCjno1a^z}~xdkEFz3Ka6Vy z>e}o7G2MLjI`cUbKp|bqJv}xn?MMzGkg}@-*?3>mBr&u+f7<%ieY3|c}nf>`C;$6-W&y=p2N}{J_$?NT6$4v zOz}8DLfrAD#k>mZw!7#rr-OfP&%?G2?yH>kWOcR-L9mdxhKL99kE{~V!>~|qS`aG5 z5)n`QHj4M22fad^LpUu8wEejqf1RYoPlzc)j(u^jRC!(}9#TW@3I~W1d=qgGQD+Hm zCiwFozqDP7(lTT34EPuMef0n!FECrP)(`C7DUS5nWH|e6wwwJrbR>~oHTXx5G`TB& z;t^Jw;1pFiBpQyV{JYVQ3lzQ=?MU*mzV_^b_w%AGLtY{BlCr?MV7ngt|0LNrUVqDOk$B8;aDF{-W{cl}TZ&4mjBAt1wh;DWT0WwJ!DG89 zbfzWFKRcP;d7j!UVa8pbzJim!-?msWxMm4$2D9BK*OkVa@dz2!sN@1pyx+GSc`Vp< zR}B3^bXA(c3lG^({G$w=*YJyu%}GVLkm+Mn80JeFD++v?Rn|p)#Ci|cLZDY^-c|gu zKJ;tXDg^{N^*&10Qd7hn3Y^~)&uHK*9>nNqvO+Xrpar;Rd+>BX;-IpLW0;G$&Bg%< z4=&S=F`xppIH0}VQ33_oOxj{a$-t9}A5(H7XozY2Z65DGgqSSzS1JP|+} zshX^aRoev3cE}ltL#0*>1KZ)L!Mp~290Evf`w9y4=%dB_G!{zOh#TBUo?0D1!5P83>7ljxJHdsMJH}aZI*X}GB$bN3`6ByVt?H%1zhOXM=I)EM2Lw#C@T)JQjB220M*L%!%u0d3m7mlr>^_a? zdGFoIm~gGmZGeT~;3jV%68@tM52Wy`c07doDa}Qv$P+qJBH0eBA(Qex`DThxwRNWb z6bps*L!#E>em=D6`uGAder>V6b-(M`X^sRH9;`Bj!dX#D+(?Az@s!W#s3oT`h*VkH z3|UkNG)fHED@L~Q0uZ5AfB8BZ>*^r-dJUpT*0jLJ`EG!;+HGh6)RN+51kvaXCuc!f z^I4?1nMh|xB+x3ZlY$#Svx@thq-+o&I8GUA4RMBSO+}s5W=S2AAcGIIB5(?f(ob1u zekvQA+6X;rI1*C85?WPY-fT6+1Ro@gnxmCn7JcEDpa0YF(S0g*Dy6My5EUfrgm!0W zvD&f}O2__~U92DqnD>5SESH$Q!O|E^9?vO#u$q&^3o0AVhYs5nGRseTgXfl$y2rRc%uKjSBpQAq|zHs$wO218JLAovchX~a3{ zEv>*!lgEx=fru(Z2o5UOYu_sjU10KKln?y}yZkCY3xxLA&9X!XILFham-)AxP65@c z0I^iSgI+5O*?WE`x`|3-gRZH*w==-I0u2o5Mch}_@F(iZG?~wyyv*Khtx+cd?6AFa z|AamhSdU(-2BJ6ItQh$TMKD+_6w7FT{Z4v4_xil4CrjxwcItHfc+vp+^mNqE#CP7R zWglZQVI42?kLqMkAt&cYujPa;`rrs&4Br;!+VSAlMV{E>Z{ce*$-;k584}jz@Ilys zM&~Hf)Ur(&Qg+0U$b!F}A>COY3&jm8EZRnCbvDM_KJ}Eq>LXg6O5&}}JmeadQ1hnP zK_ZjvN}1-m2aNzM*&30yhhh@*nr*uUuqf7~T6OBLa?Ff=BkeV79w;zNGHJ_Ne%b02hNRW9+PObMLg{Op`C%f4d1bVM3IdeZplUCS~Mt`MdO_zsSHuhEuz8(}ONLcAS&I0Jba z$n$>Fj4)9?J~|9#D&*5oK#E+WEf^y$a-}xuKm{@CB44x^PHW5Unec%IN}L%fW;Nbx zphMcwgT9|`pfx=Dmg3qyFgx9=LLhiz#`0x?4`FYHcs|K|GFc7Isp^2v51ZoEmF-HZ zW>_Lwl|rg|LV$eOS<;rP0@E?q&|)Z8G=U!rNI=lcBGY`7ARoBO5d!tHEZ3 z2C-BO8m^2r-J<>2sd}M%gv;1;XcLvBA*AcNw!=u&v9vEs8bWG>R?SB7;iMNys-m*4 zQS68(q65H`7!Bo>ryLp=Cu32RAmWX|FiITteCdH}25EF|m`!?nT94o(DsPgmvW^w8 zFRI;OLmKq7E|UTjMN=UtEeA=HXlLhtJ$323DyTD~Wn$qNFi;&xT#8DmsyAbu?+}*_ zM|JAycNRP50!-r>u9X9>ZDoofU+8RS=!Ts zHtL)fb>>e+ibXkjm7GAsVI>bM;EFZG2|o*`z}fpq!THE)Rlm?O`*#uz1&R-8b0Do0 znM+W;Pit~s&9khcG)wDi&+O@M;+TD=W~{C6KTtn4psjH*T{S4W7E;XT zKW-WN9n(4)$lE}p3c*{sZPj4FZG{lN3I}D*thn%38_e7Z#pQH?$mjw_0~{HBu*%$U zFhtQZpHx$AGWE1*1j%*gwHB6EB$W-QRQ7>*k4)f|G%*ET3_+T;N$lgkW&TbIU<@8D zB6PtSr^kcMo)VMb3?5o;=6t^}IJKe^hp>^3lqwYwAa2(%%En7c0*xBxNTWL?zQLF* zgZFehmAVEOa=Y2O;O^M@n<~cp447nZG$}kQ?#VOsMa=T)nyW`6-5cpglG*JFuS>ng zCISA@9KJqVaoQV?xq6PvnEq|}6BHE8d%#3#=}=%NeN&dq3GA%FD{01!n1JT7%R@=mFp=>>87g#}xR9f11PvndU(Ik2P2Cp0-zP;`6Pz|? z=XO++?lM>NG^5zT9YKpg9Akb6b5E%S zt~F^&C8W)ZC9%FdjsWDlFS=<4Yp6g5OY&K4^YSVYhCf>#36P+~SX*B9$z+Z5km3xS z)~fFPNcm(E=G5A{Vh;b_Zv1$Y2D2RDQuxI}PqLpl^IS4o!c~}YV+?Im^idkaS7~EY zACoR@9I=9bZI028&@WE{Cw)ly>GFC0*@D`i7x$Lr>8tR$5UD zG+jDIC#No=Hz!eM#0*ELRUhjEi^@Rz2jd zR25|(L-|0v=toBI7@31dNcaQHo^44c(vWq zI4TQ~g+Za|CT&hlDnJYXkMVR-S7d$N*K_ja(iw&I+L;|VgC$8w-kD57vLK9223tja zQ^jpu#`CRXD+}w23_I11yCJgoln^H>r_?iaBj#f@s}PyKGwIR0Pv^MRkohwR^ryX9 z-sjutGOxg}@+~CsAdBIC8xostX^tQTL+sE4$F&&fyF>E(28NI%3F&OFdBZw=#?)~g zI-GQ(+FV4_UgX`W%Y?8vR_roCzJrqx&vV0wI^|d1omu9u-65MrPv|y=jvP6h8G$WZ z7udW4vZDAomHf(eDDE0RKdTb0JF5VWwwL)>Fa-J;Z7(vTQqV*rRFTM&a#^vvEV1;}WE(?7dN<-!8u;0n5XH*>P+=ytN zXa<{DHhO9V_WR$=@EM)VWAIcvULn$gz#XK8_i|I1c7?xrqi?*r4Ul?#*{mcsNE-nd z@-eE0pOfZgVO)5VVKN0L}SKpI1O}KOI z0QxAlCt~?st=**raIzn^mr#^9$KG{xIDuFI;Ve5ES9wbkXKF3_0wxg4n3KQoe+@!M z+3xs#_xeNroxk>*zfJzugWCS$ySI0jntHxi=-=OiAbR>*Y_S;alp#J^P-DaT~7biJ)W<7ov#b?#&*f+ug-BwYWRdJi#n|St)g56!$6(BDyEuZt3 zHM&NJ3timYXf^O-MNXL4{f5DhQ`P z`FWQAY5ecMF@Da!Rl7k%L27>%hF_+1Ue=iV2hs#7trVBJxU24}KRx^5QdHob>J?qO zJ@>>v$G>!$q&s07o+xk_A}ji(s)?F7>|*}q$R4GQ64EfXXcFP_{b%F~A z>F6G}bv27HMt{}-Q_tI)8z-C|MjKuJNo8tEVna>mEOW%a0P;Gc*G~jmQk)^x$N(^C snNb0#vfPZQx=T$~^cXuQfvl$b2;|7N^JldZ0HxS@GaWGMisiHa0ohJYwg3PC literal 0 HcmV?d00001 From 00bab69598c11bbe86d9d95a635136fdd5f83d68 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 23 Mar 2026 02:32:37 +0300 Subject: [PATCH 08/22] meta: move public crates to `packages/` Signed-off-by: NotAShelf Change-Id: I928162008cb1ba02e1aa0e7aa971e8326a6a6964 --- Cargo.lock | Bin 244619 -> 244939 bytes Cargo.toml | 52 +++++++++++------- crates/pinakes-core/Cargo.toml | 20 +++---- crates/pinakes-plugin-api/Cargo.toml | 21 +++---- .../pinakes-server/Cargo.toml | 8 +-- .../pinakes-server/src/api_doc.rs | 0 .../pinakes-server/src/app.rs | 0 .../pinakes-server/src/auth.rs | 0 .../pinakes-server/src/dto/analytics.rs | 0 .../pinakes-server/src/dto/audit.rs | 0 .../pinakes-server/src/dto/batch.rs | 0 .../pinakes-server/src/dto/collections.rs | 0 .../pinakes-server/src/dto/config.rs | 0 .../pinakes-server/src/dto/enrichment.rs | 0 .../pinakes-server/src/dto/media.rs | 0 .../pinakes-server/src/dto/mod.rs | 0 .../pinakes-server/src/dto/playlists.rs | 0 .../pinakes-server/src/dto/plugins.rs | 0 .../pinakes-server/src/dto/scan.rs | 0 .../pinakes-server/src/dto/search.rs | 0 .../pinakes-server/src/dto/sharing.rs | 0 .../pinakes-server/src/dto/social.rs | 0 .../pinakes-server/src/dto/statistics.rs | 0 .../pinakes-server/src/dto/subtitles.rs | 0 .../pinakes-server/src/dto/sync.rs | 0 .../pinakes-server/src/dto/tags.rs | 0 .../pinakes-server/src/dto/transcode.rs | 0 .../pinakes-server/src/dto/users.rs | 0 .../pinakes-server/src/error.rs | 0 .../pinakes-server/src/lib.rs | 0 .../pinakes-server/src/main.rs | 0 .../pinakes-server/src/routes/analytics.rs | 0 .../pinakes-server/src/routes/audit.rs | 0 .../pinakes-server/src/routes/auth.rs | 0 .../pinakes-server/src/routes/backup.rs | 0 .../pinakes-server/src/routes/books.rs | 0 .../pinakes-server/src/routes/collections.rs | 0 .../pinakes-server/src/routes/config.rs | 0 .../pinakes-server/src/routes/database.rs | 0 .../pinakes-server/src/routes/duplicates.rs | 0 .../pinakes-server/src/routes/enrichment.rs | 0 .../pinakes-server/src/routes/export.rs | 0 .../pinakes-server/src/routes/health.rs | 0 .../pinakes-server/src/routes/integrity.rs | 0 .../pinakes-server/src/routes/jobs.rs | 0 .../pinakes-server/src/routes/media.rs | 0 .../pinakes-server/src/routes/mod.rs | 0 .../pinakes-server/src/routes/notes.rs | 0 .../pinakes-server/src/routes/photos.rs | 0 .../pinakes-server/src/routes/playlists.rs | 0 .../pinakes-server/src/routes/plugins.rs | 0 .../src/routes/saved_searches.rs | 0 .../pinakes-server/src/routes/scan.rs | 0 .../src/routes/scheduled_tasks.rs | 0 .../pinakes-server/src/routes/search.rs | 0 .../pinakes-server/src/routes/shares.rs | 0 .../pinakes-server/src/routes/social.rs | 0 .../pinakes-server/src/routes/statistics.rs | 0 .../pinakes-server/src/routes/streaming.rs | 0 .../pinakes-server/src/routes/subtitles.rs | 0 .../pinakes-server/src/routes/sync.rs | 0 .../pinakes-server/src/routes/tags.rs | 0 .../pinakes-server/src/routes/transcode.rs | 0 .../pinakes-server/src/routes/upload.rs | 0 .../pinakes-server/src/routes/users.rs | 0 .../pinakes-server/src/routes/webhooks.rs | 0 .../pinakes-server/src/state.rs | 0 .../pinakes-server/tests/api.rs | 0 .../pinakes-server/tests/books.rs | 0 .../pinakes-server/tests/common/mod.rs | 0 .../pinakes-server/tests/e2e.rs | 0 .../pinakes-server/tests/enrichment.rs | 0 .../pinakes-server/tests/media_ops.rs | 0 .../pinakes-server/tests/notes.rs | 0 .../pinakes-server/tests/plugin.rs | 0 .../pinakes-server/tests/shares.rs | 0 .../pinakes-server/tests/sync.rs | 0 .../pinakes-server/tests/users.rs | 0 .../pinakes-server/tests/webhooks.rs | 0 {crates => packages}/pinakes-tui/Cargo.toml | 0 {crates => packages}/pinakes-tui/src/app.rs | 0 .../pinakes-tui/src/client.rs | 0 {crates => packages}/pinakes-tui/src/event.rs | 0 {crates => packages}/pinakes-tui/src/input.rs | 0 {crates => packages}/pinakes-tui/src/main.rs | 0 .../pinakes-tui/src/ui/admin.rs | 0 .../pinakes-tui/src/ui/audit.rs | 0 .../pinakes-tui/src/ui/books.rs | 0 .../pinakes-tui/src/ui/collections.rs | 0 .../pinakes-tui/src/ui/database.rs | 0 .../pinakes-tui/src/ui/detail.rs | 0 .../pinakes-tui/src/ui/duplicates.rs | 0 .../pinakes-tui/src/ui/import.rs | 0 .../pinakes-tui/src/ui/library.rs | 0 .../pinakes-tui/src/ui/metadata_edit.rs | 0 .../pinakes-tui/src/ui/mod.rs | 0 .../pinakes-tui/src/ui/playlists.rs | 0 .../pinakes-tui/src/ui/queue.rs | 0 .../pinakes-tui/src/ui/search.rs | 0 .../pinakes-tui/src/ui/settings.rs | 0 .../pinakes-tui/src/ui/statistics.rs | 0 .../pinakes-tui/src/ui/tags.rs | 0 .../pinakes-tui/src/ui/tasks.rs | 0 {crates => packages}/pinakes-ui/Cargo.toml | 12 ++-- {crates => packages}/pinakes-ui/Dioxus.toml | 0 .../pinakes-ui/assets/css/main.css | 0 .../pinakes-ui/assets/styles/_audit.scss | 0 .../pinakes-ui/assets/styles/_base.scss | 0 .../pinakes-ui/assets/styles/_components.scss | 0 .../pinakes-ui/assets/styles/_graph.scss | 0 .../pinakes-ui/assets/styles/_layout.scss | 0 .../pinakes-ui/assets/styles/_media.scss | 0 .../pinakes-ui/assets/styles/_mixins.scss | 0 .../pinakes-ui/assets/styles/_plugins.scss | 0 .../pinakes-ui/assets/styles/_sections.scss | 0 .../pinakes-ui/assets/styles/_themes.scss | 0 .../pinakes-ui/assets/styles/_variables.scss | 0 .../pinakes-ui/assets/styles/main.scss | 0 {crates => packages}/pinakes-ui/src/app.rs | 0 {crates => packages}/pinakes-ui/src/client.rs | 0 .../pinakes-ui/src/components/audit.rs | 0 .../src/components/backlinks_panel.rs | 0 .../pinakes-ui/src/components/books.rs | 0 .../pinakes-ui/src/components/breadcrumb.rs | 0 .../pinakes-ui/src/components/collections.rs | 0 .../pinakes-ui/src/components/database.rs | 0 .../pinakes-ui/src/components/detail.rs | 0 .../pinakes-ui/src/components/duplicates.rs | 0 .../pinakes-ui/src/components/graph_view.rs | 0 .../pinakes-ui/src/components/image_viewer.rs | 0 .../pinakes-ui/src/components/import.rs | 0 .../pinakes-ui/src/components/library.rs | 0 .../pinakes-ui/src/components/loading.rs | 0 .../pinakes-ui/src/components/login.rs | 0 .../src/components/markdown_viewer.rs | 0 .../pinakes-ui/src/components/media_player.rs | 0 .../pinakes-ui/src/components/mod.rs | 0 .../pinakes-ui/src/components/pagination.rs | 0 .../pinakes-ui/src/components/pdf_viewer.rs | 0 .../pinakes-ui/src/components/playlists.rs | 0 .../pinakes-ui/src/components/search.rs | 0 .../pinakes-ui/src/components/settings.rs | 0 .../pinakes-ui/src/components/statistics.rs | 0 .../pinakes-ui/src/components/tags.rs | 0 .../pinakes-ui/src/components/tasks.rs | 0 .../pinakes-ui/src/components/utils.rs | 0 {crates => packages}/pinakes-ui/src/main.rs | 0 .../pinakes-ui/src/plugin_ui/actions.rs | 0 .../pinakes-ui/src/plugin_ui/data.rs | 0 .../pinakes-ui/src/plugin_ui/expr.rs | 0 .../pinakes-ui/src/plugin_ui/mod.rs | 0 .../pinakes-ui/src/plugin_ui/registry.rs | 0 .../pinakes-ui/src/plugin_ui/renderer.rs | 0 .../pinakes-ui/src/plugin_ui/widget.rs | 0 {crates => packages}/pinakes-ui/src/state.rs | 0 {crates => packages}/pinakes-ui/src/styles.rs | 0 156 files changed, 57 insertions(+), 56 deletions(-) rename {crates => packages}/pinakes-server/Cargo.toml (96%) rename {crates => packages}/pinakes-server/src/api_doc.rs (100%) rename {crates => packages}/pinakes-server/src/app.rs (100%) rename {crates => packages}/pinakes-server/src/auth.rs (100%) rename {crates => packages}/pinakes-server/src/dto/analytics.rs (100%) rename {crates => packages}/pinakes-server/src/dto/audit.rs (100%) rename {crates => packages}/pinakes-server/src/dto/batch.rs (100%) rename {crates => packages}/pinakes-server/src/dto/collections.rs (100%) rename {crates => packages}/pinakes-server/src/dto/config.rs (100%) rename {crates => packages}/pinakes-server/src/dto/enrichment.rs (100%) rename {crates => packages}/pinakes-server/src/dto/media.rs (100%) rename {crates => packages}/pinakes-server/src/dto/mod.rs (100%) rename {crates => packages}/pinakes-server/src/dto/playlists.rs (100%) rename {crates => packages}/pinakes-server/src/dto/plugins.rs (100%) rename {crates => packages}/pinakes-server/src/dto/scan.rs (100%) rename {crates => packages}/pinakes-server/src/dto/search.rs (100%) rename {crates => packages}/pinakes-server/src/dto/sharing.rs (100%) rename {crates => packages}/pinakes-server/src/dto/social.rs (100%) rename {crates => packages}/pinakes-server/src/dto/statistics.rs (100%) rename {crates => packages}/pinakes-server/src/dto/subtitles.rs (100%) rename {crates => packages}/pinakes-server/src/dto/sync.rs (100%) rename {crates => packages}/pinakes-server/src/dto/tags.rs (100%) rename {crates => packages}/pinakes-server/src/dto/transcode.rs (100%) rename {crates => packages}/pinakes-server/src/dto/users.rs (100%) rename {crates => packages}/pinakes-server/src/error.rs (100%) rename {crates => packages}/pinakes-server/src/lib.rs (100%) rename {crates => packages}/pinakes-server/src/main.rs (100%) rename {crates => packages}/pinakes-server/src/routes/analytics.rs (100%) rename {crates => packages}/pinakes-server/src/routes/audit.rs (100%) rename {crates => packages}/pinakes-server/src/routes/auth.rs (100%) rename {crates => packages}/pinakes-server/src/routes/backup.rs (100%) rename {crates => packages}/pinakes-server/src/routes/books.rs (100%) rename {crates => packages}/pinakes-server/src/routes/collections.rs (100%) rename {crates => packages}/pinakes-server/src/routes/config.rs (100%) rename {crates => packages}/pinakes-server/src/routes/database.rs (100%) rename {crates => packages}/pinakes-server/src/routes/duplicates.rs (100%) rename {crates => packages}/pinakes-server/src/routes/enrichment.rs (100%) rename {crates => packages}/pinakes-server/src/routes/export.rs (100%) rename {crates => packages}/pinakes-server/src/routes/health.rs (100%) rename {crates => packages}/pinakes-server/src/routes/integrity.rs (100%) rename {crates => packages}/pinakes-server/src/routes/jobs.rs (100%) rename {crates => packages}/pinakes-server/src/routes/media.rs (100%) rename {crates => packages}/pinakes-server/src/routes/mod.rs (100%) rename {crates => packages}/pinakes-server/src/routes/notes.rs (100%) rename {crates => packages}/pinakes-server/src/routes/photos.rs (100%) rename {crates => packages}/pinakes-server/src/routes/playlists.rs (100%) rename {crates => packages}/pinakes-server/src/routes/plugins.rs (100%) rename {crates => packages}/pinakes-server/src/routes/saved_searches.rs (100%) rename {crates => packages}/pinakes-server/src/routes/scan.rs (100%) rename {crates => packages}/pinakes-server/src/routes/scheduled_tasks.rs (100%) rename {crates => packages}/pinakes-server/src/routes/search.rs (100%) rename {crates => packages}/pinakes-server/src/routes/shares.rs (100%) rename {crates => packages}/pinakes-server/src/routes/social.rs (100%) rename {crates => packages}/pinakes-server/src/routes/statistics.rs (100%) rename {crates => packages}/pinakes-server/src/routes/streaming.rs (100%) rename {crates => packages}/pinakes-server/src/routes/subtitles.rs (100%) rename {crates => packages}/pinakes-server/src/routes/sync.rs (100%) rename {crates => packages}/pinakes-server/src/routes/tags.rs (100%) rename {crates => packages}/pinakes-server/src/routes/transcode.rs (100%) rename {crates => packages}/pinakes-server/src/routes/upload.rs (100%) rename {crates => packages}/pinakes-server/src/routes/users.rs (100%) rename {crates => packages}/pinakes-server/src/routes/webhooks.rs (100%) rename {crates => packages}/pinakes-server/src/state.rs (100%) rename {crates => packages}/pinakes-server/tests/api.rs (100%) rename {crates => packages}/pinakes-server/tests/books.rs (100%) rename {crates => packages}/pinakes-server/tests/common/mod.rs (100%) rename {crates => packages}/pinakes-server/tests/e2e.rs (100%) rename {crates => packages}/pinakes-server/tests/enrichment.rs (100%) rename {crates => packages}/pinakes-server/tests/media_ops.rs (100%) rename {crates => packages}/pinakes-server/tests/notes.rs (100%) rename {crates => packages}/pinakes-server/tests/plugin.rs (100%) rename {crates => packages}/pinakes-server/tests/shares.rs (100%) rename {crates => packages}/pinakes-server/tests/sync.rs (100%) rename {crates => packages}/pinakes-server/tests/users.rs (100%) rename {crates => packages}/pinakes-server/tests/webhooks.rs (100%) rename {crates => packages}/pinakes-tui/Cargo.toml (100%) rename {crates => packages}/pinakes-tui/src/app.rs (100%) rename {crates => packages}/pinakes-tui/src/client.rs (100%) rename {crates => packages}/pinakes-tui/src/event.rs (100%) rename {crates => packages}/pinakes-tui/src/input.rs (100%) rename {crates => packages}/pinakes-tui/src/main.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/admin.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/audit.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/books.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/collections.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/database.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/detail.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/duplicates.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/import.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/library.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/metadata_edit.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/mod.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/playlists.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/queue.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/search.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/settings.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/statistics.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/tags.rs (100%) rename {crates => packages}/pinakes-tui/src/ui/tasks.rs (100%) rename {crates => packages}/pinakes-ui/Cargo.toml (100%) rename {crates => packages}/pinakes-ui/Dioxus.toml (100%) rename {crates => packages}/pinakes-ui/assets/css/main.css (100%) rename {crates => packages}/pinakes-ui/assets/styles/_audit.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_base.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_components.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_graph.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_layout.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_media.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_mixins.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_plugins.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_sections.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_themes.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/_variables.scss (100%) rename {crates => packages}/pinakes-ui/assets/styles/main.scss (100%) rename {crates => packages}/pinakes-ui/src/app.rs (100%) rename {crates => packages}/pinakes-ui/src/client.rs (100%) rename {crates => packages}/pinakes-ui/src/components/audit.rs (100%) rename {crates => packages}/pinakes-ui/src/components/backlinks_panel.rs (100%) rename {crates => packages}/pinakes-ui/src/components/books.rs (100%) rename {crates => packages}/pinakes-ui/src/components/breadcrumb.rs (100%) rename {crates => packages}/pinakes-ui/src/components/collections.rs (100%) rename {crates => packages}/pinakes-ui/src/components/database.rs (100%) rename {crates => packages}/pinakes-ui/src/components/detail.rs (100%) rename {crates => packages}/pinakes-ui/src/components/duplicates.rs (100%) rename {crates => packages}/pinakes-ui/src/components/graph_view.rs (100%) rename {crates => packages}/pinakes-ui/src/components/image_viewer.rs (100%) rename {crates => packages}/pinakes-ui/src/components/import.rs (100%) rename {crates => packages}/pinakes-ui/src/components/library.rs (100%) rename {crates => packages}/pinakes-ui/src/components/loading.rs (100%) rename {crates => packages}/pinakes-ui/src/components/login.rs (100%) rename {crates => packages}/pinakes-ui/src/components/markdown_viewer.rs (100%) rename {crates => packages}/pinakes-ui/src/components/media_player.rs (100%) rename {crates => packages}/pinakes-ui/src/components/mod.rs (100%) rename {crates => packages}/pinakes-ui/src/components/pagination.rs (100%) rename {crates => packages}/pinakes-ui/src/components/pdf_viewer.rs (100%) rename {crates => packages}/pinakes-ui/src/components/playlists.rs (100%) rename {crates => packages}/pinakes-ui/src/components/search.rs (100%) rename {crates => packages}/pinakes-ui/src/components/settings.rs (100%) rename {crates => packages}/pinakes-ui/src/components/statistics.rs (100%) rename {crates => packages}/pinakes-ui/src/components/tags.rs (100%) rename {crates => packages}/pinakes-ui/src/components/tasks.rs (100%) rename {crates => packages}/pinakes-ui/src/components/utils.rs (100%) rename {crates => packages}/pinakes-ui/src/main.rs (100%) rename {crates => packages}/pinakes-ui/src/plugin_ui/actions.rs (100%) rename {crates => packages}/pinakes-ui/src/plugin_ui/data.rs (100%) rename {crates => packages}/pinakes-ui/src/plugin_ui/expr.rs (100%) rename {crates => packages}/pinakes-ui/src/plugin_ui/mod.rs (100%) rename {crates => packages}/pinakes-ui/src/plugin_ui/registry.rs (100%) rename {crates => packages}/pinakes-ui/src/plugin_ui/renderer.rs (100%) rename {crates => packages}/pinakes-ui/src/plugin_ui/widget.rs (100%) rename {crates => packages}/pinakes-ui/src/state.rs (100%) rename {crates => packages}/pinakes-ui/src/styles.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 622df0c87350a9397623c2a8a04a75291c84fd96..41cca31ebd16b5d289e13063ec8fe7feece93315 100644 GIT binary patch delta 4897 zcmZu#X^fp!8P0c4fi6%wl+Lu!mgz!UD5Ym#fOgQb1b)cSRvSntoHe1)4rQ0bfT0+T zP?nE!WKEGsh*89#xlxQMzYJ<5RWOKQQ$r*QfoNhRh(6!k-Zmuux$Vq+_dVyl=h@!x zoBx{otAEdZ&YWM#8_nj4z?|?!1#PU5_|!fm!=={V>BuOTS$pl1GuE>rtqjT0U{!RI zN+wi$|IwrETjyWe72C3bNv{N@EEKIJ!`A}MJQwAoRfb8LO^U+PC~XPc23{C7nwKbp zqPl+cQZv@RcEMGHjWNb(NY-m1J@diFK&6l>*<_97+9YM7h=m1VlVia!k43GP)P@kf zZogVbpMU$pZOa=`pM0sec|sO0t>7$bZ<0(|rC7KVPH3B*_QDyTL{TDWk4)kFO5qw zTF=oGCo|_o<1^Gtf8U^kJ7%|c@1E6ua*Z9<)j?CLg2#zC6J7dFd} zvJKf3X1t_WM+a&x(}gN8n4G>yP93mkf}7ExE8BtC8{jtpx|X4ln&E~ijqS+CUAs@I zuUtXqw>R(JGpwudcXXRMTt1$aGJ3~6;ztyI@enuDN4mdGO*l2D+;P;iN|ShOJ~?P88t)+J*+mv!V? zGTQFE;V{&s7A5DTlGG7j!A~N1L8ZdBVlh;w#59vmXRH-n1RYBVGI;8O%euY)$^jxy z2O@UfxO2EoFQdv>R(Fh#RQe#J$siiD859oEBpstGOy+3O=}* zVc>e)5ztk#^O_b4R7ovk&N^h}nJJo!(y0Ll?!1rA1qFcs!kBZ6`MPXsK(=nL$F+Cf zvhC1b&$Y_Q6g3WOQP^AVAWoK{5n3t-E(a$>1~?!;ob!}57?MlGIikS^TDEE7^LO2P z>rd*tyUAEx%A0@GzkH>)sII@P8SAeR&4z9DiWhp56FO1A(xl)!x=DwQL;X zwmTIVn<;c&Q*E%@B65@o(iU+mR6qdqbk$$%ZMN4BcyDH1cW<+G2BT-v{+@fAMT_f} zXUOD)Q-*Wv6XR6!oUL%6TC{~L16}|!6w7DtGfT4IcA&5#a1#OMsHm*(yxSY=Z~9)d zYfWX}hw*U3(8HKi@eZK34o3x+yDfUHEP4wRKsa>7gTcfyEvqs(%@{yA(9MfKnp^LB zoQ&4@zT6yJAO8`VRd?J&#`@2^+N?UQF?Evk7T3Rh+I)_%GiiPCRI<3<^9dx&HN83Y z%KtXsJ@m{zhqoe!bZ;fy-5eb|0RD zezx>^bO0h1I#xgpGg- z2EqU}U|DJEvx`D9sBbPt!AoK%#wjSMa-h+6N<7+r_?wCT<12e-tgZTX_^4AHkCdp3 zgM(o=I*MG9jm|0to%bAPhkOR_q`@8oR|@RLbD5TsI$!QzH`zOT4_d$Ut={DmDRB!k z@2tLP@X3aVgkd@a1JuX{&UuMW3k?-7a4a5ya*)Dmg_HtS8?Ad^?;Tf9nL&=KJ74X| zhSiBzdgjQTA9$^IRR7agdml9TQ@7|w7X&oYNI(h4_EB|atfK}qMT`s!OWbe4)kSNZ z(l%0Km4+&cQbarN10RSP0U`#OOjP5y3IgUJQ9g2{5!{@Fwi;+XE=uX@V{c*q<+pl&KDK4wo}7rm z6lLJ5_zD7=Caz6zHxN@`AU#^|8j7@TdbbGk~~-+TR~RU*H=P-4{ZR@MvDYk1S7OD zA}b^hyNzAzi%Ujm1M4%iP(y=ZR51Q%!G&O?cU>&v4mOWK4G2OUuw0if zNBfVhBKw=^Q`R4>BMa+%E9$b*PFon6Ze{lHB{0rF%< z1#^~y%LQx=$Sp_+etMa%tE=0k*B;X@o?6l6!@6QEdbWQhSx}#Qgv_kJJ_}tBYsrOm z?>hYW?rO5SzPy%<40iUPwdC%h!XSJ|gk}T;$CC2`2MsW9L?-xHMmU44l!Ts_jCi3{ zDvsI`1$-ajUQCns_N@@(4eQAJ!=eI#lhVXp#;}0_AaN-b0u-#-TQ7MsxK@Ea!;%EX z6BnS$8R}r5bZ$E04S@6JndFXo_M?M)8>vsMA+u{;Pfi#vWPB{3o9hUHgv26Rhlnb2 zN=_ldV^qK=dCBjxAf4m(zcXV(D*xP~`*IpfVw*K=g2aByg zo6Mg3|1em1D#-5~9*jKba)wbrsJXU~nG%3KKv5wNAlgMt6bLoNLvdi%9Qd5})Oz5K zW=>tr$;Tbyk3NjUCx<6XEaPC^xP>H+@c?$LGx8TNfWf5*H?^2hyk|G)ckBIrwAZ|kVA^R8$m7zQU zl1Ql@3xkpM;L{jwh7BIAw^?%Iu&u^)#>p`984yM!2yle|Q!IzM#AgJbk};k`OS?Pu zP%Webfec9d&JkVo<5Q+M*bw@K!W4qgxIGmcs9p;p=_Nb}j>)Th5sm~th zj0##E+yBmX^1}N1^xb5n{rk+V6CPzcOVHYY{@x)^BTWO}KJe7N7jQPAl~4y*ClB3# zguJGJ15+Rs^__8mee$Qxcz^p|a$-|Q&gr5r(iP=OCPfv<;}DZ_h9g-Ta3G<&*@GhD zS%hH(P5^_JPAfDkR(syjbgVwNsW-o_yN{gI<<_P3_6NwE{&n|}WleqYWU^(0iwQ4Y z;CL{E43`$vYP=vQ4zpfjpe-&XL!0qN$8(oBNBj^O28a;i>N~$1S|7ak_StR;*37wscY9cmh04Zze4zze0a(E!90R)V?!9Qa@~ zUi%=x*?`Oe^jb@c^I%fJXj$wLLEZQexdiq1K15C)wC=#eaQ91=kS+cCE%MLh{{!*1 Bx~>2K delta 4794 zcmZu#X^5TG8Rokuo7u-CGpQyuaU(IBoPDWIW)z|=C>j$KRJ3P>sBw!+LusqQQe3LW z&*sn=tI+(WId^@mU-qLtQKis|#+xh_Z($TG`&&UxQwd*{WE z=im0x{JmxaQEPQO^3gf#6))a!L3Id%CSMo}(HUce6ijoQRgjM9?2Px`$Y7EvnyVy} zbSnA!f#{932R3Z{tRAO^39TZ2>+G{h#z-b9cPV9FWRbx$#bVJmDKEHY(RoQzmfZT7 z>&j_ltbOhDZ+7*nznyFx&ryZUa`qJ0WDMnk309_@xHUe7g zqPxQL-J%PF%CjxkA!R^w}Q64qtZGg{ZDA8w9l7hXQQeeRmEsT*43Or#tgBCY9azcJQV7=mcSyAFKxCt8*eCHGI;xgkCD0US@XsQl^@wQI#pS@WV7K~ z7%p{AiO1lDFo2#_GGdE-+23EkV5)42 zPB|c*D@ijzpFwvnAwLie3kF8T4e%p1c#j5a+_{JdkUGimsrt-^P51uU?HxCyVMV8e z6R5|ybkVUaFjeY}0wsYlpMdxdpWIs}IFnGe;DX3HG4FzIo8Hn#_k3&nZ`yR)%6cMi zKCIKPWvicf2TwhBB!H$3Q^JlQtqVVbH{q6vH(kh8;n3 zutEC5Ar63>Nmi70-R7nBy*rwV>(iUath)KmW^+Bbs+rY)_|9hWviiphacip}d|YN0 z0XP=HZ>OyDb3iC}q12vPlJh~bT|Hb>T<9YdD% z_rBVkzPiyrV!h>c@u6n2-uFsxMcsC&d2;YUiC*1wMYHU&9lae*qx;i)y{qvzzI<6- z_+D>TrPF&0`@bZ;CClr{2OB(DM9Fdq#o|VQI)u&?;jQkZRU4}ya2g{6Aea%5ofwLV zF?-*MyRZHvSkudAF6wVz*_&({-k$pPO%owvG%QnXwUP=h7n##&VX>)D2h9vlXln{^ zW>SvYtCSrVpfIPwr8@G10b-Zb8;T6+)(3vlO!R+yZ12Q1_3g)c=T1-yJqw_V#}rVq6zw2hUPZVD zREh(mfeI6Z1ZR1KrYam6F&aow>ECotZ{y94t}9>fT`(b>3($G^P;{ATsH7DvB0+c# zOi~7MGH?dim7xd$j7j(@NKJt;T}oxMn$ddHE4|aEv$b?Bt!v)IVE_JVZ>$^fvGw1t z_eT5fwcekbh7J$pkt+pTcTO4GLCF;wqeeVoOh*f|1+8BxOp2Un0GL->Lcg^58& zUVgo|y8rHzS%pjUU~rcp6b)24@^Ux;GgJAwxK}XV>dlf48jWx$2%yd z<5|j#gV1&Z6`>UJ!8J8?9hL zP6#>|PZ75e+$2Xf@vshHy0ESf{qG#?UAnfuaWpx1!eL0>MWdsK{rN~497Z~0HHEVW zrXrFNX9z$PgG3sFASpCDTfBk$!5rHad*{@G2$RB1_&NT_Yp)MNoLkL$CLR(ULk(r8+gdF z1c_7$wG_HoX_(>2JRRC)5HM`eR2Sq}WJ#+L8DPcG^tO3zwEyh!M zjI5~h*N|m(at+Y~J*@BF3o&0R(BJ#lkkeax)d}@K<7D0!F0M+91c10>`Q%tC{qiY9Bcq94o=k_ zkH|z8Ur2chSZ+->l5l( z>&SVZ4QixbvW_g6Kl}Q8&9*I9Ua?|)#nEsXDC{JfRTPy4=A@$w;5UGn9iqAvQX{Km zSd7x%(U6k^YBQ1<=a?nK@g>#2Z3M=Eg^|0rPOp0@nKyXK=Jf(Xhl~wrN0RZNRACjz%oW9 zmM5MOGdaw&qs>&|3pM%Sux3QILHe+X2^bF2K7>b8$~$|=hzJGQR$v$a4`a~aq6g+B zVc}jm*F{sbWp;PX!|JY8WKmtX8vi<9k@31u;9oQCXEP9<0V@*rfF!8g!0|&jWULK1 zTqCi<>LKkK?LjvRr`?i^xTrHym-^ximb81Xn^oVjJ01N;ij|X@*_Cn0Lj;!u29awdzhKJWrR+Ab3hXNB2JQ@(5&;*K! z!j3pJ5-x2qJ%jLvjBSBTY!?MCD-!oeoF#kgw=U5y#^Sm4oQuiqy6R+d%9r3&pJw>P zeJ7LihBE?^y5)|h01M2NW*LFXZfZIetVldk5il6#6Osa!Xc$SLAeDlFMOhIU#_FBR zP;}qDWO04s6tZSO_x`kTa`lQ&55dnGtipa^vrOG#Tq7Bm2)T; z;J^X_r%TXE%Pic8VI{;eWU1Hii_ZIt#D_FT-hjK}y%@5L77KdpFy8Jvb8+3elN^Cu zy77K^&{Hj$)8D(3tQ?+!^$`0|Fz{%_yWLoJN(qc*T96I_kBl&?DSj4%<-vnc!2o~o zE2Lv51`PewZ5Pz<-%S?OZ|@;1>o0bZx&4<~a`W&U1aEJA096nvva%^4l3E52$^oWk z!CFY8hb=(zSmfYp!JY}23T(;tQ(@~&{p@ diff --git a/crates/pinakes-core/Cargo.toml b/crates/pinakes-core/Cargo.toml index c4ba043..51b1dab 100644 --- a/crates/pinakes-core/Cargo.toml +++ b/crates/pinakes-core/Cargo.toml @@ -4,6 +4,9 @@ edition.workspace = true version.workspace = true license.workspace = true +[features] +ffmpeg-tests = [] + [dependencies] tokio = { workspace = true } serde = { workspace = true } @@ -43,18 +46,13 @@ moka = { workspace = true } urlencoding = { workspace = true } image_hasher = { workspace = true } rustc-hash = { workspace = true } - -# Plugin system -pinakes-plugin-api.workspace = true -wasmtime.workspace = true -ed25519-dalek.workspace = true - -[features] -ffmpeg-tests = [] - -[lints] -workspace = true +pinakes-plugin-api = { workspace = true } +wasmtime = { workspace = true } +ed25519-dalek = { workspace = true } [dev-dependencies] tempfile = { workspace = true } rand = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pinakes-plugin-api/Cargo.toml b/crates/pinakes-plugin-api/Cargo.toml index 51a6686..b8e8f49 100644 --- a/crates/pinakes-plugin-api/Cargo.toml +++ b/crates/pinakes-plugin-api/Cargo.toml @@ -4,32 +4,25 @@ version.workspace = true edition.workspace = true license.workspace = true +[features] +default = [] +wasm = ["wit-bindgen"] + [dependencies] -# Core dependencies serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } tracing = { workspace = true } - -# For plugin manifest parsing toml = { workspace = true } - -# For media types and identifiers uuid = { workspace = true } chrono = { workspace = true } mime_guess = { workspace = true } rustc-hash = { workspace = true } - -# WASM bridge types wit-bindgen = { workspace = true, optional = true } -[lints] -workspace = true - -[features] -default = [] -wasm = ["wit-bindgen"] - [dev-dependencies] tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] } + +[lints] +workspace = true diff --git a/crates/pinakes-server/Cargo.toml b/packages/pinakes-server/Cargo.toml similarity index 96% rename from crates/pinakes-server/Cargo.toml rename to packages/pinakes-server/Cargo.toml index 14e329e..aacccdf 100644 --- a/crates/pinakes-server/Cargo.toml +++ b/packages/pinakes-server/Cargo.toml @@ -36,10 +36,10 @@ utoipa = { workspace = true } utoipa-axum = { workspace = true } utoipa-swagger-ui = { workspace = true } -[lints] -workspace = true - [dev-dependencies] -http-body-util = "0.1.3" +http-body-util = { workspace = true } reqwest = { workspace = true } tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pinakes-server/src/api_doc.rs b/packages/pinakes-server/src/api_doc.rs similarity index 100% rename from crates/pinakes-server/src/api_doc.rs rename to packages/pinakes-server/src/api_doc.rs diff --git a/crates/pinakes-server/src/app.rs b/packages/pinakes-server/src/app.rs similarity index 100% rename from crates/pinakes-server/src/app.rs rename to packages/pinakes-server/src/app.rs diff --git a/crates/pinakes-server/src/auth.rs b/packages/pinakes-server/src/auth.rs similarity index 100% rename from crates/pinakes-server/src/auth.rs rename to packages/pinakes-server/src/auth.rs diff --git a/crates/pinakes-server/src/dto/analytics.rs b/packages/pinakes-server/src/dto/analytics.rs similarity index 100% rename from crates/pinakes-server/src/dto/analytics.rs rename to packages/pinakes-server/src/dto/analytics.rs diff --git a/crates/pinakes-server/src/dto/audit.rs b/packages/pinakes-server/src/dto/audit.rs similarity index 100% rename from crates/pinakes-server/src/dto/audit.rs rename to packages/pinakes-server/src/dto/audit.rs diff --git a/crates/pinakes-server/src/dto/batch.rs b/packages/pinakes-server/src/dto/batch.rs similarity index 100% rename from crates/pinakes-server/src/dto/batch.rs rename to packages/pinakes-server/src/dto/batch.rs diff --git a/crates/pinakes-server/src/dto/collections.rs b/packages/pinakes-server/src/dto/collections.rs similarity index 100% rename from crates/pinakes-server/src/dto/collections.rs rename to packages/pinakes-server/src/dto/collections.rs diff --git a/crates/pinakes-server/src/dto/config.rs b/packages/pinakes-server/src/dto/config.rs similarity index 100% rename from crates/pinakes-server/src/dto/config.rs rename to packages/pinakes-server/src/dto/config.rs diff --git a/crates/pinakes-server/src/dto/enrichment.rs b/packages/pinakes-server/src/dto/enrichment.rs similarity index 100% rename from crates/pinakes-server/src/dto/enrichment.rs rename to packages/pinakes-server/src/dto/enrichment.rs diff --git a/crates/pinakes-server/src/dto/media.rs b/packages/pinakes-server/src/dto/media.rs similarity index 100% rename from crates/pinakes-server/src/dto/media.rs rename to packages/pinakes-server/src/dto/media.rs diff --git a/crates/pinakes-server/src/dto/mod.rs b/packages/pinakes-server/src/dto/mod.rs similarity index 100% rename from crates/pinakes-server/src/dto/mod.rs rename to packages/pinakes-server/src/dto/mod.rs diff --git a/crates/pinakes-server/src/dto/playlists.rs b/packages/pinakes-server/src/dto/playlists.rs similarity index 100% rename from crates/pinakes-server/src/dto/playlists.rs rename to packages/pinakes-server/src/dto/playlists.rs diff --git a/crates/pinakes-server/src/dto/plugins.rs b/packages/pinakes-server/src/dto/plugins.rs similarity index 100% rename from crates/pinakes-server/src/dto/plugins.rs rename to packages/pinakes-server/src/dto/plugins.rs diff --git a/crates/pinakes-server/src/dto/scan.rs b/packages/pinakes-server/src/dto/scan.rs similarity index 100% rename from crates/pinakes-server/src/dto/scan.rs rename to packages/pinakes-server/src/dto/scan.rs diff --git a/crates/pinakes-server/src/dto/search.rs b/packages/pinakes-server/src/dto/search.rs similarity index 100% rename from crates/pinakes-server/src/dto/search.rs rename to packages/pinakes-server/src/dto/search.rs diff --git a/crates/pinakes-server/src/dto/sharing.rs b/packages/pinakes-server/src/dto/sharing.rs similarity index 100% rename from crates/pinakes-server/src/dto/sharing.rs rename to packages/pinakes-server/src/dto/sharing.rs diff --git a/crates/pinakes-server/src/dto/social.rs b/packages/pinakes-server/src/dto/social.rs similarity index 100% rename from crates/pinakes-server/src/dto/social.rs rename to packages/pinakes-server/src/dto/social.rs diff --git a/crates/pinakes-server/src/dto/statistics.rs b/packages/pinakes-server/src/dto/statistics.rs similarity index 100% rename from crates/pinakes-server/src/dto/statistics.rs rename to packages/pinakes-server/src/dto/statistics.rs diff --git a/crates/pinakes-server/src/dto/subtitles.rs b/packages/pinakes-server/src/dto/subtitles.rs similarity index 100% rename from crates/pinakes-server/src/dto/subtitles.rs rename to packages/pinakes-server/src/dto/subtitles.rs diff --git a/crates/pinakes-server/src/dto/sync.rs b/packages/pinakes-server/src/dto/sync.rs similarity index 100% rename from crates/pinakes-server/src/dto/sync.rs rename to packages/pinakes-server/src/dto/sync.rs diff --git a/crates/pinakes-server/src/dto/tags.rs b/packages/pinakes-server/src/dto/tags.rs similarity index 100% rename from crates/pinakes-server/src/dto/tags.rs rename to packages/pinakes-server/src/dto/tags.rs diff --git a/crates/pinakes-server/src/dto/transcode.rs b/packages/pinakes-server/src/dto/transcode.rs similarity index 100% rename from crates/pinakes-server/src/dto/transcode.rs rename to packages/pinakes-server/src/dto/transcode.rs diff --git a/crates/pinakes-server/src/dto/users.rs b/packages/pinakes-server/src/dto/users.rs similarity index 100% rename from crates/pinakes-server/src/dto/users.rs rename to packages/pinakes-server/src/dto/users.rs diff --git a/crates/pinakes-server/src/error.rs b/packages/pinakes-server/src/error.rs similarity index 100% rename from crates/pinakes-server/src/error.rs rename to packages/pinakes-server/src/error.rs diff --git a/crates/pinakes-server/src/lib.rs b/packages/pinakes-server/src/lib.rs similarity index 100% rename from crates/pinakes-server/src/lib.rs rename to packages/pinakes-server/src/lib.rs diff --git a/crates/pinakes-server/src/main.rs b/packages/pinakes-server/src/main.rs similarity index 100% rename from crates/pinakes-server/src/main.rs rename to packages/pinakes-server/src/main.rs diff --git a/crates/pinakes-server/src/routes/analytics.rs b/packages/pinakes-server/src/routes/analytics.rs similarity index 100% rename from crates/pinakes-server/src/routes/analytics.rs rename to packages/pinakes-server/src/routes/analytics.rs diff --git a/crates/pinakes-server/src/routes/audit.rs b/packages/pinakes-server/src/routes/audit.rs similarity index 100% rename from crates/pinakes-server/src/routes/audit.rs rename to packages/pinakes-server/src/routes/audit.rs diff --git a/crates/pinakes-server/src/routes/auth.rs b/packages/pinakes-server/src/routes/auth.rs similarity index 100% rename from crates/pinakes-server/src/routes/auth.rs rename to packages/pinakes-server/src/routes/auth.rs diff --git a/crates/pinakes-server/src/routes/backup.rs b/packages/pinakes-server/src/routes/backup.rs similarity index 100% rename from crates/pinakes-server/src/routes/backup.rs rename to packages/pinakes-server/src/routes/backup.rs diff --git a/crates/pinakes-server/src/routes/books.rs b/packages/pinakes-server/src/routes/books.rs similarity index 100% rename from crates/pinakes-server/src/routes/books.rs rename to packages/pinakes-server/src/routes/books.rs diff --git a/crates/pinakes-server/src/routes/collections.rs b/packages/pinakes-server/src/routes/collections.rs similarity index 100% rename from crates/pinakes-server/src/routes/collections.rs rename to packages/pinakes-server/src/routes/collections.rs diff --git a/crates/pinakes-server/src/routes/config.rs b/packages/pinakes-server/src/routes/config.rs similarity index 100% rename from crates/pinakes-server/src/routes/config.rs rename to packages/pinakes-server/src/routes/config.rs diff --git a/crates/pinakes-server/src/routes/database.rs b/packages/pinakes-server/src/routes/database.rs similarity index 100% rename from crates/pinakes-server/src/routes/database.rs rename to packages/pinakes-server/src/routes/database.rs diff --git a/crates/pinakes-server/src/routes/duplicates.rs b/packages/pinakes-server/src/routes/duplicates.rs similarity index 100% rename from crates/pinakes-server/src/routes/duplicates.rs rename to packages/pinakes-server/src/routes/duplicates.rs diff --git a/crates/pinakes-server/src/routes/enrichment.rs b/packages/pinakes-server/src/routes/enrichment.rs similarity index 100% rename from crates/pinakes-server/src/routes/enrichment.rs rename to packages/pinakes-server/src/routes/enrichment.rs diff --git a/crates/pinakes-server/src/routes/export.rs b/packages/pinakes-server/src/routes/export.rs similarity index 100% rename from crates/pinakes-server/src/routes/export.rs rename to packages/pinakes-server/src/routes/export.rs diff --git a/crates/pinakes-server/src/routes/health.rs b/packages/pinakes-server/src/routes/health.rs similarity index 100% rename from crates/pinakes-server/src/routes/health.rs rename to packages/pinakes-server/src/routes/health.rs diff --git a/crates/pinakes-server/src/routes/integrity.rs b/packages/pinakes-server/src/routes/integrity.rs similarity index 100% rename from crates/pinakes-server/src/routes/integrity.rs rename to packages/pinakes-server/src/routes/integrity.rs diff --git a/crates/pinakes-server/src/routes/jobs.rs b/packages/pinakes-server/src/routes/jobs.rs similarity index 100% rename from crates/pinakes-server/src/routes/jobs.rs rename to packages/pinakes-server/src/routes/jobs.rs diff --git a/crates/pinakes-server/src/routes/media.rs b/packages/pinakes-server/src/routes/media.rs similarity index 100% rename from crates/pinakes-server/src/routes/media.rs rename to packages/pinakes-server/src/routes/media.rs diff --git a/crates/pinakes-server/src/routes/mod.rs b/packages/pinakes-server/src/routes/mod.rs similarity index 100% rename from crates/pinakes-server/src/routes/mod.rs rename to packages/pinakes-server/src/routes/mod.rs diff --git a/crates/pinakes-server/src/routes/notes.rs b/packages/pinakes-server/src/routes/notes.rs similarity index 100% rename from crates/pinakes-server/src/routes/notes.rs rename to packages/pinakes-server/src/routes/notes.rs diff --git a/crates/pinakes-server/src/routes/photos.rs b/packages/pinakes-server/src/routes/photos.rs similarity index 100% rename from crates/pinakes-server/src/routes/photos.rs rename to packages/pinakes-server/src/routes/photos.rs diff --git a/crates/pinakes-server/src/routes/playlists.rs b/packages/pinakes-server/src/routes/playlists.rs similarity index 100% rename from crates/pinakes-server/src/routes/playlists.rs rename to packages/pinakes-server/src/routes/playlists.rs diff --git a/crates/pinakes-server/src/routes/plugins.rs b/packages/pinakes-server/src/routes/plugins.rs similarity index 100% rename from crates/pinakes-server/src/routes/plugins.rs rename to packages/pinakes-server/src/routes/plugins.rs diff --git a/crates/pinakes-server/src/routes/saved_searches.rs b/packages/pinakes-server/src/routes/saved_searches.rs similarity index 100% rename from crates/pinakes-server/src/routes/saved_searches.rs rename to packages/pinakes-server/src/routes/saved_searches.rs diff --git a/crates/pinakes-server/src/routes/scan.rs b/packages/pinakes-server/src/routes/scan.rs similarity index 100% rename from crates/pinakes-server/src/routes/scan.rs rename to packages/pinakes-server/src/routes/scan.rs diff --git a/crates/pinakes-server/src/routes/scheduled_tasks.rs b/packages/pinakes-server/src/routes/scheduled_tasks.rs similarity index 100% rename from crates/pinakes-server/src/routes/scheduled_tasks.rs rename to packages/pinakes-server/src/routes/scheduled_tasks.rs diff --git a/crates/pinakes-server/src/routes/search.rs b/packages/pinakes-server/src/routes/search.rs similarity index 100% rename from crates/pinakes-server/src/routes/search.rs rename to packages/pinakes-server/src/routes/search.rs diff --git a/crates/pinakes-server/src/routes/shares.rs b/packages/pinakes-server/src/routes/shares.rs similarity index 100% rename from crates/pinakes-server/src/routes/shares.rs rename to packages/pinakes-server/src/routes/shares.rs diff --git a/crates/pinakes-server/src/routes/social.rs b/packages/pinakes-server/src/routes/social.rs similarity index 100% rename from crates/pinakes-server/src/routes/social.rs rename to packages/pinakes-server/src/routes/social.rs diff --git a/crates/pinakes-server/src/routes/statistics.rs b/packages/pinakes-server/src/routes/statistics.rs similarity index 100% rename from crates/pinakes-server/src/routes/statistics.rs rename to packages/pinakes-server/src/routes/statistics.rs diff --git a/crates/pinakes-server/src/routes/streaming.rs b/packages/pinakes-server/src/routes/streaming.rs similarity index 100% rename from crates/pinakes-server/src/routes/streaming.rs rename to packages/pinakes-server/src/routes/streaming.rs diff --git a/crates/pinakes-server/src/routes/subtitles.rs b/packages/pinakes-server/src/routes/subtitles.rs similarity index 100% rename from crates/pinakes-server/src/routes/subtitles.rs rename to packages/pinakes-server/src/routes/subtitles.rs diff --git a/crates/pinakes-server/src/routes/sync.rs b/packages/pinakes-server/src/routes/sync.rs similarity index 100% rename from crates/pinakes-server/src/routes/sync.rs rename to packages/pinakes-server/src/routes/sync.rs diff --git a/crates/pinakes-server/src/routes/tags.rs b/packages/pinakes-server/src/routes/tags.rs similarity index 100% rename from crates/pinakes-server/src/routes/tags.rs rename to packages/pinakes-server/src/routes/tags.rs diff --git a/crates/pinakes-server/src/routes/transcode.rs b/packages/pinakes-server/src/routes/transcode.rs similarity index 100% rename from crates/pinakes-server/src/routes/transcode.rs rename to packages/pinakes-server/src/routes/transcode.rs diff --git a/crates/pinakes-server/src/routes/upload.rs b/packages/pinakes-server/src/routes/upload.rs similarity index 100% rename from crates/pinakes-server/src/routes/upload.rs rename to packages/pinakes-server/src/routes/upload.rs diff --git a/crates/pinakes-server/src/routes/users.rs b/packages/pinakes-server/src/routes/users.rs similarity index 100% rename from crates/pinakes-server/src/routes/users.rs rename to packages/pinakes-server/src/routes/users.rs diff --git a/crates/pinakes-server/src/routes/webhooks.rs b/packages/pinakes-server/src/routes/webhooks.rs similarity index 100% rename from crates/pinakes-server/src/routes/webhooks.rs rename to packages/pinakes-server/src/routes/webhooks.rs diff --git a/crates/pinakes-server/src/state.rs b/packages/pinakes-server/src/state.rs similarity index 100% rename from crates/pinakes-server/src/state.rs rename to packages/pinakes-server/src/state.rs diff --git a/crates/pinakes-server/tests/api.rs b/packages/pinakes-server/tests/api.rs similarity index 100% rename from crates/pinakes-server/tests/api.rs rename to packages/pinakes-server/tests/api.rs diff --git a/crates/pinakes-server/tests/books.rs b/packages/pinakes-server/tests/books.rs similarity index 100% rename from crates/pinakes-server/tests/books.rs rename to packages/pinakes-server/tests/books.rs diff --git a/crates/pinakes-server/tests/common/mod.rs b/packages/pinakes-server/tests/common/mod.rs similarity index 100% rename from crates/pinakes-server/tests/common/mod.rs rename to packages/pinakes-server/tests/common/mod.rs diff --git a/crates/pinakes-server/tests/e2e.rs b/packages/pinakes-server/tests/e2e.rs similarity index 100% rename from crates/pinakes-server/tests/e2e.rs rename to packages/pinakes-server/tests/e2e.rs diff --git a/crates/pinakes-server/tests/enrichment.rs b/packages/pinakes-server/tests/enrichment.rs similarity index 100% rename from crates/pinakes-server/tests/enrichment.rs rename to packages/pinakes-server/tests/enrichment.rs diff --git a/crates/pinakes-server/tests/media_ops.rs b/packages/pinakes-server/tests/media_ops.rs similarity index 100% rename from crates/pinakes-server/tests/media_ops.rs rename to packages/pinakes-server/tests/media_ops.rs diff --git a/crates/pinakes-server/tests/notes.rs b/packages/pinakes-server/tests/notes.rs similarity index 100% rename from crates/pinakes-server/tests/notes.rs rename to packages/pinakes-server/tests/notes.rs diff --git a/crates/pinakes-server/tests/plugin.rs b/packages/pinakes-server/tests/plugin.rs similarity index 100% rename from crates/pinakes-server/tests/plugin.rs rename to packages/pinakes-server/tests/plugin.rs diff --git a/crates/pinakes-server/tests/shares.rs b/packages/pinakes-server/tests/shares.rs similarity index 100% rename from crates/pinakes-server/tests/shares.rs rename to packages/pinakes-server/tests/shares.rs diff --git a/crates/pinakes-server/tests/sync.rs b/packages/pinakes-server/tests/sync.rs similarity index 100% rename from crates/pinakes-server/tests/sync.rs rename to packages/pinakes-server/tests/sync.rs diff --git a/crates/pinakes-server/tests/users.rs b/packages/pinakes-server/tests/users.rs similarity index 100% rename from crates/pinakes-server/tests/users.rs rename to packages/pinakes-server/tests/users.rs diff --git a/crates/pinakes-server/tests/webhooks.rs b/packages/pinakes-server/tests/webhooks.rs similarity index 100% rename from crates/pinakes-server/tests/webhooks.rs rename to packages/pinakes-server/tests/webhooks.rs diff --git a/crates/pinakes-tui/Cargo.toml b/packages/pinakes-tui/Cargo.toml similarity index 100% rename from crates/pinakes-tui/Cargo.toml rename to packages/pinakes-tui/Cargo.toml diff --git a/crates/pinakes-tui/src/app.rs b/packages/pinakes-tui/src/app.rs similarity index 100% rename from crates/pinakes-tui/src/app.rs rename to packages/pinakes-tui/src/app.rs diff --git a/crates/pinakes-tui/src/client.rs b/packages/pinakes-tui/src/client.rs similarity index 100% rename from crates/pinakes-tui/src/client.rs rename to packages/pinakes-tui/src/client.rs diff --git a/crates/pinakes-tui/src/event.rs b/packages/pinakes-tui/src/event.rs similarity index 100% rename from crates/pinakes-tui/src/event.rs rename to packages/pinakes-tui/src/event.rs diff --git a/crates/pinakes-tui/src/input.rs b/packages/pinakes-tui/src/input.rs similarity index 100% rename from crates/pinakes-tui/src/input.rs rename to packages/pinakes-tui/src/input.rs diff --git a/crates/pinakes-tui/src/main.rs b/packages/pinakes-tui/src/main.rs similarity index 100% rename from crates/pinakes-tui/src/main.rs rename to packages/pinakes-tui/src/main.rs diff --git a/crates/pinakes-tui/src/ui/admin.rs b/packages/pinakes-tui/src/ui/admin.rs similarity index 100% rename from crates/pinakes-tui/src/ui/admin.rs rename to packages/pinakes-tui/src/ui/admin.rs diff --git a/crates/pinakes-tui/src/ui/audit.rs b/packages/pinakes-tui/src/ui/audit.rs similarity index 100% rename from crates/pinakes-tui/src/ui/audit.rs rename to packages/pinakes-tui/src/ui/audit.rs diff --git a/crates/pinakes-tui/src/ui/books.rs b/packages/pinakes-tui/src/ui/books.rs similarity index 100% rename from crates/pinakes-tui/src/ui/books.rs rename to packages/pinakes-tui/src/ui/books.rs diff --git a/crates/pinakes-tui/src/ui/collections.rs b/packages/pinakes-tui/src/ui/collections.rs similarity index 100% rename from crates/pinakes-tui/src/ui/collections.rs rename to packages/pinakes-tui/src/ui/collections.rs diff --git a/crates/pinakes-tui/src/ui/database.rs b/packages/pinakes-tui/src/ui/database.rs similarity index 100% rename from crates/pinakes-tui/src/ui/database.rs rename to packages/pinakes-tui/src/ui/database.rs diff --git a/crates/pinakes-tui/src/ui/detail.rs b/packages/pinakes-tui/src/ui/detail.rs similarity index 100% rename from crates/pinakes-tui/src/ui/detail.rs rename to packages/pinakes-tui/src/ui/detail.rs diff --git a/crates/pinakes-tui/src/ui/duplicates.rs b/packages/pinakes-tui/src/ui/duplicates.rs similarity index 100% rename from crates/pinakes-tui/src/ui/duplicates.rs rename to packages/pinakes-tui/src/ui/duplicates.rs diff --git a/crates/pinakes-tui/src/ui/import.rs b/packages/pinakes-tui/src/ui/import.rs similarity index 100% rename from crates/pinakes-tui/src/ui/import.rs rename to packages/pinakes-tui/src/ui/import.rs diff --git a/crates/pinakes-tui/src/ui/library.rs b/packages/pinakes-tui/src/ui/library.rs similarity index 100% rename from crates/pinakes-tui/src/ui/library.rs rename to packages/pinakes-tui/src/ui/library.rs diff --git a/crates/pinakes-tui/src/ui/metadata_edit.rs b/packages/pinakes-tui/src/ui/metadata_edit.rs similarity index 100% rename from crates/pinakes-tui/src/ui/metadata_edit.rs rename to packages/pinakes-tui/src/ui/metadata_edit.rs diff --git a/crates/pinakes-tui/src/ui/mod.rs b/packages/pinakes-tui/src/ui/mod.rs similarity index 100% rename from crates/pinakes-tui/src/ui/mod.rs rename to packages/pinakes-tui/src/ui/mod.rs diff --git a/crates/pinakes-tui/src/ui/playlists.rs b/packages/pinakes-tui/src/ui/playlists.rs similarity index 100% rename from crates/pinakes-tui/src/ui/playlists.rs rename to packages/pinakes-tui/src/ui/playlists.rs diff --git a/crates/pinakes-tui/src/ui/queue.rs b/packages/pinakes-tui/src/ui/queue.rs similarity index 100% rename from crates/pinakes-tui/src/ui/queue.rs rename to packages/pinakes-tui/src/ui/queue.rs diff --git a/crates/pinakes-tui/src/ui/search.rs b/packages/pinakes-tui/src/ui/search.rs similarity index 100% rename from crates/pinakes-tui/src/ui/search.rs rename to packages/pinakes-tui/src/ui/search.rs diff --git a/crates/pinakes-tui/src/ui/settings.rs b/packages/pinakes-tui/src/ui/settings.rs similarity index 100% rename from crates/pinakes-tui/src/ui/settings.rs rename to packages/pinakes-tui/src/ui/settings.rs diff --git a/crates/pinakes-tui/src/ui/statistics.rs b/packages/pinakes-tui/src/ui/statistics.rs similarity index 100% rename from crates/pinakes-tui/src/ui/statistics.rs rename to packages/pinakes-tui/src/ui/statistics.rs diff --git a/crates/pinakes-tui/src/ui/tags.rs b/packages/pinakes-tui/src/ui/tags.rs similarity index 100% rename from crates/pinakes-tui/src/ui/tags.rs rename to packages/pinakes-tui/src/ui/tags.rs diff --git a/crates/pinakes-tui/src/ui/tasks.rs b/packages/pinakes-tui/src/ui/tasks.rs similarity index 100% rename from crates/pinakes-tui/src/ui/tasks.rs rename to packages/pinakes-tui/src/ui/tasks.rs diff --git a/crates/pinakes-ui/Cargo.toml b/packages/pinakes-ui/Cargo.toml similarity index 100% rename from crates/pinakes-ui/Cargo.toml rename to packages/pinakes-ui/Cargo.toml index 6c52e77..1142850 100644 --- a/crates/pinakes-ui/Cargo.toml +++ b/packages/pinakes-ui/Cargo.toml @@ -4,6 +4,12 @@ edition.workspace = true version.workspace = true license.workspace = true +[features] +default = ["web"] +web = ["dioxus/web"] +desktop = ["dioxus/desktop"] +mobile = ["dioxus/mobile"] + [dependencies] serde = { workspace = true } serde_json = { workspace = true } @@ -32,9 +38,3 @@ rustc-hash = { workspace = true } [lints] workspace = true - -[features] -default = ["web"] -web = ["dioxus/web"] -desktop = ["dioxus/desktop"] -mobile = ["dioxus/mobile"] diff --git a/crates/pinakes-ui/Dioxus.toml b/packages/pinakes-ui/Dioxus.toml similarity index 100% rename from crates/pinakes-ui/Dioxus.toml rename to packages/pinakes-ui/Dioxus.toml diff --git a/crates/pinakes-ui/assets/css/main.css b/packages/pinakes-ui/assets/css/main.css similarity index 100% rename from crates/pinakes-ui/assets/css/main.css rename to packages/pinakes-ui/assets/css/main.css diff --git a/crates/pinakes-ui/assets/styles/_audit.scss b/packages/pinakes-ui/assets/styles/_audit.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_audit.scss rename to packages/pinakes-ui/assets/styles/_audit.scss diff --git a/crates/pinakes-ui/assets/styles/_base.scss b/packages/pinakes-ui/assets/styles/_base.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_base.scss rename to packages/pinakes-ui/assets/styles/_base.scss diff --git a/crates/pinakes-ui/assets/styles/_components.scss b/packages/pinakes-ui/assets/styles/_components.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_components.scss rename to packages/pinakes-ui/assets/styles/_components.scss diff --git a/crates/pinakes-ui/assets/styles/_graph.scss b/packages/pinakes-ui/assets/styles/_graph.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_graph.scss rename to packages/pinakes-ui/assets/styles/_graph.scss diff --git a/crates/pinakes-ui/assets/styles/_layout.scss b/packages/pinakes-ui/assets/styles/_layout.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_layout.scss rename to packages/pinakes-ui/assets/styles/_layout.scss diff --git a/crates/pinakes-ui/assets/styles/_media.scss b/packages/pinakes-ui/assets/styles/_media.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_media.scss rename to packages/pinakes-ui/assets/styles/_media.scss diff --git a/crates/pinakes-ui/assets/styles/_mixins.scss b/packages/pinakes-ui/assets/styles/_mixins.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_mixins.scss rename to packages/pinakes-ui/assets/styles/_mixins.scss diff --git a/crates/pinakes-ui/assets/styles/_plugins.scss b/packages/pinakes-ui/assets/styles/_plugins.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_plugins.scss rename to packages/pinakes-ui/assets/styles/_plugins.scss diff --git a/crates/pinakes-ui/assets/styles/_sections.scss b/packages/pinakes-ui/assets/styles/_sections.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_sections.scss rename to packages/pinakes-ui/assets/styles/_sections.scss diff --git a/crates/pinakes-ui/assets/styles/_themes.scss b/packages/pinakes-ui/assets/styles/_themes.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_themes.scss rename to packages/pinakes-ui/assets/styles/_themes.scss diff --git a/crates/pinakes-ui/assets/styles/_variables.scss b/packages/pinakes-ui/assets/styles/_variables.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/_variables.scss rename to packages/pinakes-ui/assets/styles/_variables.scss diff --git a/crates/pinakes-ui/assets/styles/main.scss b/packages/pinakes-ui/assets/styles/main.scss similarity index 100% rename from crates/pinakes-ui/assets/styles/main.scss rename to packages/pinakes-ui/assets/styles/main.scss diff --git a/crates/pinakes-ui/src/app.rs b/packages/pinakes-ui/src/app.rs similarity index 100% rename from crates/pinakes-ui/src/app.rs rename to packages/pinakes-ui/src/app.rs diff --git a/crates/pinakes-ui/src/client.rs b/packages/pinakes-ui/src/client.rs similarity index 100% rename from crates/pinakes-ui/src/client.rs rename to packages/pinakes-ui/src/client.rs diff --git a/crates/pinakes-ui/src/components/audit.rs b/packages/pinakes-ui/src/components/audit.rs similarity index 100% rename from crates/pinakes-ui/src/components/audit.rs rename to packages/pinakes-ui/src/components/audit.rs diff --git a/crates/pinakes-ui/src/components/backlinks_panel.rs b/packages/pinakes-ui/src/components/backlinks_panel.rs similarity index 100% rename from crates/pinakes-ui/src/components/backlinks_panel.rs rename to packages/pinakes-ui/src/components/backlinks_panel.rs diff --git a/crates/pinakes-ui/src/components/books.rs b/packages/pinakes-ui/src/components/books.rs similarity index 100% rename from crates/pinakes-ui/src/components/books.rs rename to packages/pinakes-ui/src/components/books.rs diff --git a/crates/pinakes-ui/src/components/breadcrumb.rs b/packages/pinakes-ui/src/components/breadcrumb.rs similarity index 100% rename from crates/pinakes-ui/src/components/breadcrumb.rs rename to packages/pinakes-ui/src/components/breadcrumb.rs diff --git a/crates/pinakes-ui/src/components/collections.rs b/packages/pinakes-ui/src/components/collections.rs similarity index 100% rename from crates/pinakes-ui/src/components/collections.rs rename to packages/pinakes-ui/src/components/collections.rs diff --git a/crates/pinakes-ui/src/components/database.rs b/packages/pinakes-ui/src/components/database.rs similarity index 100% rename from crates/pinakes-ui/src/components/database.rs rename to packages/pinakes-ui/src/components/database.rs diff --git a/crates/pinakes-ui/src/components/detail.rs b/packages/pinakes-ui/src/components/detail.rs similarity index 100% rename from crates/pinakes-ui/src/components/detail.rs rename to packages/pinakes-ui/src/components/detail.rs diff --git a/crates/pinakes-ui/src/components/duplicates.rs b/packages/pinakes-ui/src/components/duplicates.rs similarity index 100% rename from crates/pinakes-ui/src/components/duplicates.rs rename to packages/pinakes-ui/src/components/duplicates.rs diff --git a/crates/pinakes-ui/src/components/graph_view.rs b/packages/pinakes-ui/src/components/graph_view.rs similarity index 100% rename from crates/pinakes-ui/src/components/graph_view.rs rename to packages/pinakes-ui/src/components/graph_view.rs diff --git a/crates/pinakes-ui/src/components/image_viewer.rs b/packages/pinakes-ui/src/components/image_viewer.rs similarity index 100% rename from crates/pinakes-ui/src/components/image_viewer.rs rename to packages/pinakes-ui/src/components/image_viewer.rs diff --git a/crates/pinakes-ui/src/components/import.rs b/packages/pinakes-ui/src/components/import.rs similarity index 100% rename from crates/pinakes-ui/src/components/import.rs rename to packages/pinakes-ui/src/components/import.rs diff --git a/crates/pinakes-ui/src/components/library.rs b/packages/pinakes-ui/src/components/library.rs similarity index 100% rename from crates/pinakes-ui/src/components/library.rs rename to packages/pinakes-ui/src/components/library.rs diff --git a/crates/pinakes-ui/src/components/loading.rs b/packages/pinakes-ui/src/components/loading.rs similarity index 100% rename from crates/pinakes-ui/src/components/loading.rs rename to packages/pinakes-ui/src/components/loading.rs diff --git a/crates/pinakes-ui/src/components/login.rs b/packages/pinakes-ui/src/components/login.rs similarity index 100% rename from crates/pinakes-ui/src/components/login.rs rename to packages/pinakes-ui/src/components/login.rs diff --git a/crates/pinakes-ui/src/components/markdown_viewer.rs b/packages/pinakes-ui/src/components/markdown_viewer.rs similarity index 100% rename from crates/pinakes-ui/src/components/markdown_viewer.rs rename to packages/pinakes-ui/src/components/markdown_viewer.rs diff --git a/crates/pinakes-ui/src/components/media_player.rs b/packages/pinakes-ui/src/components/media_player.rs similarity index 100% rename from crates/pinakes-ui/src/components/media_player.rs rename to packages/pinakes-ui/src/components/media_player.rs diff --git a/crates/pinakes-ui/src/components/mod.rs b/packages/pinakes-ui/src/components/mod.rs similarity index 100% rename from crates/pinakes-ui/src/components/mod.rs rename to packages/pinakes-ui/src/components/mod.rs diff --git a/crates/pinakes-ui/src/components/pagination.rs b/packages/pinakes-ui/src/components/pagination.rs similarity index 100% rename from crates/pinakes-ui/src/components/pagination.rs rename to packages/pinakes-ui/src/components/pagination.rs diff --git a/crates/pinakes-ui/src/components/pdf_viewer.rs b/packages/pinakes-ui/src/components/pdf_viewer.rs similarity index 100% rename from crates/pinakes-ui/src/components/pdf_viewer.rs rename to packages/pinakes-ui/src/components/pdf_viewer.rs diff --git a/crates/pinakes-ui/src/components/playlists.rs b/packages/pinakes-ui/src/components/playlists.rs similarity index 100% rename from crates/pinakes-ui/src/components/playlists.rs rename to packages/pinakes-ui/src/components/playlists.rs diff --git a/crates/pinakes-ui/src/components/search.rs b/packages/pinakes-ui/src/components/search.rs similarity index 100% rename from crates/pinakes-ui/src/components/search.rs rename to packages/pinakes-ui/src/components/search.rs diff --git a/crates/pinakes-ui/src/components/settings.rs b/packages/pinakes-ui/src/components/settings.rs similarity index 100% rename from crates/pinakes-ui/src/components/settings.rs rename to packages/pinakes-ui/src/components/settings.rs diff --git a/crates/pinakes-ui/src/components/statistics.rs b/packages/pinakes-ui/src/components/statistics.rs similarity index 100% rename from crates/pinakes-ui/src/components/statistics.rs rename to packages/pinakes-ui/src/components/statistics.rs diff --git a/crates/pinakes-ui/src/components/tags.rs b/packages/pinakes-ui/src/components/tags.rs similarity index 100% rename from crates/pinakes-ui/src/components/tags.rs rename to packages/pinakes-ui/src/components/tags.rs diff --git a/crates/pinakes-ui/src/components/tasks.rs b/packages/pinakes-ui/src/components/tasks.rs similarity index 100% rename from crates/pinakes-ui/src/components/tasks.rs rename to packages/pinakes-ui/src/components/tasks.rs diff --git a/crates/pinakes-ui/src/components/utils.rs b/packages/pinakes-ui/src/components/utils.rs similarity index 100% rename from crates/pinakes-ui/src/components/utils.rs rename to packages/pinakes-ui/src/components/utils.rs diff --git a/crates/pinakes-ui/src/main.rs b/packages/pinakes-ui/src/main.rs similarity index 100% rename from crates/pinakes-ui/src/main.rs rename to packages/pinakes-ui/src/main.rs diff --git a/crates/pinakes-ui/src/plugin_ui/actions.rs b/packages/pinakes-ui/src/plugin_ui/actions.rs similarity index 100% rename from crates/pinakes-ui/src/plugin_ui/actions.rs rename to packages/pinakes-ui/src/plugin_ui/actions.rs diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/packages/pinakes-ui/src/plugin_ui/data.rs similarity index 100% rename from crates/pinakes-ui/src/plugin_ui/data.rs rename to packages/pinakes-ui/src/plugin_ui/data.rs diff --git a/crates/pinakes-ui/src/plugin_ui/expr.rs b/packages/pinakes-ui/src/plugin_ui/expr.rs similarity index 100% rename from crates/pinakes-ui/src/plugin_ui/expr.rs rename to packages/pinakes-ui/src/plugin_ui/expr.rs diff --git a/crates/pinakes-ui/src/plugin_ui/mod.rs b/packages/pinakes-ui/src/plugin_ui/mod.rs similarity index 100% rename from crates/pinakes-ui/src/plugin_ui/mod.rs rename to packages/pinakes-ui/src/plugin_ui/mod.rs diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/packages/pinakes-ui/src/plugin_ui/registry.rs similarity index 100% rename from crates/pinakes-ui/src/plugin_ui/registry.rs rename to packages/pinakes-ui/src/plugin_ui/registry.rs diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/packages/pinakes-ui/src/plugin_ui/renderer.rs similarity index 100% rename from crates/pinakes-ui/src/plugin_ui/renderer.rs rename to packages/pinakes-ui/src/plugin_ui/renderer.rs diff --git a/crates/pinakes-ui/src/plugin_ui/widget.rs b/packages/pinakes-ui/src/plugin_ui/widget.rs similarity index 100% rename from crates/pinakes-ui/src/plugin_ui/widget.rs rename to packages/pinakes-ui/src/plugin_ui/widget.rs diff --git a/crates/pinakes-ui/src/state.rs b/packages/pinakes-ui/src/state.rs similarity index 100% rename from crates/pinakes-ui/src/state.rs rename to packages/pinakes-ui/src/state.rs diff --git a/crates/pinakes-ui/src/styles.rs b/packages/pinakes-ui/src/styles.rs similarity index 100% rename from crates/pinakes-ui/src/styles.rs rename to packages/pinakes-ui/src/styles.rs From d61b5d32d1ffdf44daddaf38d71e51e9a9268859 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 23 Mar 2026 02:33:08 +0300 Subject: [PATCH 09/22] docs: link to 'hacking' guidelines in README; document project license Signed-off-by: NotAShelf Change-Id: I6caae0f7de73aa02150b48a3d7d8dcd06a6a6964 --- HACKING.md => docs/HACKING.md | 0 docs/README.md | 37 ++++++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) rename HACKING.md => docs/HACKING.md (100%) diff --git a/HACKING.md b/docs/HACKING.md similarity index 100% rename from HACKING.md rename to docs/HACKING.md diff --git a/docs/README.md b/docs/README.md index 9d8a072..d1aa4f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -201,8 +201,11 @@ and design. ## Storage Backends Two storage backends are supported. For convenience, SQLite is the default -backend out of the box but for production deployments you may choose to prefer -PostgreSQL. +backend out of the box but for production deployments, with improved search and +scaling capabilities you may choose to prefer PostgreSQL. Both backends are +considered first-class citizens, and will be developed as such going further. If +your needs for Pinakes are modest, or if you are simply testing it out, SQLite +is the recommended database. ### **SQLite** (default) @@ -211,6 +214,34 @@ guarantees FTS5 availability. ### **PostgreSQL** +[pg_trgm]: https://www.postgresql.org/docs/current/pgtrgm.html + Native async with connection pooling (deadpool-postgres). Uses tsvector with -weighted columns for full-text search and pg_trgm for fuzzy matching. Requires +weighted columns for full-text search and [pg_trgm] for fuzzy matching. Requires the `pg_trgm` extension. + +## Contributing + +[HACKING document]: ./HACKING.md + +Pinakes, despite all the work going into it, is still in an early beta. Some of +the features still lack the polish they deserve and there _may_ be breaking +changes to the UI, databases, and the APIs. You may find a comprehensive +introduction to developing Pinakes in the [HACKING document]. + +It is generally advisable that you familiarize yourself with the codebase before +proposing or contributing changes. Pinakes consists of _many_ moving parts, and +it is better for the contributor experience to approach issues slowly and +discuss them beforehand. + +## License + + + +[here]: https://interoperable-europe.ec.europa.eu/sites/default/files/custom-page/attachment/eupl_v1.2_en.pdf + +This project is made available under European Union Public Licence (EUPL) +version 1.2. See [LICENSE](LICENSE) for more details on the exact conditions. An +online copy is provided [here]. + + From 273d0244aa1ce97957f1cabb1abe51bc38528a98 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 23 Mar 2026 02:33:54 +0300 Subject: [PATCH 10/22] chore: generate a documentation index for REST API docs in `docs/api` Signed-off-by: NotAShelf Change-Id: Ia8426a63a50d07a6cec2b104951d58eb6a6a6964 --- docs/api.md | 47 ++++++++++++++++++++++++++++++++ docs/api/books.md | 6 ++--- docs/api/openapi.json | 63 ++++++++++++++++++++++++++++++++++++++----- docs/api/subtitles.md | 10 +++---- xtask/src/docs.rs | 39 ++++++++++++++++++++++++++- 5 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 docs/api.md diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..dbc8c8d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,47 @@ +# API Documentation + +This is the index of all generated REST API documentation for Pinakes. + +Documentation is generated from OpenAPI annotations via `cargo xtask docs` (or +`just docs`). Do not edit generated files by hand. + +## Reference + +- [openapi.json](api/openapi.json) - Full OpenAPI 3.0 specification + +## Endpoints by Tag + +- [Analytics](api/analytics.md) - Usage analytics and viewing history +- [Audit](api/audit.md) - Audit log entries +- [Auth](api/auth.md) - Authentication and session management +- [Backup](api/backup.md) - Database backup +- [Books](api/books.md) - Book metadata, series, authors, and reading progress +- [Collections](api/collections.md) - Media collections +- [Config](api/config.md) - Server configuration +- [Database](api/database.md) - Database administration +- [Duplicates](api/duplicates.md) - Duplicate media detection +- [Enrichment](api/enrichment.md) - External metadata enrichment +- [Export](api/export.md) - Media library export +- [Health](api/health.md) - Server health checks +- [Integrity](api/integrity.md) - Library integrity checks and repairs +- [Jobs](api/jobs.md) - Background job management +- [Media](api/media.md) - Media item management +- [Notes](api/notes.md) - Markdown notes link graph +- [Photos](api/photos.md) - Photo timeline and map view +- [Playlists](api/playlists.md) - Media playlists +- [Plugins](api/plugins.md) - Plugin management +- [Saved_searches](api/saved_searches.md) - Saved search queries +- [Scan](api/scan.md) - Directory scanning +- [Scheduled_tasks](api/scheduled_tasks.md) - Scheduled background tasks +- [Search](api/search.md) - Full-text media search +- [Shares](api/shares.md) - Media sharing and notifications +- [Social](api/social.md) - Ratings, comments, favorites, and share links +- [Statistics](api/statistics.md) - Library statistics +- [Streaming](api/streaming.md) - HLS and DASH adaptive streaming +- [Subtitles](api/subtitles.md) - Media subtitle management +- [Sync](api/sync.md) - Multi-device library synchronization +- [Tags](api/tags.md) - Media tag management +- [Transcode](api/transcode.md) - Video transcoding sessions +- [Upload](api/upload.md) - File upload and managed storage +- [Users](api/users.md) - User and library access management +- [Webhooks](api/webhooks.md) - Webhook configuration diff --git a/docs/api/books.md b/docs/api/books.md index 3ddf863..1ab3162 100644 --- a/docs/api/books.md +++ b/docs/api/books.md @@ -85,9 +85,9 @@ Get user's reading list #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ------------------------ | -| `status` | query | No | Filter by reading status | +| Name | In | Required | Description | +| -------- | ----- | -------- | ------------------------------------------------------------------------------ | +| `status` | query | No | Filter by reading status. Valid values: to_read, reading, completed, abandoned | #### Responses diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 3f86923..7e25e7c 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1275,7 +1275,7 @@ { "name": "status", "in": "query", - "description": "Filter by reading status", + "description": "Filter by reading status. Valid values: to_read, reading, completed, abandoned", "required": false, "schema": { "type": "string" @@ -4704,14 +4704,11 @@ ], "responses": { "200": { - "description": "Subtitles", + "description": "Subtitles and available embedded tracks", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubtitleResponse" - } + "$ref": "#/components/schemas/SubtitleListResponse" } } } @@ -8529,6 +8526,7 @@ "integer", "null" ], + "format": "int32", "minimum": 0 } } @@ -11787,6 +11785,28 @@ ], "description": "Response for accessing shared content.\nSingle-media shares return the media object directly (backwards compatible).\nCollection/Tag/SavedSearch shares return a list of items." }, + "SubtitleListResponse": { + "type": "object", + "description": "Response for listing subtitles on a media item.", + "required": [ + "subtitles", + "available_tracks" + ], + "properties": { + "available_tracks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubtitleTrackInfoResponse" + } + }, + "subtitles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubtitleResponse" + } + } + } + }, "SubtitleResponse": { "type": "object", "required": [ @@ -11829,10 +11849,41 @@ "integer", "null" ], + "format": "int32", "minimum": 0 } } }, + "SubtitleTrackInfoResponse": { + "type": "object", + "description": "Information about an embedded subtitle track available for extraction.", + "required": [ + "index", + "format" + ], + "properties": { + "format": { + "type": "string" + }, + "index": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "language": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + } + } + }, "SyncChangeResponse": { "type": "object", "required": [ diff --git a/docs/api/subtitles.md b/docs/api/subtitles.md index d170681..8387484 100644 --- a/docs/api/subtitles.md +++ b/docs/api/subtitles.md @@ -16,11 +16,11 @@ Media subtitle management #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | Subtitles | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +| ------ | --------------------------------------- | +| 200 | Subtitles and available embedded tracks | +| 401 | Unauthorized | +| 404 | Not found | --- diff --git a/xtask/src/docs.rs b/xtask/src/docs.rs index 5f9872c..bcf373a 100644 --- a/xtask/src/docs.rs +++ b/xtask/src/docs.rs @@ -95,8 +95,45 @@ pub fn run() { files_written += 1; } + // Generate docs/api.md index + let index_path = std::path::Path::new("docs/api.md"); + let mut index = String::new(); + index.push_str("# API Documentation\n\n"); + index.push_str( + "This is the index of all generated REST API documentation for \ + Pinakes.\n\n", + ); + index.push_str( + "Documentation is generated from OpenAPI annotations via `cargo xtask \ + docs`\n", + ); + index.push_str("(or `just docs`). Do not edit generated files by hand.\n\n"); + index.push_str("## Reference\n\n"); + index.push_str( + "- [openapi.json](api/openapi.json) - Full OpenAPI 3.0 specification\n\n", + ); + index.push_str("## Endpoints by Tag\n\n"); + for tag_name in tag_ops.keys() { + let file_name = format!("{}.md", tag_name.replace('/', "_")); + let description = tag_descriptions.get(tag_name).map_or("", String::as_str); + if description.is_empty() { + writeln!(index, "- [{}](api/{file_name})", title_case(tag_name)) + .expect("write to String"); + } else { + writeln!( + index, + "- [{}](api/{file_name}) - {description}", + title_case(tag_name) + ) + .expect("write to String"); + } + } + std::fs::write(index_path, &index).expect("write docs/api.md"); + println!("Written docs/api.md"); + println!( - "Done: wrote docs/api/openapi.json and {files_written} markdown files." + "Done: wrote docs/api/openapi.json, docs/api.md, and {files_written} \ + markdown files." ); } From 520489ab48577143c4b300e8890e4685661e8826 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 23 Mar 2026 03:30:12 +0300 Subject: [PATCH 11/22] docs: finalize hacking guidelines Signed-off-by: NotAShelf Change-Id: I2d3a5a9d745630f5da305664f0bdc66e6a6a6964 --- docs/HACKING.md | 278 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 261 insertions(+), 17 deletions(-) diff --git a/docs/HACKING.md b/docs/HACKING.md index 5f5bafb..28eb21c 100644 --- a/docs/HACKING.md +++ b/docs/HACKING.md @@ -1,11 +1,12 @@ # Hacking Pinakes -Pinakes is a lot of things. One of the things it aims to be is _complete_. To be -complete in features, to be complete in documentation and to be complete in -hackability. This document covers, very comprehensively, how you may: +Pinakes is a lot of things. It also _plans_ to be a lot of things. One of those +things, as you might have noticed, is _complete_. To be complete in features, to +be complete in documentation and to be complete in hackability. This document +covers, very comprehensively, how you may: -- Build Pinakes - Develop Pinakes +- Build Pinakes - Contribute to Pinakes for developers as well as: @@ -14,19 +15,262 @@ for developers as well as: for Pinakes maintainers and packagers. -## Building Pinakes - -Pinakes is built with Rust (nightly edition) and various crates. The most -_notable_ crate among those is Dioxus, which provides its own toolkit. The UI -for Pinakes is usually _not_ built with the Dioxus CLI but instead with -`cargo build`. This also applies to distributable build results. +## Development Environment +[Nix]: https://nixos.org [Direnv]: https://direnv.net -To build Pinakes, simply pick the components you want and build them with -`cargo build --release --package `. A Nix shell is provided for -reproducible developer environments and you may obtain all build dependencies by -simply running `nix develop` or `direnv allow` if you use [Direnv]. Nix is a -cross-platform build tool and works on most Linux distributions as well as -Darwin. While distro-specific package managers _might_ work, Nix is the only -supported one. +Pinakes uses [Nix] with flakes for pure, reproducible developer environments. +All of the build dependencies that you need, from Rust to GTK and webkit, are +provided in the devshell. In addition to _not_ requiring system libraries, we'd +like to _actively discourage you_ from relying on system libraries. + +[Direnv] may be preferred by some users as an alternative to `nix develop`. + +```bash +# Enter the dev shell +$ nix develop + +# Or with direnv (recommended) +$ direnv allow +``` + +Nix is the only supported way to get all dependencies. Distro package managers +_may_ work for the core crates but are not supported for the UI. + +## Building Pinakes + +Pinakes is a Cargo workspace with multiple crates targeting multiple aspects of +the projects. The user-facing crates are provided in `packages/*`. +`pinakes-server` and `pinakes-tui` can be built normally with `cargo` while the +UI crate, i.e., `pinakes-ui` **must** be built with the Dioxus CLI (`dx`) so +that the SCSS stylesheets are compiled correctly. Everything else should work +perfectly fine with the standard `cargo build`. + +[Just]: https://just.systems + +To make the things a _little_ bit more convenient, this project uses [Just] as +its command runner and provides a few recipes for common tasks. + +```bash +# Build all crates (server + TUI + UI) +$ just build-all + +# Build individual components +$ just build-server # pinakes-server (HTTP API) +$ just build-tui # pinakes-tui (terminal UI) +$ just build-ui # pinakes-ui (Dioxus desktop/web UI, requires dx) +``` + +The dependencies required for the recipes, including Just itself, is provided by +the Nix devshell. You may also run the cargo equivalents of those commands: + +```bash +# Core crates +$ cargo build -p pinakes-server +$ cargo build -p pinakes-tui + +# The UI crate must be built with dx, not cargo. +$ dx build -p pinakes-ui +``` + +Release builds follow the same pattern, but with `--release`. Nothing else +should be required for the most part. + +## Running Pinakes + +For the time being, UI clients require the server to be running before they can +function. Offline sync is planned, but likely come later alongside cross-device +sync. All UIs connect to the server over HTTP. + +```bash +# 1. Copy and edit the example config (first time only) +$ cp pinakes.example.toml pinakes.toml + +# 2. Start the server (defaults to 127.0.0.1:3000) +$ cargo run -p pinakes-server -- pinakes.toml + +# 3a. Run the TUI client +$ cargo run -p pinakes-tui + +# 3b. Run the desktop/web UI +$ cargo run -p pinakes-ui + +# Connect TUI to a non-default server address +$ cargo run -p pinakes-tui -- --server http://localhost:3000 +``` + +The GUI reads `PINAKES_SERVER_URL` if set; otherwise it assumes +`http://127.0.0.1:3000`. You may change the host and port if you require. + +## Testing + +[cargo-nextest]: https://nexte.st + +Pinakes boasts, or well, attempts to boast a very comprehensive testing suite to +ensure no regressions are introduced while in development. For fast parallel +test execution, we use [cargo-nextest] and while you are recommended to use it +over `cargo test` both should be supported. For CI and package tests, only +`cargo-nextest` is supported. + +```bash +# Run all workspace tests (preferred) +$ just test + +# Equivalent without Just +$ cargo nextest run --workspace + +# Tests for a single crate +$ cargo nextest run -p pinakes-core +$ cargo nextest run -p pinakes-server + +# Run a specific test by name and show output +$ cargo test -p pinakes-core -- test_name --nocapture +``` + +## Linting and Formatting + +### Formatting + +There is a treewide formatter provided within the `flake.nix` that invokes the +recommended formatter tooling across the various filetypes, including database +migrations and Markdown sources. While a `just fmt` recipe is provided, it will +not cover most of the files. Still, you may format the Rust sources as such: + +```bash +# Format all code +$ just fmt # or: cargo fmt + +# Check formatting without modifying files +$ cargo fmt --check +``` + +### Linting + +For now lints only cover Rust sources, and are provided by the Clippy tool. You +may, as usual, invoke it with `just lint`. + +```bash +# Run Clippy +$ just lint # or: cargo clippy --workspace + +# Treat Clippy warnings as errors (used in CI) +$ cargo clippy --workspace -- -D warnings # `-D warnings` is recommended +``` + +All Clippy warnings, besides some of the really annoying ones or false +positives, must be resolved at the source. **Do not** use `#[allow(...)]` or +`#[expect(...)]` to silence warnings unless there is a documented reason. + +It may be okay, at times, to suppress lints but you should focus on _resolving_ +them rather than suppressing them. _If_ suppressing, prefer `#[expect]` and +provide the `reason` parameter. + +## Generating API Documentation + +The REST API documentation is generated from OpenAPI annotations in +`pinakes-server` using `cargo xtask`: + +```bash +# Generate the API documentation at docs/api/ +$ just docs # or: cargo xtask docs +``` + +This writes: + +- `docs/api.md` - Index linking all generated files +- `docs/api/.md` - One Markdown file per API tag +- `docs/api/openapi.json` - Full OpenAPI 3.0 specification + +Please do not edit files under `docs/api/` by hand; they are regenerated on each +run. + +## Code Style + +There are many conventions that are in play within the Pinakes codebase. You'll +get used and adjust to most of them naturally, but here are some of the general +conventions written down for convenience. + +### General + +1. **Make illegal states unrepresentable**; ıse ADTs and newtype wrappers to + encode constraints in the type system. Parse and validate at system + boundaries (user input, external APIs); trust internal types everywhere else. +2. This one is kind of obvious, but please use the `tracing` crate with + structured fields. Do not use `println!` or `eprintln!` in library or server + code. +3. All storage operations must be implemented for both the SQLite and PostgreSQL + backends. Neither backend is optional. + +Naming is kind of obvious, but I'd like to remind you that we use `snake_case` +for functions and variables, `PascalCase` for types and traits, +`SCREAMING_SNAKE_CASE` for constants. There is not much else to it. + +### Error Handling + +- **`unwrap()` and `expect()` are banned** in non-test code. Use `?`, + `ok_or_else`, `match`, or `if let` instead. + - The only accepted exception is when a failure is truly impossible (e.g., a + hardcoded regex that is known-valid at compile time). In that case, add + `#[expect(clippy::expect_used, reason = "...")]` with a clear explanation. +- Wrap storage errors with the `db_ctx` helper so error messages include the + operation and relevant ID: + + ```rust + stmt.execute(params).map_err(db_ctx("insert_media", &media_id))?; + ``` + +### Disallowed Types and APIs + +Pinakes uses hashers from `rustc_hash`, i.e., `FxHashMap` and `FxHashSet` over +the `std` equivalents due to their faster nature without random state. This is +enforced by clippy. + +As Pinakes uses Rust 1.90+, the now-deprecated `once_cell` crate is also banned. +Use the stdlib equivalents: + +- `once_cell::unsync::OnceCell` -> `std::cell::OnceCell` +- `once_cell::sync::OnceCell` -> `std::sync::OnceLock` +- `once_cell::unsync::Lazy` -> `std::cell::LazyCell` +- `once_cell::sync::Lazy` -> `std::sync::LazyLock` + +## Contributing + +1. Fork the repository and create a feature branch. +2. Enter the dev shell: `nix develop` (or `direnv allow`). +3. Make your changes; keep commits focused and descriptive. We use **scoped + commits**. +4. Run `just fmt` and `just lint` before opening a pull request. +5. Run `just test` to verify nothing is broken. +6. Open a pull request against `main`. Describe what you changed and why. + +For significant changes, consider opening an issue first to discuss the +approach. + +## Packaging and Distribution + +Pinakes is released under EUPL v1.2. For distributable release builds, please +respect the license. You may create release builds for the user-facing crates +using `cargo build --release` for server and TUI components. Use `dx` instead +for the GUI: + +```bash +# Server +$ cargo build --release -p pinakes-server + +# TUI +$ cargo build --release -p pinakes-tui + +# GUI +$ dx build --release -p pinakes-ui +``` + +The resulting binaries are self-contained except for system libraries required +by the UI (GTK3, libsoup, webkit2gtk). Database migrations run automatically on +first server startup via the `refinery` crate; no manual migration step is +needed. + +SQLite (the default backend) requires no external database server. PostgreSQL +deployments need the `pg_trgm` extension enabled on the target database. It is +generally advisable to document those requirements for the users, even though +they do not require additional packaging steps. From ad3aff9a205f2b7742099a519488ac890fd9cedb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 23 Mar 2026 03:30:44 +0300 Subject: [PATCH 12/22] pinakes-core: drain both `exchange_buffer` and `pending_events` from the store via wrappers Signed-off-by: NotAShelf Change-Id: I3afcdf5be8eaf552b8e62a09cc10bc1a6a6a6964 --- crates/pinakes-core/src/plugin/pipeline.rs | 22 +++-- crates/pinakes-core/src/plugin/runtime.rs | 94 +++++++++++++++++++--- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/crates/pinakes-core/src/plugin/pipeline.rs b/crates/pinakes-core/src/plugin/pipeline.rs index f4301a5..f094e73 100644 --- a/crates/pinakes-core/src/plugin/pipeline.rs +++ b/crates/pinakes-core/src/plugin/pipeline.rs @@ -690,7 +690,7 @@ impl PluginPipeline { /// Internal dispatcher for events. async fn dispatch_event( - &self, + self: &Arc, event_type: &str, payload: &serde_json::Value, ) { @@ -739,23 +739,35 @@ impl PluginPipeline { payload: payload.clone(), }; - // Event handlers return nothing meaningful; we just care about - // success/failure. match wasm - .call_function_json::( + .call_function_json_with_events::( "handle_event", &req, timeout, ) .await { - Ok(_) => { + Ok((_resp, emitted_events)) => { self.record_success(id).await; debug!( plugin_id = %id, event_type = event_type, "event handled" ); + // Re-dispatch any events the handler itself emitted. + for (emitted_type, payload_str) in emitted_events { + if let Ok(emitted_payload) = + serde_json::from_str::(&payload_str) + { + self.emit_event(&emitted_type, &emitted_payload); + } else { + warn!( + plugin_id = %id, + event_type = %emitted_type, + "plugin emitted event with unparseable JSON payload; skipping" + ); + } + } }, Err(e) => { warn!( diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs index b550b05..e07a1c4 100644 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ b/crates/pinakes-core/src/plugin/runtime.rs @@ -86,20 +86,23 @@ impl WasmPlugin { &self.context } - /// Execute a plugin function + /// Execute a plugin function, returning both the result bytes and any + /// events the plugin queued via `host_emit_event`. /// /// Creates a fresh store and instance per invocation with host functions - /// linked, calls the requested exported function, and returns the result. + /// linked, calls the requested exported function, drains both the exchange + /// buffer and the pending events list before the store is dropped, and + /// returns both. /// /// # Errors /// /// Returns an error if the function cannot be found, instantiation fails, /// or the function call returns an error. - pub async fn call_function( + pub async fn call_function_with_events( &self, function_name: &str, params: &[u8], - ) -> Result> { + ) -> Result<(Vec, Vec<(String, String)>)> { let engine = self.module.engine(); // Build memory limiter from capabilities @@ -205,18 +208,38 @@ impl WasmPlugin { .await?; } - // Prefer data written into the exchange buffer by host functions + // Drain both buffers before the store is dropped. + let pending_events = std::mem::take(&mut store.data_mut().pending_events); let exchange = std::mem::take(&mut store.data_mut().exchange_buffer); - if !exchange.is_empty() { - return Ok(exchange); - } - // Fall back to serialising the WASM return value - if let Some(Val::I32(ret)) = results.first() { - Ok(ret.to_le_bytes().to_vec()) + let result = if !exchange.is_empty() { + exchange + } else if let Some(Val::I32(ret)) = results.first() { + ret.to_le_bytes().to_vec() } else { - Ok(Vec::new()) - } + Vec::new() + }; + + Ok((result, pending_events)) + } + + /// Execute a plugin function, discarding any events the plugin queued. + /// + /// This is a thin wrapper around [`Self::call_function_with_events`]. + /// + /// # Errors + /// + /// Returns an error if the function cannot be found, instantiation fails, + /// or the function call returns an error. + pub async fn call_function( + &self, + function_name: &str, + params: &[u8], + ) -> Result> { + let (data, _events) = self + .call_function_with_events(function_name, params) + .await?; + Ok(data) } /// Call a plugin function with JSON request/response serialization. @@ -259,6 +282,51 @@ impl WasmPlugin { ) }) } + + /// Call a plugin function with JSON serialization, also returning any + /// events the plugin queued via `host_emit_event`. + /// + /// Mirrors [`Self::call_function_json`] but delegates to + /// [`Self::call_function_with_events`] so the pending events list is not + /// discarded before returning. + /// + /// # Errors + /// + /// Returns an error if serialization fails, the call times out, the plugin + /// traps, or the response is malformed JSON. + #[allow(clippy::future_not_send)] // Req doesn't need Sync; called within local tasks + pub async fn call_function_json_with_events( + &self, + function_name: &str, + request: &Req, + timeout: std::time::Duration, + ) -> anyhow::Result<(Resp, Vec<(String, String)>)> + where + Req: serde::Serialize, + Resp: serde::de::DeserializeOwned, + { + let request_bytes = serde_json::to_vec(request) + .map_err(|e| anyhow::anyhow!("failed to serialize request: {e}"))?; + + let (result, pending_events) = tokio::time::timeout( + timeout, + self.call_function_with_events(function_name, &request_bytes), + ) + .await + .map_err(|_| { + anyhow::anyhow!( + "plugin call '{function_name}' timed out after {timeout:?}" + ) + })??; + + let resp = serde_json::from_slice(&result).map_err(|e| { + anyhow::anyhow!( + "failed to deserialize response from '{function_name}': {e}" + ) + })?; + + Ok((resp, pending_events)) + } } #[cfg(test)] From b1e724b7da72cc932336823c9e39a8ded6e5fdbb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 23 Mar 2026 04:25:47 +0300 Subject: [PATCH 13/22] nix: watch flake lockfile & devshell config Signed-off-by: NotAShelf Change-Id: I7289206b7fc990f8ed2a25811b8f1a536a6a6964 --- .envrc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.envrc b/.envrc index 3550a30..4ba1317 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,4 @@ +watch_file nix/shell.nix +watch_file flake.lock + use flake From 33d4ffe2bc6fdf320e178bc9e152008e88a50b66 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 23 Mar 2026 07:32:43 +0300 Subject: [PATCH 14/22] nix: bump flake inputs Signed-off-by: NotAShelf Change-Id: I9014a63d82b1f50edfb5e7ab4323a5e26a6a6964 --- flake.lock | Bin 1108 -> 1108 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/flake.lock b/flake.lock index c1c549c0e85f6f19922b770e30104398546b1878..d08ec7d3c5fa8838d68ceff5af21f96aacc8c931 100644 GIT binary patch delta 220 zcmW;FJ5B;Y7y#fP1suVKo`hz0{&|)zkKhsqNr+juo0)$`(G6rHag|d*w)GZXfdg1@ zAi(Bpzfy=Dc_}OlP#afq994xl!D%__TTQ>gpd&45| z#qsBHWCc+Sut4b)=0ZD1v9Lx-l6&AeXB`KVBk2zPr&HRidjmlQ5RYE7B-`rgzTFk$ zhzCu*ZlIjaALg{&O_C~pGjcO+d6j*z+V5`rvyn1{Q&0+x<+d<}f<-{4D58^A8xL|K JEY+9o_a7!VMQZ>6 From 9f9aa802655431bf637286d32a5970a3d301f804 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 May 2026 17:01:03 +0300 Subject: [PATCH 15/22] treewide: move migration logic into `pinakes-migrations` crate Signed-off-by: NotAShelf Change-Id: I98b8ed2eee464ecfd42f492dec49adeb6a6a6964 --- crates/pinakes-core/Cargo.toml | 2 +- crates/pinakes-core/src/storage/migrations.rs | 23 +++------- crates/pinakes-core/src/storage/sqlite.rs | 26 ++++++------ crates/pinakes-migrations/Cargo.toml | 14 +++++++ .../postgres/V10__incremental_scan.sql | 0 .../postgres/V11__session_persistence.sql | 0 .../postgres/V12__book_management.sql | 0 .../postgres/V13__photo_metadata.sql | 0 .../postgres/V14__perceptual_hash.sql | 0 .../postgres/V15__managed_storage.sql | 0 .../migrations}/postgres/V16__sync_system.sql | 0 .../postgres/V17__enhanced_sharing.sql | 0 .../postgres/V18__file_management.sql | 0 .../postgres/V19__markdown_links.sql | 0 .../postgres/V1__initial_schema.sql | 0 .../migrations}/postgres/V2__fts_indexes.sql | 0 .../postgres/V3__audit_indexes.sql | 0 .../postgres/V4__thumbnail_path.sql | 0 .../V5__integrity_and_saved_searches.sql | 0 .../postgres/V6__plugin_system.sql | 0 .../postgres/V7__user_management.sql | 0 .../postgres/V8__media_server_features.sql | 0 .../V9__fix_indexes_and_constraints.sql | 0 .../sqlite/V10__incremental_scan.sql | 0 .../sqlite/V11__session_persistence.sql | 0 .../sqlite/V12__book_management.sql | 0 .../sqlite/V13__photo_metadata.sql | 0 .../sqlite/V14__perceptual_hash.sql | 0 .../sqlite/V15__managed_storage.sql | 0 .../migrations}/sqlite/V16__sync_system.sql | 0 .../sqlite/V17__enhanced_sharing.sql | 0 .../sqlite/V18__file_management.sql | 0 .../sqlite/V19__markdown_links.sql | 0 .../migrations}/sqlite/V1__initial_schema.sql | 0 .../migrations}/sqlite/V2__fts5_indexes.sql | 0 .../migrations}/sqlite/V3__audit_indexes.sql | 0 .../migrations}/sqlite/V4__thumbnail_path.sql | 0 .../V5__integrity_and_saved_searches.sql | 0 .../migrations}/sqlite/V6__plugin_system.sql | 0 .../sqlite/V7__user_management.sql | 0 .../sqlite/V8__media_server_features.sql | 0 .../V9__fix_indexes_and_constraints.sql | 0 crates/pinakes-migrations/src/lib.rs | 42 +++++++++++++++++++ 43 files changed, 76 insertions(+), 31 deletions(-) create mode 100644 crates/pinakes-migrations/Cargo.toml rename {migrations => crates/pinakes-migrations/migrations}/postgres/V10__incremental_scan.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V11__session_persistence.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V12__book_management.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V13__photo_metadata.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V14__perceptual_hash.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V15__managed_storage.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V16__sync_system.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V17__enhanced_sharing.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V18__file_management.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V19__markdown_links.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V1__initial_schema.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V2__fts_indexes.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V3__audit_indexes.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V4__thumbnail_path.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V5__integrity_and_saved_searches.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V6__plugin_system.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V7__user_management.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V8__media_server_features.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/postgres/V9__fix_indexes_and_constraints.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V10__incremental_scan.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V11__session_persistence.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V12__book_management.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V13__photo_metadata.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V14__perceptual_hash.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V15__managed_storage.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V16__sync_system.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V17__enhanced_sharing.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V18__file_management.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V19__markdown_links.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V1__initial_schema.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V2__fts5_indexes.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V3__audit_indexes.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V4__thumbnail_path.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V5__integrity_and_saved_searches.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V6__plugin_system.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V7__user_management.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V8__media_server_features.sql (100%) rename {migrations => crates/pinakes-migrations/migrations}/sqlite/V9__fix_indexes_and_constraints.sql (100%) create mode 100644 crates/pinakes-migrations/src/lib.rs diff --git a/crates/pinakes-core/Cargo.toml b/crates/pinakes-core/Cargo.toml index 51b1dab..8c4529d 100644 --- a/crates/pinakes-core/Cargo.toml +++ b/crates/pinakes-core/Cargo.toml @@ -29,7 +29,7 @@ deadpool-postgres = { workspace = true } postgres-types = { workspace = true } postgres-native-tls = { workspace = true } native-tls = { workspace = true } -refinery = { workspace = true } +pinakes-migrations = { workspace = true } walkdir = { workspace = true } notify = { workspace = true } winnow = { workspace = true } diff --git a/crates/pinakes-core/src/storage/migrations.rs b/crates/pinakes-core/src/storage/migrations.rs index cedd987..196397f 100644 --- a/crates/pinakes-core/src/storage/migrations.rs +++ b/crates/pinakes-core/src/storage/migrations.rs @@ -1,28 +1,17 @@ use crate::error::{PinakesError, Result}; -mod sqlite_migrations { - use refinery::embed_migrations; - embed_migrations!("../../migrations/sqlite"); -} - -mod postgres_migrations { - use refinery::embed_migrations; - embed_migrations!("../../migrations/postgres"); -} - pub fn run_sqlite_migrations(conn: &mut rusqlite::Connection) -> Result<()> { - sqlite_migrations::migrations::runner() - .run(conn) - .map_err(|e| PinakesError::Migration(e.to_string()))?; - Ok(()) + pinakes_migrations::sqlite_migrations() + .to_latest(conn) + .map_err(|e| PinakesError::Migration(e.to_string())) } pub async fn run_postgres_migrations( client: &mut tokio_postgres::Client, ) -> Result<()> { - postgres_migrations::migrations::runner() + pinakes_migrations::postgres_runner() .run_async(client) .await - .map_err(|e| PinakesError::Migration(e.to_string()))?; - Ok(()) + .map(|_| ()) + .map_err(|e| PinakesError::Migration(e.to_string())) } diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index ecc1b00..4b72f95 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -1082,8 +1082,8 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; let count: u64 = db.query_row("SELECT COUNT(*) FROM media_items", [], |row| { - row.get(0) - })?; + row.get::<_, i64>(0) + })?.cast_unsigned(); db.execute("DELETE FROM media_items", [])?; count }; @@ -2441,12 +2441,12 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; let total_media: u64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |r| r.get(0))?; + db.query_row("SELECT COUNT(*) FROM media_items", [], |r| r.get::<_, i64>(0))?.cast_unsigned(); let total_size: u64 = db.query_row( "SELECT COALESCE(SUM(file_size), 0) FROM media_items", [], - |r| r.get(0), - )?; + |r| r.get::<_, i64>(0), + )?.cast_unsigned(); let avg_size: u64 = total_size.checked_div(total_media).unwrap_or(0); // Media count by type @@ -2455,7 +2455,7 @@ impl StorageBackend for SqliteBackend { ORDER BY COUNT(*) DESC", )?; let media_by_type: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? .filter_map(std::result::Result::ok) .collect(); @@ -2465,7 +2465,7 @@ impl StorageBackend for SqliteBackend { GROUP BY media_type ORDER BY SUM(file_size) DESC", )?; let storage_by_type: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? .filter_map(std::result::Result::ok) .collect(); @@ -2491,7 +2491,7 @@ impl StorageBackend for SqliteBackend { mt.tag_id = t.id GROUP BY t.id ORDER BY cnt DESC LIMIT 10", )?; let top_tags: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? .filter_map(std::result::Result::ok) .collect(); @@ -2502,22 +2502,22 @@ impl StorageBackend for SqliteBackend { DESC LIMIT 10", )?; let top_collections: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? .filter_map(std::result::Result::ok) .collect(); let total_tags: u64 = - db.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get(0))?; + db.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get::<_, i64>(0))?.cast_unsigned(); let total_collections: u64 = - db.query_row("SELECT COUNT(*) FROM collections", [], |r| r.get(0))?; + db.query_row("SELECT COUNT(*) FROM collections", [], |r| r.get::<_, i64>(0))?.cast_unsigned(); // Duplicates: count of hashes that appear more than once let total_duplicates: u64 = db.query_row( "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP BY \ content_hash HAVING COUNT(*) > 1)", [], - |r| r.get(0), - )?; + |r| r.get::<_, i64>(0), + )?.cast_unsigned(); Ok(super::LibraryStatistics { total_media, diff --git a/crates/pinakes-migrations/Cargo.toml b/crates/pinakes-migrations/Cargo.toml new file mode 100644 index 0000000..42284da --- /dev/null +++ b/crates/pinakes-migrations/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "pinakes-migrations" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +rusqlite = { workspace = true } +tokio-postgres = { workspace = true } +rusqlite_migration = { workspace = true } +refinery = { workspace = true } + +[lints] +workspace = true diff --git a/migrations/postgres/V10__incremental_scan.sql b/crates/pinakes-migrations/migrations/postgres/V10__incremental_scan.sql similarity index 100% rename from migrations/postgres/V10__incremental_scan.sql rename to crates/pinakes-migrations/migrations/postgres/V10__incremental_scan.sql diff --git a/migrations/postgres/V11__session_persistence.sql b/crates/pinakes-migrations/migrations/postgres/V11__session_persistence.sql similarity index 100% rename from migrations/postgres/V11__session_persistence.sql rename to crates/pinakes-migrations/migrations/postgres/V11__session_persistence.sql diff --git a/migrations/postgres/V12__book_management.sql b/crates/pinakes-migrations/migrations/postgres/V12__book_management.sql similarity index 100% rename from migrations/postgres/V12__book_management.sql rename to crates/pinakes-migrations/migrations/postgres/V12__book_management.sql diff --git a/migrations/postgres/V13__photo_metadata.sql b/crates/pinakes-migrations/migrations/postgres/V13__photo_metadata.sql similarity index 100% rename from migrations/postgres/V13__photo_metadata.sql rename to crates/pinakes-migrations/migrations/postgres/V13__photo_metadata.sql diff --git a/migrations/postgres/V14__perceptual_hash.sql b/crates/pinakes-migrations/migrations/postgres/V14__perceptual_hash.sql similarity index 100% rename from migrations/postgres/V14__perceptual_hash.sql rename to crates/pinakes-migrations/migrations/postgres/V14__perceptual_hash.sql diff --git a/migrations/postgres/V15__managed_storage.sql b/crates/pinakes-migrations/migrations/postgres/V15__managed_storage.sql similarity index 100% rename from migrations/postgres/V15__managed_storage.sql rename to crates/pinakes-migrations/migrations/postgres/V15__managed_storage.sql diff --git a/migrations/postgres/V16__sync_system.sql b/crates/pinakes-migrations/migrations/postgres/V16__sync_system.sql similarity index 100% rename from migrations/postgres/V16__sync_system.sql rename to crates/pinakes-migrations/migrations/postgres/V16__sync_system.sql diff --git a/migrations/postgres/V17__enhanced_sharing.sql b/crates/pinakes-migrations/migrations/postgres/V17__enhanced_sharing.sql similarity index 100% rename from migrations/postgres/V17__enhanced_sharing.sql rename to crates/pinakes-migrations/migrations/postgres/V17__enhanced_sharing.sql diff --git a/migrations/postgres/V18__file_management.sql b/crates/pinakes-migrations/migrations/postgres/V18__file_management.sql similarity index 100% rename from migrations/postgres/V18__file_management.sql rename to crates/pinakes-migrations/migrations/postgres/V18__file_management.sql diff --git a/migrations/postgres/V19__markdown_links.sql b/crates/pinakes-migrations/migrations/postgres/V19__markdown_links.sql similarity index 100% rename from migrations/postgres/V19__markdown_links.sql rename to crates/pinakes-migrations/migrations/postgres/V19__markdown_links.sql diff --git a/migrations/postgres/V1__initial_schema.sql b/crates/pinakes-migrations/migrations/postgres/V1__initial_schema.sql similarity index 100% rename from migrations/postgres/V1__initial_schema.sql rename to crates/pinakes-migrations/migrations/postgres/V1__initial_schema.sql diff --git a/migrations/postgres/V2__fts_indexes.sql b/crates/pinakes-migrations/migrations/postgres/V2__fts_indexes.sql similarity index 100% rename from migrations/postgres/V2__fts_indexes.sql rename to crates/pinakes-migrations/migrations/postgres/V2__fts_indexes.sql diff --git a/migrations/postgres/V3__audit_indexes.sql b/crates/pinakes-migrations/migrations/postgres/V3__audit_indexes.sql similarity index 100% rename from migrations/postgres/V3__audit_indexes.sql rename to crates/pinakes-migrations/migrations/postgres/V3__audit_indexes.sql diff --git a/migrations/postgres/V4__thumbnail_path.sql b/crates/pinakes-migrations/migrations/postgres/V4__thumbnail_path.sql similarity index 100% rename from migrations/postgres/V4__thumbnail_path.sql rename to crates/pinakes-migrations/migrations/postgres/V4__thumbnail_path.sql diff --git a/migrations/postgres/V5__integrity_and_saved_searches.sql b/crates/pinakes-migrations/migrations/postgres/V5__integrity_and_saved_searches.sql similarity index 100% rename from migrations/postgres/V5__integrity_and_saved_searches.sql rename to crates/pinakes-migrations/migrations/postgres/V5__integrity_and_saved_searches.sql diff --git a/migrations/postgres/V6__plugin_system.sql b/crates/pinakes-migrations/migrations/postgres/V6__plugin_system.sql similarity index 100% rename from migrations/postgres/V6__plugin_system.sql rename to crates/pinakes-migrations/migrations/postgres/V6__plugin_system.sql diff --git a/migrations/postgres/V7__user_management.sql b/crates/pinakes-migrations/migrations/postgres/V7__user_management.sql similarity index 100% rename from migrations/postgres/V7__user_management.sql rename to crates/pinakes-migrations/migrations/postgres/V7__user_management.sql diff --git a/migrations/postgres/V8__media_server_features.sql b/crates/pinakes-migrations/migrations/postgres/V8__media_server_features.sql similarity index 100% rename from migrations/postgres/V8__media_server_features.sql rename to crates/pinakes-migrations/migrations/postgres/V8__media_server_features.sql diff --git a/migrations/postgres/V9__fix_indexes_and_constraints.sql b/crates/pinakes-migrations/migrations/postgres/V9__fix_indexes_and_constraints.sql similarity index 100% rename from migrations/postgres/V9__fix_indexes_and_constraints.sql rename to crates/pinakes-migrations/migrations/postgres/V9__fix_indexes_and_constraints.sql diff --git a/migrations/sqlite/V10__incremental_scan.sql b/crates/pinakes-migrations/migrations/sqlite/V10__incremental_scan.sql similarity index 100% rename from migrations/sqlite/V10__incremental_scan.sql rename to crates/pinakes-migrations/migrations/sqlite/V10__incremental_scan.sql diff --git a/migrations/sqlite/V11__session_persistence.sql b/crates/pinakes-migrations/migrations/sqlite/V11__session_persistence.sql similarity index 100% rename from migrations/sqlite/V11__session_persistence.sql rename to crates/pinakes-migrations/migrations/sqlite/V11__session_persistence.sql diff --git a/migrations/sqlite/V12__book_management.sql b/crates/pinakes-migrations/migrations/sqlite/V12__book_management.sql similarity index 100% rename from migrations/sqlite/V12__book_management.sql rename to crates/pinakes-migrations/migrations/sqlite/V12__book_management.sql diff --git a/migrations/sqlite/V13__photo_metadata.sql b/crates/pinakes-migrations/migrations/sqlite/V13__photo_metadata.sql similarity index 100% rename from migrations/sqlite/V13__photo_metadata.sql rename to crates/pinakes-migrations/migrations/sqlite/V13__photo_metadata.sql diff --git a/migrations/sqlite/V14__perceptual_hash.sql b/crates/pinakes-migrations/migrations/sqlite/V14__perceptual_hash.sql similarity index 100% rename from migrations/sqlite/V14__perceptual_hash.sql rename to crates/pinakes-migrations/migrations/sqlite/V14__perceptual_hash.sql diff --git a/migrations/sqlite/V15__managed_storage.sql b/crates/pinakes-migrations/migrations/sqlite/V15__managed_storage.sql similarity index 100% rename from migrations/sqlite/V15__managed_storage.sql rename to crates/pinakes-migrations/migrations/sqlite/V15__managed_storage.sql diff --git a/migrations/sqlite/V16__sync_system.sql b/crates/pinakes-migrations/migrations/sqlite/V16__sync_system.sql similarity index 100% rename from migrations/sqlite/V16__sync_system.sql rename to crates/pinakes-migrations/migrations/sqlite/V16__sync_system.sql diff --git a/migrations/sqlite/V17__enhanced_sharing.sql b/crates/pinakes-migrations/migrations/sqlite/V17__enhanced_sharing.sql similarity index 100% rename from migrations/sqlite/V17__enhanced_sharing.sql rename to crates/pinakes-migrations/migrations/sqlite/V17__enhanced_sharing.sql diff --git a/migrations/sqlite/V18__file_management.sql b/crates/pinakes-migrations/migrations/sqlite/V18__file_management.sql similarity index 100% rename from migrations/sqlite/V18__file_management.sql rename to crates/pinakes-migrations/migrations/sqlite/V18__file_management.sql diff --git a/migrations/sqlite/V19__markdown_links.sql b/crates/pinakes-migrations/migrations/sqlite/V19__markdown_links.sql similarity index 100% rename from migrations/sqlite/V19__markdown_links.sql rename to crates/pinakes-migrations/migrations/sqlite/V19__markdown_links.sql diff --git a/migrations/sqlite/V1__initial_schema.sql b/crates/pinakes-migrations/migrations/sqlite/V1__initial_schema.sql similarity index 100% rename from migrations/sqlite/V1__initial_schema.sql rename to crates/pinakes-migrations/migrations/sqlite/V1__initial_schema.sql diff --git a/migrations/sqlite/V2__fts5_indexes.sql b/crates/pinakes-migrations/migrations/sqlite/V2__fts5_indexes.sql similarity index 100% rename from migrations/sqlite/V2__fts5_indexes.sql rename to crates/pinakes-migrations/migrations/sqlite/V2__fts5_indexes.sql diff --git a/migrations/sqlite/V3__audit_indexes.sql b/crates/pinakes-migrations/migrations/sqlite/V3__audit_indexes.sql similarity index 100% rename from migrations/sqlite/V3__audit_indexes.sql rename to crates/pinakes-migrations/migrations/sqlite/V3__audit_indexes.sql diff --git a/migrations/sqlite/V4__thumbnail_path.sql b/crates/pinakes-migrations/migrations/sqlite/V4__thumbnail_path.sql similarity index 100% rename from migrations/sqlite/V4__thumbnail_path.sql rename to crates/pinakes-migrations/migrations/sqlite/V4__thumbnail_path.sql diff --git a/migrations/sqlite/V5__integrity_and_saved_searches.sql b/crates/pinakes-migrations/migrations/sqlite/V5__integrity_and_saved_searches.sql similarity index 100% rename from migrations/sqlite/V5__integrity_and_saved_searches.sql rename to crates/pinakes-migrations/migrations/sqlite/V5__integrity_and_saved_searches.sql diff --git a/migrations/sqlite/V6__plugin_system.sql b/crates/pinakes-migrations/migrations/sqlite/V6__plugin_system.sql similarity index 100% rename from migrations/sqlite/V6__plugin_system.sql rename to crates/pinakes-migrations/migrations/sqlite/V6__plugin_system.sql diff --git a/migrations/sqlite/V7__user_management.sql b/crates/pinakes-migrations/migrations/sqlite/V7__user_management.sql similarity index 100% rename from migrations/sqlite/V7__user_management.sql rename to crates/pinakes-migrations/migrations/sqlite/V7__user_management.sql diff --git a/migrations/sqlite/V8__media_server_features.sql b/crates/pinakes-migrations/migrations/sqlite/V8__media_server_features.sql similarity index 100% rename from migrations/sqlite/V8__media_server_features.sql rename to crates/pinakes-migrations/migrations/sqlite/V8__media_server_features.sql diff --git a/migrations/sqlite/V9__fix_indexes_and_constraints.sql b/crates/pinakes-migrations/migrations/sqlite/V9__fix_indexes_and_constraints.sql similarity index 100% rename from migrations/sqlite/V9__fix_indexes_and_constraints.sql rename to crates/pinakes-migrations/migrations/sqlite/V9__fix_indexes_and_constraints.sql diff --git a/crates/pinakes-migrations/src/lib.rs b/crates/pinakes-migrations/src/lib.rs new file mode 100644 index 0000000..8092426 --- /dev/null +++ b/crates/pinakes-migrations/src/lib.rs @@ -0,0 +1,42 @@ +use rusqlite_migration::{M, Migrations}; + +mod postgres_migrations { + use refinery::embed_migrations; + embed_migrations!("migrations/postgres"); +} + +pub fn sqlite_migrations() -> Migrations<'static> { + Migrations::new(vec![ + M::up(include_str!("../migrations/sqlite/V1__initial_schema.sql")), + M::up(include_str!("../migrations/sqlite/V2__fts5_indexes.sql")), + M::up(include_str!("../migrations/sqlite/V3__audit_indexes.sql")), + M::up(include_str!("../migrations/sqlite/V4__thumbnail_path.sql")), + M::up(include_str!( + "../migrations/sqlite/V5__integrity_and_saved_searches.sql" + )), + M::up(include_str!("../migrations/sqlite/V6__plugin_system.sql")), + M::up(include_str!("../migrations/sqlite/V7__user_management.sql")), + M::up(include_str!( + "../migrations/sqlite/V8__media_server_features.sql" + )), + M::up(include_str!( + "../migrations/sqlite/V9__fix_indexes_and_constraints.sql" + )), + M::up(include_str!("../migrations/sqlite/V10__incremental_scan.sql")), + M::up(include_str!( + "../migrations/sqlite/V11__session_persistence.sql" + )), + M::up(include_str!("../migrations/sqlite/V12__book_management.sql")), + M::up(include_str!("../migrations/sqlite/V13__photo_metadata.sql")), + M::up(include_str!("../migrations/sqlite/V14__perceptual_hash.sql")), + M::up(include_str!("../migrations/sqlite/V15__managed_storage.sql")), + M::up(include_str!("../migrations/sqlite/V16__sync_system.sql")), + M::up(include_str!("../migrations/sqlite/V17__enhanced_sharing.sql")), + M::up(include_str!("../migrations/sqlite/V18__file_management.sql")), + M::up(include_str!("../migrations/sqlite/V19__markdown_links.sql")), + ]) +} + +pub fn postgres_runner() -> refinery::Runner { + postgres_migrations::migrations::runner() +} From e955f167b94d6087b7f667e16c5712df5183d07e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 May 2026 17:34:42 +0300 Subject: [PATCH 16/22] treewide: extract various components from pinakes-core into their own crates Signed-off-by: NotAShelf Change-Id: Ida2b25c66d62b40e75eeee924fe9c39c6a6a6964 --- Cargo.toml | 65 +- crates/pinakes-enrichment/Cargo.toml | 22 + crates/pinakes-enrichment/src/books.rs | 298 +++ crates/pinakes-enrichment/src/googlebooks.rs | 294 +++ crates/pinakes-enrichment/src/lastfm.rs | 116 ++ crates/pinakes-enrichment/src/lib.rs | 76 + crates/pinakes-enrichment/src/mod.rs | 79 + crates/pinakes-enrichment/src/musicbrainz.rs | 148 ++ crates/pinakes-enrichment/src/openlibrary.rs | 307 +++ crates/pinakes-enrichment/src/tmdb.rs | 125 ++ crates/pinakes-metadata/Cargo.toml | 23 + crates/pinakes-metadata/src/audio.rs | 91 + crates/pinakes-metadata/src/document.rs | 460 +++++ crates/pinakes-metadata/src/image.rs | 300 +++ crates/pinakes-metadata/src/lib.rs | 73 + crates/pinakes-metadata/src/markdown.rs | 46 + crates/pinakes-metadata/src/mod.rs | 70 + crates/pinakes-metadata/src/video.rs | 129 ++ crates/pinakes-plugin/Cargo.toml | 29 + crates/pinakes-plugin/src/lib.rs | 15 + crates/pinakes-plugin/src/loader.rs | 432 ++++ crates/pinakes-plugin/src/manager.rs | 916 +++++++++ crates/pinakes-plugin/src/registry.rs | 309 +++ crates/pinakes-plugin/src/rpc.rs | 240 +++ crates/pinakes-plugin/src/runtime.rs | 925 +++++++++ crates/pinakes-plugin/src/security.rs | 473 +++++ crates/pinakes-plugin/src/signature.rs | 252 +++ crates/pinakes-sync/Cargo.toml | 21 + crates/pinakes-sync/src/chunked.rs | 326 +++ crates/pinakes-sync/src/conflict.rs | 148 ++ crates/pinakes-sync/src/lib.rs | 7 + crates/pinakes-sync/src/models.rs | 382 ++++ crates/pinakes-types/Cargo.toml | 18 + crates/pinakes-types/src/config.rs | 1761 +++++++++++++++++ crates/pinakes-types/src/error.rs | 142 ++ crates/pinakes-types/src/lib.rs | 4 + .../pinakes-types/src/media_type/builtin.rs | 292 +++ crates/pinakes-types/src/media_type/mod.rs | 281 +++ .../pinakes-types/src/media_type/registry.rs | 297 +++ crates/pinakes-types/src/model.rs | 688 +++++++ 40 files changed, 10654 insertions(+), 26 deletions(-) create mode 100644 crates/pinakes-enrichment/Cargo.toml create mode 100644 crates/pinakes-enrichment/src/books.rs create mode 100644 crates/pinakes-enrichment/src/googlebooks.rs create mode 100644 crates/pinakes-enrichment/src/lastfm.rs create mode 100644 crates/pinakes-enrichment/src/lib.rs create mode 100644 crates/pinakes-enrichment/src/mod.rs create mode 100644 crates/pinakes-enrichment/src/musicbrainz.rs create mode 100644 crates/pinakes-enrichment/src/openlibrary.rs create mode 100644 crates/pinakes-enrichment/src/tmdb.rs create mode 100644 crates/pinakes-metadata/Cargo.toml create mode 100644 crates/pinakes-metadata/src/audio.rs create mode 100644 crates/pinakes-metadata/src/document.rs create mode 100644 crates/pinakes-metadata/src/image.rs create mode 100644 crates/pinakes-metadata/src/lib.rs create mode 100644 crates/pinakes-metadata/src/markdown.rs create mode 100644 crates/pinakes-metadata/src/mod.rs create mode 100644 crates/pinakes-metadata/src/video.rs create mode 100644 crates/pinakes-plugin/Cargo.toml create mode 100644 crates/pinakes-plugin/src/lib.rs create mode 100644 crates/pinakes-plugin/src/loader.rs create mode 100644 crates/pinakes-plugin/src/manager.rs create mode 100644 crates/pinakes-plugin/src/registry.rs create mode 100644 crates/pinakes-plugin/src/rpc.rs create mode 100644 crates/pinakes-plugin/src/runtime.rs create mode 100644 crates/pinakes-plugin/src/security.rs create mode 100644 crates/pinakes-plugin/src/signature.rs create mode 100644 crates/pinakes-sync/Cargo.toml create mode 100644 crates/pinakes-sync/src/chunked.rs create mode 100644 crates/pinakes-sync/src/conflict.rs create mode 100644 crates/pinakes-sync/src/lib.rs create mode 100644 crates/pinakes-sync/src/models.rs create mode 100644 crates/pinakes-types/Cargo.toml create mode 100644 crates/pinakes-types/src/config.rs create mode 100644 crates/pinakes-types/src/error.rs create mode 100644 crates/pinakes-types/src/lib.rs create mode 100644 crates/pinakes-types/src/media_type/builtin.rs create mode 100644 crates/pinakes-types/src/media_type/mod.rs create mode 100644 crates/pinakes-types/src/media_type/registry.rs create mode 100644 crates/pinakes-types/src/model.rs diff --git a/Cargo.toml b/Cargo.toml index 395f440..a52b16f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,12 @@ [workspace] members = ["crates/*", "packages/*", "xtask"] -exclude = ["crates/pinakes-core/tests/fixtures/test-plugin"] +exclude = [ + "crates/pinakes-core/tests/fixtures/test-plugin", + "examples/plugins/auto-tagger", + "examples/plugins/text-enrichment", + "examples/plugins/subtitle-detector", + "examples/plugins/cbz-comics", +] resolver = "3" [workspace.package] @@ -15,6 +21,12 @@ rust-version = "1.95.0" # follows nightly Rust # while building any package. pinakes-core = { path = "./crates/pinakes-core" } pinakes-plugin-api = { path = "./crates/pinakes-plugin-api" } +pinakes-migrations = { path = "./crates/pinakes-migrations" } +pinakes-types = { path = "./crates/pinakes-types" } +pinakes-metadata = { path = "./crates/pinakes-metadata" } +pinakes-plugin = { path = "./crates/pinakes-plugin" } +pinakes-enrichment = { path = "./crates/pinakes-enrichment" } +pinakes-sync = { path = "./crates/pinakes-sync" } # Pinakes itself is a REST API server. UI and TUI are official visual components # that connect to the server. Using the API documentation, the user can write @@ -27,53 +39,54 @@ pinakes-tui = { path = "./packages/pinakes-tui" } # Other dependencies. Declaring them in the virtual manifests lets use reuse the crates # without having to track individual crate version across different types of crates. This # also includes *dev* dependencies. -tokio = { version = "1.50.0", features = ["full"] } +tokio = { version = "1.52.3", features = ["full"] } tokio-util = { version = "0.7.18", features = ["rt"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -toml = "1.0.7" -clap = { version = "4.6.0", features = ["derive", "env"] } +toml = "1.1.2" +clap = { version = "4.6.1", features = ["derive", "env"] } chrono = { version = "0.4.44", features = ["serde"] } -uuid = { version = "1.22.0", features = ["v7", "serde"] } +uuid = { version = "1.23.1", features = ["v7", "serde"] } thiserror = "2.0.18" anyhow = "1.0.102" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } -blake3 = "1.8.3" -rustc-hash = "2.1.1" +blake3 = "1.8.5" +rustc-hash = "2.1.2" ed25519-dalek = { version = "2.2.0", features = ["std"] } -lofty = "0.23.3" +lofty = "0.24.0" lopdf = "0.40.0" epub = "2.1.5" -matroska = "0.30.0" +matroska = "0.30.1" gray_matter = "0.3.2" kamadak-exif = "0.6.1" -rusqlite = { version = "0.37.0", features = ["bundled", "column_decltype"] } -tokio-postgres = { version = "0.7.16", features = [ +rusqlite = { version = "0.39.0", features = ["bundled", "column_decltype"] } +tokio-postgres = { version = "0.7.17", features = [ "with-uuid-1", "with-chrono-0_4", "with-serde_json-1", ] } deadpool-postgres = "0.14.1" -postgres-types = { version = "0.2.12", features = ["derive"] } -postgres-native-tls = "0.5.2" +postgres-types = { version = "0.2.13", features = ["derive"] } +postgres-native-tls = "0.5.3" native-tls = "0.2.18" -refinery = { version = "0.9.0", features = ["rusqlite", "tokio-postgres"] } +refinery = { version = "0.9.1", features = ["tokio-postgres"] } +rusqlite_migration = "2.5.0" walkdir = "2.5.0" notify = { version = "8.2.0", features = ["macos_fsevent"] } -winnow = "1.0.0" -axum = { version = "0.8.8", features = ["macros", "multipart"] } +winnow = "1.0.3" +axum = { version = "0.8.9", features = ["macros", "multipart"] } axum-server = { version = "0.8.0" } tower = "0.5.3" -tower-http = { version = "0.6.8", features = ["cors", "trace", "set-header"] } +tower-http = { version = "0.6.11", features = ["cors", "trace", "set-header"] } governor = "0.10.4" tower_governor = "0.8.0" -reqwest = { version = "0.13.2", features = ["json", "query", "blocking"] } +reqwest = { version = "0.13.3", features = ["json", "query", "blocking"] } url = "2.5" ratatui = "0.30.0" crossterm = "0.29.0" -dioxus = { version = "0.7.3", features = ["desktop", "router"] } -dioxus-core = { version = "0.7.3" } +dioxus = { version = "0.7.9", features = ["desktop", "router"] } +dioxus-core = { version = "0.7.9" } async-trait = "0.1.89" futures = "0.3.32" image = { version = "0.25.10", default-features = false, features = [ @@ -84,24 +97,24 @@ image = { version = "0.25.10", default-features = false, features = [ "tiff", "bmp", ] } -pulldown-cmark = "0.13.3" +pulldown-cmark = "0.13.4" ammonia = "4.1.2" argon2 = { version = "0.5.3", features = ["std"] } mime_guess = "2.0.5" regex = "1.12.3" dioxus-free-icons = { version = "0.10.0", features = ["font-awesome-solid"] } rfd = "0.17.2" -gloo-timers = { version = "0.3.0", features = ["futures"] } -rand = "0.10.0" +gloo-timers = { version = "0.4.0", features = ["futures"] } +rand = "0.10.1" moka = { version = "0.12.15", features = ["future"] } urlencoding = "2.1.3" image_hasher = "3.1.1" percent-encoding = "2.3.2" http = "1.4.0" -wasmtime = { version = "43.0.0", features = ["component-model"] } -wit-bindgen = "0.54.0" +wasmtime = { version = "44.0.1", features = ["component-model"] } +wit-bindgen = "0.57.1" tempfile = "3.27.0" -utoipa = { version = "5.4.0", features = ["axum_extras", "uuid", "chrono"] } +utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] } utoipa-axum = { version = "0.2.0" } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } http-body-util = "0.1.3" diff --git a/crates/pinakes-enrichment/Cargo.toml b/crates/pinakes-enrichment/Cargo.toml new file mode 100644 index 0000000..bffcdc1 --- /dev/null +++ b/crates/pinakes-enrichment/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "pinakes-enrichment" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +pinakes-types = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +async-trait = { workspace = true } +regex = { workspace = true } +urlencoding = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pinakes-enrichment/src/books.rs b/crates/pinakes-enrichment/src/books.rs new file mode 100644 index 0000000..63b09c6 --- /dev/null +++ b/crates/pinakes-enrichment/src/books.rs @@ -0,0 +1,298 @@ +use std::sync::LazyLock; + +use chrono::Utc; +use pinakes_types::{ + error::{PinakesError, Result}, + model::MediaItem, +}; +use uuid::Uuid; + +use super::{ + EnrichmentSourceType, + ExternalMetadata, + MetadataEnricher, + googlebooks::GoogleBooksClient, + openlibrary::OpenLibraryClient, +}; + +// --- ISBN helper (duplicated from pinakes-core::books to avoid circular dep) +// --- +static ISBN_PATTERNS: LazyLock> = LazyLock::new(|| { + [ + r"ISBN(?:-13)?(?:\s+is|:)?\s*(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)", + r"ISBN(?:-10)?(?:\s+is|:)?\s*(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])", + r"ISBN(?:-13)?\s+(\d{13})", + r"ISBN(?:-10)?\s+(\d{9}[\dXx])", + r"\b(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)\b", + r"\b(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])\b", + ] + .iter() + .filter_map(|p| regex::Regex::new(p).ok()) + .collect() +}); + +fn extract_isbn_from_text(text: &str) -> Option { + for pattern in ISBN_PATTERNS.iter() { + if let Some(captures) = pattern.captures(text) + && let Some(isbn) = captures.get(1) + { + return Some(isbn.as_str().to_string()); + } + } + None +} + +/// Book enricher that tries `OpenLibrary` first, then falls back to Google +/// Books +pub struct BookEnricher { + openlibrary: OpenLibraryClient, + googlebooks: GoogleBooksClient, +} + +impl BookEnricher { + #[must_use] + pub fn new(google_api_key: Option) -> Self { + Self { + openlibrary: OpenLibraryClient::new(), + googlebooks: GoogleBooksClient::new(google_api_key), + } + } + + /// Try to enrich from `OpenLibrary` first + /// + /// # Errors + /// + /// Returns an error if the metadata cannot be serialized. + pub async fn try_openlibrary( + &self, + isbn: &str, + ) -> Result> { + match self.openlibrary.fetch_by_isbn(isbn).await { + Ok(book) => { + let metadata_json = serde_json::to_string(&book).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {e}")) + })?; + + Ok(Some(ExternalMetadata { + id: Uuid::new_v4(), + media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */ + source: EnrichmentSourceType::OpenLibrary, + external_id: None, + metadata_json, + confidence: calculate_openlibrary_confidence(&book), + last_updated: Utc::now(), + })) + }, + Err(_) => Ok(None), + } + } + + /// Try to enrich from Google Books + /// + /// # Errors + /// + /// Returns an error if the metadata cannot be serialized. + pub async fn try_googlebooks( + &self, + isbn: &str, + ) -> Result> { + match self.googlebooks.fetch_by_isbn(isbn).await { + Ok(books) if !books.is_empty() => { + let book = &books[0]; + let metadata_json = serde_json::to_string(book).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {e}")) + })?; + + Ok(Some(ExternalMetadata { + id: Uuid::new_v4(), + media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */ + source: EnrichmentSourceType::GoogleBooks, + external_id: Some(book.id.clone()), + metadata_json, + confidence: calculate_googlebooks_confidence(&book.volume_info), + last_updated: Utc::now(), + })) + }, + _ => Ok(None), + } + } + + /// Try to enrich by searching with title and author + /// + /// # Errors + /// + /// Returns an error if the metadata cannot be serialized. + pub async fn enrich_by_search( + &self, + title: &str, + author: Option<&str>, + ) -> Result> { + // Try OpenLibrary search first + if let Ok(results) = self.openlibrary.search(title, author).await + && let Some(result) = results.first() + { + let metadata_json = serde_json::to_string(result).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {e}")) + })?; + + return Ok(Some(ExternalMetadata { + id: Uuid::new_v4(), + media_id: pinakes_types::model::MediaId(Uuid::nil()), + source: EnrichmentSourceType::OpenLibrary, + external_id: result.key.clone(), + metadata_json, + confidence: 0.6, // Lower confidence for search results + last_updated: Utc::now(), + })); + } + + // Fall back to Google Books + if let Ok(results) = self.googlebooks.search(title, author).await + && let Some(book) = results.first() + { + let metadata_json = serde_json::to_string(book).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {e}")) + })?; + + return Ok(Some(ExternalMetadata { + id: Uuid::new_v4(), + media_id: pinakes_types::model::MediaId(Uuid::nil()), + source: EnrichmentSourceType::GoogleBooks, + external_id: Some(book.id.clone()), + metadata_json, + confidence: 0.6, + last_updated: Utc::now(), + })); + } + + Ok(None) + } +} + +#[async_trait::async_trait] +impl MetadataEnricher for BookEnricher { + fn source(&self) -> EnrichmentSourceType { + // Returns the preferred source + EnrichmentSourceType::OpenLibrary + } + + async fn enrich(&self, item: &MediaItem) -> Result> { + // Try ISBN-based enrichment first by checking title/description for ISBN + // patterns + if let Some(ref title) = item.title { + if let Some(isbn) = extract_isbn_from_text(title) { + if let Some(mut metadata) = self.try_openlibrary(&isbn).await? { + metadata.media_id = item.id; + return Ok(Some(metadata)); + } + if let Some(mut metadata) = self.try_googlebooks(&isbn).await? { + metadata.media_id = item.id; + return Ok(Some(metadata)); + } + } + + // Fall back to title/author search + let author = item.artist.as_deref(); + return self.enrich_by_search(title, author).await; + } + + // No title available + Ok(None) + } +} + +/// Calculate confidence score for `OpenLibrary` metadata +#[must_use] +pub fn calculate_openlibrary_confidence( + book: &super::openlibrary::OpenLibraryBook, +) -> f64 { + let mut score: f64 = 0.5; // Base score + + if book.title.is_some() { + score += 0.1; + } + if !book.authors.is_empty() { + score += 0.1; + } + if !book.publishers.is_empty() { + score += 0.05; + } + if book.publish_date.is_some() { + score += 0.05; + } + if book.description.is_some() { + score += 0.1; + } + if !book.covers.is_empty() { + score += 0.1; + } + + score.min(1.0) +} + +/// Calculate confidence score for Google Books metadata +#[must_use] +pub fn calculate_googlebooks_confidence( + info: &super::googlebooks::VolumeInfo, +) -> f64 { + let mut score: f64 = 0.5; // Base score + + if info.title.is_some() { + score += 0.1; + } + if !info.authors.is_empty() { + score += 0.1; + } + if info.publisher.is_some() { + score += 0.05; + } + if info.published_date.is_some() { + score += 0.05; + } + if info.description.is_some() { + score += 0.1; + } + if info.image_links.is_some() { + score += 0.1; + } + + score.min(1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_openlibrary_confidence_calculation() { + let book = super::super::openlibrary::OpenLibraryBook { + title: Some("Test Book".to_string()), + subtitle: None, + authors: vec![], + publishers: vec![], + publish_date: None, + number_of_pages: None, + subjects: vec![], + covers: vec![], + isbn_10: vec![], + isbn_13: vec![], + series: vec![], + description: None, + languages: vec![], + }; + + let confidence = calculate_openlibrary_confidence(&book); + assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title + } + + #[test] + fn test_googlebooks_confidence_calculation() { + let info = super::super::googlebooks::VolumeInfo { + title: Some("Test Book".to_string()), + ..Default::default() + }; + + let confidence = calculate_googlebooks_confidence(&info); + assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title + } +} diff --git a/crates/pinakes-enrichment/src/googlebooks.rs b/crates/pinakes-enrichment/src/googlebooks.rs new file mode 100644 index 0000000..1b4abe2 --- /dev/null +++ b/crates/pinakes-enrichment/src/googlebooks.rs @@ -0,0 +1,294 @@ +use std::fmt::Write as _; + +use pinakes_types::error::{PinakesError, Result}; +use serde::{Deserialize, Serialize}; + +/// Google Books API client for book metadata enrichment +pub struct GoogleBooksClient { + client: reqwest::Client, + api_key: Option, +} + +impl GoogleBooksClient { + /// Create a new `GoogleBooksClient`. + #[must_use] + pub fn new(api_key: Option) -> Self { + let client = reqwest::Client::builder() + .user_agent("Pinakes/1.0") + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { client, api_key } + } + + /// Fetch book metadata by ISBN + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be + /// parsed. + pub async fn fetch_by_isbn(&self, isbn: &str) -> Result> { + let mut url = + format!("https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}"); + + if let Some(ref key) = self.api_key { + let _ = write!(url, "&key={key}"); + } + + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Google Books request failed: {e}")) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Google Books returned status: {}", + response.status() + ))); + } + + let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { + PinakesError::External(format!( + "Failed to parse Google Books response: {e}" + )) + })?; + + Ok(volumes.items) + } + + /// Search for books by title and author + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be + /// parsed. + pub async fn search( + &self, + title: &str, + author: Option<&str>, + ) -> Result> { + let mut query = format!("intitle:{}", urlencoding::encode(title)); + + if let Some(author) = author { + let _ = write!(query, "+inauthor:{}", urlencoding::encode(author)); + } + + let mut url = format!( + "https://www.googleapis.com/books/v1/volumes?q={query}&maxResults=5" + ); + + if let Some(ref key) = self.api_key { + let _ = write!(url, "&key={key}"); + } + + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Google Books search failed: {e}")) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Google Books search returned status: {}", + response.status() + ))); + } + + let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { + PinakesError::External(format!("Failed to parse search results: {e}")) + })?; + + Ok(volumes.items) + } + + /// Download cover image from Google Books + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be + /// read. + pub async fn fetch_cover(&self, image_link: &str) -> Result> { + // Replace thumbnail link with higher resolution if possible + let high_res_link = image_link + .replace("&zoom=1", "&zoom=2") + .replace("&edge=curl", ""); + + let response = + self.client.get(&high_res_link).send().await.map_err(|e| { + PinakesError::External(format!("Cover download failed: {e}")) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Cover download returned status: {}", + response.status() + ))); + } + + response.bytes().await.map(|b| b.to_vec()).map_err(|e| { + PinakesError::External(format!("Failed to read cover data: {e}")) + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GoogleBooksResponse { + #[serde(default)] + pub items: Vec, + + #[serde(default)] + pub total_items: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GoogleBook { + pub id: String, + + #[serde(default)] + pub volume_info: VolumeInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VolumeInfo { + #[serde(default)] + pub title: Option, + + #[serde(default)] + pub subtitle: Option, + + #[serde(default)] + pub authors: Vec, + + #[serde(default)] + pub publisher: Option, + + #[serde(default)] + pub published_date: Option, + + #[serde(default)] + pub description: Option, + + #[serde(default)] + pub page_count: Option, + + #[serde(default)] + pub categories: Vec, + + #[serde(default)] + pub average_rating: Option, + + #[serde(default)] + pub ratings_count: Option, + + #[serde(default)] + pub image_links: Option, + + #[serde(default)] + pub language: Option, + + #[serde(default)] + pub industry_identifiers: Vec, + + #[serde(default)] + pub main_category: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageLinks { + #[serde(default)] + pub small_thumbnail: Option, + + #[serde(default)] + pub thumbnail: Option, + + #[serde(default)] + pub small: Option, + + #[serde(default)] + pub medium: Option, + + #[serde(default)] + pub large: Option, + + #[serde(default)] + pub extra_large: Option, +} + +impl ImageLinks { + /// Get the best available image link (highest resolution) + #[must_use] + pub fn best_link(&self) -> Option<&String> { + self + .extra_large + .as_ref() + .or(self.large.as_ref()) + .or(self.medium.as_ref()) + .or(self.small.as_ref()) + .or(self.thumbnail.as_ref()) + .or(self.small_thumbnail.as_ref()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndustryIdentifier { + #[serde(rename = "type")] + pub identifier_type: String, + + pub identifier: String, +} + +impl IndustryIdentifier { + /// Check if this is an ISBN-13 + #[must_use] + pub fn is_isbn13(&self) -> bool { + self.identifier_type == "ISBN_13" + } + + /// Check if this is an ISBN-10 + #[must_use] + pub fn is_isbn10(&self) -> bool { + self.identifier_type == "ISBN_10" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_googlebooks_client_creation() { + let client = GoogleBooksClient::new(None); + assert!(client.api_key.is_none()); + + let client_with_key = GoogleBooksClient::new(Some("test-key".to_string())); + assert_eq!(client_with_key.api_key, Some("test-key".to_string())); + } + + #[test] + fn test_image_links_best_link() { + let links = ImageLinks { + small_thumbnail: Some("small.jpg".to_string()), + thumbnail: Some("thumb.jpg".to_string()), + small: None, + medium: Some("medium.jpg".to_string()), + large: Some("large.jpg".to_string()), + extra_large: None, + }; + + assert_eq!(links.best_link(), Some(&"large.jpg".to_string())); + } + + #[test] + fn test_industry_identifier_type_checks() { + let isbn13 = IndustryIdentifier { + identifier_type: "ISBN_13".to_string(), + identifier: "9780123456789".to_string(), + }; + assert!(isbn13.is_isbn13()); + assert!(!isbn13.is_isbn10()); + + let isbn10 = IndustryIdentifier { + identifier_type: "ISBN_10".to_string(), + identifier: "0123456789".to_string(), + }; + assert!(!isbn10.is_isbn13()); + assert!(isbn10.is_isbn10()); + } +} diff --git a/crates/pinakes-enrichment/src/lastfm.rs b/crates/pinakes-enrichment/src/lastfm.rs new file mode 100644 index 0000000..cdcb4bd --- /dev/null +++ b/crates/pinakes-enrichment/src/lastfm.rs @@ -0,0 +1,116 @@ +//! Last.fm metadata enrichment for audio files. + +use std::time::Duration; + +use chrono::Utc; +use pinakes_types::{ + error::{PinakesError, Result}, + model::MediaItem, +}; +use uuid::Uuid; + +use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; + +pub struct LastFmEnricher { + client: reqwest::Client, + api_key: String, + base_url: String, +} + +impl LastFmEnricher { + /// Create a new `LastFmEnricher`. + #[must_use] + pub fn new(api_key: String) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { + client, + api_key, + base_url: "https://ws.audioscrobbler.com/2.0".to_string(), + } + } +} + +#[async_trait::async_trait] +impl MetadataEnricher for LastFmEnricher { + fn source(&self) -> EnrichmentSourceType { + EnrichmentSourceType::LastFm + } + + async fn enrich(&self, item: &MediaItem) -> Result> { + let artist = match &item.artist { + Some(a) if !a.is_empty() => a, + _ => return Ok(None), + }; + + let title = match &item.title { + Some(t) if !t.is_empty() => t, + _ => return Ok(None), + }; + + let url = format!("{}/", self.base_url); + + let resp = self + .client + .get(&url) + .query(&[ + ("method", "track.getInfo"), + ("api_key", self.api_key.as_str()), + ("artist", artist.as_str()), + ("track", title.as_str()), + ("format", "json"), + ]) + .send() + .await + .map_err(|e| { + PinakesError::MetadataExtraction(format!("Last.fm request failed: {e}")) + })?; + + if !resp.status().is_success() { + return Ok(None); + } + + let body = resp.text().await.map_err(|e| { + PinakesError::MetadataExtraction(format!( + "Last.fm response read failed: {e}" + )) + })?; + + let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + PinakesError::MetadataExtraction(format!( + "Last.fm JSON parse failed: {e}" + )) + })?; + + // Check for error response + if json.get("error").is_some() { + return Ok(None); + } + + let Some(track) = json.get("track") else { + return Ok(None); + }; + + let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from); + let listeners = track + .get("listeners") + .and_then(|l| l.as_str()) + .and_then(|l| l.parse::().ok()) + .unwrap_or(0.0); + // Normalize listeners to confidence (arbitrary scale) + let confidence = (listeners / 1_000_000.0).min(1.0); + + Ok(Some(ExternalMetadata { + id: Uuid::now_v7(), + media_id: item.id, + source: EnrichmentSourceType::LastFm, + external_id: mbid, + metadata_json: body, + confidence, + last_updated: Utc::now(), + })) + } +} diff --git a/crates/pinakes-enrichment/src/lib.rs b/crates/pinakes-enrichment/src/lib.rs new file mode 100644 index 0000000..ed6c3dc --- /dev/null +++ b/crates/pinakes-enrichment/src/lib.rs @@ -0,0 +1,76 @@ +pub mod books; +pub mod googlebooks; +pub mod lastfm; +pub mod musicbrainz; +pub mod openlibrary; +pub mod tmdb; + +use chrono::{DateTime, Utc}; +use pinakes_types::{ + error::Result, + model::{MediaId, MediaItem}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Externally-sourced metadata for a media item. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalMetadata { + pub id: Uuid, + pub media_id: MediaId, + pub source: EnrichmentSourceType, + pub external_id: Option, + pub metadata_json: String, + pub confidence: f64, + pub last_updated: DateTime, +} + +/// Supported enrichment data sources. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnrichmentSourceType { + #[serde(rename = "musicbrainz")] + MusicBrainz, + #[serde(rename = "tmdb")] + Tmdb, + #[serde(rename = "lastfm")] + LastFm, + #[serde(rename = "openlibrary")] + OpenLibrary, + #[serde(rename = "googlebooks")] + GoogleBooks, +} + +impl std::fmt::Display for EnrichmentSourceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::MusicBrainz => "musicbrainz", + Self::Tmdb => "tmdb", + Self::LastFm => "lastfm", + Self::OpenLibrary => "openlibrary", + Self::GoogleBooks => "googlebooks", + }; + write!(f, "{s}") + } +} + +impl std::str::FromStr for EnrichmentSourceType { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s { + "musicbrainz" => Ok(Self::MusicBrainz), + "tmdb" => Ok(Self::Tmdb), + "lastfm" => Ok(Self::LastFm), + "openlibrary" => Ok(Self::OpenLibrary), + "googlebooks" => Ok(Self::GoogleBooks), + _ => Err(format!("unknown enrichment source: {s}")), + } + } +} + +/// Trait for metadata enrichment providers. +#[async_trait::async_trait] +pub trait MetadataEnricher: Send + Sync { + fn source(&self) -> EnrichmentSourceType; + async fn enrich(&self, item: &MediaItem) -> Result>; +} diff --git a/crates/pinakes-enrichment/src/mod.rs b/crates/pinakes-enrichment/src/mod.rs new file mode 100644 index 0000000..527601d --- /dev/null +++ b/crates/pinakes-enrichment/src/mod.rs @@ -0,0 +1,79 @@ +//! Metadata enrichment from external sources. + +pub mod books; +pub mod googlebooks; +pub mod lastfm; +pub mod musicbrainz; +pub mod openlibrary; +pub mod tmdb; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use pinakes_types::{ + error::Result, + model::{MediaId, MediaItem}, +}; + +/// Externally-sourced metadata for a media item. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalMetadata { + pub id: Uuid, + pub media_id: MediaId, + pub source: EnrichmentSourceType, + pub external_id: Option, + pub metadata_json: String, + pub confidence: f64, + pub last_updated: DateTime, +} + +/// Supported enrichment data sources. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnrichmentSourceType { + #[serde(rename = "musicbrainz")] + MusicBrainz, + #[serde(rename = "tmdb")] + Tmdb, + #[serde(rename = "lastfm")] + LastFm, + #[serde(rename = "openlibrary")] + OpenLibrary, + #[serde(rename = "googlebooks")] + GoogleBooks, +} + +impl std::fmt::Display for EnrichmentSourceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::MusicBrainz => "musicbrainz", + Self::Tmdb => "tmdb", + Self::LastFm => "lastfm", + Self::OpenLibrary => "openlibrary", + Self::GoogleBooks => "googlebooks", + }; + write!(f, "{s}") + } +} + +impl std::str::FromStr for EnrichmentSourceType { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s { + "musicbrainz" => Ok(Self::MusicBrainz), + "tmdb" => Ok(Self::Tmdb), + "lastfm" => Ok(Self::LastFm), + "openlibrary" => Ok(Self::OpenLibrary), + "googlebooks" => Ok(Self::GoogleBooks), + _ => Err(format!("unknown enrichment source: {s}")), + } + } +} + +/// Trait for metadata enrichment providers. +#[async_trait::async_trait] +pub trait MetadataEnricher: Send + Sync { + fn source(&self) -> EnrichmentSourceType; + async fn enrich(&self, item: &MediaItem) -> Result>; +} diff --git a/crates/pinakes-enrichment/src/musicbrainz.rs b/crates/pinakes-enrichment/src/musicbrainz.rs new file mode 100644 index 0000000..344e6f0 --- /dev/null +++ b/crates/pinakes-enrichment/src/musicbrainz.rs @@ -0,0 +1,148 @@ +//! `MusicBrainz` metadata enrichment for audio files. + +use std::{fmt::Write as _, time::Duration}; + +use chrono::Utc; +use pinakes_types::{ + error::{PinakesError, Result}, + model::MediaItem, +}; +use uuid::Uuid; + +use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; + +pub struct MusicBrainzEnricher { + client: reqwest::Client, + base_url: String, +} + +impl Default for MusicBrainzEnricher { + fn default() -> Self { + Self::new() + } +} + +impl MusicBrainzEnricher { + /// Create a new `MusicBrainzEnricher`. + #[must_use] + pub fn new() -> Self { + let client = reqwest::Client::builder() + .user_agent("Pinakes/0.1 (https://github.com/notashelf/pinakes)") + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { + client, + base_url: "https://musicbrainz.org/ws/2".to_string(), + } + } +} + +fn escape_lucene_query(s: &str) -> String { + let special_chars = [ + '+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', + '?', ':', '\\', '/', + ]; + let mut escaped = String::with_capacity(s.len() * 2); + for c in s.chars() { + if special_chars.contains(&c) { + escaped.push('\\'); + } + escaped.push(c); + } + escaped +} + +#[async_trait::async_trait] +impl MetadataEnricher for MusicBrainzEnricher { + fn source(&self) -> EnrichmentSourceType { + EnrichmentSourceType::MusicBrainz + } + + async fn enrich(&self, item: &MediaItem) -> Result> { + let title = match &item.title { + Some(t) if !t.is_empty() => t, + _ => return Ok(None), + }; + + let mut query = format!("recording:{}", escape_lucene_query(title)); + if let Some(ref artist) = item.artist { + let _ = write!(query, " AND artist:{}", escape_lucene_query(artist)); + } + + let url = format!("{}/recording/", self.base_url); + + let resp = self + .client + .get(&url) + .query(&[ + ("query", &query), + ("fmt", &"json".to_string()), + ("limit", &"1".to_string()), + ]) + .send() + .await + .map_err(|e| { + PinakesError::MetadataExtraction(format!( + "MusicBrainz request failed: {e}" + )) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + if status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status == reqwest::StatusCode::SERVICE_UNAVAILABLE + { + return Err(PinakesError::MetadataExtraction(format!( + "MusicBrainz rate limited (HTTP {})", + status.as_u16() + ))); + } + return Ok(None); + } + + let body = resp.text().await.map_err(|e| { + PinakesError::MetadataExtraction(format!( + "MusicBrainz response read failed: {e}" + )) + })?; + + // Parse to check if we got results + let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + PinakesError::MetadataExtraction(format!( + "MusicBrainz JSON parse failed: {e}" + )) + })?; + + let recordings = json.get("recordings").and_then(|r| r.as_array()); + if recordings.is_none_or(std::vec::Vec::is_empty) { + return Ok(None); + } + + let Some(recordings) = recordings else { + return Ok(None); + }; + let recording = &recordings[0]; + let external_id = recording + .get("id") + .and_then(|id| id.as_str()) + .map(String::from); + let score = (recording + .get("score") + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0) + / 100.0) + .min(1.0); + + Ok(Some(ExternalMetadata { + id: Uuid::now_v7(), + media_id: item.id, + source: EnrichmentSourceType::MusicBrainz, + external_id, + metadata_json: body, + confidence: score, + last_updated: Utc::now(), + })) + } +} diff --git a/crates/pinakes-enrichment/src/openlibrary.rs b/crates/pinakes-enrichment/src/openlibrary.rs new file mode 100644 index 0000000..0dd4db7 --- /dev/null +++ b/crates/pinakes-enrichment/src/openlibrary.rs @@ -0,0 +1,307 @@ +use std::fmt::Write as _; + +use pinakes_types::error::{PinakesError, Result}; +use serde::{Deserialize, Serialize}; + +/// `OpenLibrary` API client for book metadata enrichment +pub struct OpenLibraryClient { + client: reqwest::Client, + base_url: String, +} + +impl Default for OpenLibraryClient { + fn default() -> Self { + Self::new() + } +} + +impl OpenLibraryClient { + /// Create a new `OpenLibraryClient`. + #[must_use] + pub fn new() -> Self { + let client = reqwest::Client::builder() + .user_agent("Pinakes/1.0") + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { + client, + base_url: "https://openlibrary.org".to_string(), + } + } + + /// Fetch book metadata by ISBN + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be + /// parsed. + pub async fn fetch_by_isbn(&self, isbn: &str) -> Result { + let url = format!("{}/isbn/{}.json", self.base_url, isbn); + + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("OpenLibrary request failed: {e}")) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "OpenLibrary returned status: {}", + response.status() + ))); + } + + response.json::().await.map_err(|e| { + PinakesError::External(format!( + "Failed to parse OpenLibrary response: {e}" + )) + }) + } + + /// Search for books by title and author + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be + /// parsed. + pub async fn search( + &self, + title: &str, + author: Option<&str>, + ) -> Result> { + let mut url = format!( + "{}/search.json?title={}", + self.base_url, + urlencoding::encode(title) + ); + + if let Some(author) = author { + let _ = write!(url, "&author={}", urlencoding::encode(author)); + } + + url.push_str("&limit=5"); + + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("OpenLibrary search failed: {e}")) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "OpenLibrary search returned status: {}", + response.status() + ))); + } + + let search_response: OpenLibrarySearchResponse = + response.json().await.map_err(|e| { + PinakesError::External(format!("Failed to parse search results: {e}")) + })?; + + Ok(search_response.docs) + } + + /// Fetch cover image by cover ID + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be + /// read. + pub async fn fetch_cover( + &self, + cover_id: i64, + size: CoverSize, + ) -> Result> { + let size_str = match size { + CoverSize::Small => "S", + CoverSize::Medium => "M", + CoverSize::Large => "L", + }; + + let url = + format!("https://covers.openlibrary.org/b/id/{cover_id}-{size_str}.jpg"); + + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Cover download failed: {e}")) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Cover download returned status: {}", + response.status() + ))); + } + + response.bytes().await.map(|b| b.to_vec()).map_err(|e| { + PinakesError::External(format!("Failed to read cover data: {e}")) + }) + } + + /// Fetch cover by ISBN + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be + /// read. + pub async fn fetch_cover_by_isbn( + &self, + isbn: &str, + size: CoverSize, + ) -> Result> { + let size_str = match size { + CoverSize::Small => "S", + CoverSize::Medium => "M", + CoverSize::Large => "L", + }; + + let url = + format!("https://covers.openlibrary.org/b/isbn/{isbn}-{size_str}.jpg"); + + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Cover download failed: {e}")) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Cover download returned status: {}", + response.status() + ))); + } + + response.bytes().await.map(|b| b.to_vec()).map_err(|e| { + PinakesError::External(format!("Failed to read cover data: {e}")) + }) + } +} + +#[derive(Debug, Clone, Copy)] +pub enum CoverSize { + Small, // 256x256 + Medium, // 600x800 + Large, // Original +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenLibraryBook { + #[serde(default)] + pub title: Option, + + #[serde(default)] + pub subtitle: Option, + + #[serde(default)] + pub authors: Vec, + + #[serde(default)] + pub publishers: Vec, + + #[serde(default)] + pub publish_date: Option, + + #[serde(default)] + pub number_of_pages: Option, + + #[serde(default)] + pub subjects: Vec, + + #[serde(default)] + pub covers: Vec, + + #[serde(default)] + pub isbn_10: Vec, + + #[serde(default)] + pub isbn_13: Vec, + + #[serde(default)] + pub series: Vec, + + #[serde(default)] + pub description: Option, + + #[serde(default)] + pub languages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorRef { + pub key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LanguageRef { + pub key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum StringOrObject { + String(String), + Object { value: String }, +} + +impl StringOrObject { + #[must_use] + pub fn as_str(&self) -> &str { + match self { + Self::String(s) => s, + Self::Object { value } => value, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenLibrarySearchResponse { + #[serde(default)] + pub docs: Vec, + + #[serde(default)] + pub num_found: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenLibrarySearchResult { + #[serde(default)] + pub key: Option, + + #[serde(default)] + pub title: Option, + + #[serde(default)] + pub author_name: Vec, + + #[serde(default)] + pub first_publish_year: Option, + + #[serde(default)] + pub publisher: Vec, + + #[serde(default)] + pub isbn: Vec, + + #[serde(default)] + pub cover_i: Option, + + #[serde(default)] + pub subject: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_openlibrary_client_creation() { + let client = OpenLibraryClient::new(); + assert_eq!(client.base_url, "https://openlibrary.org"); + } + + #[test] + fn test_string_or_object_parsing() { + let string_desc: StringOrObject = + serde_json::from_str(r#""Simple description""#).unwrap(); + assert_eq!(string_desc.as_str(), "Simple description"); + + let object_desc: StringOrObject = + serde_json::from_str(r#"{"value": "Object description"}"#).unwrap(); + assert_eq!(object_desc.as_str(), "Object description"); + } +} diff --git a/crates/pinakes-enrichment/src/tmdb.rs b/crates/pinakes-enrichment/src/tmdb.rs new file mode 100644 index 0000000..810db2e --- /dev/null +++ b/crates/pinakes-enrichment/src/tmdb.rs @@ -0,0 +1,125 @@ +//! TMDB (The Movie Database) metadata enrichment for video files. + +use std::time::Duration; + +use chrono::Utc; +use pinakes_types::{ + error::{PinakesError, Result}, + model::MediaItem, +}; +use uuid::Uuid; + +use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; + +pub struct TmdbEnricher { + client: reqwest::Client, + api_key: String, + base_url: String, +} + +impl TmdbEnricher { + /// Create a new `TMDb` enricher. + /// + /// # Panics + /// + /// Panics if the HTTP client cannot be built (programming error in client + /// configuration). + #[must_use] + pub fn new(api_key: String) -> Self { + Self { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .expect("failed to build HTTP client with configured timeouts"), + api_key, + base_url: "https://api.themoviedb.org/3".to_string(), + } + } +} + +#[async_trait::async_trait] +impl MetadataEnricher for TmdbEnricher { + fn source(&self) -> EnrichmentSourceType { + EnrichmentSourceType::Tmdb + } + + async fn enrich(&self, item: &MediaItem) -> Result> { + let title = match &item.title { + Some(t) if !t.is_empty() => t, + _ => return Ok(None), + }; + + let url = format!("{}/search/movie", self.base_url); + + let resp = self + .client + .get(&url) + .query(&[ + ("api_key", &self.api_key), + ("query", &title.clone()), + ("page", &"1".to_string()), + ]) + .send() + .await + .map_err(|e| { + PinakesError::MetadataExtraction(format!("TMDB request failed: {e}")) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(PinakesError::MetadataExtraction( + "TMDB API key is invalid (401)".into(), + )); + } + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + tracing::warn!("TMDB rate limit exceeded (429)"); + return Ok(None); + } + tracing::debug!(status = %status, "TMDB search returned non-success status"); + return Ok(None); + } + + let body = resp.text().await.map_err(|e| { + PinakesError::MetadataExtraction(format!( + "TMDB response read failed: {e}" + )) + })?; + + let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + PinakesError::MetadataExtraction(format!("TMDB JSON parse failed: {e}")) + })?; + + let results = json.get("results").and_then(|r| r.as_array()); + if results.is_none_or(std::vec::Vec::is_empty) { + return Ok(None); + } + + let Some(results) = results else { + return Ok(None); + }; + let movie = &results[0]; + let external_id = match movie.get("id").and_then(serde_json::Value::as_i64) + { + Some(id) => id.to_string(), + None => return Ok(None), + }; + let popularity = movie + .get("popularity") + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0); + // Normalize popularity to 0-1 range (TMDB popularity can be very high) + let confidence = (popularity / 100.0).min(1.0); + + Ok(Some(ExternalMetadata { + id: Uuid::now_v7(), + media_id: item.id, + source: EnrichmentSourceType::Tmdb, + external_id: Some(external_id), + metadata_json: body, + confidence, + last_updated: Utc::now(), + })) + } +} diff --git a/crates/pinakes-metadata/Cargo.toml b/crates/pinakes-metadata/Cargo.toml new file mode 100644 index 0000000..5c26a23 --- /dev/null +++ b/crates/pinakes-metadata/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "pinakes-metadata" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +pinakes-types = { workspace = true } +lofty = { workspace = true } +lopdf = { workspace = true } +epub = { workspace = true } +matroska = { workspace = true } +image = { workspace = true } +kamadak-exif = { workspace = true } +gray_matter = { workspace = true } +rustc-hash = { workspace = true } +chrono = { workspace = true } +image_hasher = { workspace = true } +tracing = { workspace = true } +regex = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pinakes-metadata/src/audio.rs b/crates/pinakes-metadata/src/audio.rs new file mode 100644 index 0000000..b33ff3f --- /dev/null +++ b/crates/pinakes-metadata/src/audio.rs @@ -0,0 +1,91 @@ +use std::path::Path; + +use lofty::{ + file::{AudioFile, TaggedFileExt}, + tag::Accessor, +}; +use pinakes_types::{ + error::{PinakesError, Result}, + media_type::{BuiltinMediaType, MediaType}, +}; + +use super::{ExtractedMetadata, MetadataExtractor}; + +pub struct AudioExtractor; + +impl MetadataExtractor for AudioExtractor { + fn extract(&self, path: &Path) -> Result { + let tagged_file = lofty::read_from_path(path).map_err(|e| { + PinakesError::MetadataExtraction(format!("audio metadata: {e}")) + })?; + + let mut meta = ExtractedMetadata::default(); + + if let Some(tag) = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + { + meta.title = tag.title().map(|s| s.to_string()); + meta.artist = tag.artist().map(|s| s.to_string()); + meta.album = tag.album().map(|s| s.to_string()); + meta.genre = tag.genre().map(|s| s.to_string()); + meta.year = tag.date().map(|ts| i32::from(ts.year)); + } + + if let Some(tag) = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + { + if let Some(track) = tag.track() { + meta + .extra + .insert("track_number".to_string(), track.to_string()); + } + if let Some(disc) = tag.disk() { + meta + .extra + .insert("disc_number".to_string(), disc.to_string()); + } + if let Some(comment) = tag.comment() { + meta + .extra + .insert("comment".to_string(), comment.to_string()); + } + } + + let properties = tagged_file.properties(); + let duration = properties.duration(); + if !duration.is_zero() { + meta.duration_secs = Some(duration.as_secs_f64()); + } + + if let Some(bitrate) = properties.audio_bitrate() { + meta + .extra + .insert("bitrate".to_string(), format!("{bitrate} kbps")); + } + if let Some(sample_rate) = properties.sample_rate() { + meta + .extra + .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); + } + if let Some(channels) = properties.channels() { + meta + .extra + .insert("channels".to_string(), channels.to_string()); + } + + Ok(meta) + } + + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Mp3), + MediaType::Builtin(BuiltinMediaType::Flac), + MediaType::Builtin(BuiltinMediaType::Ogg), + MediaType::Builtin(BuiltinMediaType::Wav), + MediaType::Builtin(BuiltinMediaType::Aac), + MediaType::Builtin(BuiltinMediaType::Opus), + ] + } +} diff --git a/crates/pinakes-metadata/src/document.rs b/crates/pinakes-metadata/src/document.rs new file mode 100644 index 0000000..0752f2f --- /dev/null +++ b/crates/pinakes-metadata/src/document.rs @@ -0,0 +1,460 @@ +use std::{path::Path, sync::LazyLock}; + +use pinakes_types::{ + error::{PinakesError, Result}, + media_type::{BuiltinMediaType, MediaType}, +}; + +use super::{ExtractedMetadata, MetadataExtractor}; + +// --- ISBN helpers (duplicated from pinakes-core::books to avoid circular dep) +// --- + +static ISBN_PATTERNS: LazyLock> = LazyLock::new(|| { + [ + r"ISBN(?:-13)?(?:\s+is|:)?\s*(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)", + r"ISBN(?:-10)?(?:\s+is|:)?\s*(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])", + r"ISBN(?:-13)?\s+(\d{13})", + r"ISBN(?:-10)?\s+(\d{9}[\dXx])", + r"\b(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)\b", + r"\b(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])\b", + ] + .iter() + .filter_map(|p| regex::Regex::new(p).ok()) + .collect() +}); + +fn extract_isbn_from_text(text: &str) -> Option { + for pattern in ISBN_PATTERNS.iter() { + if let Some(captures) = pattern.captures(text) + && let Some(isbn) = captures.get(1) + && let Ok(normalized) = normalize_isbn(isbn.as_str()) + { + return Some(normalized); + } + } + None +} + +fn normalize_isbn(isbn: &str) -> std::result::Result { + let clean: String = isbn + .chars() + .filter(|c| c.is_ascii_digit() || *c == 'X' || *c == 'x') + .collect(); + + match clean.len() { + 10 => isbn10_to_isbn13(&clean), + 13 => { + if is_valid_isbn13(&clean) { + Ok(clean) + } else { + Err(()) + } + }, + _ => Err(()), + } +} + +fn isbn10_to_isbn13(isbn10: &str) -> std::result::Result { + if isbn10.len() != 10 { + return Err(()); + } + let mut isbn13 = format!("978{}", &isbn10[..9]); + let check_digit = calculate_isbn13_check_digit(&isbn13).ok_or(())?; + isbn13.push_str(&check_digit.to_string()); + Ok(isbn13) +} + +fn calculate_isbn13_check_digit(isbn_without_check: &str) -> Option { + if isbn_without_check.len() != 12 { + return None; + } + let sum: u32 = isbn_without_check + .chars() + .enumerate() + .filter_map(|(i, c)| { + c.to_digit(10).map(|d| if i % 2 == 0 { d } else { d * 3 }) + }) + .sum(); + Some((10 - (sum % 10)) % 10) +} + +fn is_valid_isbn13(isbn13: &str) -> bool { + if isbn13.len() != 13 { + return false; + } + let sum: u32 = isbn13 + .chars() + .enumerate() + .filter_map(|(i, c)| { + c.to_digit(10).map(|d| if i % 2 == 0 { d } else { d * 3 }) + }) + .sum(); + sum.is_multiple_of(10) +} + +pub struct DocumentExtractor; + +impl MetadataExtractor for DocumentExtractor { + fn extract(&self, path: &Path) -> Result { + match MediaType::from_path(path) { + Some(MediaType::Builtin(BuiltinMediaType::Pdf)) => extract_pdf(path), + Some(MediaType::Builtin(BuiltinMediaType::Epub)) => extract_epub(path), + Some(MediaType::Builtin(BuiltinMediaType::Djvu)) => extract_djvu(path), + _ => Ok(ExtractedMetadata::default()), + } + } + + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Pdf), + MediaType::Builtin(BuiltinMediaType::Epub), + MediaType::Builtin(BuiltinMediaType::Djvu), + ] + } +} + +fn extract_pdf(path: &Path) -> Result { + let doc = lopdf::Document::load(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?; + + let mut meta = ExtractedMetadata::default(); + let mut book_meta = pinakes_types::model::BookMetadata::default(); + + // Find the Info dictionary via the trailer + if let Ok(info_ref) = doc.trailer.get(b"Info") { + let info_obj = info_ref + .as_reference() + .map_or(Some(info_ref), |reference| doc.get_object(reference).ok()); + + if let Some(obj) = info_obj + && let Ok(dict) = obj.as_dict() + { + if let Ok(title) = dict.get(b"Title") { + meta.title = pdf_object_to_string(title); + } + if let Ok(author) = dict.get(b"Author") { + let author_str = pdf_object_to_string(author); + meta.artist.clone_from(&author_str); + + // Parse multiple authors if separated by semicolon, comma, or "and" + if let Some(authors_str) = author_str { + book_meta.authors = authors_str + .split(&[';', ','][..]) + .flat_map(|part| part.split(" and ")) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .enumerate() + .map(|(pos, name)| { + let mut author = pinakes_types::model::AuthorInfo::new(name); + author.position = i32::try_from(pos).unwrap_or(i32::MAX); + author + }) + .collect(); + } + } + if let Ok(subject) = dict.get(b"Subject") { + meta.description = pdf_object_to_string(subject); + } + if let Ok(creator) = dict.get(b"Creator") { + meta.extra.insert( + "creator".to_string(), + pdf_object_to_string(creator).unwrap_or_default(), + ); + } + if let Ok(producer) = dict.get(b"Producer") { + meta.extra.insert( + "producer".to_string(), + pdf_object_to_string(producer).unwrap_or_default(), + ); + } + } + } + + // Page count + let pages = doc.get_pages(); + let page_count = pages.len(); + if page_count > 0 { + book_meta.page_count = Some(i32::try_from(page_count).unwrap_or(i32::MAX)); + } + + // Try to extract ISBN from first few pages + // Extract text from up to the first 5 pages and search for ISBN patterns + let mut extracted_text = String::new(); + let max_pages = page_count.min(5); + + for (_page_num, page_id) in pages.iter().take(max_pages) { + if let Ok(content) = doc.get_page_content(*page_id) { + // PDF content streams contain raw operators, but may have text strings + if let Ok(text) = std::str::from_utf8(&content) { + extracted_text.push_str(text); + extracted_text.push(' '); + } + } + } + + // Extract ISBN from the text + if let Some(isbn) = extract_isbn_from_text(&extracted_text) + && let Ok(normalized) = normalize_isbn(&isbn) + { + book_meta.isbn13 = Some(normalized); + book_meta.isbn = Some(isbn); + } + + // Set format + book_meta.format = Some("pdf".to_string()); + + meta.book_metadata = Some(book_meta); + Ok(meta) +} + +fn pdf_object_to_string(obj: &lopdf::Object) -> Option { + match obj { + lopdf::Object::String(bytes, _) => { + Some(String::from_utf8_lossy(bytes).into_owned()) + }, + lopdf::Object::Name(name) => { + Some(String::from_utf8_lossy(name).into_owned()) + }, + _ => None, + } +} + +fn extract_epub(path: &Path) -> Result { + let mut doc = epub::doc::EpubDoc::new(path).map_err(|e| { + PinakesError::MetadataExtraction(format!("EPUB parse: {e}")) + })?; + + let mut meta = ExtractedMetadata { + title: doc.mdata("title").map(|item| item.value.clone()), + artist: doc.mdata("creator").map(|item| item.value.clone()), + description: doc.mdata("description").map(|item| item.value.clone()), + ..Default::default() + }; + + let mut book_meta = pinakes_types::model::BookMetadata::default(); + + // Extract basic metadata + if let Some(lang) = doc.mdata("language") { + book_meta.language = Some(lang.value.clone()); + } + if let Some(publisher) = doc.mdata("publisher") { + book_meta.publisher = Some(publisher.value.clone()); + } + if let Some(date) = doc.mdata("date") { + // Try to parse as YYYY-MM-DD or just YYYY + if let Ok(parsed_date) = + chrono::NaiveDate::parse_from_str(&date.value, "%Y-%m-%d") + { + book_meta.publication_date = Some(parsed_date); + } else if let Ok(year) = date.value.parse::() { + book_meta.publication_date = chrono::NaiveDate::from_ymd_opt(year, 1, 1); + } + } + + // Extract authors - iterate through all metadata items + let mut authors = Vec::new(); + let mut position = 0; + for item in &doc.metadata { + if item.property == "creator" || item.property == "dc:creator" { + let mut author = + pinakes_types::model::AuthorInfo::new(item.value.clone()); + author.position = position; + position += 1; + + // Check for file-as in refinements + if let Some(file_as_ref) = item.refinement("file-as") { + author.file_as = Some(file_as_ref.value.clone()); + } + + // Check for role in refinements + if let Some(role_ref) = item.refinement("role") { + author.role.clone_from(&role_ref.value); + } + + authors.push(author); + } + } + book_meta.authors = authors; + + // Extract ISBNs from identifiers + let mut identifiers = rustc_hash::FxHashMap::default(); + for item in &doc.metadata { + if item.property == "identifier" || item.property == "dc:identifier" { + // Try to get scheme from refinements + let scheme = item + .refinement("identifier-type") + .map(|r| r.value.to_lowercase()); + + let id_type = match scheme.as_deref() { + Some("isbn" | "isbn-10" | "isbn10") => "isbn", + Some("isbn-13" | "isbn13") => "isbn13", + Some("asin") => "asin", + Some("doi") => "doi", + _ => { + // Fallback: detect from value pattern. + // ISBN-10 = 10 chars bare, ISBN-13 = 13 chars bare, + // hyphenated ISBN-13 = 17 chars (e.g. 978-0-123-45678-9). + // Parentheses required: && binds tighter than ||. + if (item.value.len() == 10 || item.value.len() == 13) + || (item.value.contains('-') + && (item.value.len() == 13 || item.value.len() == 17)) + { + "isbn" + } else { + "other" + } + }, + }; + + // Try to normalize ISBN + if (id_type == "isbn" || id_type == "isbn13") + && let Ok(normalized) = normalize_isbn(&item.value) + { + book_meta.isbn13 = Some(normalized.clone()); + book_meta.isbn = Some(item.value.clone()); + } + + identifiers + .entry(id_type.to_string()) + .or_insert_with(Vec::new) + .push(item.value.clone()); + } + } + book_meta.identifiers = identifiers; + + // Extract Calibre series metadata by parsing the content.opf file + // Try common OPF locations + let opf_paths = vec!["OEBPS/content.opf", "content.opf", "OPS/content.opf"]; + let mut opf_data = None; + for path in opf_paths { + if let Some(data) = doc.get_resource_str_by_path(path) { + opf_data = Some(data); + break; + } + } + + if let Some(opf_content) = opf_data { + // Look for + if let Some(series_start) = opf_content.find("name=\"calibre:series\"") + && let Some(content_start) = + opf_content[series_start..].find("content=\"") + { + let after_content = &opf_content[series_start + content_start + 9..]; + if let Some(quote_end) = after_content.find('"') { + book_meta.series_name = Some(after_content[..quote_end].to_string()); + } + } + + // Look for + if let Some(index_start) = opf_content.find("name=\"calibre:series_index\"") + && let Some(content_start) = opf_content[index_start..].find("content=\"") + { + let after_content = &opf_content[index_start + content_start + 9..]; + if let Some(quote_end) = after_content.find('"') + && let Ok(index) = after_content[..quote_end].parse::() + { + book_meta.series_index = Some(index); + } + } + } + + // Set format + book_meta.format = Some("epub".to_string()); + + meta.book_metadata = Some(book_meta); + Ok(meta) +} + +fn extract_djvu(path: &Path) -> Result { + // DjVu files contain metadata in SEXPR (S-expression) format within + // ANTa/ANTz chunks, or in the DIRM chunk. We parse the raw bytes to + // extract any metadata fields we can find. + + // Guard against loading very large DjVu files into memory. + const MAX_DJVU_SIZE: u64 = 50 * 1024 * 1024; // 50 MB + let file_meta = std::fs::metadata(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("DjVu stat: {e}")))?; + if file_meta.len() > MAX_DJVU_SIZE { + return Ok(ExtractedMetadata::default()); + } + + let data = std::fs::read(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("DjVu read: {e}")))?; + + let mut meta = ExtractedMetadata::default(); + + // DjVu files start with "AT&T" magic followed by FORM:DJVU or FORM:DJVM + if data.len() < 16 { + return Ok(meta); + } + + // Search for metadata annotations in the file. DjVu metadata is stored + // as S-expressions like (metadata (key "value") ...) within ANTa chunks. + let content = String::from_utf8_lossy(&data); + + // Look for (metadata ...) blocks + if let Some(meta_start) = content.find("(metadata") { + let remainder = &content[meta_start..]; + // Extract key-value pairs like (title "Some Title") + extract_djvu_field(remainder, "title", &mut meta.title); + extract_djvu_field(remainder, "author", &mut meta.artist); + + let mut desc = None; + extract_djvu_field(remainder, "subject", &mut desc); + if desc.is_none() { + extract_djvu_field(remainder, "description", &mut desc); + } + meta.description = desc; + + let mut year_str = None; + extract_djvu_field(remainder, "year", &mut year_str); + if let Some(ref y) = year_str { + meta.year = y.parse().ok(); + } + + let mut creator = None; + extract_djvu_field(remainder, "creator", &mut creator); + if let Some(c) = creator { + meta.extra.insert("creator".to_string(), c); + } + } + + // Also check for booklet-style metadata that some DjVu encoders write + // outside the metadata SEXPR + if meta.title.is_none() + && let Some(title_start) = content.find("(bookmarks") + { + let remainder = &content[title_start..]; + // First bookmark title is often the document title + if let Some(q1) = remainder.find('"') { + let after_q1 = &remainder[q1 + 1..]; + if let Some(q2) = after_q1.find('"') { + let val = &after_q1[..q2]; + if !val.is_empty() { + meta.title = Some(val.to_string()); + } + } + } + } + + Ok(meta) +} + +fn extract_djvu_field(sexpr: &str, key: &str, out: &mut Option) { + // Look for patterns like (key "value") in the S-expression + let pattern = format!("({key}"); + if let Some(start) = sexpr.find(&pattern) { + let remainder = &sexpr[start + pattern.len()..]; + // Find the quoted value + if let Some(q1) = remainder.find('"') { + let after_q1 = &remainder[q1 + 1..]; + if let Some(q2) = after_q1.find('"') { + let val = &after_q1[..q2]; + if !val.is_empty() { + *out = Some(val.to_string()); + } + } + } + } +} diff --git a/crates/pinakes-metadata/src/image.rs b/crates/pinakes-metadata/src/image.rs new file mode 100644 index 0000000..196ad09 --- /dev/null +++ b/crates/pinakes-metadata/src/image.rs @@ -0,0 +1,300 @@ +use std::path::Path; + +use pinakes_types::{ + error::Result, + media_type::{BuiltinMediaType, MediaType}, +}; + +use super::{ExtractedMetadata, MetadataExtractor}; + +pub struct ImageExtractor; + +impl MetadataExtractor for ImageExtractor { + fn extract(&self, path: &Path) -> Result { + let mut meta = ExtractedMetadata::default(); + + let file = std::fs::File::open(path)?; + let mut buf_reader = std::io::BufReader::new(&file); + + let Ok(exif_data) = + exif::Reader::new().read_from_container(&mut buf_reader) + else { + return Ok(meta); + }; + + // Image dimensions + if let Some(width) = exif_data + .get_field(exif::Tag::PixelXDimension, exif::In::PRIMARY) + .or_else(|| exif_data.get_field(exif::Tag::ImageWidth, exif::In::PRIMARY)) + && let Some(w) = field_to_u32(width) + { + meta.extra.insert("width".to_string(), w.to_string()); + } + if let Some(height) = exif_data + .get_field(exif::Tag::PixelYDimension, exif::In::PRIMARY) + .or_else(|| { + exif_data.get_field(exif::Tag::ImageLength, exif::In::PRIMARY) + }) + && let Some(h) = field_to_u32(height) + { + meta.extra.insert("height".to_string(), h.to_string()); + } + + // Camera make and model - set both in top-level fields and extra + if let Some(make) = exif_data.get_field(exif::Tag::Make, exif::In::PRIMARY) + { + let val = make.display_value().to_string().trim().to_string(); + if !val.is_empty() { + meta.camera_make = Some(val.clone()); + meta.extra.insert("camera_make".to_string(), val); + } + } + if let Some(model) = + exif_data.get_field(exif::Tag::Model, exif::In::PRIMARY) + { + let val = model.display_value().to_string().trim().to_string(); + if !val.is_empty() { + meta.camera_model = Some(val.clone()); + meta.extra.insert("camera_model".to_string(), val); + } + } + + // Date taken - parse EXIF date format (YYYY:MM:DD HH:MM:SS) + if let Some(date) = exif_data + .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) + .or_else(|| exif_data.get_field(exif::Tag::DateTime, exif::In::PRIMARY)) + { + let val = date.display_value().to_string(); + if !val.is_empty() { + // Try parsing EXIF format: "YYYY:MM:DD HH:MM:SS" + if let Some(dt) = parse_exif_datetime(&val) { + meta.date_taken = Some(dt); + } + meta.extra.insert("date_taken".to_string(), val); + } + } + + // GPS coordinates - set both in top-level fields and extra + if let (Some(lat), Some(lat_ref), Some(lon), Some(lon_ref)) = ( + exif_data.get_field(exif::Tag::GPSLatitude, exif::In::PRIMARY), + exif_data.get_field(exif::Tag::GPSLatitudeRef, exif::In::PRIMARY), + exif_data.get_field(exif::Tag::GPSLongitude, exif::In::PRIMARY), + exif_data.get_field(exif::Tag::GPSLongitudeRef, exif::In::PRIMARY), + ) && let (Some(lat_val), Some(lon_val)) = + (dms_to_decimal(lat, lat_ref), dms_to_decimal(lon, lon_ref)) + { + meta.latitude = Some(lat_val); + meta.longitude = Some(lon_val); + meta + .extra + .insert("gps_latitude".to_string(), format!("{lat_val:.6}")); + meta + .extra + .insert("gps_longitude".to_string(), format!("{lon_val:.6}")); + } + + // Exposure info + if let Some(iso) = + exif_data.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY) + { + let val = iso.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("iso".to_string(), val); + } + } + if let Some(exposure) = + exif_data.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY) + { + let val = exposure.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("exposure_time".to_string(), val); + } + } + if let Some(aperture) = + exif_data.get_field(exif::Tag::FNumber, exif::In::PRIMARY) + { + let val = aperture.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("f_number".to_string(), val); + } + } + if let Some(focal) = + exif_data.get_field(exif::Tag::FocalLength, exif::In::PRIMARY) + { + let val = focal.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("focal_length".to_string(), val); + } + } + + // Lens model + if let Some(lens) = + exif_data.get_field(exif::Tag::LensModel, exif::In::PRIMARY) + { + let val = lens.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta + .extra + .insert("lens_model".to_string(), val.trim_matches('"').to_string()); + } + } + + // Flash + if let Some(flash) = + exif_data.get_field(exif::Tag::Flash, exif::In::PRIMARY) + { + let val = flash.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("flash".to_string(), val); + } + } + + // Orientation + if let Some(orientation) = + exif_data.get_field(exif::Tag::Orientation, exif::In::PRIMARY) + { + let val = orientation.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("orientation".to_string(), val); + } + } + + // Software + if let Some(software) = + exif_data.get_field(exif::Tag::Software, exif::In::PRIMARY) + { + let val = software.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("software".to_string(), val); + } + } + + // Image description as title + if let Some(desc) = + exif_data.get_field(exif::Tag::ImageDescription, exif::In::PRIMARY) + { + let val = desc.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.title = Some(val.trim_matches('"').to_string()); + } + } + + // Artist + if let Some(artist) = + exif_data.get_field(exif::Tag::Artist, exif::In::PRIMARY) + { + let val = artist.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.artist = Some(val.trim_matches('"').to_string()); + } + } + + // Copyright as description + if let Some(copyright) = + exif_data.get_field(exif::Tag::Copyright, exif::In::PRIMARY) + { + let val = copyright.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.description = Some(val.trim_matches('"').to_string()); + } + } + + Ok(meta) + } + + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Jpeg), + MediaType::Builtin(BuiltinMediaType::Png), + MediaType::Builtin(BuiltinMediaType::Gif), + MediaType::Builtin(BuiltinMediaType::Webp), + MediaType::Builtin(BuiltinMediaType::Avif), + MediaType::Builtin(BuiltinMediaType::Tiff), + MediaType::Builtin(BuiltinMediaType::Bmp), + // RAW formats (TIFF-based, kamadak-exif handles these) + MediaType::Builtin(BuiltinMediaType::Cr2), + MediaType::Builtin(BuiltinMediaType::Nef), + MediaType::Builtin(BuiltinMediaType::Arw), + MediaType::Builtin(BuiltinMediaType::Dng), + MediaType::Builtin(BuiltinMediaType::Orf), + MediaType::Builtin(BuiltinMediaType::Rw2), + // HEIC + MediaType::Builtin(BuiltinMediaType::Heic), + ] + } +} + +fn field_to_u32(field: &exif::Field) -> Option { + match &field.value { + exif::Value::Long(v) => v.first().copied(), + exif::Value::Short(v) => v.first().map(|&x| u32::from(x)), + _ => None, + } +} + +fn dms_to_decimal( + dms_field: &exif::Field, + ref_field: &exif::Field, +) -> Option { + if let exif::Value::Rational(ref rationals) = dms_field.value + && rationals.len() >= 3 + { + let degrees = rationals[0].to_f64(); + let minutes = rationals[1].to_f64(); + let seconds = rationals[2].to_f64(); + let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0; + + let ref_str = ref_field.display_value().to_string(); + if ref_str.contains('S') || ref_str.contains('W') { + decimal = -decimal; + } + + return Some(decimal); + } + None +} + +/// Parse EXIF datetime format: "YYYY:MM:DD HH:MM:SS" +fn parse_exif_datetime(s: &str) -> Option> { + use chrono::NaiveDateTime; + + // EXIF format is "YYYY:MM:DD HH:MM:SS" + let s = s.trim().trim_matches('"'); + + // Try standard EXIF format + if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S") { + return Some(dt.and_utc()); + } + + // Try ISO format as fallback + if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return Some(dt.and_utc()); + } + + None +} + +/// Generate a perceptual hash for an image file. +/// +/// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity +/// detection. Returns a hex-encoded hash string, or None if the image cannot be +/// processed. +#[must_use] +pub fn generate_perceptual_hash(path: &Path) -> Option { + use image_hasher::{HashAlg, HasherConfig}; + + // Open and decode the image + let img = image::open(path).ok()?; + + // Create hasher with DCT algorithm (good for finding similar images) + let hasher = HasherConfig::new() + .hash_alg(HashAlg::DoubleGradient) + .hash_size(8, 8) // 64-bit hash + .to_hasher(); + + // Generate hash + let hash = hasher.hash_image(&img); + + // Convert to hex string for storage + Some(hash.to_base64()) +} diff --git a/crates/pinakes-metadata/src/lib.rs b/crates/pinakes-metadata/src/lib.rs new file mode 100644 index 0000000..7a89362 --- /dev/null +++ b/crates/pinakes-metadata/src/lib.rs @@ -0,0 +1,73 @@ +pub mod audio; +pub mod document; +pub mod image; +pub mod markdown; +pub mod video; + +use std::path::Path; + +use pinakes_types::{ + error::Result, + media_type::MediaType, + model::BookMetadata, +}; +use rustc_hash::FxHashMap; + +#[derive(Debug, Clone, Default)] +pub struct ExtractedMetadata { + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + pub extra: FxHashMap, + pub book_metadata: Option, + + // Photo-specific metadata + pub date_taken: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub rating: Option, +} + +pub trait MetadataExtractor: Send + Sync { + /// Extract metadata from a file at the given path. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read or parsed. + fn extract(&self, path: &Path) -> Result; + fn supported_types(&self) -> Vec; +} + +/// Extract metadata from a file using the appropriate extractor for the given +/// media type. +/// +/// # Errors +/// +/// Returns an error if extraction fails. Returns a default `ExtractedMetadata` +/// when no extractor supports the media type. +pub fn extract_metadata( + path: &Path, + media_type: &MediaType, +) -> Result { + let extractors: Vec> = vec![ + Box::new(audio::AudioExtractor), + Box::new(document::DocumentExtractor), + Box::new(video::VideoExtractor), + Box::new(markdown::MarkdownExtractor), + Box::new(image::ImageExtractor), + ]; + + for extractor in &extractors { + if extractor.supported_types().contains(media_type) { + return extractor.extract(path); + } + } + + Ok(ExtractedMetadata::default()) +} diff --git a/crates/pinakes-metadata/src/markdown.rs b/crates/pinakes-metadata/src/markdown.rs new file mode 100644 index 0000000..e9b4b1a --- /dev/null +++ b/crates/pinakes-metadata/src/markdown.rs @@ -0,0 +1,46 @@ +use std::path::Path; + +use pinakes_types::{ + error::Result, + media_type::{BuiltinMediaType, MediaType}, +}; + +use super::{ExtractedMetadata, MetadataExtractor}; + +pub struct MarkdownExtractor; + +impl MetadataExtractor for MarkdownExtractor { + fn extract(&self, path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let parsed = + gray_matter::Matter::::new().parse(&content); + + let mut meta = ExtractedMetadata::default(); + + if let Some(data) = parsed.ok().and_then(|p| p.data) + && let gray_matter::Pod::Hash(map) = data + { + if let Some(gray_matter::Pod::String(title)) = map.get("title") { + meta.title = Some(title.clone()); + } + if let Some(gray_matter::Pod::String(author)) = map.get("author") { + meta.artist = Some(author.clone()); + } + if let Some(gray_matter::Pod::String(desc)) = map.get("description") { + meta.description = Some(desc.clone()); + } + if let Some(gray_matter::Pod::String(date)) = map.get("date") { + meta.extra.insert("date".to_string(), date.clone()); + } + } + + Ok(meta) + } + + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Markdown), + MediaType::Builtin(BuiltinMediaType::PlainText), + ] + } +} diff --git a/crates/pinakes-metadata/src/mod.rs b/crates/pinakes-metadata/src/mod.rs new file mode 100644 index 0000000..403a06b --- /dev/null +++ b/crates/pinakes-metadata/src/mod.rs @@ -0,0 +1,70 @@ +pub mod audio; +pub mod document; +pub mod image; +pub mod markdown; +pub mod video; + +use std::path::Path; + +use rustc_hash::FxHashMap; + +use pinakes_types::{error::Result, media_type::MediaType, model::BookMetadata}; + +#[derive(Debug, Clone, Default)] +pub struct ExtractedMetadata { + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + pub extra: FxHashMap, + pub book_metadata: Option, + + // Photo-specific metadata + pub date_taken: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub rating: Option, +} + +pub trait MetadataExtractor: Send + Sync { + /// Extract metadata from a file at the given path. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read or parsed. + fn extract(&self, path: &Path) -> Result; + fn supported_types(&self) -> Vec; +} + +/// Extract metadata from a file using the appropriate extractor for the given +/// media type. +/// +/// # Errors +/// +/// Returns an error if no extractor supports the media type, or if extraction +/// fails. +pub fn extract_metadata( + path: &Path, + media_type: &MediaType, +) -> Result { + let extractors: Vec> = vec![ + Box::new(audio::AudioExtractor), + Box::new(document::DocumentExtractor), + Box::new(video::VideoExtractor), + Box::new(markdown::MarkdownExtractor), + Box::new(image::ImageExtractor), + ]; + + for extractor in &extractors { + if extractor.supported_types().contains(media_type) { + return extractor.extract(path); + } + } + + Ok(ExtractedMetadata::default()) +} diff --git a/crates/pinakes-metadata/src/video.rs b/crates/pinakes-metadata/src/video.rs new file mode 100644 index 0000000..5720d42 --- /dev/null +++ b/crates/pinakes-metadata/src/video.rs @@ -0,0 +1,129 @@ +use std::path::Path; + +use pinakes_types::{ + error::{PinakesError, Result}, + media_type::{BuiltinMediaType, MediaType}, +}; + +use super::{ExtractedMetadata, MetadataExtractor}; + +pub struct VideoExtractor; + +impl MetadataExtractor for VideoExtractor { + fn extract(&self, path: &Path) -> Result { + match MediaType::from_path(path) { + Some(MediaType::Builtin(BuiltinMediaType::Mkv)) => extract_mkv(path), + Some(MediaType::Builtin(BuiltinMediaType::Mp4)) => extract_mp4(path), + _ => Ok(ExtractedMetadata::default()), + } + } + + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Mp4), + MediaType::Builtin(BuiltinMediaType::Mkv), + ] + } +} + +fn extract_mkv(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mkv = matroska::Matroska::open(file) + .map_err(|e| PinakesError::MetadataExtraction(format!("MKV parse: {e}")))?; + + let mut meta = ExtractedMetadata { + title: mkv.info.title.clone(), + duration_secs: mkv.info.duration.map(|dur| dur.as_secs_f64()), + ..Default::default() + }; + + // Extract resolution and codec info from tracks + for track in &mkv.tracks { + match &track.settings { + matroska::Settings::Video(v) => { + meta.extra.insert( + "resolution".to_string(), + format!("{}x{}", v.pixel_width, v.pixel_height), + ); + if !track.codec_id.is_empty() { + meta + .extra + .insert("video_codec".to_string(), track.codec_id.clone()); + } + }, + matroska::Settings::Audio(a) => { + meta.extra.insert( + "sample_rate".to_string(), + format!("{:.0} Hz", a.sample_rate), + ); + meta + .extra + .insert("channels".to_string(), a.channels.to_string()); + if !track.codec_id.is_empty() { + meta + .extra + .insert("audio_codec".to_string(), track.codec_id.clone()); + } + }, + matroska::Settings::None => {}, + } + } + + Ok(meta) +} + +fn extract_mp4(path: &Path) -> Result { + use lofty::{ + file::{AudioFile, TaggedFileExt}, + tag::Accessor, + }; + + let tagged_file = lofty::read_from_path(path).map_err(|e| { + PinakesError::MetadataExtraction(format!("MP4 metadata: {e}")) + })?; + + let mut meta = ExtractedMetadata::default(); + + if let Some(tag) = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + { + meta.title = tag + .title() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.artist = tag + .artist() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.album = tag + .album() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.genre = tag + .genre() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.year = tag.date().map(|ts| i32::from(ts.year)); + } + + let properties = tagged_file.properties(); + let duration = properties.duration(); + if !duration.is_zero() { + meta.duration_secs = Some(duration.as_secs_f64()); + } + + if let Some(bitrate) = properties.audio_bitrate() { + meta + .extra + .insert("audio_bitrate".to_string(), format!("{bitrate} kbps")); + } + if let Some(sample_rate) = properties.sample_rate() { + meta + .extra + .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); + } + if let Some(channels) = properties.channels() { + meta + .extra + .insert("channels".to_string(), channels.to_string()); + } + + Ok(meta) +} diff --git a/crates/pinakes-plugin/Cargo.toml b/crates/pinakes-plugin/Cargo.toml new file mode 100644 index 0000000..d3d6192 --- /dev/null +++ b/crates/pinakes-plugin/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "pinakes-plugin" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +pinakes-types = { workspace = true } +pinakes-plugin-api = { workspace = true } +wasmtime = { workspace = true } +ed25519-dalek = { workspace = true } +reqwest = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +rustc-hash = { workspace = true } +walkdir = { workspace = true } +uuid = { workspace = true } +url = { workspace = true } +blake3 = { workspace = true } +rand = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pinakes-plugin/src/lib.rs b/crates/pinakes-plugin/src/lib.rs new file mode 100644 index 0000000..08d063a --- /dev/null +++ b/crates/pinakes-plugin/src/lib.rs @@ -0,0 +1,15 @@ +pub mod loader; +pub mod registry; +pub mod rpc; +pub mod runtime; +pub mod security; +pub mod signature; + +pub use loader::PluginLoader; +pub use registry::{PluginRegistry, RegisteredPlugin}; +pub use runtime::{WasmPlugin, WasmRuntime}; +pub use security::CapabilityEnforcer; +pub use signature::{SignatureStatus, verify_plugin_signature}; + +mod manager; +pub use manager::{PluginManager, PluginManagerConfig}; diff --git a/crates/pinakes-plugin/src/loader.rs b/crates/pinakes-plugin/src/loader.rs new file mode 100644 index 0000000..f8242e8 --- /dev/null +++ b/crates/pinakes-plugin/src/loader.rs @@ -0,0 +1,432 @@ +//! Plugin loader for discovering and loading plugins from the filesystem + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use pinakes_plugin_api::PluginManifest; +use tracing::{debug, info, warn}; +use walkdir::WalkDir; + +/// Plugin loader handles discovery and loading of plugins from directories +pub struct PluginLoader { + /// Directories to search for plugins + plugin_dirs: Vec, +} + +impl PluginLoader { + /// Create a new plugin loader + #[must_use] + pub const fn new(plugin_dirs: Vec) -> Self { + Self { plugin_dirs } + } + + /// Discover all plugins in configured directories + /// + /// # Errors + /// + /// Returns an error if a plugin directory cannot be searched. + pub fn discover_plugins(&self) -> Result> { + let mut manifests = Vec::new(); + + for dir in &self.plugin_dirs { + if !dir.exists() { + warn!("Plugin directory does not exist: {:?}", dir); + continue; + } + + info!("Discovering plugins in: {:?}", dir); + + let found = Self::discover_in_directory(dir); + info!("Found {} plugins in {:?}", found.len(), dir); + manifests.extend(found); + } + + Ok(manifests) + } + + /// Discover plugins in a specific directory + fn discover_in_directory(dir: &Path) -> Vec { + let mut manifests = Vec::new(); + + // Walk the directory looking for plugin.toml files + for entry in WalkDir::new(dir) + .max_depth(3) // Don't go too deep + .follow_links(false) + { + let entry = match entry { + Ok(e) => e, + Err(e) => { + warn!("Error reading directory entry: {}", e); + continue; + }, + }; + + let path = entry.path(); + + // Look for plugin.toml files + if path.file_name() == Some(std::ffi::OsStr::new("plugin.toml")) { + debug!("Found plugin manifest: {:?}", path); + + match PluginManifest::from_file(path) { + Ok(manifest) => { + info!("Loaded manifest for plugin: {}", manifest.plugin.name); + manifests.push(manifest); + }, + Err(e) => { + warn!("Failed to load manifest from {:?}: {}", path, e); + }, + } + } + } + + manifests + } + + /// Resolve the WASM binary path from a manifest + /// + /// # Errors + /// + /// Returns an error if the WASM binary is not found or its path escapes the + /// plugin directory. + pub fn resolve_wasm_path( + &self, + manifest: &PluginManifest, + ) -> Result { + // The WASM path in the manifest is relative to the manifest file + // We need to search for it in the plugin directories + + for dir in &self.plugin_dirs { + // Look for a directory matching the plugin name + let plugin_dir = dir.join(&manifest.plugin.name); + if !plugin_dir.exists() { + continue; + } + + // Check for plugin.toml in this directory + let manifest_path = plugin_dir.join("plugin.toml"); + if !manifest_path.exists() { + continue; + } + + // Resolve WASM path relative to this directory + let wasm_path = plugin_dir.join(&manifest.plugin.binary.wasm); + if wasm_path.exists() { + // Verify the resolved path is within the plugin directory (prevent path + // traversal) + let canonical_wasm = wasm_path + .canonicalize() + .map_err(|e| anyhow!("Failed to canonicalize WASM path: {e}"))?; + let canonical_plugin_dir = plugin_dir + .canonicalize() + .map_err(|e| anyhow!("Failed to canonicalize plugin dir: {e}"))?; + if !canonical_wasm.starts_with(&canonical_plugin_dir) { + return Err(anyhow!( + "WASM binary path escapes plugin directory: {}", + wasm_path.display() + )); + } + return Ok(canonical_wasm); + } + } + + Err(anyhow!( + "WASM binary not found for plugin: {}", + manifest.plugin.name + )) + } + + /// Download a plugin from a URL + /// + /// # Errors + /// + /// Returns an error if the URL is not HTTPS, no plugin directories are + /// configured, the download fails, the archive is too large, or extraction + /// fails. + pub async fn download_plugin(&self, url: &str) -> Result { + const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB + + // Only allow HTTPS downloads + if !url.starts_with("https://") { + return Err(anyhow!( + "Only HTTPS URLs are allowed for plugin downloads: {url}" + )); + } + + let dest_dir = self + .plugin_dirs + .first() + .ok_or_else(|| anyhow!("No plugin directories configured"))?; + + std::fs::create_dir_all(dest_dir)?; + + // Download the archive with timeout and size limits + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_mins(5)) + .build() + .map_err(|e| anyhow!("Failed to build HTTP client: {e}"))?; + + let response = client + .get(url) + .send() + .await + .map_err(|e| anyhow!("Failed to download plugin: {e}"))?; + + if !response.status().is_success() { + return Err(anyhow!( + "Plugin download failed with status: {}", + response.status() + )); + } + + // Check content-length header before downloading + if let Some(content_length) = response.content_length() + && content_length > MAX_PLUGIN_SIZE + { + return Err(anyhow!( + "Plugin archive too large: {content_length} bytes (max \ + {MAX_PLUGIN_SIZE} bytes)" + )); + } + + let bytes = response + .bytes() + .await + .map_err(|e| anyhow!("Failed to read plugin response: {e}"))?; + + // Check actual size after download + if bytes.len() as u64 > MAX_PLUGIN_SIZE { + return Err(anyhow!( + "Plugin archive too large: {} bytes (max {} bytes)", + bytes.len(), + MAX_PLUGIN_SIZE + )); + } + + // Write archive to a unique temp file + let temp_archive = + dest_dir.join(format!(".download-{}.tar.gz", uuid::Uuid::now_v7())); + std::fs::write(&temp_archive, &bytes)?; + + // Extract using tar with -C to target directory + let canonical_dest = dest_dir + .canonicalize() + .map_err(|e| anyhow!("Failed to canonicalize dest dir: {e}"))?; + let output = std::process::Command::new("tar") + .args([ + "xzf", + &temp_archive.to_string_lossy(), + "-C", + &canonical_dest.to_string_lossy(), + ]) + .output() + .map_err(|e| anyhow!("Failed to extract plugin archive: {e}"))?; + + // Clean up the archive + let _ = std::fs::remove_file(&temp_archive); + + if !output.status.success() { + return Err(anyhow!( + "Failed to extract plugin archive: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // Validate that all extracted files are within dest_dir + for entry in WalkDir::new(&canonical_dest).follow_links(false) { + let entry = entry?; + let entry_canonical = entry.path().canonicalize()?; + if !entry_canonical.starts_with(&canonical_dest) { + return Err(anyhow!( + "Extracted file escapes destination directory: {}", + entry.path().display() + )); + } + } + + // Find the extracted plugin directory by looking for plugin.toml + for entry in WalkDir::new(dest_dir).max_depth(2).follow_links(false) { + let entry = entry?; + if entry.file_name() == "plugin.toml" { + let plugin_dir = entry + .path() + .parent() + .ok_or_else(|| anyhow!("Invalid plugin.toml location"))?; + + // Validate the manifest + let manifest = PluginManifest::from_file(entry.path())?; + info!("Downloaded and extracted plugin: {}", manifest.plugin.name); + + return Ok(plugin_dir.to_path_buf()); + } + } + + Err(anyhow!( + "No plugin.toml found after extracting archive from: {url}" + )) + } + + /// Validate a plugin package + /// + /// # Errors + /// + /// Returns an error if the path does not exist, is missing `plugin.toml`, + /// the WASM binary is not found, or the WASM file is invalid. + pub fn validate_plugin_package(&self, path: &Path) -> Result<()> { + // Check that the path exists + if !path.exists() { + return Err(anyhow!("Plugin path does not exist: {}", path.display())); + } + + // Check for plugin.toml + let manifest_path = path.join("plugin.toml"); + if !manifest_path.exists() { + return Err(anyhow!("Missing plugin.toml in {}", path.display())); + } + + // Parse and validate manifest + let manifest = PluginManifest::from_file(&manifest_path)?; + + // Check that WASM binary exists + let wasm_path = path.join(&manifest.plugin.binary.wasm); + if !wasm_path.exists() { + return Err(anyhow!( + "WASM binary not found: {}", + manifest.plugin.binary.wasm + )); + } + + // Verify the WASM path is within the plugin directory (prevent path + // traversal) + let canonical_wasm = wasm_path.canonicalize()?; + let canonical_path = path.canonicalize()?; + if !canonical_wasm.starts_with(&canonical_path) { + return Err(anyhow!( + "WASM binary path escapes plugin directory: {}", + wasm_path.display() + )); + } + + // Validate WASM file + let wasm_bytes = std::fs::read(&wasm_path)?; + if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" { + return Err(anyhow!("Invalid WASM file: {}", wasm_path.display())); + } + + Ok(()) + } + + /// Get plugin directory path for a given plugin name + #[must_use] + pub fn get_plugin_dir(&self, plugin_name: &str) -> Option { + for dir in &self.plugin_dirs { + let plugin_dir = dir.join(plugin_name); + if plugin_dir.exists() { + return Some(plugin_dir); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_discover_plugins_empty() { + let temp_dir = TempDir::new().unwrap(); + let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); + + let manifests = loader.discover_plugins().unwrap(); + assert_eq!(manifests.len(), 0); + } + + #[test] + fn test_discover_plugins_with_manifest() { + let temp_dir = TempDir::new().unwrap(); + let plugin_dir = temp_dir.path().join("test-plugin"); + std::fs::create_dir(&plugin_dir).unwrap(); + + // Create a valid manifest + let manifest_content = r#" +[plugin] +name = "test-plugin" +version = "1.0.0" +api_version = "1.0" +kind = ["media_type"] + +[plugin.binary] +wasm = "plugin.wasm" +"#; + std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); + + // Create dummy WASM file + std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") + .unwrap(); + + let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); + let manifests = loader.discover_plugins().unwrap(); + + assert_eq!(manifests.len(), 1); + assert_eq!(manifests[0].plugin.name, "test-plugin"); + } + + #[test] + fn test_validate_plugin_package() { + let temp_dir = TempDir::new().unwrap(); + let plugin_dir = temp_dir.path().join("test-plugin"); + std::fs::create_dir(&plugin_dir).unwrap(); + + // Create a valid manifest + let manifest_content = r#" +[plugin] +name = "test-plugin" +version = "1.0.0" +api_version = "1.0" +kind = ["media_type"] + +[plugin.binary] +wasm = "plugin.wasm" +"#; + std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); + + let loader = PluginLoader::new(vec![]); + + // Should fail without WASM file + assert!(loader.validate_plugin_package(&plugin_dir).is_err()); + + // Create valid WASM file (magic number only) + std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") + .unwrap(); + + // Should succeed now + assert!(loader.validate_plugin_package(&plugin_dir).is_ok()); + } + + #[test] + fn test_validate_invalid_wasm() { + let temp_dir = TempDir::new().unwrap(); + let plugin_dir = temp_dir.path().join("test-plugin"); + std::fs::create_dir(&plugin_dir).unwrap(); + + let manifest_content = r#" +[plugin] +name = "test-plugin" +version = "1.0.0" +api_version = "1.0" +kind = ["media_type"] + +[plugin.binary] +wasm = "plugin.wasm" +"#; + std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); + + // Create invalid WASM file + std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap(); + + let loader = PluginLoader::new(vec![]); + assert!(loader.validate_plugin_package(&plugin_dir).is_err()); + } +} diff --git a/crates/pinakes-plugin/src/manager.rs b/crates/pinakes-plugin/src/manager.rs new file mode 100644 index 0000000..22609e2 --- /dev/null +++ b/crates/pinakes-plugin/src/manager.rs @@ -0,0 +1,916 @@ +use std::{path::PathBuf, sync::Arc}; + +use anyhow::Result; +use pinakes_plugin_api::{PluginContext, PluginMetadata}; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; + +use crate::{ + CapabilityEnforcer, + PluginLoader, + PluginRegistry, + RegisteredPlugin, + SignatureStatus, + WasmPlugin, + WasmRuntime, + signature, +}; + +/// Plugin manager coordinates plugin lifecycle and operations +pub struct PluginManager { + /// Plugin registry + registry: Arc>, + + /// WASM runtime for executing plugins + runtime: Arc, + + /// Plugin loader for discovery and loading + loader: PluginLoader, + + /// Capability enforcer for security + enforcer: CapabilityEnforcer, + + /// Plugin data directory + data_dir: PathBuf, + + /// Plugin cache directory + cache_dir: PathBuf, + + /// Configuration + config: PluginManagerConfig, +} + +/// Configuration for the plugin manager +#[derive(Debug, Clone)] +pub struct PluginManagerConfig { + /// Directories to search for plugins + pub plugin_dirs: Vec, + + /// Whether to enable hot-reload (for development) + pub enable_hot_reload: bool, + + /// Whether to allow unsigned plugins + pub allow_unsigned: bool, + + /// Maximum number of concurrent plugin operations + pub max_concurrent_ops: usize, + + /// Plugin timeout in seconds + pub plugin_timeout_secs: u64, + + /// Timeout configuration for different call types + pub timeouts: pinakes_types::config::PluginTimeoutConfig, + + /// Max consecutive failures before circuit breaker disables plugin + pub max_consecutive_failures: u32, + + /// Trusted Ed25519 public keys for signature verification (hex-encoded) + pub trusted_keys: Vec, +} + +impl Default for PluginManagerConfig { + fn default() -> Self { + Self { + plugin_dirs: vec![], + enable_hot_reload: false, + allow_unsigned: false, + max_concurrent_ops: 4, + plugin_timeout_secs: 30, + timeouts: + pinakes_types::config::PluginTimeoutConfig::default(), + max_consecutive_failures: 5, + trusted_keys: vec![], + } + } +} + +impl From for PluginManagerConfig { + fn from(cfg: pinakes_types::config::PluginsConfig) -> Self { + Self { + plugin_dirs: cfg.plugin_dirs, + enable_hot_reload: cfg.enable_hot_reload, + allow_unsigned: cfg.allow_unsigned, + max_concurrent_ops: cfg.max_concurrent_ops, + plugin_timeout_secs: cfg.plugin_timeout_secs, + timeouts: cfg.timeouts, + max_consecutive_failures: cfg.max_consecutive_failures, + trusted_keys: cfg.trusted_keys, + } + } +} + +impl PluginManager { + /// Create a new plugin manager + /// + /// # Errors + /// + /// Returns an error if the data or cache directories cannot be created, or + /// if the WASM runtime cannot be initialized. + pub fn new( + data_dir: PathBuf, + cache_dir: PathBuf, + config: PluginManagerConfig, + ) -> Result { + // Ensure directories exist + std::fs::create_dir_all(&data_dir)?; + std::fs::create_dir_all(&cache_dir)?; + + let runtime = Arc::new(WasmRuntime::new()?); + let registry = Arc::new(RwLock::new(PluginRegistry::new())); + let loader = PluginLoader::new(config.plugin_dirs.clone()); + let enforcer = CapabilityEnforcer::new(); + + Ok(Self { + registry, + runtime, + loader, + enforcer, + data_dir, + cache_dir, + config, + }) + } + + /// Discover and load all plugins from configured directories. + /// + /// Plugins are loaded in dependency order: if plugin A declares a + /// dependency on plugin B, B is loaded first. Cycles and missing + /// dependencies are detected and reported as warnings; affected plugins + /// are skipped rather than causing a hard failure. + /// + /// # Errors + /// + /// Returns an error if plugin discovery fails. + pub async fn discover_and_load_all(&self) -> Result> { + info!("Discovering plugins from {:?}", self.config.plugin_dirs); + + let manifests = self.loader.discover_plugins()?; + let ordered = Self::resolve_load_order(&manifests); + let mut loaded_plugins = Vec::new(); + + for manifest in ordered { + match self.load_plugin_from_manifest(&manifest).await { + Ok(plugin_id) => { + info!("Loaded plugin: {}", plugin_id); + loaded_plugins.push(plugin_id); + }, + Err(e) => { + warn!("Failed to load plugin {}: {}", manifest.plugin.name, e); + }, + } + } + + Ok(loaded_plugins) + } + + /// Topological sort of manifests by their declared `dependencies`. + /// + /// Uses Kahn's algorithm. Plugins whose dependencies are missing or form + /// a cycle are logged as warnings and excluded from the result. + fn resolve_load_order( + manifests: &[pinakes_plugin_api::PluginManifest], + ) -> Vec { + use std::collections::VecDeque; + + use rustc_hash::{FxHashMap, FxHashSet}; + + // Index manifests by name for O(1) lookup + let by_name: FxHashMap<&str, usize> = manifests + .iter() + .enumerate() + .map(|(i, m)| (m.plugin.name.as_str(), i)) + .collect(); + + // Check for missing dependencies and warn early + let known: FxHashSet<&str> = by_name.keys().copied().collect(); + for manifest in manifests { + for dep in &manifest.plugin.dependencies { + if !known.contains(dep.as_str()) { + warn!( + "Plugin '{}' depends on '{}' which was not discovered; it will be \ + skipped", + manifest.plugin.name, dep + ); + } + } + } + + // Build adjacency: in_degree[i] = number of deps that must load before i + let mut in_degree = vec![0usize; manifests.len()]; + // dependents[i] = indices that depend on i (i must load before them) + let mut dependents: Vec> = vec![vec![]; manifests.len()]; + + for (i, manifest) in manifests.iter().enumerate() { + for dep in &manifest.plugin.dependencies { + if let Some(&dep_idx) = by_name.get(dep.as_str()) { + in_degree[i] += 1; + dependents[dep_idx].push(i); + } else { + // Missing dep: set in_degree impossibly high so it never resolves + in_degree[i] = usize::MAX; + } + } + } + + // Kahn's algorithm + let mut queue: VecDeque = VecDeque::new(); + for (i, °) in in_degree.iter().enumerate() { + if deg == 0 { + queue.push_back(i); + } + } + + let mut result = Vec::with_capacity(manifests.len()); + while let Some(idx) = queue.pop_front() { + result.push(manifests[idx].clone()); + for &dependent in &dependents[idx] { + if in_degree[dependent] == usize::MAX { + continue; // already poisoned by missing dep + } + in_degree[dependent] -= 1; + if in_degree[dependent] == 0 { + queue.push_back(dependent); + } + } + } + + // Anything not in `result` is part of a cycle or has a missing dep + if result.len() < manifests.len() { + let loaded: FxHashSet<&str> = + result.iter().map(|m| m.plugin.name.as_str()).collect(); + for manifest in manifests { + if !loaded.contains(manifest.plugin.name.as_str()) { + warn!( + "Plugin '{}' was skipped due to unresolved dependencies or a \ + dependency cycle", + manifest.plugin.name + ); + } + } + } + + result + } + + /// Load a plugin from a manifest file + /// + /// # Errors + /// + /// Returns an error if the plugin ID is invalid, capability validation + /// fails, the WASM binary cannot be loaded, or the plugin cannot be + /// registered. + async fn load_plugin_from_manifest( + &self, + manifest: &pinakes_plugin_api::PluginManifest, + ) -> Result { + let plugin_id = manifest.plugin_id(); + + // Validate plugin_id to prevent path traversal + if plugin_id.contains('/') + || plugin_id.contains('\\') + || plugin_id.contains("..") + { + return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}")); + } + + // Check if already loaded + { + let registry = self.registry.read().await; + if registry.is_loaded(&plugin_id) { + return Ok(plugin_id); + } + } + + // Validate capabilities + let capabilities = manifest.to_capabilities(); + self.enforcer.validate_capabilities(&capabilities)?; + + // Create plugin context + let plugin_data_dir = self.data_dir.join(&plugin_id); + let plugin_cache_dir = self.cache_dir.join(&plugin_id); + tokio::fs::create_dir_all(&plugin_data_dir).await?; + tokio::fs::create_dir_all(&plugin_cache_dir).await?; + + let context = PluginContext { + data_dir: plugin_data_dir, + cache_dir: plugin_cache_dir, + config: manifest + .config + .iter() + .map(|(k, v)| { + ( + k.clone(), + serde_json::to_value(v).unwrap_or_else(|e| { + tracing::warn!( + "failed to serialize config value for key {}: {}", + k, + e + ); + serde_json::Value::Null + }), + ) + }) + .collect(), + capabilities: capabilities.clone(), + }; + + // Load WASM binary + let wasm_path = self.loader.resolve_wasm_path(manifest)?; + + // Verify plugin signature unless unsigned plugins are allowed + if !self.config.allow_unsigned { + let plugin_dir = wasm_path + .parent() + .ok_or_else(|| anyhow::anyhow!("WASM path has no parent directory"))?; + + let trusted_keys: Vec = self + .config + .trusted_keys + .iter() + .filter_map(|hex| { + signature::parse_public_key(hex) + .map_err(|e| warn!("Ignoring malformed trusted key: {e}")) + .ok() + }) + .collect(); + + match signature::verify_plugin_signature( + plugin_dir, + &wasm_path, + &trusted_keys, + )? { + SignatureStatus::Valid => { + debug!("Plugin '{plugin_id}' signature verified"); + }, + SignatureStatus::Unsigned => { + return Err(anyhow::anyhow!( + "Plugin '{plugin_id}' is unsigned and allow_unsigned is false" + )); + }, + SignatureStatus::Invalid(reason) => { + return Err(anyhow::anyhow!( + "Plugin '{plugin_id}' has an invalid signature: {reason}" + )); + }, + } + } + + let wasm_plugin = self.runtime.load_plugin(&wasm_path, context)?; + + // Initialize plugin + let init_succeeded = match wasm_plugin + .call_function("initialize", &[]) + .await + { + Ok(_) => true, + Err(e) => { + tracing::warn!(plugin_id = %plugin_id, "plugin initialization failed: {}", e); + false + }, + }; + + // Register plugin + let metadata = PluginMetadata { + id: plugin_id.clone(), + name: manifest.plugin.name.clone(), + version: manifest.plugin.version.clone(), + author: manifest.plugin.author.clone().unwrap_or_default(), + description: manifest + .plugin + .description + .clone() + .unwrap_or_default(), + api_version: manifest.plugin.api_version.clone(), + capabilities_required: capabilities, + }; + + // Derive manifest_path from the loader's plugin directories + let manifest_path = self + .loader + .get_plugin_dir(&manifest.plugin.name) + .map(|dir| dir.join("plugin.toml")); + + let registered = RegisteredPlugin { + id: plugin_id.clone(), + metadata, + wasm_plugin, + manifest: manifest.clone(), + manifest_path, + enabled: init_succeeded, + }; + + { + let mut registry = self.registry.write().await; + registry.register(registered)?; + } + + Ok(plugin_id) + } + + /// Install a plugin from a file or URL + /// + /// # Errors + /// + /// Returns an error if the plugin cannot be downloaded, the manifest cannot + /// be read, or the plugin cannot be loaded. + pub async fn install_plugin(&self, source: &str) -> Result { + info!("Installing plugin from: {}", source); + + // Download/copy plugin to plugins directory + let plugin_path = + if source.starts_with("http://") || source.starts_with("https://") { + // Download from URL + self.loader.download_plugin(source).await? + } else { + // Copy from local file + PathBuf::from(source) + }; + + // Load the manifest + let manifest_path = plugin_path.join("plugin.toml"); + let manifest = + pinakes_plugin_api::PluginManifest::from_file(&manifest_path)?; + + // Load the plugin + self.load_plugin_from_manifest(&manifest).await + } + + /// Uninstall a plugin + /// + /// # Errors + /// + /// Returns an error if the plugin ID is invalid, the plugin cannot be shut + /// down, cannot be unregistered, or its data directories cannot be removed. + pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { + // Validate plugin_id to prevent path traversal + if plugin_id.contains('/') + || plugin_id.contains('\\') + || plugin_id.contains("..") + { + return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}")); + } + + info!("Uninstalling plugin: {}", plugin_id); + + // Shutdown plugin first + self.shutdown_plugin(plugin_id).await?; + + // Remove from registry + { + let mut registry = self.registry.write().await; + registry.unregister(plugin_id)?; + } + + // Remove plugin data and cache + let plugin_data_dir = self.data_dir.join(plugin_id); + let plugin_cache_dir = self.cache_dir.join(plugin_id); + + if plugin_data_dir.exists() { + std::fs::remove_dir_all(&plugin_data_dir)?; + } + if plugin_cache_dir.exists() { + std::fs::remove_dir_all(&plugin_cache_dir)?; + } + + Ok(()) + } + + /// Enable a plugin + /// + /// # Errors + /// + /// Returns an error if the plugin ID is not found in the registry. + pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> { + let mut registry = self.registry.write().await; + registry.enable(plugin_id) + } + + /// Disable a plugin + /// + /// # Errors + /// + /// Returns an error if the plugin ID is not found in the registry. + pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> { + let mut registry = self.registry.write().await; + registry.disable(plugin_id) + } + + /// Shutdown a specific plugin + /// + /// # Errors + /// + /// Returns an error if the plugin ID is not found in the registry. + pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> { + debug!("Shutting down plugin: {}", plugin_id); + + let registry = self.registry.read().await; + if let Some(plugin) = registry.get(plugin_id) { + let _ = plugin.wasm_plugin.call_function("shutdown", &[]).await; + Ok(()) + } else { + Err(anyhow::anyhow!("Plugin not found: {plugin_id}")) + } + } + + /// Shutdown all plugins + /// + /// # Errors + /// + /// This function always returns `Ok(())`. Individual plugin shutdown errors + /// are logged but do not cause the overall operation to fail. + pub async fn shutdown_all(&self) -> Result<()> { + info!("Shutting down all plugins"); + + let plugin_ids: Vec = { + let registry = self.registry.read().await; + registry.list_all().iter().map(|p| p.id.clone()).collect() + }; + + for plugin_id in plugin_ids { + if let Err(e) = self.shutdown_plugin(&plugin_id).await { + error!("Failed to shutdown plugin {}: {}", plugin_id, e); + } + } + + Ok(()) + } + + /// Get list of all registered plugins + pub async fn list_plugins(&self) -> Vec { + let registry = self.registry.read().await; + registry + .list_all() + .iter() + .map(|p| p.metadata.clone()) + .collect() + } + + /// Get plugin metadata by ID + pub async fn get_plugin(&self, plugin_id: &str) -> Option { + let registry = self.registry.read().await; + registry.get(plugin_id).map(|p| p.metadata.clone()) + } + + /// Get enabled plugins of a specific kind, sorted by priority (ascending). + /// + /// # Returns + /// + /// `(plugin_id, priority, kinds, wasm_plugin)` tuples. + pub async fn get_enabled_by_kind_sorted( + &self, + kind: &str, + ) -> Vec<(String, u16, Vec, WasmPlugin)> { + let registry = self.registry.read().await; + let mut plugins: Vec<_> = registry + .get_by_kind(kind) + .into_iter() + .filter(|p| p.enabled) + .map(|p| { + ( + p.id.clone(), + p.manifest.plugin.priority, + p.manifest.plugin.kind.clone(), + p.wasm_plugin.clone(), + ) + }) + .collect(); + drop(registry); + plugins.sort_by_key(|(_, priority, ..)| *priority); + plugins + } + + /// Get a reference to the capability enforcer. + #[must_use] + pub const fn enforcer(&self) -> &CapabilityEnforcer { + &self.enforcer + } + + /// List all UI pages provided by loaded plugins. + /// + /// Returns a vector of `(plugin_id, page)` tuples for all enabled plugins + /// that provide pages in their manifests. Both inline and file-referenced + /// page entries are resolved. + pub async fn list_ui_pages( + &self, + ) -> Vec<(String, pinakes_plugin_api::UiPage)> { + self + .list_ui_pages_with_endpoints() + .await + .into_iter() + .map(|(id, page, _)| (id, page)) + .collect() + } + + /// List all UI pages provided by loaded plugins, including each plugin's + /// declared endpoint allowlist. + /// + /// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples. The + /// `allowed_endpoints` list mirrors the `required_endpoints` field from the + /// plugin manifest's `[ui]` section. + pub async fn list_ui_pages_with_endpoints( + &self, + ) -> Vec<(String, pinakes_plugin_api::UiPage, Vec)> { + let registry = self.registry.read().await; + let mut pages = Vec::new(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + let allowed = plugin.manifest.ui.required_endpoints.clone(); + let plugin_dir = plugin + .manifest_path + .as_ref() + .and_then(|p| p.parent()) + .map(std::path::Path::to_path_buf); + let Some(plugin_dir) = plugin_dir else { + for entry in &plugin.manifest.ui.pages { + if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry + { + pages.push((plugin.id.clone(), (**page).clone(), allowed.clone())); + } + } + continue; + }; + match plugin.manifest.load_ui_pages(&plugin_dir) { + Ok(loaded) => { + for page in loaded { + pages.push((plugin.id.clone(), page, allowed.clone())); + } + }, + Err(e) => { + tracing::warn!( + "Failed to load UI pages for plugin '{}': {e}", + plugin.id + ); + }, + } + } + pages + } + + /// Collect CSS custom property overrides declared by all enabled plugins. + /// + /// When multiple plugins declare the same property name, later-loaded plugins + /// overwrite earlier ones. Returns an empty map if no plugins are loaded or + /// none declare theme extensions. + pub async fn list_ui_theme_extensions( + &self, + ) -> rustc_hash::FxHashMap { + let registry = self.registry.read().await; + let mut merged = rustc_hash::FxHashMap::default(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + for (k, v) in &plugin.manifest.ui.theme_extensions { + merged.insert(k.clone(), v.clone()); + } + } + merged + } + + /// List all UI widgets provided by loaded plugins. + /// + /// Returns a vector of `(plugin_id, widget)` tuples for all enabled plugins + /// that provide widgets in their manifests. + pub async fn list_ui_widgets( + &self, + ) -> Vec<(String, pinakes_plugin_api::UiWidget)> { + let registry = self.registry.read().await; + let mut widgets = Vec::new(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + for widget in &plugin.manifest.ui.widgets { + widgets.push((plugin.id.clone(), widget.clone())); + } + } + widgets + } + + /// Check if a plugin is loaded and enabled + pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool { + let registry = self.registry.read().await; + registry.is_enabled(plugin_id).unwrap_or(false) + } + + /// Reload a plugin (for hot-reload during development) + /// + /// # Errors + /// + /// Returns an error if hot-reload is disabled, the plugin is not found, it + /// cannot be shut down, or the reloaded plugin cannot be registered. + pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> { + if !self.config.enable_hot_reload { + return Err(anyhow::anyhow!("Hot-reload is disabled")); + } + + info!("Reloading plugin: {}", plugin_id); + + // Re-read the manifest from disk if possible, falling back to cached + // version + let manifest = { + let registry = self.registry.read().await; + let plugin = registry + .get(plugin_id) + .ok_or_else(|| anyhow::anyhow!("Plugin not found"))?; + let manifest = plugin.manifest_path.as_ref().map_or_else( + || plugin.manifest.clone(), + |manifest_path| { + pinakes_plugin_api::PluginManifest::from_file(manifest_path) + .unwrap_or_else(|e| { + warn!( + "Failed to re-read manifest from disk, using cached: {}", + e + ); + plugin.manifest.clone() + }) + }, + ); + drop(registry); + manifest + }; + + // Shutdown and unload current version + self.shutdown_plugin(plugin_id).await?; + { + let mut registry = self.registry.write().await; + registry.unregister(plugin_id)?; + } + + // Reload from manifest + self.load_plugin_from_manifest(&manifest).await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[tokio::test] + async fn test_plugin_manager_creation() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().join("data"); + let cache_dir = temp_dir.path().join("cache"); + + let config = PluginManagerConfig::default(); + let manager = + PluginManager::new(data_dir.clone(), cache_dir.clone(), config); + + assert!(manager.is_ok()); + assert!(data_dir.exists()); + assert!(cache_dir.exists()); + } + + #[tokio::test] + async fn test_list_plugins_empty() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().join("data"); + let cache_dir = temp_dir.path().join("cache"); + + let config = PluginManagerConfig::default(); + let manager = PluginManager::new(data_dir, cache_dir, config).unwrap(); + + let plugins = manager.list_plugins().await; + assert_eq!(plugins.len(), 0); + } + + /// Build a minimal manifest for dependency resolution tests + fn test_manifest( + name: &str, + deps: Vec, + ) -> pinakes_plugin_api::PluginManifest { + use pinakes_plugin_api::manifest::{PluginBinary, PluginInfo}; + + pinakes_plugin_api::PluginManifest { + plugin: PluginInfo { + name: name.to_string(), + version: "1.0.0".to_string(), + api_version: "1.0".to_string(), + author: None, + description: None, + homepage: None, + license: None, + priority: 500, + kind: vec!["media_type".to_string()], + binary: PluginBinary { + wasm: "plugin.wasm".to_string(), + entrypoint: None, + }, + dependencies: deps, + }, + capabilities: Default::default(), + config: Default::default(), + ui: Default::default(), + } + } + + #[test] + fn test_resolve_load_order_no_deps() { + let manifests = vec![ + test_manifest("alpha", vec![]), + test_manifest("beta", vec![]), + test_manifest("gamma", vec![]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + assert_eq!(ordered.len(), 3); + } + + #[test] + fn test_resolve_load_order_linear_chain() { + // gamma depends on beta, beta depends on alpha + let manifests = vec![ + test_manifest("gamma", vec!["beta".to_string()]), + test_manifest("alpha", vec![]), + test_manifest("beta", vec!["alpha".to_string()]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + assert_eq!(ordered.len(), 3); + + let names: Vec<&str> = + ordered.iter().map(|m| m.plugin.name.as_str()).collect(); + let alpha_pos = names.iter().position(|&n| n == "alpha").unwrap(); + let beta_pos = names.iter().position(|&n| n == "beta").unwrap(); + let gamma_pos = names.iter().position(|&n| n == "gamma").unwrap(); + assert!(alpha_pos < beta_pos, "alpha must load before beta"); + assert!(beta_pos < gamma_pos, "beta must load before gamma"); + } + + #[test] + fn test_resolve_load_order_cycle_detected() { + // A -> B -> C -> A (cycle) + let manifests = vec![ + test_manifest("a", vec!["c".to_string()]), + test_manifest("b", vec!["a".to_string()]), + test_manifest("c", vec!["b".to_string()]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + // All three should be excluded due to cycle + assert_eq!(ordered.len(), 0); + } + + #[test] + fn test_resolve_load_order_missing_dependency() { + let manifests = vec![ + test_manifest("good", vec![]), + test_manifest("bad", vec!["nonexistent".to_string()]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + // Only "good" should be loaded; "bad" depends on something missing + assert_eq!(ordered.len(), 1); + assert_eq!(ordered[0].plugin.name, "good"); + } + + #[test] + fn test_resolve_load_order_partial_cycle() { + // "ok" has no deps, "cycle_a" and "cycle_b" form a cycle + let manifests = vec![ + test_manifest("ok", vec![]), + test_manifest("cycle_a", vec!["cycle_b".to_string()]), + test_manifest("cycle_b", vec!["cycle_a".to_string()]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + assert_eq!(ordered.len(), 1); + assert_eq!(ordered[0].plugin.name, "ok"); + } + + #[test] + fn test_resolve_load_order_diamond() { + // Man look at how beautiful my diamond is... + // A + // / \ + // B C + // \ / + // D + let manifests = vec![ + test_manifest("d", vec!["b".to_string(), "c".to_string()]), + test_manifest("b", vec!["a".to_string()]), + test_manifest("c", vec!["a".to_string()]), + test_manifest("a", vec![]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + assert_eq!(ordered.len(), 4); + + let names: Vec<&str> = + ordered.iter().map(|m| m.plugin.name.as_str()).collect(); + let a_pos = names.iter().position(|&n| n == "a").unwrap(); + let b_pos = names.iter().position(|&n| n == "b").unwrap(); + let c_pos = names.iter().position(|&n| n == "c").unwrap(); + let d_pos = names.iter().position(|&n| n == "d").unwrap(); + assert!(a_pos < b_pos); + assert!(a_pos < c_pos); + assert!(b_pos < d_pos); + assert!(c_pos < d_pos); + } +} diff --git a/crates/pinakes-plugin/src/registry.rs b/crates/pinakes-plugin/src/registry.rs new file mode 100644 index 0000000..ce13d86 --- /dev/null +++ b/crates/pinakes-plugin/src/registry.rs @@ -0,0 +1,309 @@ +//! Plugin registry for managing loaded plugins + +use std::path::PathBuf; + +use anyhow::{Result, anyhow}; +use pinakes_plugin_api::{PluginManifest, PluginMetadata}; +use rustc_hash::FxHashMap; + +use super::runtime::WasmPlugin; + +/// A registered plugin with its metadata and runtime state +#[derive(Clone)] +pub struct RegisteredPlugin { + pub id: String, + pub metadata: PluginMetadata, + pub wasm_plugin: WasmPlugin, + pub manifest: PluginManifest, + pub manifest_path: Option, + pub enabled: bool, +} + +/// Plugin registry maintains the state of all loaded plugins +pub struct PluginRegistry { + /// Map of plugin ID to registered plugin + plugins: FxHashMap, +} + +impl PluginRegistry { + /// Create a new empty registry + #[must_use] + pub fn new() -> Self { + Self { + plugins: FxHashMap::default(), + } + } + + /// Register a new plugin + /// + /// # Errors + /// + /// Returns an error if a plugin with the same ID is already registered. + pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> { + if self.plugins.contains_key(&plugin.id) { + return Err(anyhow!("Plugin already registered: {}", plugin.id)); + } + + self.plugins.insert(plugin.id.clone(), plugin); + Ok(()) + } + + /// Unregister a plugin by ID + /// + /// # Errors + /// + /// Returns an error if the plugin ID is not found. + pub fn unregister(&mut self, plugin_id: &str) -> Result<()> { + self + .plugins + .remove(plugin_id) + .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?; + Ok(()) + } + + /// Get a plugin by ID + #[must_use] + pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> { + self.plugins.get(plugin_id) + } + + /// Get a mutable reference to a plugin by ID + pub fn get_mut(&mut self, plugin_id: &str) -> Option<&mut RegisteredPlugin> { + self.plugins.get_mut(plugin_id) + } + + /// Check if a plugin is loaded + #[must_use] + pub fn is_loaded(&self, plugin_id: &str) -> bool { + self.plugins.contains_key(plugin_id) + } + + /// Check if a plugin is enabled. Returns `None` if the plugin is not found. + #[must_use] + pub fn is_enabled(&self, plugin_id: &str) -> Option { + self.plugins.get(plugin_id).map(|p| p.enabled) + } + + /// Enable a plugin + /// + /// # Errors + /// + /// Returns an error if the plugin ID is not found. + pub fn enable(&mut self, plugin_id: &str) -> Result<()> { + let plugin = self + .plugins + .get_mut(plugin_id) + .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?; + + plugin.enabled = true; + Ok(()) + } + + /// Disable a plugin + /// + /// # Errors + /// + /// Returns an error if the plugin ID is not found. + pub fn disable(&mut self, plugin_id: &str) -> Result<()> { + let plugin = self + .plugins + .get_mut(plugin_id) + .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?; + + plugin.enabled = false; + Ok(()) + } + + /// List all registered plugins + #[must_use] + pub fn list_all(&self) -> Vec<&RegisteredPlugin> { + self.plugins.values().collect() + } + + /// List all enabled plugins + #[must_use] + pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> { + self.plugins.values().filter(|p| p.enabled).collect() + } + + /// Get plugins by kind (e.g., "`media_type`", "`metadata_extractor`") + #[must_use] + pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> { + self + .plugins + .values() + .filter(|p| p.manifest.plugin.kind.iter().any(|k| k == kind)) + .collect() + } + + /// Get count of registered plugins + #[must_use] + pub fn count(&self) -> usize { + self.plugins.len() + } + + /// Get count of enabled plugins + #[must_use] + pub fn count_enabled(&self) -> usize { + self.plugins.values().filter(|p| p.enabled).count() + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use pinakes_plugin_api::{Capabilities, manifest::ManifestCapabilities}; + use rustc_hash::FxHashMap; + + use super::*; + + fn create_test_plugin(id: &str, kind: Vec) -> RegisteredPlugin { + let manifest = PluginManifest { + plugin: pinakes_plugin_api::manifest::PluginInfo { + name: id.to_string(), + version: "1.0.0".to_string(), + api_version: "1.0".to_string(), + author: Some("Test".to_string()), + description: Some("Test plugin".to_string()), + homepage: None, + license: None, + kind, + binary: pinakes_plugin_api::manifest::PluginBinary { + wasm: "test.wasm".to_string(), + entrypoint: None, + }, + dependencies: vec![], + priority: 0, + }, + capabilities: ManifestCapabilities::default(), + config: FxHashMap::default(), + ui: Default::default(), + }; + + RegisteredPlugin { + id: id.to_string(), + metadata: PluginMetadata { + id: id.to_string(), + name: id.to_string(), + version: "1.0.0".to_string(), + author: "Test".to_string(), + description: "Test plugin".to_string(), + api_version: "1.0".to_string(), + capabilities_required: Capabilities::default(), + }, + wasm_plugin: WasmPlugin::default(), + manifest, + manifest_path: None, + enabled: true, + } + } + + #[test] + fn test_registry_register_and_get() { + let mut registry = PluginRegistry::new(); + let plugin = + create_test_plugin("test-plugin", vec!["media_type".to_string()]); + + registry.register(plugin).unwrap(); + + assert!(registry.is_loaded("test-plugin")); + assert!(registry.get("test-plugin").is_some()); + } + + #[test] + fn test_registry_duplicate_register() { + let mut registry = PluginRegistry::new(); + let plugin = + create_test_plugin("test-plugin", vec!["media_type".to_string()]); + + registry.register(plugin.clone()).unwrap(); + let result = registry.register(plugin); + + assert!(result.is_err()); + } + + #[test] + fn test_registry_unregister() { + let mut registry = PluginRegistry::new(); + let plugin = + create_test_plugin("test-plugin", vec!["media_type".to_string()]); + + registry.register(plugin).unwrap(); + registry.unregister("test-plugin").unwrap(); + + assert!(!registry.is_loaded("test-plugin")); + } + + #[test] + fn test_registry_enable_disable() { + let mut registry = PluginRegistry::new(); + let plugin = + create_test_plugin("test-plugin", vec!["media_type".to_string()]); + + registry.register(plugin).unwrap(); + assert_eq!(registry.is_enabled("test-plugin"), Some(true)); + + registry.disable("test-plugin").unwrap(); + assert_eq!(registry.is_enabled("test-plugin"), Some(false)); + + registry.enable("test-plugin").unwrap(); + assert_eq!(registry.is_enabled("test-plugin"), Some(true)); + + assert_eq!(registry.is_enabled("nonexistent"), None); + } + + #[test] + fn test_registry_get_by_kind() { + let mut registry = PluginRegistry::new(); + + registry + .register(create_test_plugin("plugin1", vec![ + "media_type".to_string(), + ])) + .unwrap(); + registry + .register(create_test_plugin("plugin2", vec![ + "metadata_extractor".to_string(), + ])) + .unwrap(); + registry + .register(create_test_plugin("plugin3", vec![ + "media_type".to_string(), + ])) + .unwrap(); + + let media_type_plugins = registry.get_by_kind("media_type"); + assert_eq!(media_type_plugins.len(), 2); + + let extractor_plugins = registry.get_by_kind("metadata_extractor"); + assert_eq!(extractor_plugins.len(), 1); + } + + #[test] + fn test_registry_counts() { + let mut registry = PluginRegistry::new(); + + registry + .register(create_test_plugin("plugin1", vec![ + "media_type".to_string(), + ])) + .unwrap(); + registry + .register(create_test_plugin("plugin2", vec![ + "media_type".to_string(), + ])) + .unwrap(); + + assert_eq!(registry.count(), 2); + assert_eq!(registry.count_enabled(), 2); + + registry.disable("plugin1").unwrap(); + assert_eq!(registry.count(), 2); + assert_eq!(registry.count_enabled(), 1); + } +} diff --git a/crates/pinakes-plugin/src/rpc.rs b/crates/pinakes-plugin/src/rpc.rs new file mode 100644 index 0000000..e875d11 --- /dev/null +++ b/crates/pinakes-plugin/src/rpc.rs @@ -0,0 +1,240 @@ +//! JSON RPC types for structured plugin function calls. +//! +//! Each extension point maps to well-known exported function names. +//! Requests are serialized to JSON, passed to the plugin, and responses +//! are deserialized from JSON written by the plugin via `host_set_result`. + +use std::path::PathBuf; + +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; + +/// Request to check if a plugin can handle a file +#[derive(Debug, Serialize)] +pub struct CanHandleRequest { + pub path: PathBuf, + pub mime_type: Option, +} + +/// Response from `can_handle` +#[derive(Debug, Deserialize)] +pub struct CanHandleResponse { + pub can_handle: bool, +} + +/// Media type definition returned by `supported_media_types` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginMediaTypeDefinition { + pub id: String, + pub name: String, + pub category: Option, + pub extensions: Vec, + pub mime_types: Vec, +} + +/// Request to extract metadata from a file +#[derive(Debug, Serialize)] +pub struct ExtractMetadataRequest { + pub path: PathBuf, +} + +/// Metadata response from a plugin (all fields optional for partial results) +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct ExtractMetadataResponse { + #[serde(default)] + pub title: Option, + #[serde(default)] + pub artist: Option, + #[serde(default)] + pub album: Option, + #[serde(default)] + pub genre: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub duration_secs: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub extra: FxHashMap, +} + +/// Request to generate a thumbnail +#[derive(Debug, Serialize)] +pub struct GenerateThumbnailRequest { + pub source_path: PathBuf, + pub output_path: PathBuf, + pub max_width: u32, + pub max_height: u32, + pub format: String, +} + +/// Response from thumbnail generation +#[derive(Debug, Deserialize)] +pub struct GenerateThumbnailResponse { + pub path: PathBuf, + pub width: u32, + pub height: u32, + pub format: String, +} + +/// Event sent to event handler plugins +#[derive(Debug, Serialize)] +pub struct HandleEventRequest { + pub event_type: String, + pub payload: serde_json::Value, +} + +/// Search request for search backend plugins +#[derive(Debug, Serialize)] +pub struct SearchRequest { + pub query: String, + pub limit: usize, + pub offset: usize, +} + +/// Search response +#[derive(Debug, Clone, Deserialize)] +pub struct SearchResponse { + pub results: Vec, + #[serde(default)] + pub total_count: Option, +} + +/// Individual search result +#[derive(Debug, Clone, Deserialize)] +pub struct SearchResultItem { + pub id: String, + pub score: f64, + pub snippet: Option, +} + +/// Request to index a media item in a search backend +#[derive(Debug, Serialize)] +pub struct IndexItemRequest { + pub id: String, + pub title: Option, + pub artist: Option, + pub album: Option, + pub description: Option, + pub tags: Vec, + pub media_type: String, + pub path: PathBuf, +} + +/// Request to remove a media item from a search backend +#[derive(Debug, Serialize)] +pub struct RemoveItemRequest { + pub id: String, +} + +/// A theme definition returned by a theme provider plugin +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginThemeDefinition { + pub id: String, + pub name: String, + pub description: Option, + pub dark: bool, +} + +/// Response from `load_theme` +#[derive(Debug, Clone, Deserialize)] +pub struct LoadThemeResponse { + pub css: Option, + pub colors: FxHashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_metadata_request_serialization() { + let req = ExtractMetadataRequest { + path: "/tmp/test.mp3".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("/tmp/test.mp3")); + } + + #[test] + fn test_extract_metadata_response_partial() { + let json = r#"{"title":"My Song","extra":{"bpm":"120"}}"#; + let resp: ExtractMetadataResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.title.as_deref(), Some("My Song")); + assert_eq!(resp.artist, None); + assert_eq!(resp.extra.get("bpm").map(String::as_str), Some("120")); + } + + #[test] + fn test_extract_metadata_response_empty() { + let json = "{}"; + let resp: ExtractMetadataResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.title, None); + assert!(resp.extra.is_empty()); + } + + #[test] + fn test_can_handle_response() { + let json = r#"{"can_handle":true}"#; + let resp: CanHandleResponse = serde_json::from_str(json).unwrap(); + assert!(resp.can_handle); + } + + #[test] + fn test_can_handle_response_false() { + let json = r#"{"can_handle":false}"#; + let resp: CanHandleResponse = serde_json::from_str(json).unwrap(); + assert!(!resp.can_handle); + } + + #[test] + fn test_plugin_media_type_definition_round_trip() { + let def = PluginMediaTypeDefinition { + id: "heif".to_string(), + name: "HEIF Image".to_string(), + category: Some("image".to_string()), + extensions: vec!["heif".to_string(), "heic".to_string()], + mime_types: vec!["image/heif".to_string()], + }; + let json = serde_json::to_string(&def).unwrap(); + let parsed: PluginMediaTypeDefinition = + serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.id, "heif"); + assert_eq!(parsed.extensions.len(), 2); + } + + #[test] + fn test_search_response() { + let json = + r#"{"results":[{"id":"abc","score":0.95,"snippet":"match here"}]}"#; + let resp: SearchResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.results.len(), 1); + assert_eq!(resp.results[0].id, "abc"); + } + + #[test] + fn test_generate_thumbnail_request_serialization() { + let req = GenerateThumbnailRequest { + source_path: "/media/photo.heif".into(), + output_path: "/tmp/thumb.jpg".into(), + max_width: 256, + max_height: 256, + format: "jpeg".to_string(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("photo.heif")); + assert!(json.contains("256")); + } + + #[test] + fn test_handle_event_request_serialization() { + let req = HandleEventRequest { + event_type: "MediaImported".to_string(), + payload: serde_json::json!({"id": "abc-123"}), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("MediaImported")); + assert!(json.contains("abc-123")); + } +} diff --git a/crates/pinakes-plugin/src/runtime.rs b/crates/pinakes-plugin/src/runtime.rs new file mode 100644 index 0000000..e07a1c4 --- /dev/null +++ b/crates/pinakes-plugin/src/runtime.rs @@ -0,0 +1,925 @@ +//! WASM runtime for executing plugins + +use std::{path::Path, sync::Arc}; + +use anyhow::{Result, anyhow}; +use pinakes_plugin_api::PluginContext; +use wasmtime::{ + Caller, + Config, + Engine, + Linker, + Module, + Store, + StoreLimitsBuilder, + Val, + anyhow, +}; + +/// WASM runtime wrapper for executing plugins +pub struct WasmRuntime { + engine: Engine, +} + +impl WasmRuntime { + /// Create a new WASM runtime + /// + /// # Errors + /// + /// Returns an error if the WASM engine cannot be created with the given + /// configuration. + pub fn new() -> Result { + let mut config = Config::new(); + config.wasm_component_model(true); + config.max_wasm_stack(1024 * 1024); // 1MB stack + config.consume_fuel(true); // enable fuel metering for CPU limits + + let engine = Engine::new(&config)?; + + Ok(Self { engine }) + } + + /// Load a plugin from a WASM file + /// + /// # Errors + /// + /// Returns an error if the WASM file does not exist, cannot be read, or + /// cannot be compiled. + pub fn load_plugin( + &self, + wasm_path: &Path, + context: PluginContext, + ) -> Result { + if !wasm_path.exists() { + return Err(anyhow!("WASM file not found: {}", wasm_path.display())); + } + + let wasm_bytes = std::fs::read(wasm_path)?; + let module = Module::new(&self.engine, &wasm_bytes)?; + + Ok(WasmPlugin { + module: Arc::new(module), + context, + }) + } +} + +/// Store data passed to each WASM invocation +pub struct PluginStoreData { + pub context: PluginContext, + pub exchange_buffer: Vec, + pub pending_events: Vec<(String, String)>, + pub limiter: wasmtime::StoreLimits, +} + +/// A loaded WASM plugin instance +#[derive(Clone)] +pub struct WasmPlugin { + module: Arc, + context: PluginContext, +} + +impl WasmPlugin { + /// Get the plugin context + #[must_use] + pub const fn context(&self) -> &PluginContext { + &self.context + } + + /// Execute a plugin function, returning both the result bytes and any + /// events the plugin queued via `host_emit_event`. + /// + /// Creates a fresh store and instance per invocation with host functions + /// linked, calls the requested exported function, drains both the exchange + /// buffer and the pending events list before the store is dropped, and + /// returns both. + /// + /// # Errors + /// + /// Returns an error if the function cannot be found, instantiation fails, + /// or the function call returns an error. + pub async fn call_function_with_events( + &self, + function_name: &str, + params: &[u8], + ) -> Result<(Vec, Vec<(String, String)>)> { + let engine = self.module.engine(); + + // Build memory limiter from capabilities + let memory_limit = self + .context + .capabilities + .max_memory_bytes + .unwrap_or(512 * 1024 * 1024); // default 512 MB + + let limiter = StoreLimitsBuilder::new().memory_size(memory_limit).build(); + + let store_data = PluginStoreData { + context: self.context.clone(), + exchange_buffer: Vec::new(), + pending_events: Vec::new(), + limiter, + }; + let mut store = Store::new(engine, store_data); + store.limiter(|data| &mut data.limiter); + + // Set fuel limit based on capabilities + if let Some(max_cpu_time_ms) = self.context.capabilities.max_cpu_time_ms { + let fuel = max_cpu_time_ms * 100_000; + store.set_fuel(fuel)?; + } else { + store.set_fuel(1_000_000_000)?; + } + + let mut linker = Linker::new(engine); + HostFunctions::setup_linker(&mut linker)?; + + let instance = linker.instantiate_async(&mut store, &self.module).await?; + + let memory = instance.get_memory(&mut store, "memory"); + + // If there are params and memory is available, write them to the module + let mut alloc_offset: i32 = 0; + if !params.is_empty() + && let Some(mem) = &memory + { + // Call the plugin's alloc function if available, otherwise write at + // offset 0 + let offset = if let Ok(alloc) = + instance.get_typed_func::(&mut store, "alloc") + { + let result = alloc + .call_async( + &mut store, + i32::try_from(params.len()).unwrap_or(i32::MAX), + ) + .await?; + if result < 0 { + return Err(anyhow!( + "plugin alloc returned negative offset: {result}" + )); + } + u32::try_from(result).unwrap_or(0) as usize + } else { + 0 + }; + + alloc_offset = i32::try_from(offset).unwrap_or(i32::MAX); + let mem_data = mem.data_mut(&mut store); + if offset + params.len() <= mem_data.len() { + mem_data[offset..offset + params.len()].copy_from_slice(params); + } + } + + let func = + instance + .get_func(&mut store, function_name) + .ok_or_else(|| { + anyhow!("exported function '{function_name}' not found") + })?; + + let func_ty = func.ty(&store); + let param_count = func_ty.params().len(); + let result_count = func_ty.results().len(); + + let mut results = vec![Val::I32(0); result_count]; + + // Call with appropriate params based on function signature; convention: + // (ptr, len) + if param_count == 2 && !params.is_empty() { + func + .call_async( + &mut store, + &[ + Val::I32(alloc_offset), + Val::I32(i32::try_from(params.len()).unwrap_or(i32::MAX)), + ], + &mut results, + ) + .await?; + } else if param_count == 0 { + func.call_async(&mut store, &[], &mut results).await?; + } else { + // Generic: fill with zeroes + let params_vals: Vec = + std::iter::repeat_n(Val::I32(0), param_count).collect(); + func + .call_async(&mut store, ¶ms_vals, &mut results) + .await?; + } + + // Drain both buffers before the store is dropped. + let pending_events = std::mem::take(&mut store.data_mut().pending_events); + let exchange = std::mem::take(&mut store.data_mut().exchange_buffer); + + let result = if !exchange.is_empty() { + exchange + } else if let Some(Val::I32(ret)) = results.first() { + ret.to_le_bytes().to_vec() + } else { + Vec::new() + }; + + Ok((result, pending_events)) + } + + /// Execute a plugin function, discarding any events the plugin queued. + /// + /// This is a thin wrapper around [`Self::call_function_with_events`]. + /// + /// # Errors + /// + /// Returns an error if the function cannot be found, instantiation fails, + /// or the function call returns an error. + pub async fn call_function( + &self, + function_name: &str, + params: &[u8], + ) -> Result> { + let (data, _events) = self + .call_function_with_events(function_name, params) + .await?; + Ok(data) + } + + /// Call a plugin function with JSON request/response serialization. + /// + /// Serializes `request` to JSON, calls the named function, deserializes + /// the response. Wraps the call with `tokio::time::timeout`. + /// + /// # Errors + /// + /// Returns an error if serialization fails, the call times out, the plugin + /// traps, or the response is malformed JSON. + #[allow(clippy::future_not_send)] // Req doesn't need Sync; called within local tasks + pub async fn call_function_json( + &self, + function_name: &str, + request: &Req, + timeout: std::time::Duration, + ) -> anyhow::Result + where + Req: serde::Serialize, + Resp: serde::de::DeserializeOwned, + { + let request_bytes = serde_json::to_vec(request) + .map_err(|e| anyhow::anyhow!("failed to serialize request: {e}"))?; + + let result = tokio::time::timeout( + timeout, + self.call_function(function_name, &request_bytes), + ) + .await + .map_err(|_| { + anyhow::anyhow!( + "plugin call '{function_name}' timed out after {timeout:?}" + ) + })??; + + serde_json::from_slice(&result).map_err(|e| { + anyhow::anyhow!( + "failed to deserialize response from '{function_name}': {e}" + ) + }) + } + + /// Call a plugin function with JSON serialization, also returning any + /// events the plugin queued via `host_emit_event`. + /// + /// Mirrors [`Self::call_function_json`] but delegates to + /// [`Self::call_function_with_events`] so the pending events list is not + /// discarded before returning. + /// + /// # Errors + /// + /// Returns an error if serialization fails, the call times out, the plugin + /// traps, or the response is malformed JSON. + #[allow(clippy::future_not_send)] // Req doesn't need Sync; called within local tasks + pub async fn call_function_json_with_events( + &self, + function_name: &str, + request: &Req, + timeout: std::time::Duration, + ) -> anyhow::Result<(Resp, Vec<(String, String)>)> + where + Req: serde::Serialize, + Resp: serde::de::DeserializeOwned, + { + let request_bytes = serde_json::to_vec(request) + .map_err(|e| anyhow::anyhow!("failed to serialize request: {e}"))?; + + let (result, pending_events) = tokio::time::timeout( + timeout, + self.call_function_with_events(function_name, &request_bytes), + ) + .await + .map_err(|_| { + anyhow::anyhow!( + "plugin call '{function_name}' timed out after {timeout:?}" + ) + })??; + + let resp = serde_json::from_slice(&result).map_err(|e| { + anyhow::anyhow!( + "failed to deserialize response from '{function_name}': {e}" + ) + })?; + + Ok((resp, pending_events)) + } +} + +#[cfg(test)] +impl Default for WasmPlugin { + fn default() -> Self { + let engine = Engine::default(); + let module = Module::new(&engine, br"(module)").unwrap(); + + Self { + module: Arc::new(module), + context: PluginContext { + data_dir: std::env::temp_dir(), + cache_dir: std::env::temp_dir(), + config: Default::default(), + capabilities: Default::default(), + }, + } + } +} + +/// Host functions that plugins can call +pub struct HostFunctions; + +impl HostFunctions { + /// Registers all host ABI functions (`host_log`, `host_read_file`, + /// `host_write_file`, `host_http_request`, `host_get_config`, + /// `host_get_env`, `host_get_buffer`, `host_set_result`, + /// `host_emit_event`) into the given linker. + /// + /// # Errors + /// + /// Returns an error if any host function cannot be registered in the linker. + pub fn setup_linker(linker: &mut Linker) -> Result<()> { + linker.func_wrap( + "env", + "host_log", + |mut caller: Caller<'_, PluginStoreData>, + level: i32, + ptr: i32, + len: i32| { + if ptr < 0 || len < 0 { + return; + } + let memory = caller + .get_export("memory") + .and_then(wasmtime::Extern::into_memory); + if let Some(mem) = memory { + let data = mem.data(&caller); + let start = u32::try_from(ptr).unwrap_or(0) as usize; + let end = start + u32::try_from(len).unwrap_or(0) as usize; + if end <= data.len() + && let Ok(msg) = std::str::from_utf8(&data[start..end]) + { + match level { + 0 => tracing::error!(plugin = true, "{}", msg), + 1 => tracing::warn!(plugin = true, "{}", msg), + 2 => tracing::info!(plugin = true, "{}", msg), + _ => tracing::debug!(plugin = true, "{}", msg), + } + } + } + }, + )?; + + linker.func_wrap( + "env", + "host_read_file", + |mut caller: Caller<'_, PluginStoreData>, + path_ptr: i32, + path_len: i32| + -> i32 { + if path_ptr < 0 || path_len < 0 { + return -1; + } + let memory = caller + .get_export("memory") + .and_then(wasmtime::Extern::into_memory); + let Some(mem) = memory else { return -1 }; + + let data = mem.data(&caller); + let start = u32::try_from(path_ptr).unwrap_or(0) as usize; + let end = start + u32::try_from(path_len).unwrap_or(0) as usize; + if end > data.len() { + return -1; + } + + let path_str = match std::str::from_utf8(&data[start..end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + + // Canonicalize path before checking permissions to prevent traversal + let Ok(path) = std::path::Path::new(&path_str).canonicalize() else { + return -1; + }; + + // Check read permission against canonicalized path + let can_read = caller + .data() + .context + .capabilities + .filesystem + .read + .iter() + .any(|allowed| { + allowed.canonicalize().is_ok_and(|a| path.starts_with(a)) + }); + + if !can_read { + tracing::warn!(path = %path_str, "plugin read access denied"); + return -2; + } + + std::fs::read(&path).map_or(-1, |contents| { + let len = i32::try_from(contents.len()).unwrap_or(i32::MAX); + caller.data_mut().exchange_buffer = contents; + len + }) + }, + )?; + + linker.func_wrap( + "env", + "host_write_file", + |mut caller: Caller<'_, PluginStoreData>, + path_ptr: i32, + path_len: i32, + data_ptr: i32, + data_len: i32| + -> i32 { + if path_ptr < 0 || path_len < 0 || data_ptr < 0 || data_len < 0 { + return -1; + } + let memory = caller + .get_export("memory") + .and_then(wasmtime::Extern::into_memory); + let Some(mem) = memory else { return -1 }; + + let mem_data = mem.data(&caller); + let path_start = u32::try_from(path_ptr).unwrap_or(0) as usize; + let path_end = + path_start + u32::try_from(path_len).unwrap_or(0) as usize; + let data_start = u32::try_from(data_ptr).unwrap_or(0) as usize; + let data_end = + data_start + u32::try_from(data_len).unwrap_or(0) as usize; + + if path_end > mem_data.len() || data_end > mem_data.len() { + return -1; + } + + let path_str = + match std::str::from_utf8(&mem_data[path_start..path_end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + let file_data = mem_data[data_start..data_end].to_vec(); + + // Canonicalize path for write (file may not exist yet) + let path = std::path::Path::new(&path_str); + let canonical = if path.exists() { + path.canonicalize().ok() + } else { + path + .parent() + .and_then(|p| p.canonicalize().ok()) + .map(|p| p.join(path.file_name().unwrap_or_default())) + }; + let Some(canonical) = canonical else { + return -1; + }; + + // Check write permission against canonicalized path + let can_write = caller + .data() + .context + .capabilities + .filesystem + .write + .iter() + .any(|allowed| { + allowed + .canonicalize() + .is_ok_and(|a| canonical.starts_with(a)) + }); + + if !can_write { + tracing::warn!(path = %path_str, "plugin write access denied"); + return -2; + } + + match std::fs::write(&canonical, &file_data) { + Ok(()) => 0, + Err(_) => -1, + } + }, + )?; + + linker.func_wrap( + "env", + "host_http_request", + |mut caller: Caller<'_, PluginStoreData>, + url_ptr: i32, + url_len: i32| + -> i32 { + if url_ptr < 0 || url_len < 0 { + return -1; + } + let memory = caller + .get_export("memory") + .and_then(wasmtime::Extern::into_memory); + let Some(mem) = memory else { return -1 }; + + let data = mem.data(&caller); + let start = u32::try_from(url_ptr).unwrap_or(0) as usize; + let end = start + u32::try_from(url_len).unwrap_or(0) as usize; + if end > data.len() { + return -1; + } + + let url_str = match std::str::from_utf8(&data[start..end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + + // Check network permission + if !caller.data().context.capabilities.network.enabled { + tracing::warn!(url = %url_str, "plugin network access denied"); + return -2; + } + + // Check domain whitelist if configured + if let Some(ref allowed) = + caller.data().context.capabilities.network.allowed_domains + { + let parsed = if let Ok(u) = url::Url::parse(&url_str) { + u + } else { + tracing::warn!(url = %url_str, "plugin provided invalid URL"); + return -1; + }; + let domain = parsed.host_str().unwrap_or(""); + + if !allowed.iter().any(|d| d.eq_ignore_ascii_case(domain)) { + tracing::warn!( + url = %url_str, + domain = domain, + "plugin domain not in allowlist" + ); + return -3; + } + } + + // Use block_in_place to avoid blocking the async runtime's thread pool. + // Falls back to a blocking client with timeout if block_in_place is + // unavailable. + let result = std::panic::catch_unwind(|| { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + let resp = client + .get(&url_str) + .send() + .await + .map_err(|e| e.to_string())?; + let bytes = resp.bytes().await.map_err(|e| e.to_string())?; + Ok::<_, String>(bytes) + }) + }) + }); + + match result { + Ok(Ok(bytes)) => { + let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); + caller.data_mut().exchange_buffer = bytes.to_vec(); + len + }, + Ok(Err(_)) => -1, + Err(_) => { + // block_in_place panicked (e.g. current-thread runtime); + // fall back to blocking client with timeout + let Ok(client) = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + else { + return -1; + }; + client.get(&url_str).send().map_or(-1, |resp| { + resp.bytes().map_or(-1, |bytes| { + let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); + caller.data_mut().exchange_buffer = bytes.to_vec(); + len + }) + }) + }, + } + }, + )?; + + linker.func_wrap( + "env", + "host_get_config", + |mut caller: Caller<'_, PluginStoreData>, + key_ptr: i32, + key_len: i32| + -> i32 { + if key_ptr < 0 || key_len < 0 { + return -1; + } + let memory = caller + .get_export("memory") + .and_then(wasmtime::Extern::into_memory); + let Some(mem) = memory else { return -1 }; + + let data = mem.data(&caller); + let start = u32::try_from(key_ptr).unwrap_or(0) as usize; + let end = start + u32::try_from(key_len).unwrap_or(0) as usize; + if end > data.len() { + return -1; + } + + let key_str = match std::str::from_utf8(&data[start..end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + + let bytes = caller + .data() + .context + .config + .get(&key_str) + .map(|value| value.to_string().into_bytes()); + bytes.map_or(-1, |b| { + let len = i32::try_from(b.len()).unwrap_or(i32::MAX); + caller.data_mut().exchange_buffer = b; + len + }) + }, + )?; + + linker.func_wrap( + "env", + "host_get_env", + |mut caller: Caller<'_, PluginStoreData>, + key_ptr: i32, + key_len: i32| + -> i32 { + if key_ptr < 0 || key_len < 0 { + return -1; + } + let memory = caller + .get_export("memory") + .and_then(wasmtime::Extern::into_memory); + let Some(mem) = memory else { return -1 }; + + let data = mem.data(&caller); + let start = u32::try_from(key_ptr).unwrap_or(0) as usize; + let end = start + u32::try_from(key_len).unwrap_or(0) as usize; + if end > data.len() { + return -1; + } + + let key_str = match std::str::from_utf8(&data[start..end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + + // Check environment capability + let env_cap = &caller.data().context.capabilities.environment; + if !env_cap.enabled { + tracing::warn!( + var = %key_str, + "plugin environment access denied" + ); + return -2; + } + + // Check against allowed variables list if configured + if let Some(ref allowed) = env_cap.allowed_vars + && !allowed.iter().any(|v| v == &key_str) + { + tracing::warn!( + var = %key_str, + "plugin env var not in allowlist" + ); + return -2; + } + + match std::env::var(&key_str) { + Ok(value) => { + let bytes = value.into_bytes(); + let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); + caller.data_mut().exchange_buffer = bytes; + len + }, + Err(_) => -1, + } + }, + )?; + + linker.func_wrap( + "env", + "host_get_buffer", + |mut caller: Caller<'_, PluginStoreData>, + dest_ptr: i32, + dest_len: i32| + -> i32 { + if dest_ptr < 0 || dest_len < 0 { + return -1; + } + let buf = caller.data().exchange_buffer.clone(); + let copy_len = + buf.len().min(u32::try_from(dest_len).unwrap_or(0) as usize); + + let memory = caller + .get_export("memory") + .and_then(wasmtime::Extern::into_memory); + let Some(mem) = memory else { return -1 }; + + let mem_data = mem.data_mut(&mut caller); + let start = u32::try_from(dest_ptr).unwrap_or(0) as usize; + if start + copy_len > mem_data.len() { + return -1; + } + + mem_data[start..start + copy_len].copy_from_slice(&buf[..copy_len]); + i32::try_from(copy_len).unwrap_or(i32::MAX) + }, + )?; + + linker.func_wrap( + "env", + "host_set_result", + |mut caller: Caller<'_, PluginStoreData>, ptr: i32, len: i32| { + if ptr < 0 || len < 0 { + return; + } + let memory = caller + .get_export("memory") + .and_then(wasmtime::Extern::into_memory); + let Some(mem) = memory else { return }; + + let data = mem.data(&caller); + let start = u32::try_from(ptr).unwrap_or(0) as usize; + let end = start + u32::try_from(len).unwrap_or(0) as usize; + if end <= data.len() { + caller.data_mut().exchange_buffer = data[start..end].to_vec(); + } + }, + )?; + + linker.func_wrap( + "env", + "host_emit_event", + |mut caller: Caller<'_, PluginStoreData>, + type_ptr: i32, + type_len: i32, + payload_ptr: i32, + payload_len: i32| + -> i32 { + const MAX_PENDING_EVENTS: usize = 1000; + + if type_ptr < 0 || type_len < 0 || payload_ptr < 0 || payload_len < 0 { + return -1; + } + let memory = caller + .get_export("memory") + .and_then(wasmtime::Extern::into_memory); + let Some(mem) = memory else { return -1 }; + + let type_start = u32::try_from(type_ptr).unwrap_or(0) as usize; + let type_end = + type_start + u32::try_from(type_len).unwrap_or(0) as usize; + let payload_start = u32::try_from(payload_ptr).unwrap_or(0) as usize; + let payload_end = + payload_start + u32::try_from(payload_len).unwrap_or(0) as usize; + + // Extract owned strings in a block so the immutable borrow of + // `caller` (via `mem.data`) is dropped before `caller.data_mut()`. + let (event_type, payload) = { + let data = mem.data(&caller); + if type_end > data.len() || payload_end > data.len() { + return -1; + } + let event_type = + match std::str::from_utf8(&data[type_start..type_end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + let payload = + match std::str::from_utf8(&data[payload_start..payload_end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + (event_type, payload) + }; + + if caller.data().pending_events.len() >= MAX_PENDING_EVENTS { + tracing::warn!("plugin exceeded max pending events limit"); + return -4; + } + + caller.data_mut().pending_events.push((event_type, payload)); + 0 + }, + )?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use pinakes_plugin_api::PluginContext; + use rustc_hash::FxHashMap; + + use super::*; + + #[test] + fn test_wasm_runtime_creation() { + let runtime = WasmRuntime::new(); + assert!(runtime.is_ok()); + } + + #[test] + fn test_host_functions_file_access() { + let mut capabilities = pinakes_plugin_api::Capabilities::default(); + capabilities.filesystem.read.push("/tmp".into()); + capabilities.filesystem.write.push("/tmp/output".into()); + + let context = PluginContext { + data_dir: "/tmp/data".into(), + cache_dir: "/tmp/cache".into(), + config: Default::default(), + capabilities, + }; + + // Verify capability checks work via context fields + let can_read = context + .capabilities + .filesystem + .read + .iter() + .any(|p| Path::new("/tmp/test.txt").starts_with(p)); + assert!(can_read); + + let cant_read = context + .capabilities + .filesystem + .read + .iter() + .any(|p| Path::new("/etc/passwd").starts_with(p)); + assert!(!cant_read); + + let can_write = context + .capabilities + .filesystem + .write + .iter() + .any(|p| Path::new("/tmp/output/file.txt").starts_with(p)); + assert!(can_write); + + let cant_write = context + .capabilities + .filesystem + .write + .iter() + .any(|p| Path::new("/tmp/file.txt").starts_with(p)); + assert!(!cant_write); + } + + #[test] + fn test_host_functions_network_access() { + let mut context = PluginContext { + data_dir: "/tmp/data".into(), + cache_dir: "/tmp/cache".into(), + config: FxHashMap::default(), + capabilities: Default::default(), + }; + + assert!(!context.capabilities.network.enabled); + + context.capabilities.network.enabled = true; + assert!(context.capabilities.network.enabled); + } + + #[test] + fn test_linker_setup() { + let engine = Engine::default(); + let mut linker = Linker::::new(&engine); + let result = HostFunctions::setup_linker(&mut linker); + assert!(result.is_ok()); + } +} diff --git a/crates/pinakes-plugin/src/security.rs b/crates/pinakes-plugin/src/security.rs new file mode 100644 index 0000000..6bebb94 --- /dev/null +++ b/crates/pinakes-plugin/src/security.rs @@ -0,0 +1,473 @@ +//! Capability-based security for plugins + +use std::path::{Path, PathBuf}; + +use anyhow::{Result, anyhow}; +use pinakes_plugin_api::Capabilities; + +/// Capability enforcer validates and enforces plugin capabilities +pub struct CapabilityEnforcer { + /// Maximum allowed memory per plugin (bytes) + max_memory_limit: usize, + + /// Maximum allowed CPU time per plugin (milliseconds) + max_cpu_time_limit: u64, + + /// Allowed filesystem read paths (system-wide) + allowed_read_paths: Vec, + + /// Allowed filesystem write paths (system-wide) + allowed_write_paths: Vec, + + /// Whether to allow network access by default + allow_network_default: bool, +} + +impl CapabilityEnforcer { + /// Create a new capability enforcer with default limits + #[must_use] + pub const fn new() -> Self { + Self { + max_memory_limit: 512 * 1024 * 1024, // 512 MB + max_cpu_time_limit: 60 * 1000, // 60 seconds + allowed_read_paths: vec![], + allowed_write_paths: vec![], + allow_network_default: false, + } + } + + /// Set maximum memory limit + #[must_use] + pub const fn with_max_memory(mut self, bytes: usize) -> Self { + self.max_memory_limit = bytes; + self + } + + /// Set maximum CPU time limit + #[must_use] + pub const fn with_max_cpu_time(mut self, milliseconds: u64) -> Self { + self.max_cpu_time_limit = milliseconds; + self + } + + /// Add allowed read path + #[must_use] + pub fn allow_read_path(mut self, path: PathBuf) -> Self { + self.allowed_read_paths.push(path); + self + } + + /// Add allowed write path + #[must_use] + pub fn allow_write_path(mut self, path: PathBuf) -> Self { + self.allowed_write_paths.push(path); + self + } + + /// Set default network access policy + #[must_use] + pub const fn with_network_default(mut self, allow: bool) -> Self { + self.allow_network_default = allow; + self + } + + /// Validate capabilities requested by a plugin + /// + /// # Errors + /// + /// Returns an error if the plugin requests capabilities that exceed the + /// configured system limits, such as memory, CPU time, filesystem paths, or + /// network access. + pub fn validate_capabilities( + &self, + capabilities: &Capabilities, + ) -> Result<()> { + // Validate memory limit + if let Some(memory) = capabilities.max_memory_bytes + && memory > self.max_memory_limit + { + return Err(anyhow!( + "Requested memory ({} bytes) exceeds limit ({} bytes)", + memory, + self.max_memory_limit + )); + } + + // Validate CPU time limit + if let Some(cpu_time) = capabilities.max_cpu_time_ms + && cpu_time > self.max_cpu_time_limit + { + return Err(anyhow!( + "Requested CPU time ({} ms) exceeds limit ({} ms)", + cpu_time, + self.max_cpu_time_limit + )); + } + + // Validate filesystem access + self.validate_filesystem_access(capabilities)?; + + // Validate network access + if capabilities.network.enabled && !self.allow_network_default { + return Err(anyhow!( + "Plugin requests network access, but network access is disabled by \ + policy" + )); + } + + Ok(()) + } + + /// Validate filesystem access capabilities + fn validate_filesystem_access( + &self, + capabilities: &Capabilities, + ) -> Result<()> { + // Check read paths + for path in &capabilities.filesystem.read { + if !self.is_read_allowed(path) { + return Err(anyhow!( + "Plugin requests read access to {} which is not in allowed paths", + path.display() + )); + } + } + + // Check write paths + for path in &capabilities.filesystem.write { + if !self.is_write_allowed(path) { + return Err(anyhow!( + "Plugin requests write access to {} which is not in allowed paths", + path.display() + )); + } + } + + Ok(()) + } + + /// Check if a path is allowed for reading + #[must_use] + pub fn is_read_allowed(&self, path: &Path) -> bool { + if self.allowed_read_paths.is_empty() { + return false; // deny-all when unconfigured + } + let Ok(canonical) = path.canonicalize() else { + return false; + }; + self.allowed_read_paths.iter().any(|allowed| { + allowed + .canonicalize() + .is_ok_and(|a| canonical.starts_with(a)) + }) + } + + /// Check if a path is allowed for writing + #[must_use] + pub fn is_write_allowed(&self, path: &Path) -> bool { + if self.allowed_write_paths.is_empty() { + return false; // deny-all when unconfigured + } + let canonical = if path.exists() { + path.canonicalize().ok() + } else { + path + .parent() + .and_then(|p| p.canonicalize().ok()) + .map(|p| p.join(path.file_name().unwrap_or_default())) + }; + let Some(canonical) = canonical else { + return false; + }; + self.allowed_write_paths.iter().any(|allowed| { + allowed + .canonicalize() + .is_ok_and(|a| canonical.starts_with(a)) + }) + } + + /// Check if network access is allowed for a plugin + #[must_use] + pub const fn is_network_allowed(&self, capabilities: &Capabilities) -> bool { + capabilities.network.enabled && self.allow_network_default + } + + /// Check if a specific domain is allowed + #[must_use] + pub fn is_domain_allowed( + &self, + capabilities: &Capabilities, + domain: &str, + ) -> bool { + if !capabilities.network.enabled { + return false; + } + + // If no domain restrictions, allow all domains + if capabilities.network.allowed_domains.is_none() { + return self.allow_network_default; + } + + // Check against allowed domains list + capabilities + .network + .allowed_domains + .as_ref() + .is_some_and(|domains| { + domains.iter().any(|d| d.eq_ignore_ascii_case(domain)) + }) + } + + /// Get effective memory limit for a plugin + #[must_use] + pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize { + capabilities + .max_memory_bytes + .unwrap_or(self.max_memory_limit) + .min(self.max_memory_limit) + } + + /// Get effective CPU time limit for a plugin + #[must_use] + pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 { + capabilities + .max_cpu_time_ms + .unwrap_or(self.max_cpu_time_limit) + .min(self.max_cpu_time_limit) + } + + /// Validate that a function call is allowed for a plugin's declared kinds. + /// + /// Defense-in-depth: even though the pipeline filters by kind, this prevents + /// bugs from calling wrong functions on plugins. Returns `true` if allowed. + #[must_use] + pub fn validate_function_call( + &self, + plugin_kinds: &[String], + function_name: &str, + ) -> bool { + match function_name { + // Lifecycle functions are always allowed + "initialize" | "shutdown" | "health_check" => true, + // MediaTypeProvider + "supported_media_types" | "can_handle" => { + plugin_kinds.iter().any(|k| k == "media_type") + }, + // supported_types is shared by metadata_extractor and thumbnail_generator + "supported_types" => { + plugin_kinds + .iter() + .any(|k| k == "metadata_extractor" || k == "thumbnail_generator") + }, + // MetadataExtractor + "extract_metadata" => { + plugin_kinds.iter().any(|k| k == "metadata_extractor") + }, + // ThumbnailGenerator + "generate_thumbnail" => { + plugin_kinds.iter().any(|k| k == "thumbnail_generator") + }, + // SearchBackend + "search" | "index_item" | "remove_item" | "get_stats" => { + plugin_kinds.iter().any(|k| k == "search_backend") + }, + // EventHandler + "interested_events" | "handle_event" => { + plugin_kinds.iter().any(|k| k == "event_handler") + }, + // ThemeProvider + "get_themes" | "load_theme" => { + plugin_kinds.iter().any(|k| k == "theme_provider") + }, + // Unknown function names are not allowed + _ => false, + } + } +} + +impl Default for CapabilityEnforcer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + #[allow(unused_imports)] + use pinakes_plugin_api::{FilesystemCapability, NetworkCapability}; + + use super::*; + + #[test] + fn test_validate_memory_limit() { + let enforcer = CapabilityEnforcer::new().with_max_memory(100 * 1024 * 1024); // 100 MB + + let mut caps = Capabilities::default(); + caps.max_memory_bytes = Some(50 * 1024 * 1024); // 50 MB - OK + assert!(enforcer.validate_capabilities(&caps).is_ok()); + + caps.max_memory_bytes = Some(200 * 1024 * 1024); // 200 MB - exceeds limit + assert!(enforcer.validate_capabilities(&caps).is_err()); + } + + #[test] + fn test_validate_cpu_time_limit() { + let enforcer = CapabilityEnforcer::new().with_max_cpu_time(30_000); // 30 seconds + + let mut caps = Capabilities::default(); + caps.max_cpu_time_ms = Some(10_000); // 10 seconds - OK + assert!(enforcer.validate_capabilities(&caps).is_ok()); + + caps.max_cpu_time_ms = Some(60_000); // 60 seconds - exceeds limit + assert!(enforcer.validate_capabilities(&caps).is_err()); + } + + #[test] + fn test_filesystem_read_allowed() { + // Use real temp directories so canonicalize works + let tmp = tempfile::tempdir().unwrap(); + let allowed_dir = tmp.path().join("allowed"); + std::fs::create_dir_all(&allowed_dir).unwrap(); + let test_file = allowed_dir.join("test.txt"); + std::fs::write(&test_file, "test").unwrap(); + + let enforcer = CapabilityEnforcer::new().allow_read_path(allowed_dir); + + assert!(enforcer.is_read_allowed(&test_file)); + assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd"))); + } + + #[test] + fn test_filesystem_read_denied_when_empty() { + let enforcer = CapabilityEnforcer::new(); + assert!(!enforcer.is_read_allowed(Path::new("/tmp/test.txt"))); + } + + #[test] + fn test_filesystem_write_allowed() { + let tmp = tempfile::tempdir().unwrap(); + let output_dir = tmp.path().join("output"); + std::fs::create_dir_all(&output_dir).unwrap(); + // Existing file in allowed dir + let existing = output_dir.join("file.txt"); + std::fs::write(&existing, "test").unwrap(); + + let enforcer = + CapabilityEnforcer::new().allow_write_path(output_dir.clone()); + + assert!(enforcer.is_write_allowed(&existing)); + // New file in allowed dir (parent exists) + assert!(enforcer.is_write_allowed(&output_dir.join("new_file.txt"))); + assert!(!enforcer.is_write_allowed(Path::new("/etc/config"))); + } + + #[test] + fn test_filesystem_write_denied_when_empty() { + let enforcer = CapabilityEnforcer::new(); + assert!(!enforcer.is_write_allowed(Path::new("/tmp/file.txt"))); + } + + #[test] + fn test_network_allowed() { + let enforcer = CapabilityEnforcer::new().with_network_default(true); + + let mut caps = Capabilities::default(); + caps.network.enabled = true; + + assert!(enforcer.is_network_allowed(&caps)); + + caps.network.enabled = false; + assert!(!enforcer.is_network_allowed(&caps)); + } + + #[test] + fn test_domain_restrictions() { + let enforcer = CapabilityEnforcer::new().with_network_default(true); + + let mut caps = Capabilities::default(); + caps.network.enabled = true; + caps.network.allowed_domains = Some(vec![ + "api.example.com".to_string(), + "cdn.example.com".to_string(), + ]); + + assert!(enforcer.is_domain_allowed(&caps, "api.example.com")); + assert!(enforcer.is_domain_allowed(&caps, "cdn.example.com")); + assert!(!enforcer.is_domain_allowed(&caps, "evil.com")); + } + + #[test] + fn test_get_effective_limits() { + let enforcer = CapabilityEnforcer::new() + .with_max_memory(100 * 1024 * 1024) + .with_max_cpu_time(30_000); + + let mut caps = Capabilities::default(); + + // No limits specified, use the defaults + assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); + assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); + + // Plugin requests lower limits, use plugin's + caps.max_memory_bytes = Some(50 * 1024 * 1024); + caps.max_cpu_time_ms = Some(10_000); + assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024); + assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000); + + // Plugin requests higher limits, cap at system max + caps.max_memory_bytes = Some(200 * 1024 * 1024); + caps.max_cpu_time_ms = Some(60_000); + assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); + assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); + } + + #[test] + fn test_validate_function_call_lifecycle_always_allowed() { + let enforcer = CapabilityEnforcer::new(); + let kinds = vec!["metadata_extractor".to_string()]; + assert!(enforcer.validate_function_call(&kinds, "initialize")); + assert!(enforcer.validate_function_call(&kinds, "shutdown")); + assert!(enforcer.validate_function_call(&kinds, "health_check")); + } + + #[test] + fn test_validate_function_call_metadata_extractor() { + let enforcer = CapabilityEnforcer::new(); + let kinds = vec!["metadata_extractor".to_string()]; + assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); + assert!(enforcer.validate_function_call(&kinds, "supported_types")); + assert!(!enforcer.validate_function_call(&kinds, "search")); + assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail")); + assert!(!enforcer.validate_function_call(&kinds, "can_handle")); + } + + #[test] + fn test_validate_function_call_multi_kind() { + let enforcer = CapabilityEnforcer::new(); + let kinds = + vec!["media_type".to_string(), "metadata_extractor".to_string()]; + assert!(enforcer.validate_function_call(&kinds, "can_handle")); + assert!(enforcer.validate_function_call(&kinds, "supported_media_types")); + assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); + assert!(!enforcer.validate_function_call(&kinds, "search")); + } + + #[test] + fn test_validate_function_call_unknown_function() { + let enforcer = CapabilityEnforcer::new(); + let kinds = vec!["metadata_extractor".to_string()]; + assert!(!enforcer.validate_function_call(&kinds, "unknown_func")); + assert!(!enforcer.validate_function_call(&kinds, "")); + } + + #[test] + fn test_validate_function_call_shared_supported_types() { + let enforcer = CapabilityEnforcer::new(); + let extractor = vec!["metadata_extractor".to_string()]; + let generator = vec!["thumbnail_generator".to_string()]; + let search = vec!["search_backend".to_string()]; + assert!(enforcer.validate_function_call(&extractor, "supported_types")); + assert!(enforcer.validate_function_call(&generator, "supported_types")); + assert!(!enforcer.validate_function_call(&search, "supported_types")); + } +} diff --git a/crates/pinakes-plugin/src/signature.rs b/crates/pinakes-plugin/src/signature.rs new file mode 100644 index 0000000..64f9dc5 --- /dev/null +++ b/crates/pinakes-plugin/src/signature.rs @@ -0,0 +1,252 @@ +//! Plugin signature verification using Ed25519 + BLAKE3 +//! +//! Each plugin directory may contain a `plugin.sig` file alongside its +//! `plugin.toml`. The signature covers the BLAKE3 hash of the WASM binary +//! referenced by the manifest. Verification uses Ed25519 public keys +//! configured as trusted in the server's plugin settings. +//! +//! When `allow_unsigned` is false, plugins _must_ carry a valid signature +//! from one of the trusted keys or they will be rejected at load time. + +use std::path::Path; + +use anyhow::{Result, anyhow}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + +/// Outcome of a signature check on a plugin package. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignatureStatus { + /// Signature is present and valid against a trusted key. + Valid, + /// No signature file found. + Unsigned, + /// Signature file exists but does not match any trusted key. + Invalid(String), +} + +/// Verify the signature of a plugin's WASM binary. +/// +/// Reads `plugin.sig` from `plugin_dir`, computes the BLAKE3 hash of the +/// WASM binary at `wasm_path`, and verifies the signature against each of +/// the `trusted_keys`. The signature file is raw 64-byte Ed25519 signature +/// over the 32-byte BLAKE3 digest. +/// +/// # Errors +/// +/// Returns an error only on I/O failures, never for cryptographic rejection, +/// which is reported via [`SignatureStatus`] instead. +pub fn verify_plugin_signature( + plugin_dir: &Path, + wasm_path: &Path, + trusted_keys: &[VerifyingKey], +) -> Result { + let sig_path = plugin_dir.join("plugin.sig"); + if !sig_path.exists() { + return Ok(SignatureStatus::Unsigned); + } + + let sig_bytes = std::fs::read(&sig_path) + .map_err(|e| anyhow!("failed to read plugin.sig: {e}"))?; + + let signature = Signature::from_slice(&sig_bytes).map_err(|e| { + // Malformed signature file is an invalid signature, not an I/O error + tracing::warn!(path = %sig_path.display(), "malformed plugin.sig: {e}"); + anyhow!("malformed plugin.sig: {e}") + }); + let Ok(signature) = signature else { + return Ok(SignatureStatus::Invalid( + "malformed signature file".to_string(), + )); + }; + + // BLAKE3 hash of the WASM binary is the signed message + let wasm_bytes = std::fs::read(wasm_path) + .map_err(|e| anyhow!("failed to read WASM binary for verification: {e}"))?; + let digest = blake3::hash(&wasm_bytes); + let message = digest.as_bytes(); + + for key in trusted_keys { + if key.verify(message, &signature).is_ok() { + return Ok(SignatureStatus::Valid); + } + } + + Ok(SignatureStatus::Invalid( + "signature did not match any trusted key".to_string(), + )) +} + +/// Parse a hex-encoded Ed25519 public key (64 hex characters = 32 bytes). +/// +/// # Errors +/// +/// Returns an error if the string is not valid hex or is the wrong length. +pub fn parse_public_key(hex_str: &str) -> Result { + let hex_str = hex_str.trim(); + if hex_str.len() != 64 { + return Err(anyhow!( + "expected 64 hex characters for Ed25519 public key, got {}", + hex_str.len() + )); + } + + let mut bytes = [0u8; 32]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16) + .map_err(|e| anyhow!("invalid hex in public key: {e}"))?; + } + + VerifyingKey::from_bytes(&bytes) + .map_err(|e| anyhow!("invalid Ed25519 public key: {e}")) +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::{Signer, SigningKey}; + use rand::RngExt; + + use super::*; + + fn make_keypair() -> (SigningKey, VerifyingKey) { + let secret_bytes: [u8; 32] = rand::rng().random(); + let signing = SigningKey::from_bytes(&secret_bytes); + let verifying = signing.verifying_key(); + (signing, verifying) + } + + #[test] + fn test_verify_unsigned_plugin() { + let dir = tempfile::tempdir().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap(); + + let (_, vk) = make_keypair(); + let status = + verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); + assert_eq!(status, SignatureStatus::Unsigned); + } + + #[test] + fn test_verify_valid_signature() { + let dir = tempfile::tempdir().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code_here"; + std::fs::write(&wasm_path, wasm_bytes).unwrap(); + + let (sk, vk) = make_keypair(); + + // Sign the BLAKE3 hash of the WASM binary + let digest = blake3::hash(wasm_bytes); + let signature = sk.sign(digest.as_bytes()); + std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) + .unwrap(); + + let status = + verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); + assert_eq!(status, SignatureStatus::Valid); + } + + #[test] + fn test_verify_wrong_key() { + let dir = tempfile::tempdir().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code"; + std::fs::write(&wasm_path, wasm_bytes).unwrap(); + + let (sk, _) = make_keypair(); + let (_, wrong_vk) = make_keypair(); + + let digest = blake3::hash(wasm_bytes); + let signature = sk.sign(digest.as_bytes()); + std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) + .unwrap(); + + let status = + verify_plugin_signature(dir.path(), &wasm_path, &[wrong_vk]).unwrap(); + assert!(matches!(status, SignatureStatus::Invalid(_))); + } + + #[test] + fn test_verify_tampered_wasm() { + let dir = tempfile::tempdir().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + let original = b"\0asm\x01\x00\x00\x00original"; + std::fs::write(&wasm_path, original).unwrap(); + + let (sk, vk) = make_keypair(); + let digest = blake3::hash(original); + let signature = sk.sign(digest.as_bytes()); + std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) + .unwrap(); + + // Tamper with the WASM file after signing + std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00tampered").unwrap(); + + let status = + verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); + assert!(matches!(status, SignatureStatus::Invalid(_))); + } + + #[test] + fn test_verify_malformed_sig_file() { + let dir = tempfile::tempdir().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap(); + + // Write garbage to plugin.sig (wrong length) + std::fs::write(dir.path().join("plugin.sig"), b"not a signature").unwrap(); + + let (_, vk) = make_keypair(); + let status = + verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); + assert!(matches!(status, SignatureStatus::Invalid(_))); + } + + #[test] + fn test_verify_multiple_trusted_keys() { + let dir = tempfile::tempdir().unwrap(); + let wasm_path = dir.path().join("plugin.wasm"); + let wasm_bytes = b"\0asm\x01\x00\x00\x00multi_key_test"; + std::fs::write(&wasm_path, wasm_bytes).unwrap(); + + let (sk2, vk2) = make_keypair(); + let (_, vk1) = make_keypair(); + let (_, vk3) = make_keypair(); + + // Sign with key 2 + let digest = blake3::hash(wasm_bytes); + let signature = sk2.sign(digest.as_bytes()); + std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) + .unwrap(); + + // Verify against [vk1, vk2, vk3]; should find vk2 + let status = + verify_plugin_signature(dir.path(), &wasm_path, &[vk1, vk2, vk3]) + .unwrap(); + assert_eq!(status, SignatureStatus::Valid); + } + + #[test] + fn test_parse_public_key_valid() { + let (_, vk) = make_keypair(); + let hex = hex_encode(vk.as_bytes()); + let parsed = parse_public_key(&hex).unwrap(); + assert_eq!(parsed, vk); + } + + #[test] + fn test_parse_public_key_wrong_length() { + assert!(parse_public_key("abcdef").is_err()); + } + + #[test] + fn test_parse_public_key_invalid_hex() { + let bad = + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; + assert!(parse_public_key(bad).is_err()); + } + + fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } +} diff --git a/crates/pinakes-sync/Cargo.toml b/crates/pinakes-sync/Cargo.toml new file mode 100644 index 0000000..b613325 --- /dev/null +++ b/crates/pinakes-sync/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "pinakes-sync" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +pinakes-types = { workspace = true } +tokio = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +blake3 = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pinakes-sync/src/chunked.rs b/crates/pinakes-sync/src/chunked.rs new file mode 100644 index 0000000..7fb7802 --- /dev/null +++ b/crates/pinakes-sync/src/chunked.rs @@ -0,0 +1,326 @@ +//! Chunked upload handling for large file sync. + +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use pinakes_types::error::{PinakesError, Result}; +use tokio::{ + fs, + io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, +}; +use tracing::{debug, info}; +use uuid::Uuid; + +use super::{ChunkInfo, UploadSession}; + +/// Manager for chunked uploads. +#[derive(Debug, Clone)] +pub struct ChunkedUploadManager { + temp_dir: PathBuf, +} + +impl ChunkedUploadManager { + /// Create a new chunked upload manager. + #[must_use] + pub const fn new(temp_dir: PathBuf) -> Self { + Self { temp_dir } + } + + /// Initialize the temp directory. + /// + /// # Errors + /// + /// Returns an error if the directory cannot be created. + pub async fn init(&self) -> Result<()> { + fs::create_dir_all(&self.temp_dir).await?; + Ok(()) + } + + /// Get the temp file path for an upload session. + #[must_use] + pub fn temp_path(&self, session_id: Uuid) -> PathBuf { + self.temp_dir.join(format!("{session_id}.upload")) + } + + /// Create the temp file for a new upload session. + /// + /// # Errors + /// + /// Returns an error if the file cannot be created or sized. + pub async fn create_temp_file(&self, session: &UploadSession) -> Result<()> { + let path = self.temp_path(session.id); + + // Create a sparse file of the expected size + let file = fs::File::create(&path).await?; + file.set_len(session.expected_size).await?; + + debug!( + session_id = %session.id, + size = session.expected_size, + "created temp file for upload" + ); + + Ok(()) + } + + /// Write a chunk to the temp file. + /// + /// # Errors + /// + /// Returns an error if the session file is not found, the chunk index is out + /// of range, the chunk size is wrong, or the write fails. + pub async fn write_chunk( + &self, + session: &UploadSession, + chunk_index: u64, + data: &[u8], + ) -> Result { + let path = self.temp_path(session.id); + + if !path.exists() { + return Err(PinakesError::UploadSessionNotFound(session.id.to_string())); + } + + // Calculate offset + let offset = chunk_index * session.chunk_size; + + // Validate chunk + if offset >= session.expected_size { + return Err(PinakesError::ChunkOutOfOrder { + expected: session.chunk_count - 1, + actual: chunk_index, + }); + } + + // Calculate expected chunk size + let expected_size = if chunk_index == session.chunk_count - 1 { + // Last chunk may be smaller + session.expected_size - offset + } else { + session.chunk_size + }; + + if data.len() as u64 != expected_size { + return Err(PinakesError::InvalidData(format!( + "chunk {} has wrong size: expected {}, got {}", + chunk_index, + expected_size, + data.len() + ))); + } + + // Write chunk to file at offset + let mut file = fs::OpenOptions::new().write(true).open(&path).await?; + + file.seek(std::io::SeekFrom::Start(offset)).await?; + file.write_all(data).await?; + file.flush().await?; + + // Compute chunk hash + let hash = blake3::hash(data).to_hex().to_string(); + + debug!( + session_id = %session.id, + chunk_index, + offset, + size = data.len(), + "wrote chunk" + ); + + Ok(ChunkInfo { + upload_id: session.id, + chunk_index, + offset, + size: data.len() as u64, + hash, + received_at: Utc::now(), + }) + } + + /// Verify and finalize the upload. + /// + /// Checks that: + /// 1. All chunks are received + /// 2. File size matches expected + /// 3. Content hash matches expected + /// + /// # Errors + /// + /// Returns an error if chunks are missing, the file size does not match, the + /// hash does not match, or the file metadata cannot be read. + pub async fn finalize( + &self, + session: &UploadSession, + received_chunks: &[ChunkInfo], + ) -> Result { + let path = self.temp_path(session.id); + + // Check all chunks received + if received_chunks.len() as u64 != session.chunk_count { + return Err(PinakesError::InvalidData(format!( + "missing chunks: expected {}, got {}", + session.chunk_count, + received_chunks.len() + ))); + } + + // Verify chunk indices + let mut indices: Vec = + received_chunks.iter().map(|c| c.chunk_index).collect(); + indices.sort_unstable(); + for (i, idx) in indices.iter().enumerate() { + if *idx != i as u64 { + return Err(PinakesError::InvalidData(format!( + "chunk {i} missing or out of order" + ))); + } + } + + // Verify file size + let metadata = fs::metadata(&path).await?; + if metadata.len() != session.expected_size { + return Err(PinakesError::InvalidData(format!( + "file size mismatch: expected {}, got {}", + session.expected_size, + metadata.len() + ))); + } + + // Verify content hash + let computed_hash = compute_file_hash(&path).await?; + if computed_hash != session.expected_hash.0 { + return Err(PinakesError::StorageIntegrity(format!( + "hash mismatch: expected {}, computed {}", + session.expected_hash, computed_hash + ))); + } + + info!( + session_id = %session.id, + hash = %session.expected_hash, + size = session.expected_size, + "finalized chunked upload" + ); + + Ok(path) + } + + /// Cancel an upload and clean up temp file. + /// + /// # Errors + /// + /// Returns an error if the temp file cannot be removed. + pub async fn cancel(&self, session_id: Uuid) -> Result<()> { + let path = self.temp_path(session_id); + if path.exists() { + fs::remove_file(&path).await?; + debug!(session_id = %session_id, "cancelled upload, removed temp file"); + } + Ok(()) + } + + /// Clean up expired temp files. + /// + /// # Errors + /// + /// Returns an error if the temp directory cannot be read. + pub async fn cleanup_expired(&self, max_age_hours: u64) -> Result { + let mut count = 0u64; + let max_age = std::time::Duration::from_secs(max_age_hours * 3600); + + let mut entries = fs::read_dir(&self.temp_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "upload") + && let Ok(metadata) = fs::metadata(&path).await + && let Ok(modified) = metadata.modified() + { + let age = std::time::SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + if age > max_age { + let _ = fs::remove_file(&path).await; + count += 1; + } + } + } + + if count > 0 { + info!(count, "cleaned up expired upload temp files"); + } + Ok(count) + } +} + +/// Compute the BLAKE3 hash of a file. +async fn compute_file_hash(path: &Path) -> Result { + let mut file = fs::File::open(path).await?; + let mut hasher = blake3::Hasher::new(); + let mut buf = vec![0u8; 64 * 1024]; + + loop { + let n = file.read(&mut buf).await?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + + Ok(hasher.finalize().to_hex().to_string()) +} + +#[cfg(test)] +mod tests { + use pinakes_types::model::ContentHash; + use tempfile::tempdir; + + use super::*; + use crate::UploadStatus; + + #[tokio::test] + async fn test_chunked_upload() { + let dir = tempdir().unwrap(); + let manager = ChunkedUploadManager::new(dir.path().to_path_buf()); + manager.init().await.unwrap(); + + // Create test data + let data = b"Hello, World! This is test data for chunked upload."; + let hash = blake3::hash(data).to_hex().to_string(); + let chunk_size = 20u64; + + let session = UploadSession { + id: Uuid::now_v7(), + device_id: super::super::DeviceId::new(), + target_path: "/test/file.txt".to_string(), + expected_hash: ContentHash::new(hash.clone()), + expected_size: data.len() as u64, + chunk_size, + chunk_count: (data.len() as u64).div_ceil(chunk_size), + status: UploadStatus::InProgress, + created_at: Utc::now(), + expires_at: Utc::now() + chrono::Duration::hours(24), + last_activity: Utc::now(), + }; + + manager.create_temp_file(&session).await.unwrap(); + + // Write chunks + let mut chunks = Vec::new(); + for i in 0..session.chunk_count { + let start = (i * chunk_size) as usize; + let end = ((i + 1) * chunk_size).min(data.len() as u64) as usize; + let chunk_data = &data[start..end]; + + let chunk = manager.write_chunk(&session, i, chunk_data).await.unwrap(); + chunks.push(chunk); + } + + // Finalize + let final_path = manager.finalize(&session, &chunks).await.unwrap(); + assert!(final_path.exists()); + + // Verify content + let content = fs::read(&final_path).await.unwrap(); + assert_eq!(&content[..], data); + } +} diff --git a/crates/pinakes-sync/src/conflict.rs b/crates/pinakes-sync/src/conflict.rs new file mode 100644 index 0000000..986ccdd --- /dev/null +++ b/crates/pinakes-sync/src/conflict.rs @@ -0,0 +1,148 @@ +//! Conflict detection and resolution for sync. + +use pinakes_types::config::ConflictResolution; + +use super::DeviceSyncState; + +/// Detect if there's a conflict between local and server state. +#[must_use] +pub fn detect_conflict(state: &DeviceSyncState) -> Option { + // If either side has no hash, no conflict possible + let local_hash = state.local_hash.as_ref()?; + let server_hash = state.server_hash.as_ref()?; + + // Same hash = no conflict + if local_hash == server_hash { + return None; + } + + // Both have different hashes = conflict + Some(ConflictInfo { + path: state.path.clone(), + local_hash: local_hash.clone(), + server_hash: server_hash.clone(), + local_mtime: state.local_mtime, + server_mtime: state.server_mtime, + }) +} + +/// Information about a detected conflict. +#[derive(Debug, Clone)] +pub struct ConflictInfo { + pub path: String, + pub local_hash: String, + pub server_hash: String, + pub local_mtime: Option, + pub server_mtime: Option, +} + +/// Result of resolving a conflict. +#[derive(Debug, Clone)] +pub enum ConflictOutcome { + /// Use the server version + UseServer, + /// Use the local version (upload it) + UseLocal, + /// Keep both versions (rename one) + KeepBoth { new_local_path: String }, + /// Requires manual intervention + Manual, +} + +/// Resolve a conflict based on the configured strategy. +#[must_use] +pub fn resolve_conflict( + conflict: &ConflictInfo, + resolution: ConflictResolution, +) -> ConflictOutcome { + match resolution { + ConflictResolution::ServerWins => ConflictOutcome::UseServer, + ConflictResolution::ClientWins => ConflictOutcome::UseLocal, + ConflictResolution::KeepBoth => { + let new_path = + generate_conflict_path(&conflict.path, &conflict.local_hash); + ConflictOutcome::KeepBoth { + new_local_path: new_path, + } + }, + ConflictResolution::Manual => ConflictOutcome::Manual, + } +} + +/// Generate a new path for the conflicting local file. +/// Format: filename.conflict-<`short_hash>.ext` +fn generate_conflict_path(original_path: &str, local_hash: &str) -> String { + let short_hash = &local_hash[..8.min(local_hash.len())]; + + if let Some((base, ext)) = original_path.rsplit_once('.') { + format!("{base}.conflict-{short_hash}.{ext}") + } else { + format!("{original_path}.conflict-{short_hash}") + } +} + +/// Automatic conflict resolution based on modification times. +/// Useful when `ConflictResolution` is set to a time-based strategy. +#[must_use] +pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome { + match (conflict.local_mtime, conflict.server_mtime) { + (Some(local), Some(server)) => { + if local > server { + ConflictOutcome::UseLocal + } else { + ConflictOutcome::UseServer + } + }, + (Some(_), None) => ConflictOutcome::UseLocal, + (None, Some(_)) => ConflictOutcome::UseServer, + (None, None) => ConflictOutcome::UseServer, // Default to server + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::FileSyncStatus; + + #[test] + fn test_generate_conflict_path() { + assert_eq!( + generate_conflict_path("/path/to/file.txt", "abc12345"), + "/path/to/file.conflict-abc12345.txt" + ); + + assert_eq!( + generate_conflict_path("/path/to/file", "abc12345"), + "/path/to/file.conflict-abc12345" + ); + } + + #[test] + fn test_detect_conflict() { + let state_no_conflict = DeviceSyncState { + device_id: super::super::DeviceId::new(), + path: "/test".to_string(), + local_hash: Some("abc".to_string()), + server_hash: Some("abc".to_string()), + local_mtime: None, + server_mtime: None, + sync_status: FileSyncStatus::Synced, + last_synced_at: None, + conflict_info_json: None, + }; + assert!(detect_conflict(&state_no_conflict).is_none()); + + let state_conflict = DeviceSyncState { + device_id: super::super::DeviceId::new(), + path: "/test".to_string(), + local_hash: Some("abc".to_string()), + server_hash: Some("def".to_string()), + local_mtime: None, + server_mtime: None, + sync_status: FileSyncStatus::Conflict, + last_synced_at: None, + conflict_info_json: None, + }; + assert!(detect_conflict(&state_conflict).is_some()); + } +} diff --git a/crates/pinakes-sync/src/lib.rs b/crates/pinakes-sync/src/lib.rs new file mode 100644 index 0000000..e54a4ed --- /dev/null +++ b/crates/pinakes-sync/src/lib.rs @@ -0,0 +1,7 @@ +mod chunked; +mod conflict; +mod models; + +pub use chunked::*; +pub use conflict::*; +pub use models::*; diff --git a/crates/pinakes-sync/src/models.rs b/crates/pinakes-sync/src/models.rs new file mode 100644 index 0000000..229c5e9 --- /dev/null +++ b/crates/pinakes-sync/src/models.rs @@ -0,0 +1,382 @@ +//! Sync domain models. + +use std::fmt; + +use chrono::{DateTime, Utc}; +use pinakes_types::{ + config::ConflictResolution, + model::{ContentHash, MediaId, UserId}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Unique identifier for a sync device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct DeviceId(pub Uuid); + +impl DeviceId { + #[must_use] + pub fn new() -> Self { + Self(Uuid::now_v7()) + } +} + +impl Default for DeviceId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for DeviceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Type of sync device. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, +)] +#[serde(rename_all = "lowercase")] +pub enum DeviceType { + Desktop, + Mobile, + Tablet, + Server, + #[default] + Other, +} + +impl fmt::Display for DeviceType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Desktop => write!(f, "desktop"), + Self::Mobile => write!(f, "mobile"), + Self::Tablet => write!(f, "tablet"), + Self::Server => write!(f, "server"), + Self::Other => write!(f, "other"), + } + } +} + +impl std::str::FromStr for DeviceType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "desktop" => Ok(Self::Desktop), + "mobile" => Ok(Self::Mobile), + "tablet" => Ok(Self::Tablet), + "server" => Ok(Self::Server), + "other" => Ok(Self::Other), + _ => Err(format!("unknown device type: {s}")), + } + } +} + +/// A registered sync device. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncDevice { + pub id: DeviceId, + pub user_id: UserId, + pub name: String, + pub device_type: DeviceType, + pub client_version: String, + pub os_info: Option, + pub last_sync_at: Option>, + pub last_seen_at: DateTime, + pub sync_cursor: Option, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl SyncDevice { + #[must_use] + pub fn new( + user_id: UserId, + name: String, + device_type: DeviceType, + client_version: String, + ) -> Self { + let now = Utc::now(); + Self { + id: DeviceId::new(), + user_id, + name, + device_type, + client_version, + os_info: None, + last_sync_at: None, + last_seen_at: now, + sync_cursor: None, + enabled: true, + created_at: now, + updated_at: now, + } + } +} + +/// Type of change recorded in the sync log. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncChangeType { + Created, + Modified, + Deleted, + Moved, + MetadataUpdated, +} + +impl fmt::Display for SyncChangeType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Created => write!(f, "created"), + Self::Modified => write!(f, "modified"), + Self::Deleted => write!(f, "deleted"), + Self::Moved => write!(f, "moved"), + Self::MetadataUpdated => write!(f, "metadata_updated"), + } + } +} + +impl std::str::FromStr for SyncChangeType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "created" => Ok(Self::Created), + "modified" => Ok(Self::Modified), + "deleted" => Ok(Self::Deleted), + "moved" => Ok(Self::Moved), + "metadata_updated" => Ok(Self::MetadataUpdated), + _ => Err(format!("unknown sync change type: {s}")), + } + } +} + +/// An entry in the sync log tracking a change. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncLogEntry { + pub id: Uuid, + pub sequence: i64, + pub change_type: SyncChangeType, + pub media_id: Option, + pub path: String, + pub content_hash: Option, + pub file_size: Option, + pub metadata_json: Option, + pub changed_by_device: Option, + pub timestamp: DateTime, +} + +impl SyncLogEntry { + #[must_use] + pub fn new( + change_type: SyncChangeType, + path: String, + media_id: Option, + content_hash: Option, + ) -> Self { + Self { + id: Uuid::now_v7(), + sequence: 0, // Will be assigned by database + change_type, + media_id, + path, + content_hash, + file_size: None, + metadata_json: None, + changed_by_device: None, + timestamp: Utc::now(), + } + } +} + +/// Sync status for a file on a device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileSyncStatus { + Synced, + PendingUpload, + PendingDownload, + Conflict, + Deleted, +} + +impl fmt::Display for FileSyncStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Synced => write!(f, "synced"), + Self::PendingUpload => write!(f, "pending_upload"), + Self::PendingDownload => write!(f, "pending_download"), + Self::Conflict => write!(f, "conflict"), + Self::Deleted => write!(f, "deleted"), + } + } +} + +impl std::str::FromStr for FileSyncStatus { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "synced" => Ok(Self::Synced), + "pending_upload" => Ok(Self::PendingUpload), + "pending_download" => Ok(Self::PendingDownload), + "conflict" => Ok(Self::Conflict), + "deleted" => Ok(Self::Deleted), + _ => Err(format!("unknown file sync status: {s}")), + } + } +} + +/// Sync state for a specific file on a specific device. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceSyncState { + pub device_id: DeviceId, + pub path: String, + pub local_hash: Option, + pub server_hash: Option, + pub local_mtime: Option, + pub server_mtime: Option, + pub sync_status: FileSyncStatus, + pub last_synced_at: Option>, + pub conflict_info_json: Option, +} + +/// A sync conflict that needs resolution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncConflict { + pub id: Uuid, + pub device_id: DeviceId, + pub path: String, + pub local_hash: String, + pub local_mtime: i64, + pub server_hash: String, + pub server_mtime: i64, + pub detected_at: DateTime, + pub resolved_at: Option>, + pub resolution: Option, +} + +impl SyncConflict { + #[must_use] + pub fn new( + device_id: DeviceId, + path: String, + local_hash: String, + local_mtime: i64, + server_hash: String, + server_mtime: i64, + ) -> Self { + Self { + id: Uuid::now_v7(), + device_id, + path, + local_hash, + local_mtime, + server_hash, + server_mtime, + detected_at: Utc::now(), + resolved_at: None, + resolution: None, + } + } +} + +/// Status of an upload session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UploadStatus { + Pending, + InProgress, + Completed, + Failed, + Expired, + Cancelled, +} + +impl fmt::Display for UploadStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pending => write!(f, "pending"), + Self::InProgress => write!(f, "in_progress"), + Self::Completed => write!(f, "completed"), + Self::Failed => write!(f, "failed"), + Self::Expired => write!(f, "expired"), + Self::Cancelled => write!(f, "cancelled"), + } + } +} + +impl std::str::FromStr for UploadStatus { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "pending" => Ok(Self::Pending), + "in_progress" => Ok(Self::InProgress), + "completed" => Ok(Self::Completed), + "failed" => Ok(Self::Failed), + "expired" => Ok(Self::Expired), + "cancelled" => Ok(Self::Cancelled), + _ => Err(format!("unknown upload status: {s}")), + } + } +} + +/// A chunked upload session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadSession { + pub id: Uuid, + pub device_id: DeviceId, + pub target_path: String, + pub expected_hash: ContentHash, + pub expected_size: u64, + pub chunk_size: u64, + pub chunk_count: u64, + pub status: UploadStatus, + pub created_at: DateTime, + pub expires_at: DateTime, + pub last_activity: DateTime, +} + +impl UploadSession { + #[must_use] + pub fn new( + device_id: DeviceId, + target_path: String, + expected_hash: ContentHash, + expected_size: u64, + chunk_size: u64, + timeout_hours: u64, + ) -> Self { + let now = Utc::now(); + let chunk_count = expected_size.div_ceil(chunk_size); + Self { + id: Uuid::now_v7(), + device_id, + target_path, + expected_hash, + expected_size, + chunk_size, + chunk_count, + status: UploadStatus::Pending, + created_at: now, + expires_at: now + chrono::Duration::hours(timeout_hours as i64), + last_activity: now, + } + } +} + +/// Information about an uploaded chunk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChunkInfo { + pub upload_id: Uuid, + pub chunk_index: u64, + pub offset: u64, + pub size: u64, + pub hash: String, + pub received_at: DateTime, +} diff --git a/crates/pinakes-types/Cargo.toml b/crates/pinakes-types/Cargo.toml new file mode 100644 index 0000000..02fee48 --- /dev/null +++ b/crates/pinakes-types/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pinakes-types" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +rustc-hash = { workspace = true } +toml = { workspace = true } +anyhow = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pinakes-types/src/config.rs b/crates/pinakes-types/src/config.rs new file mode 100644 index 0000000..9c131a8 --- /dev/null +++ b/crates/pinakes-types/src/config.rs @@ -0,0 +1,1761 @@ +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Datelike, Utc}; +use serde::{Deserialize, Serialize}; + +/// A schedule for a recurring task. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum Schedule { + Interval { + secs: u64, + }, + Daily { + hour: u32, + minute: u32, + }, + Weekly { + day: u32, + hour: u32, + minute: u32, + }, +} + +impl Schedule { + #[must_use] + pub fn next_run(&self, from: DateTime) -> Option> { + match self { + Self::Interval { secs } => { + Some( + from + + chrono::Duration::seconds( + i64::try_from(*secs).unwrap_or(i64::MAX), + ), + ) + }, + Self::Daily { hour, minute } => { + let today = from.date_naive().and_hms_opt(*hour, *minute, 0)?; + let today_utc = today.and_utc(); + if today_utc > from { + Some(today_utc) + } else { + Some(today_utc + chrono::Duration::days(1)) + } + }, + Self::Weekly { day, hour, minute } => { + let current_day = from.weekday().num_days_from_monday(); + let target_day = *day; + let days_ahead = match target_day.cmp(¤t_day) { + std::cmp::Ordering::Greater => target_day - current_day, + std::cmp::Ordering::Less => 7 - (current_day - target_day), + std::cmp::Ordering::Equal => { + let today = + from.date_naive().and_hms_opt(*hour, *minute, 0)?.and_utc(); + if today > from { + return Some(today); + } + 7 + }, + }; + let target_date = + from.date_naive() + chrono::Duration::days(i64::from(days_ahead)); + Some(target_date.and_hms_opt(*hour, *minute, 0)?.and_utc()) + }, + } + } + + #[must_use] + pub fn display_string(&self) -> String { + match self { + Self::Interval { secs } => { + if *secs >= 3600 { + format!("Every {}h", secs / 3600) + } else if *secs >= 60 { + format!("Every {}m", secs / 60) + } else { + format!("Every {secs}s") + } + }, + Self::Daily { hour, minute } => { + format!("Daily {hour:02}:{minute:02}") + }, + Self::Weekly { day, hour, minute } => { + let day_name = match day { + 0 => "Mon", + 1 => "Tue", + 2 => "Wed", + 3 => "Thu", + 4 => "Fri", + 5 => "Sat", + _ => "Sun", + }; + format!("{day_name} {hour:02}:{minute:02}") + }, + } + } +} + +/// Expand environment variables in a string using `std::env::var` for lookup. +/// Supports both `${VAR_NAME}` and `$VAR_NAME` syntax. +/// Returns an error if a referenced variable is not set. +fn expand_env_var_string(input: &str) -> crate::error::Result { + expand_env_vars(input, |name| { + std::env::var(name).map_err(|_| { + crate::error::PinakesError::Config(format!( + "environment variable not set: {name}" + )) + }) + }) +} + +/// Expand environment variables in a string using the provided lookup function. +/// Supports both `${VAR_NAME}` and `$VAR_NAME` syntax. +fn expand_env_vars( + input: &str, + lookup: impl Fn(&str) -> crate::error::Result, +) -> crate::error::Result { + let mut result = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '$' { + // Check if it's ${VAR} or $VAR syntax + let use_braces = chars.peek() == Some(&'{'); + if use_braces { + chars.next(); // consume '{' + } + + // Collect variable name + let mut var_name = String::new(); + while let Some(&next_ch) = chars.peek() { + if use_braces { + if next_ch == '}' { + chars.next(); // consume '}' + break; + } + var_name.push(next_ch); + chars.next(); + } else { + // For $VAR syntax, stop at non-alphanumeric/underscore + if next_ch.is_alphanumeric() || next_ch == '_' { + var_name.push(next_ch); + chars.next(); + } else { + break; + } + } + } + + if var_name.is_empty() { + return Err(crate::error::PinakesError::Config( + "empty environment variable name".to_string(), + )); + } + + result.push_str(&lookup(&var_name)?); + } else if ch == '\\' { + // Handle escaped characters + if let Some(&next_ch) = chars.peek() { + if next_ch == '$' { + chars.next(); // consume the escaped $ + result.push('$'); + } else { + result.push(ch); + } + } else { + result.push(ch); + } + } else { + result.push(ch); + } + } + + Ok(result) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub storage: StorageConfig, + pub directories: DirectoryConfig, + pub scanning: ScanningConfig, + pub server: ServerConfig, + #[serde(default)] + pub ui: UiConfig, + #[serde(default)] + pub accounts: AccountsConfig, + #[serde(default)] + pub rate_limits: RateLimitConfig, + #[serde(default)] + pub jobs: JobsConfig, + #[serde(default)] + pub thumbnails: ThumbnailConfig, + #[serde(default)] + pub webhooks: Vec, + #[serde(default)] + pub scheduled_tasks: Vec, + #[serde(default)] + pub plugins: PluginsConfig, + #[serde(default)] + pub transcoding: TranscodingConfig, + #[serde(default)] + pub enrichment: EnrichmentConfig, + #[serde(default)] + pub cloud: CloudConfig, + #[serde(default)] + pub analytics: AnalyticsConfig, + #[serde(default)] + pub photos: PhotoConfig, + #[serde(default)] + pub managed_storage: ManagedStorageConfig, + #[serde(default)] + pub sync: SyncConfig, + #[serde(default)] + pub sharing: SharingConfig, + #[serde(default)] + pub trash: TrashConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledTaskConfig { + pub id: String, + pub enabled: bool, + pub schedule: Schedule, + pub last_run: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RateLimitConfig { + /// Global rate limit: requests per second (token replenish interval). + /// Default: 1 (combined with `burst_size=100` gives ~100 req/sec) + #[serde(default = "default_global_per_second")] + pub global_per_second: u64, + /// Global rate limit: burst size (max concurrent requests per IP) + #[serde(default = "default_global_burst")] + pub global_burst_size: u32, + /// Login rate limit: seconds between token replenishment. + /// Default: 12 (one token every 12s, combined with burst=5 gives ~5 req/min) + #[serde(default = "default_login_per_second")] + pub login_per_second: u64, + /// Login rate limit: burst size + #[serde(default = "default_login_burst")] + pub login_burst_size: u32, + /// Search rate limit: seconds between token replenishment. + /// Default: 6 (one token every 6s, combined with burst=10 gives ~10 req/min) + #[serde(default = "default_search_per_second")] + pub search_per_second: u64, + /// Search rate limit: burst size + #[serde(default = "default_search_burst")] + pub search_burst_size: u32, + /// Streaming rate limit: seconds between token replenishment. + /// Default: 60 (one per minute) + #[serde(default = "default_stream_per_second")] + pub stream_per_second: u64, + /// Streaming rate limit: burst size (max concurrent streams) + #[serde(default = "default_stream_burst")] + pub stream_burst_size: u32, + /// Share token rate limit: seconds between token replenishment. + /// Default: 2 + #[serde(default = "default_share_per_second")] + pub share_per_second: u64, + /// Share token rate limit: burst size + #[serde(default = "default_share_burst")] + pub share_burst_size: u32, +} + +const fn default_global_per_second() -> u64 { + 1 +} +const fn default_global_burst() -> u32 { + 100 +} +const fn default_login_per_second() -> u64 { + 12 +} +const fn default_login_burst() -> u32 { + 5 +} +const fn default_search_per_second() -> u64 { + 6 +} +const fn default_search_burst() -> u32 { + 10 +} +const fn default_stream_per_second() -> u64 { + 60 +} +const fn default_stream_burst() -> u32 { + 5 +} +const fn default_share_per_second() -> u64 { + 2 +} +const fn default_share_burst() -> u32 { + 20 +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + global_per_second: default_global_per_second(), + global_burst_size: default_global_burst(), + login_per_second: default_login_per_second(), + login_burst_size: default_login_burst(), + search_per_second: default_search_per_second(), + search_burst_size: default_search_burst(), + stream_per_second: default_stream_per_second(), + stream_burst_size: default_stream_burst(), + share_per_second: default_share_per_second(), + share_burst_size: default_share_burst(), + } + } +} + +impl RateLimitConfig { + /// Validate that all rate limit values are positive. + /// + /// # Errors + /// + /// Returns an error string if any rate limit value is zero. + pub fn validate(&self) -> Result<(), String> { + for (name, value) in [ + ("global_per_second", self.global_per_second), + ("global_burst_size", u64::from(self.global_burst_size)), + ("login_per_second", self.login_per_second), + ("login_burst_size", u64::from(self.login_burst_size)), + ("search_per_second", self.search_per_second), + ("search_burst_size", u64::from(self.search_burst_size)), + ("stream_per_second", self.stream_per_second), + ("stream_burst_size", u64::from(self.stream_burst_size)), + ("share_per_second", self.share_per_second), + ("share_burst_size", u64::from(self.share_burst_size)), + ] { + if value == 0 { + return Err(format!("{name} must be > 0")); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobsConfig { + #[serde(default = "default_worker_count")] + pub worker_count: usize, + #[serde(default = "default_cache_ttl")] + pub cache_ttl_secs: u64, + /// Maximum time a job is allowed to run before being cancelled (in seconds). + /// Set to 0 to disable timeout. Default: 3600 (1 hour). + #[serde(default = "default_job_timeout")] + pub job_timeout_secs: u64, +} + +const fn default_worker_count() -> usize { + 2 +} +const fn default_cache_ttl() -> u64 { + 60 +} +const fn default_job_timeout() -> u64 { + 3600 +} + +impl Default for JobsConfig { + fn default() -> Self { + Self { + worker_count: default_worker_count(), + cache_ttl_secs: default_cache_ttl(), + job_timeout_secs: default_job_timeout(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThumbnailConfig { + #[serde(default = "default_thumb_size")] + pub size: u32, + #[serde(default = "default_thumb_quality")] + pub quality: u8, + #[serde(default)] + pub ffmpeg_path: Option, + #[serde(default = "default_video_seek")] + pub video_seek_secs: u32, +} + +const fn default_thumb_size() -> u32 { + 320 +} +const fn default_thumb_quality() -> u8 { + 80 +} +const fn default_video_seek() -> u32 { + 2 +} + +impl Default for ThumbnailConfig { + fn default() -> Self { + Self { + size: default_thumb_size(), + quality: default_thumb_quality(), + ffmpeg_path: None, + video_seek_secs: default_video_seek(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookConfig { + pub url: String, + pub events: Vec, + #[serde(default)] + pub secret: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiConfig { + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_view")] + pub default_view: String, + #[serde(default = "default_page_size")] + pub default_page_size: usize, + #[serde(default = "default_view_mode")] + pub default_view_mode: String, + #[serde(default)] + pub auto_play_media: bool, + #[serde(default = "default_true")] + pub show_thumbnails: bool, + #[serde(default)] + pub sidebar_collapsed: bool, +} + +fn default_theme() -> String { + "dark".to_string() +} +fn default_view() -> String { + "library".to_string() +} +const fn default_page_size() -> usize { + 50 +} +fn default_view_mode() -> String { + "grid".to_string() +} +const fn default_true() -> bool { + true +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + theme: default_theme(), + default_view: default_view(), + default_page_size: default_page_size(), + default_view_mode: default_view_mode(), + auto_play_media: false, + show_thumbnails: true, + sidebar_collapsed: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub users: Vec, + /// Session expiry in hours. Defaults to 24. + #[serde(default = "default_session_expiry_hours")] + pub session_expiry_hours: u64, +} + +const fn default_session_expiry_hours() -> u64 { + 24 +} + +impl Default for AccountsConfig { + fn default() -> Self { + Self { + enabled: false, + users: Vec::new(), + session_expiry_hours: default_session_expiry_hours(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAccount { + pub username: String, + pub password_hash: String, + #[serde(default)] + pub role: UserRole, +} + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, +)] +#[serde(rename_all = "lowercase")] +pub enum UserRole { + Admin, + Editor, + #[default] + Viewer, +} + +impl UserRole { + #[must_use] + pub const fn can_read(self) -> bool { + true + } + + #[must_use] + pub const fn can_write(self) -> bool { + matches!(self, Self::Admin | Self::Editor) + } + + #[must_use] + pub const fn can_admin(self) -> bool { + matches!(self, Self::Admin) + } +} + +impl std::fmt::Display for UserRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Admin => write!(f, "admin"), + Self::Editor => write!(f, "editor"), + Self::Viewer => write!(f, "viewer"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginTimeoutConfig { + /// Timeout for capability discovery queries (`supported_types`, + /// `interested_events`) + #[serde(default = "default_capability_query_timeout")] + pub capability_query_secs: u64, + /// Timeout for processing calls (`extract_metadata`, `generate_thumbnail`) + #[serde(default = "default_processing_timeout")] + pub processing_secs: u64, + /// Timeout for event handler calls + #[serde(default = "default_event_handler_timeout")] + pub event_handler_secs: u64, +} + +const fn default_capability_query_timeout() -> u64 { + 2 +} + +const fn default_processing_timeout() -> u64 { + 30 +} + +const fn default_event_handler_timeout() -> u64 { + 10 +} + +impl Default for PluginTimeoutConfig { + fn default() -> Self { + Self { + capability_query_secs: default_capability_query_timeout(), + processing_secs: default_processing_timeout(), + event_handler_secs: default_event_handler_timeout(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_plugin_data_dir")] + pub data_dir: PathBuf, + #[serde(default = "default_plugin_cache_dir")] + pub cache_dir: PathBuf, + #[serde(default)] + pub plugin_dirs: Vec, + #[serde(default)] + pub enable_hot_reload: bool, + #[serde(default)] + pub allow_unsigned: bool, + #[serde(default = "default_max_concurrent_ops")] + pub max_concurrent_ops: usize, + #[serde(default = "default_plugin_timeout")] + pub plugin_timeout_secs: u64, + #[serde(default)] + pub timeouts: PluginTimeoutConfig, + #[serde(default = "default_max_consecutive_failures")] + pub max_consecutive_failures: u32, + + /// Hex-encoded Ed25519 public keys trusted for plugin signature + /// verification. Each entry is 64 hex characters (32 bytes). + #[serde(default)] + pub trusted_keys: Vec, +} + +fn default_plugin_data_dir() -> PathBuf { + Config::default_data_dir().join("plugins").join("data") +} + +fn default_plugin_cache_dir() -> PathBuf { + Config::default_data_dir().join("plugins").join("cache") +} + +const fn default_max_concurrent_ops() -> usize { + 4 +} + +const fn default_plugin_timeout() -> u64 { + 30 +} + +const fn default_max_consecutive_failures() -> u32 { + 5 +} + +impl Default for PluginsConfig { + fn default() -> Self { + Self { + enabled: false, + data_dir: default_plugin_data_dir(), + cache_dir: default_plugin_cache_dir(), + plugin_dirs: vec![], + enable_hot_reload: false, + allow_unsigned: false, + max_concurrent_ops: default_max_concurrent_ops(), + plugin_timeout_secs: default_plugin_timeout(), + timeouts: PluginTimeoutConfig::default(), + max_consecutive_failures: default_max_consecutive_failures(), + trusted_keys: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TranscodingConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub cache_dir: Option, + #[serde(default = "default_cache_ttl_hours")] + pub cache_ttl_hours: u64, + #[serde(default = "default_max_concurrent_transcodes")] + pub max_concurrent: usize, + #[serde(default)] + pub hardware_acceleration: Option, + #[serde(default)] + pub profiles: Vec, +} + +const fn default_cache_ttl_hours() -> u64 { + 48 +} + +const fn default_max_concurrent_transcodes() -> usize { + 2 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TranscodeProfile { + pub name: String, + pub video_codec: String, + pub audio_codec: String, + pub max_bitrate_kbps: u32, + pub max_resolution: String, +} + +impl Default for TranscodingConfig { + fn default() -> Self { + Self { + enabled: false, + cache_dir: None, + cache_ttl_hours: default_cache_ttl_hours(), + max_concurrent: default_max_concurrent_transcodes(), + hardware_acceleration: None, + profiles: vec![ + TranscodeProfile { + name: "high".to_string(), + video_codec: "h264".to_string(), + audio_codec: "aac".to_string(), + max_bitrate_kbps: 8000, + max_resolution: "1080p".to_string(), + }, + TranscodeProfile { + name: "medium".to_string(), + video_codec: "h264".to_string(), + audio_codec: "aac".to_string(), + max_bitrate_kbps: 4000, + max_resolution: "720p".to_string(), + }, + ], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EnrichmentConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub auto_enrich_on_import: bool, + #[serde(default)] + pub sources: EnrichmentSources, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EnrichmentSources { + #[serde(default)] + pub musicbrainz: EnrichmentSource, + #[serde(default)] + pub tmdb: EnrichmentSource, + #[serde(default)] + pub lastfm: EnrichmentSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EnrichmentSource { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub api_key: Option, + #[serde(default)] + pub api_endpoint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_auto_sync_interval")] + pub auto_sync_interval_mins: u64, + #[serde(default)] + pub accounts: Vec, +} + +const fn default_auto_sync_interval() -> u64 { + 60 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudAccount { + pub id: String, + pub provider: String, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub sync_rules: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudSyncRule { + pub local_path: PathBuf, + pub remote_path: String, + pub direction: CloudSyncDirection, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CloudSyncDirection { + Upload, + Download, + Bidirectional, +} + +impl Default for CloudConfig { + fn default() -> Self { + Self { + enabled: false, + auto_sync_interval_mins: default_auto_sync_interval(), + accounts: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnalyticsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_true")] + pub track_usage: bool, + #[serde(default = "default_retention_days")] + pub retention_days: u64, +} + +const fn default_retention_days() -> u64 { + 90 +} + +impl Default for AnalyticsConfig { + fn default() -> Self { + Self { + enabled: false, + track_usage: true, + retention_days: default_retention_days(), + } + } +} + +/// Feature toggles for photo processing (image analysis features). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhotoFeatures { + /// Generate perceptual hashes for image duplicate detection (CPU-intensive) + #[serde(default = "default_true")] + pub generate_perceptual_hash: bool, + + /// Automatically create tags from EXIF keywords + #[serde(default)] + pub auto_tag_from_exif: bool, + + /// Generate multi-resolution thumbnails (tiny, grid, preview) + #[serde(default)] + pub multi_resolution_thumbnails: bool, +} + +impl Default for PhotoFeatures { + fn default() -> Self { + Self { + generate_perceptual_hash: true, + auto_tag_from_exif: false, + multi_resolution_thumbnails: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhotoConfig { + /// Feature toggles for photo processing + #[serde(flatten)] + pub features: PhotoFeatures, + + /// Auto-detect photo events/albums based on time and location + #[serde(default)] + pub enable_event_detection: bool, + + /// Minimum number of photos to form an event + #[serde(default = "default_min_event_photos")] + pub min_event_photos: usize, + + /// Maximum time gap between photos in the same event (in seconds) + #[serde(default = "default_event_time_gap")] + pub event_time_gap_secs: i64, + + /// Maximum distance between photos in the same event (in kilometers) + #[serde(default = "default_event_distance")] + pub event_max_distance_km: f64, +} + +impl PhotoConfig { + /// Returns true if perceptual hashing is enabled. + #[must_use] + pub const fn generate_perceptual_hash(&self) -> bool { + self.features.generate_perceptual_hash + } + + /// Returns true if auto-tagging from EXIF is enabled. + #[must_use] + pub const fn auto_tag_from_exif(&self) -> bool { + self.features.auto_tag_from_exif + } + + /// Returns true if multi-resolution thumbnails are enabled. + #[must_use] + pub const fn multi_resolution_thumbnails(&self) -> bool { + self.features.multi_resolution_thumbnails + } +} + +const fn default_min_event_photos() -> usize { + 5 +} + +const fn default_event_time_gap() -> i64 { + 2 * 60 * 60 // 2 hours +} + +const fn default_event_distance() -> f64 { + 1.0 // 1 km +} + +impl Default for PhotoConfig { + fn default() -> Self { + Self { + features: PhotoFeatures::default(), + enable_event_detection: false, + min_event_photos: default_min_event_photos(), + event_time_gap_secs: default_event_time_gap(), + event_max_distance_km: default_event_distance(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManagedStorageConfig { + /// Enable managed storage for file uploads + #[serde(default)] + pub enabled: bool, + /// Directory where managed files are stored + #[serde(default = "default_managed_storage_dir")] + pub storage_dir: PathBuf, + /// Maximum upload size in bytes (default: 10GB) + #[serde(default = "default_max_upload_size")] + pub max_upload_size: u64, + /// Allowed MIME types for uploads (empty = allow all) + #[serde(default)] + pub allowed_mime_types: Vec, + /// Automatically clean up orphaned blobs + #[serde(default = "default_true")] + pub auto_cleanup: bool, + /// Verify file integrity on read + #[serde(default)] + pub verify_on_read: bool, +} + +fn default_managed_storage_dir() -> PathBuf { + Config::default_data_dir().join("managed") +} + +const fn default_max_upload_size() -> u64 { + 10 * 1024 * 1024 * 1024 // 10GB +} + +impl Default for ManagedStorageConfig { + fn default() -> Self { + Self { + enabled: false, + storage_dir: default_managed_storage_dir(), + max_upload_size: default_max_upload_size(), + allowed_mime_types: vec![], + auto_cleanup: true, + verify_on_read: false, + } + } +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, +)] +#[serde(rename_all = "snake_case")] +pub enum ConflictResolution { + ServerWins, + ClientWins, + #[default] + KeepBoth, + Manual, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncConfig { + /// Enable cross-device sync functionality + #[serde(default)] + pub enabled: bool, + /// Default conflict resolution strategy + #[serde(default)] + pub default_conflict_resolution: ConflictResolution, + /// Maximum file size for sync in MB + #[serde(default = "default_max_sync_file_size")] + pub max_file_size_mb: u64, + /// Chunk size for chunked uploads in KB + #[serde(default = "default_chunk_size")] + pub chunk_size_kb: u64, + /// Upload session timeout in hours + #[serde(default = "default_upload_timeout")] + pub upload_timeout_hours: u64, + /// Maximum concurrent uploads per device + #[serde(default = "default_max_concurrent_uploads")] + pub max_concurrent_uploads: usize, + /// Sync log retention in days + #[serde(default = "default_sync_log_retention")] + pub sync_log_retention_days: u64, + /// Temporary directory for chunked upload storage + #[serde(default = "default_temp_upload_dir")] + pub temp_upload_dir: PathBuf, +} + +const fn default_max_sync_file_size() -> u64 { + 4096 // 4GB +} + +const fn default_chunk_size() -> u64 { + 4096 // 4MB +} + +const fn default_upload_timeout() -> u64 { + 24 // 24 hours +} + +const fn default_max_concurrent_uploads() -> usize { + 3 +} + +const fn default_sync_log_retention() -> u64 { + 90 // 90 days +} + +fn default_temp_upload_dir() -> PathBuf { + Config::default_data_dir().join("temp_uploads") +} + +impl Default for SyncConfig { + fn default() -> Self { + Self { + enabled: false, + default_conflict_resolution: ConflictResolution::default(), + max_file_size_mb: default_max_sync_file_size(), + chunk_size_kb: default_chunk_size(), + upload_timeout_hours: default_upload_timeout(), + max_concurrent_uploads: default_max_concurrent_uploads(), + sync_log_retention_days: default_sync_log_retention(), + temp_upload_dir: default_temp_upload_dir(), + } + } +} + +/// Core permission flags for the sharing subsystem. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharingPermissions { + /// Enable sharing functionality + #[serde(default = "default_true")] + pub enabled: bool, + /// Allow creating public share links + #[serde(default = "default_true")] + pub allow_public_links: bool, + /// Allow users to reshare content shared with them + #[serde(default = "default_true")] + pub allow_reshare: bool, +} + +impl Default for SharingPermissions { + fn default() -> Self { + Self { + enabled: true, + allow_public_links: true, + allow_reshare: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharingConfig { + /// Core permission flags for sharing + #[serde(flatten)] + pub permissions: SharingPermissions, + /// Require password for public share links + #[serde(default)] + pub require_public_link_password: bool, + /// Enable share notifications + #[serde(default = "default_true")] + pub notifications_enabled: bool, + /// Maximum expiry time for public links in hours (0 = unlimited) + #[serde(default)] + pub max_public_link_expiry_hours: u64, + /// Notification retention in days + #[serde(default = "default_notification_retention")] + pub notification_retention_days: u64, + /// Share activity log retention in days + #[serde(default = "default_activity_retention")] + pub activity_retention_days: u64, +} + +impl SharingConfig { + /// Returns true if sharing is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + self.permissions.enabled + } + + /// Returns true if public links are allowed. + #[must_use] + pub const fn allow_public_links(&self) -> bool { + self.permissions.allow_public_links + } + + /// Returns true if resharing is allowed. + #[must_use] + pub const fn allow_reshare(&self) -> bool { + self.permissions.allow_reshare + } +} + +const fn default_notification_retention() -> u64 { + 30 +} + +const fn default_activity_retention() -> u64 { + 90 +} + +impl Default for SharingConfig { + fn default() -> Self { + Self { + permissions: SharingPermissions::default(), + require_public_link_password: false, + notifications_enabled: true, + max_public_link_expiry_hours: 0, + notification_retention_days: default_notification_retention(), + activity_retention_days: default_activity_retention(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrashConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_trash_retention_days")] + pub retention_days: u64, + #[serde(default)] + pub auto_empty: bool, +} + +const fn default_trash_retention_days() -> u64 { + 30 +} + +impl Default for TrashConfig { + fn default() -> Self { + Self { + enabled: false, + retention_days: default_trash_retention_days(), + auto_empty: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageConfig { + pub backend: StorageBackendType, + pub sqlite: Option, + pub postgres: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StorageBackendType { + Sqlite, + Postgres, +} + +impl StorageBackendType { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgres", + } + } +} + +impl std::fmt::Display for StorageBackendType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SqliteConfig { + pub path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostgresConfig { + pub host: String, + pub port: u16, + pub database: String, + pub username: String, + pub password: String, + pub max_connections: usize, + /// Enable TLS for `PostgreSQL` connections + #[serde(default)] + pub tls_enabled: bool, + /// Verify TLS certificates (default: true) + #[serde(default = "default_true")] + pub tls_verify_ca: bool, + /// Path to custom CA certificate file (PEM format) + #[serde(default)] + pub tls_ca_cert_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectoryConfig { + pub roots: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanningConfig { + pub watch: bool, + pub poll_interval_secs: u64, + pub ignore_patterns: Vec, + #[serde(default = "default_import_concurrency")] + pub import_concurrency: usize, +} + +const fn default_import_concurrency() -> usize { + 8 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub host: String, + pub port: u16, + /// Optional API key for bearer token authentication. + /// If set, all requests (except /health) must include `Authorization: Bearer + /// `. Can also be set via `PINAKES_API_KEY` environment variable. + pub api_key: Option, + /// Explicitly disable authentication (INSECURE - use only for development). + /// When true, all requests are allowed without authentication. + /// This must be explicitly set to true; empty `api_key` alone is not + /// sufficient. + #[serde(default)] + pub authentication_disabled: bool, + /// Enable CORS (Cross-Origin Resource Sharing). + /// When false, default localhost origins are used. + #[serde(default)] + pub cors_enabled: bool, + /// Allowed CORS origins when `cors_enabled` is true. + /// If empty and `cors_enabled` is true, defaults to localhost origins. + #[serde(default)] + pub cors_origins: Vec, + /// TLS/HTTPS configuration + #[serde(default)] + pub tls: TlsConfig, + /// Enable the Swagger UI at /api/docs. + /// Defaults to true. Set to false to disable in production if desired. + #[serde(default = "default_true")] + pub swagger_ui: bool, +} + +/// TLS/HTTPS configuration for secure connections +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsConfig { + /// Enable TLS (HTTPS) + #[serde(default)] + pub enabled: bool, + /// Path to the TLS certificate file (PEM format) + #[serde(default)] + pub cert_path: Option, + /// Path to the TLS private key file (PEM format) + #[serde(default)] + pub key_path: Option, + /// Enable HTTP to HTTPS redirect (starts a second listener on `http_port`) + #[serde(default)] + pub redirect_http: bool, + /// Port for HTTP redirect listener (default: 80) + #[serde(default = "default_http_port")] + pub http_port: u16, + /// Enable HSTS (HTTP Strict Transport Security) header + #[serde(default = "default_true")] + pub hsts_enabled: bool, + /// HSTS max-age in seconds (default: 1 year) + #[serde(default = "default_hsts_max_age")] + pub hsts_max_age: u64, +} + +const fn default_http_port() -> u16 { + 80 +} + +const fn default_hsts_max_age() -> u64 { + 31_536_000 // 1 year in seconds +} + +impl Default for TlsConfig { + fn default() -> Self { + Self { + enabled: false, + cert_path: None, + key_path: None, + redirect_http: false, + http_port: default_http_port(), + hsts_enabled: true, + hsts_max_age: default_hsts_max_age(), + } + } +} + +impl TlsConfig { + /// Validate TLS configuration + /// + /// # Errors + /// + /// Returns an error string if TLS is enabled but required paths are missing + /// or invalid. + pub fn validate(&self) -> Result<(), String> { + if self.enabled { + if self.cert_path.is_none() { + return Err("TLS enabled but cert_path not specified".into()); + } + if self.key_path.is_none() { + return Err("TLS enabled but key_path not specified".into()); + } + if let Some(ref cert_path) = self.cert_path + && !cert_path.exists() + { + return Err(format!( + "TLS certificate file not found: {}", + cert_path.display() + )); + } + if let Some(ref key_path) = self.key_path + && !key_path.exists() + { + return Err(format!("TLS key file not found: {}", key_path.display())); + } + } + Ok(()) + } +} + +impl Config { + /// Load configuration from a TOML file, expanding environment variables in + /// secret fields. + /// + /// # Errors + /// + /// Returns [`crate::error::PinakesError`] if the file cannot be read, parsed, + /// or contains invalid environment variable references. + pub fn from_file(path: &Path) -> crate::error::Result { + let content = std::fs::read_to_string(path).map_err(|e| { + crate::error::PinakesError::Config(format!( + "failed to read config file: {e}" + )) + })?; + let mut config: Self = toml::from_str(&content).map_err(|e| { + crate::error::PinakesError::Config(format!("failed to parse config: {e}")) + })?; + config.expand_env_vars()?; + Ok(config) + } + + /// Expand environment variables in secret fields. + /// Supports ${`VAR_NAME`} and $`VAR_NAME` syntax. + fn expand_env_vars(&mut self) -> crate::error::Result<()> { + // Postgres password + if let Some(ref mut postgres) = self.storage.postgres { + postgres.password = expand_env_var_string(&postgres.password)?; + } + + // Server API key + if let Some(ref api_key) = self.server.api_key { + self.server.api_key = Some(expand_env_var_string(api_key)?); + } + + // Webhook secrets + for webhook in &mut self.webhooks { + if let Some(ref secret) = webhook.secret { + webhook.secret = Some(expand_env_var_string(secret)?); + } + } + + // Enrichment API keys + if let Some(ref api_key) = self.enrichment.sources.musicbrainz.api_key { + self.enrichment.sources.musicbrainz.api_key = + Some(expand_env_var_string(api_key)?); + } + if let Some(ref api_key) = self.enrichment.sources.tmdb.api_key { + self.enrichment.sources.tmdb.api_key = + Some(expand_env_var_string(api_key)?); + } + if let Some(ref api_key) = self.enrichment.sources.lastfm.api_key { + self.enrichment.sources.lastfm.api_key = + Some(expand_env_var_string(api_key)?); + } + + Ok(()) + } + + /// Try loading from file, falling back to defaults if the file doesn't exist. + /// + /// # Errors + /// + /// Returns [`crate::error::PinakesError`] if the file exists but cannot be + /// read or parsed. + pub fn load_or_default(path: &Path) -> crate::error::Result { + if path.exists() { + Self::from_file(path) + } else { + let config = Self::default(); + // Ensure the data directory exists for the default SQLite database + config.ensure_dirs()?; + Ok(config) + } + } + + /// Save the current config to a TOML file. + /// + /// # Errors + /// + /// Returns [`crate::error::PinakesError`] if the file cannot be written or + /// the config cannot be serialized. + pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(self).map_err(|e| { + crate::error::PinakesError::Config(format!( + "failed to serialize config: {e}" + )) + })?; + std::fs::write(path, content)?; + Ok(()) + } + + /// Ensure all directories needed by this config exist and are writable. + /// + /// # Errors + /// + /// Returns [`crate::error::PinakesError`] if a required directory cannot be + /// created or is read-only. + pub fn ensure_dirs(&self) -> crate::error::Result<()> { + if let Some(ref sqlite) = self.storage.sqlite + && let Some(parent) = sqlite.path.parent() + { + // Skip if parent is empty string (happens with bare filenames like + // "pinakes.db") + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + let metadata = std::fs::metadata(parent)?; + if metadata.permissions().readonly() { + return Err(crate::error::PinakesError::Config(format!( + "directory is not writable: {}", + parent.display() + ))); + } + } + } + Ok(()) + } + + /// Returns the default config file path following XDG conventions. + #[must_use] + pub fn default_config_path() -> PathBuf { + std::env::var("XDG_CONFIG_HOME").map_or_else( + |_| { + std::env::var("HOME").map_or_else( + |_| PathBuf::from("pinakes.toml"), + |home| { + PathBuf::from(home) + .join(".config") + .join("pinakes") + .join("pinakes.toml") + }, + ) + }, + |xdg| PathBuf::from(xdg).join("pinakes").join("pinakes.toml"), + ) + } + + /// Validate configuration values for correctness. + /// + /// # Errors + /// + /// Returns an error string if any configuration value is invalid. + pub fn validate(&self) -> Result<(), String> { + if self.server.port == 0 { + return Err("server port cannot be 0".into()); + } + if self.server.host.is_empty() { + return Err("server host cannot be empty".into()); + } + if self.scanning.poll_interval_secs == 0 { + return Err("poll interval cannot be 0".into()); + } + if self.scanning.import_concurrency == 0 + || self.scanning.import_concurrency > 256 + { + return Err("import_concurrency must be between 1 and 256".into()); + } + + // Validate authentication configuration + let has_api_key = + self.server.api_key.as_ref().is_some_and(|k| !k.is_empty()); + let has_accounts = !self.accounts.users.is_empty(); + let auth_disabled = self.server.authentication_disabled; + + if !auth_disabled && !has_api_key && !has_accounts { + return Err( + "authentication is not configured: set an api_key, configure user \ + accounts, or explicitly set authentication_disabled = true" + .into(), + ); + } + + // Empty API key is not allowed (must use authentication_disabled flag) + if let Some(ref api_key) = self.server.api_key + && api_key.is_empty() + { + return Err( + "empty api_key is not allowed. To disable authentication, set \ + authentication_disabled = true instead" + .into(), + ); + } + + // Require TLS when authentication is enabled on non-localhost + let is_localhost = self.server.host == "127.0.0.1" + || self.server.host == "localhost" + || self.server.host == "::1"; + + if (has_api_key || has_accounts) + && !auth_disabled + && !is_localhost + && !self.server.tls.enabled + { + return Err( + "TLS must be enabled when authentication is used on non-localhost \ + hosts. Set server.tls.enabled = true or bind to localhost only" + .into(), + ); + } + + // Validate rate limits + self.rate_limits.validate()?; + + // Validate TLS configuration + self.server.tls.validate()?; + Ok(()) + } + + /// Returns the default data directory following XDG conventions. + #[must_use] + pub fn default_data_dir() -> PathBuf { + std::env::var("XDG_DATA_HOME").map_or_else( + |_| { + std::env::var("HOME").map_or_else( + |_| PathBuf::from("pinakes-data"), + |home| { + PathBuf::from(home) + .join(".local") + .join("share") + .join("pinakes") + }, + ) + }, + |xdg| PathBuf::from(xdg).join("pinakes"), + ) + } +} + +impl Default for Config { + fn default() -> Self { + let data_dir = Self::default_data_dir(); + Self { + storage: StorageConfig { + backend: StorageBackendType::Sqlite, + sqlite: Some(SqliteConfig { + path: data_dir.join("pinakes.db"), + }), + postgres: None, + }, + directories: DirectoryConfig { roots: vec![] }, + scanning: ScanningConfig { + watch: false, + poll_interval_secs: 300, + ignore_patterns: vec![ + ".*".to_string(), + "node_modules".to_string(), + "__pycache__".to_string(), + "target".to_string(), + ], + import_concurrency: default_import_concurrency(), + }, + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 3000, + api_key: None, + authentication_disabled: false, + cors_enabled: false, + cors_origins: vec![], + tls: TlsConfig::default(), + swagger_ui: true, + }, + ui: UiConfig::default(), + accounts: AccountsConfig::default(), + rate_limits: RateLimitConfig::default(), + jobs: JobsConfig::default(), + thumbnails: ThumbnailConfig::default(), + webhooks: vec![], + scheduled_tasks: vec![], + plugins: PluginsConfig::default(), + transcoding: TranscodingConfig::default(), + enrichment: EnrichmentConfig::default(), + cloud: CloudConfig::default(), + analytics: AnalyticsConfig::default(), + photos: PhotoConfig::default(), + managed_storage: ManagedStorageConfig::default(), + sync: SyncConfig::default(), + sharing: SharingConfig::default(), + trash: TrashConfig::default(), + } + } +} + +#[cfg(test)] +mod tests { + use rustc_hash::FxHashMap; + + use super::*; + + fn test_config_with_concurrency(concurrency: usize) -> Config { + let mut config = Config::default(); + config.scanning.import_concurrency = concurrency; + config.server.authentication_disabled = true; // Disable auth for concurrency tests + config + } + + #[test] + fn test_validate_import_concurrency_zero() { + let config = test_config_with_concurrency(0); + assert!(config.validate().is_err()); + assert!( + config + .validate() + .unwrap_err() + .contains("import_concurrency") + ); + } + + #[test] + fn test_validate_import_concurrency_too_high() { + let config = test_config_with_concurrency(257); + assert!(config.validate().is_err()); + assert!( + config + .validate() + .unwrap_err() + .contains("import_concurrency") + ); + } + + #[test] + fn test_validate_import_concurrency_valid() { + let config = test_config_with_concurrency(8); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_import_concurrency_boundary_low() { + let config = test_config_with_concurrency(1); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_import_concurrency_boundary_high() { + let config = test_config_with_concurrency(256); + assert!(config.validate().is_ok()); + } + + // Environment variable expansion tests using expand_env_vars with a + // HashMap lookup. This avoids unsafe std::env::set_var and is + // thread-safe for parallel test execution. + fn test_lookup<'a>( + vars: &'a FxHashMap<&str, &str>, + ) -> impl Fn(&str) -> crate::error::Result + 'a { + move |name| { + vars + .get(name) + .map(std::string::ToString::to_string) + .ok_or_else(|| { + crate::error::PinakesError::Config(format!( + "environment variable not set: {name}" + )) + }) + } + } + + #[test] + fn test_expand_env_var_simple() { + let vars = [("TEST_VAR_SIMPLE", "test_value")] + .into_iter() + .collect::>(); + let result = expand_env_vars("$TEST_VAR_SIMPLE", test_lookup(&vars)); + assert_eq!(result.unwrap(), "test_value"); + } + + #[test] + fn test_expand_env_var_braces() { + let vars = [("TEST_VAR_BRACES", "test_value")] + .into_iter() + .collect::>(); + let result = expand_env_vars("${TEST_VAR_BRACES}", test_lookup(&vars)); + assert_eq!(result.unwrap(), "test_value"); + } + + #[test] + fn test_expand_env_var_embedded() { + let vars = [("TEST_VAR_EMBEDDED", "value")] + .into_iter() + .collect::>(); + let result = + expand_env_vars("prefix_${TEST_VAR_EMBEDDED}_suffix", test_lookup(&vars)); + assert_eq!(result.unwrap(), "prefix_value_suffix"); + } + + #[test] + fn test_expand_env_var_multiple() { + let vars = [("VAR1", "value1"), ("VAR2", "value2")] + .into_iter() + .collect::>(); + let result = expand_env_vars("${VAR1}_${VAR2}", test_lookup(&vars)); + assert_eq!(result.unwrap(), "value1_value2"); + } + + #[test] + fn test_expand_env_var_missing() { + let vars = FxHashMap::default(); + let result = expand_env_vars("${NONEXISTENT_VAR}", test_lookup(&vars)); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("environment variable not set") + ); + } + + #[test] + fn test_expand_env_var_empty_name() { + let vars = FxHashMap::default(); + let result = expand_env_vars("${}", test_lookup(&vars)); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("empty environment variable name") + ); + } + + #[test] + fn test_expand_env_var_escaped() { + let vars = FxHashMap::default(); + let result = expand_env_vars("\\$NOT_A_VAR", test_lookup(&vars)); + assert_eq!(result.unwrap(), "$NOT_A_VAR"); + } + + #[test] + fn test_expand_env_var_no_vars() { + let vars = FxHashMap::default(); + let result = expand_env_vars("plain_text", test_lookup(&vars)); + assert_eq!(result.unwrap(), "plain_text"); + } + + #[test] + fn test_expand_env_var_underscore() { + let vars = [("TEST_VAR_NAME", "value")] + .into_iter() + .collect::>(); + let result = expand_env_vars("$TEST_VAR_NAME", test_lookup(&vars)); + assert_eq!(result.unwrap(), "value"); + } + + #[test] + fn test_expand_env_var_mixed_syntax() { + let vars = [("VAR1_MIXED", "v1"), ("VAR2_MIXED", "v2")] + .into_iter() + .collect::>(); + let result = + expand_env_vars("$VAR1_MIXED and ${VAR2_MIXED}", test_lookup(&vars)); + assert_eq!(result.unwrap(), "v1 and v2"); + } +} diff --git a/crates/pinakes-types/src/error.rs b/crates/pinakes-types/src/error.rs new file mode 100644 index 0000000..33f0621 --- /dev/null +++ b/crates/pinakes-types/src/error.rs @@ -0,0 +1,142 @@ +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PinakesError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("database error: {0}")] + Database(String), + + #[error("migration error: {0}")] + Migration(String), + + #[error("configuration error: {0}")] + Config(String), + + #[error("media item not found: {0}")] + NotFound(String), + + #[error("duplicate content hash: {0}")] + DuplicateHash(String), + + #[error("unsupported media type for path: {0}")] + UnsupportedMediaType(PathBuf), + + #[error("metadata extraction failed: {0}")] + MetadataExtraction(String), + + #[error("thumbnail generation failed: {0}")] + ThumbnailGeneration(String), + + #[error("search query parse error: {0}")] + SearchParse(String), + + #[error("file not found at path: {0}")] + FileNotFound(PathBuf), + + #[error("tag not found: {0}")] + TagNotFound(String), + + #[error("collection not found: {0}")] + CollectionNotFound(String), + + #[error("invalid operation: {0}")] + InvalidOperation(String), + + #[error("invalid data: {0}")] + InvalidData(String), + + #[error("authentication error: {0}")] + Authentication(String), + + #[error("authorization error: {0}")] + Authorization(String), + + #[error("path not allowed: {0}")] + PathNotAllowed(String), + + #[error("external API error: {0}")] + External(String), + + // Managed Storage errors + #[error("managed storage not enabled")] + ManagedStorageDisabled, + + #[error("upload too large: {0} bytes exceeds limit")] + UploadTooLarge(u64), + + #[error("blob not found: {0}")] + BlobNotFound(String), + + #[error("storage integrity error: {0}")] + StorageIntegrity(String), + + // Sync errors + #[error("sync not enabled")] + SyncDisabled, + + #[error("device not found: {0}")] + DeviceNotFound(String), + + #[error("sync conflict: {0}")] + SyncConflict(String), + + #[error("upload session expired: {0}")] + UploadSessionExpired(String), + + #[error("upload session not found: {0}")] + UploadSessionNotFound(String), + + #[error("chunk out of order: expected {expected}, got {actual}")] + ChunkOutOfOrder { expected: u64, actual: u64 }, + + // Sharing errors + #[error("share not found: {0}")] + ShareNotFound(String), + + #[error("share expired: {0}")] + ShareExpired(String), + + #[error("share password required")] + SharePasswordRequired, + + #[error("share password invalid")] + SharePasswordInvalid, + + #[error("insufficient share permissions")] + InsufficientSharePermissions, + + #[error("serialization error: {0}")] + Serialization(String), + + #[error("external tool `{tool}` failed: {stderr}")] + ExternalTool { tool: String, stderr: String }, + + #[error("subtitle track {index} not found in media")] + SubtitleTrackNotFound { index: u32 }, + + #[error("invalid language code: {0}")] + InvalidLanguageCode(String), +} + +impl From for PinakesError { + fn from(e: serde_json::Error) -> Self { + Self::Serialization(e.to_string()) + } +} + +/// Build a closure that wraps a database error with operation context. +/// +/// Usage: `stmt.execute(params).map_err(db_ctx("insert_media", media_id))?;` +pub fn db_ctx( + operation: &str, + entity: impl std::fmt::Display, +) -> impl FnOnce(E) -> PinakesError { + let context = format!("{operation} [{entity}]"); + move |e| PinakesError::Database(format!("{context}: {e}")) +} + +pub type Result = std::result::Result; diff --git a/crates/pinakes-types/src/lib.rs b/crates/pinakes-types/src/lib.rs new file mode 100644 index 0000000..8b482c4 --- /dev/null +++ b/crates/pinakes-types/src/lib.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod error; +pub mod media_type; +pub mod model; diff --git a/crates/pinakes-types/src/media_type/builtin.rs b/crates/pinakes-types/src/media_type/builtin.rs new file mode 100644 index 0000000..93701b7 --- /dev/null +++ b/crates/pinakes-types/src/media_type/builtin.rs @@ -0,0 +1,292 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BuiltinMediaType { + // Audio + Mp3, + Flac, + Ogg, + Wav, + Aac, + Opus, + + // Video + Mp4, + Mkv, + Avi, + Webm, + + // Documents + Pdf, + Epub, + Djvu, + + // Text + Markdown, + PlainText, + + // Images + Jpeg, + Png, + Gif, + Webp, + Svg, + Avif, + Tiff, + Bmp, + + // RAW Images + Cr2, + Nef, + Arw, + Dng, + Orf, + Rw2, + + // HEIC/HEIF + Heic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaCategory { + Audio, + Video, + Document, + Text, + Image, +} + +impl BuiltinMediaType { + /// Get the unique, stable ID for this media type. + #[must_use] + pub const fn id(&self) -> &'static str { + match self { + Self::Mp3 => "mp3", + Self::Flac => "flac", + Self::Ogg => "ogg", + Self::Wav => "wav", + Self::Aac => "aac", + Self::Opus => "opus", + Self::Mp4 => "mp4", + Self::Mkv => "mkv", + Self::Avi => "avi", + Self::Webm => "webm", + Self::Pdf => "pdf", + Self::Epub => "epub", + Self::Djvu => "djvu", + Self::Markdown => "markdown", + Self::PlainText => "plaintext", + Self::Jpeg => "jpeg", + Self::Png => "png", + Self::Gif => "gif", + Self::Webp => "webp", + Self::Svg => "svg", + Self::Avif => "avif", + Self::Tiff => "tiff", + Self::Bmp => "bmp", + Self::Cr2 => "cr2", + Self::Nef => "nef", + Self::Arw => "arw", + Self::Dng => "dng", + Self::Orf => "orf", + Self::Rw2 => "rw2", + Self::Heic => "heic", + } + } + + /// Get the display name for this media type + #[must_use] + pub fn name(&self) -> String { + match self { + Self::Mp3 => "MP3 Audio".to_string(), + Self::Flac => "FLAC Audio".to_string(), + Self::Ogg => "OGG Audio".to_string(), + Self::Wav => "WAV Audio".to_string(), + Self::Aac => "AAC Audio".to_string(), + Self::Opus => "Opus Audio".to_string(), + Self::Mp4 => "MP4 Video".to_string(), + Self::Mkv => "MKV Video".to_string(), + Self::Avi => "AVI Video".to_string(), + Self::Webm => "WebM Video".to_string(), + Self::Pdf => "PDF Document".to_string(), + Self::Epub => "EPUB eBook".to_string(), + Self::Djvu => "DjVu Document".to_string(), + Self::Markdown => "Markdown".to_string(), + Self::PlainText => "Plain Text".to_string(), + Self::Jpeg => "JPEG Image".to_string(), + Self::Png => "PNG Image".to_string(), + Self::Gif => "GIF Image".to_string(), + Self::Webp => "WebP Image".to_string(), + Self::Svg => "SVG Image".to_string(), + Self::Avif => "AVIF Image".to_string(), + Self::Tiff => "TIFF Image".to_string(), + Self::Bmp => "BMP Image".to_string(), + Self::Cr2 => "Canon RAW (CR2)".to_string(), + Self::Nef => "Nikon RAW (NEF)".to_string(), + Self::Arw => "Sony RAW (ARW)".to_string(), + Self::Dng => "Adobe DNG RAW".to_string(), + Self::Orf => "Olympus RAW (ORF)".to_string(), + Self::Rw2 => "Panasonic RAW (RW2)".to_string(), + Self::Heic => "HEIC Image".to_string(), + } + } + + #[must_use] + pub fn from_extension(ext: &str) -> Option { + match ext.to_ascii_lowercase().as_str() { + "mp3" => Some(Self::Mp3), + "flac" => Some(Self::Flac), + "ogg" | "oga" => Some(Self::Ogg), + "wav" => Some(Self::Wav), + "aac" | "m4a" => Some(Self::Aac), + "opus" => Some(Self::Opus), + "mp4" | "m4v" => Some(Self::Mp4), + "mkv" => Some(Self::Mkv), + "avi" => Some(Self::Avi), + "webm" => Some(Self::Webm), + "pdf" => Some(Self::Pdf), + "epub" => Some(Self::Epub), + "djvu" => Some(Self::Djvu), + "md" | "markdown" => Some(Self::Markdown), + "txt" | "text" => Some(Self::PlainText), + "jpg" | "jpeg" => Some(Self::Jpeg), + "png" => Some(Self::Png), + "gif" => Some(Self::Gif), + "webp" => Some(Self::Webp), + "svg" => Some(Self::Svg), + "avif" => Some(Self::Avif), + "tiff" | "tif" => Some(Self::Tiff), + "bmp" => Some(Self::Bmp), + "cr2" => Some(Self::Cr2), + "nef" => Some(Self::Nef), + "arw" => Some(Self::Arw), + "dng" => Some(Self::Dng), + "orf" => Some(Self::Orf), + "rw2" => Some(Self::Rw2), + "heic" | "heif" => Some(Self::Heic), + _ => None, + } + } + + pub fn from_path(path: &Path) -> Option { + path + .extension() + .and_then(|e| e.to_str()) + .and_then(Self::from_extension) + } + + #[must_use] + pub const fn mime_type(&self) -> &'static str { + match self { + Self::Mp3 => "audio/mpeg", + Self::Flac => "audio/flac", + Self::Ogg => "audio/ogg", + Self::Wav => "audio/wav", + Self::Aac => "audio/aac", + Self::Opus => "audio/opus", + Self::Mp4 => "video/mp4", + Self::Mkv => "video/x-matroska", + Self::Avi => "video/x-msvideo", + Self::Webm => "video/webm", + Self::Pdf => "application/pdf", + Self::Epub => "application/epub+zip", + Self::Djvu => "image/vnd.djvu", + Self::Markdown => "text/markdown", + Self::PlainText => "text/plain", + Self::Jpeg => "image/jpeg", + Self::Png => "image/png", + Self::Gif => "image/gif", + Self::Webp => "image/webp", + Self::Svg => "image/svg+xml", + Self::Avif => "image/avif", + Self::Tiff => "image/tiff", + Self::Bmp => "image/bmp", + Self::Cr2 => "image/x-canon-cr2", + Self::Nef => "image/x-nikon-nef", + Self::Arw => "image/x-sony-arw", + Self::Dng => "image/x-adobe-dng", + Self::Orf => "image/x-olympus-orf", + Self::Rw2 => "image/x-panasonic-rw2", + Self::Heic => "image/heic", + } + } + + #[must_use] + pub const fn category(&self) -> MediaCategory { + match self { + Self::Mp3 + | Self::Flac + | Self::Ogg + | Self::Wav + | Self::Aac + | Self::Opus => MediaCategory::Audio, + Self::Mp4 | Self::Mkv | Self::Avi | Self::Webm => MediaCategory::Video, + Self::Pdf | Self::Epub | Self::Djvu => MediaCategory::Document, + Self::Markdown | Self::PlainText => MediaCategory::Text, + Self::Jpeg + | Self::Png + | Self::Gif + | Self::Webp + | Self::Svg + | Self::Avif + | Self::Tiff + | Self::Bmp + | Self::Cr2 + | Self::Nef + | Self::Arw + | Self::Dng + | Self::Orf + | Self::Rw2 + | Self::Heic => MediaCategory::Image, + } + } + + #[must_use] + pub const fn extensions(&self) -> &'static [&'static str] { + match self { + Self::Mp3 => &["mp3"], + Self::Flac => &["flac"], + Self::Ogg => &["ogg", "oga"], + Self::Wav => &["wav"], + Self::Aac => &["aac", "m4a"], + Self::Opus => &["opus"], + Self::Mp4 => &["mp4", "m4v"], + Self::Mkv => &["mkv"], + Self::Avi => &["avi"], + Self::Webm => &["webm"], + Self::Pdf => &["pdf"], + Self::Epub => &["epub"], + Self::Djvu => &["djvu"], + Self::Markdown => &["md", "markdown"], + Self::PlainText => &["txt", "text"], + Self::Jpeg => &["jpg", "jpeg"], + Self::Png => &["png"], + Self::Gif => &["gif"], + Self::Webp => &["webp"], + Self::Svg => &["svg"], + Self::Avif => &["avif"], + Self::Tiff => &["tiff", "tif"], + Self::Bmp => &["bmp"], + Self::Cr2 => &["cr2"], + Self::Nef => &["nef"], + Self::Arw => &["arw"], + Self::Dng => &["dng"], + Self::Orf => &["orf"], + Self::Rw2 => &["rw2"], + Self::Heic => &["heic", "heif"], + } + } + + /// Returns true if this is a RAW image format. + #[must_use] + pub const fn is_raw(&self) -> bool { + matches!( + self, + Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2 + ) + } +} diff --git a/crates/pinakes-types/src/media_type/mod.rs b/crates/pinakes-types/src/media_type/mod.rs new file mode 100644 index 0000000..2c73ef0 --- /dev/null +++ b/crates/pinakes-types/src/media_type/mod.rs @@ -0,0 +1,281 @@ +//! Media types +//! +//! Supports both +//! built-in media types and plugin-registered custom types. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +pub mod builtin; +pub mod registry; + +pub use builtin::{BuiltinMediaType, MediaCategory}; +pub use registry::{MediaTypeDescriptor, MediaTypeRegistry}; + +/// Media type identifier, can be either built-in or custom +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MediaType { + /// Built-in media type (backward compatible) + Builtin(BuiltinMediaType), + + /// Custom media type from a plugin + Custom(String), +} + +impl MediaType { + /// Create a new custom media type + pub fn custom(id: impl Into) -> Self { + Self::Custom(id.into()) + } + + /// Get the type ID as a string + #[must_use] + pub fn id(&self) -> String { + match self { + Self::Builtin(b) => b.id().to_string(), + Self::Custom(id) => id.clone(), + } + } + + /// Get the display name for this media type + /// For custom types without a registry, returns the ID as the name + #[must_use] + pub fn name(&self) -> String { + match self { + Self::Builtin(b) => b.name(), + Self::Custom(id) => id.clone(), + } + } + + /// Get the display name for this media type with registry support + #[must_use] + pub fn name_with_registry(&self, registry: &MediaTypeRegistry) -> String { + match self { + Self::Builtin(b) => b.name(), + Self::Custom(id) => { + registry + .get(id) + .map_or_else(|| id.clone(), |d| d.name.clone()) + }, + } + } + + /// Get the category for this media type + /// For custom types without a registry, returns [`MediaCategory::Document`] + /// as default + #[must_use] + pub const fn category(&self) -> MediaCategory { + match self { + Self::Builtin(b) => b.category(), + Self::Custom(_) => MediaCategory::Document, + } + } + + /// Get the category for this media type with registry support + #[must_use] + pub fn category_with_registry( + &self, + registry: &MediaTypeRegistry, + ) -> MediaCategory { + match self { + Self::Builtin(b) => b.category(), + Self::Custom(id) => { + registry + .get(id) + .and_then(|d| d.category) + .unwrap_or(MediaCategory::Document) + }, + } + } + + /// Get the MIME type + /// For custom types without a registry, returns "application/octet-stream" + #[must_use] + pub fn mime_type(&self) -> String { + match self { + Self::Builtin(b) => b.mime_type().to_string(), + Self::Custom(_) => "application/octet-stream".to_string(), + } + } + + /// Get the MIME type with registry support + #[must_use] + pub fn mime_type_with_registry( + &self, + registry: &MediaTypeRegistry, + ) -> String { + match self { + Self::Builtin(b) => b.mime_type().to_string(), + Self::Custom(id) => { + registry + .get(id) + .and_then(|d| d.mime_types.first().cloned()) + .unwrap_or_else(|| "application/octet-stream".to_string()) + }, + } + } + + /// Get file extensions + /// For custom types without a registry, returns an empty vec + #[must_use] + pub fn extensions(&self) -> Vec { + match self { + Self::Builtin(b) => { + b.extensions() + .iter() + .map(std::string::ToString::to_string) + .collect() + }, + Self::Custom(_) => vec![], + } + } + + /// Get file extensions with registry support + #[must_use] + pub fn extensions_with_registry( + &self, + registry: &MediaTypeRegistry, + ) -> Vec { + match self { + Self::Builtin(b) => { + b.extensions() + .iter() + .map(std::string::ToString::to_string) + .collect() + }, + Self::Custom(id) => { + registry + .get(id) + .map(|d| d.extensions.clone()) + .unwrap_or_default() + }, + } + } + + /// Check if this is a RAW image format + #[must_use] + pub const fn is_raw(&self) -> bool { + match self { + Self::Builtin(b) => b.is_raw(), + Self::Custom(_) => false, + } + } + + /// Resolve a media type from file extension (built-in types only) + /// Use `from_extension_with_registry` for custom types + pub fn from_extension(ext: &str) -> Option { + BuiltinMediaType::from_extension(ext).map(Self::Builtin) + } + + /// Resolve a media type from file extension with registry (includes custom + /// types) + #[must_use] + pub fn from_extension_with_registry( + ext: &str, + registry: &MediaTypeRegistry, + ) -> Option { + // Try built-in types first + if let Some(builtin) = BuiltinMediaType::from_extension(ext) { + return Some(Self::Builtin(builtin)); + } + + // Try registered custom types + registry + .get_by_extension(ext) + .map(|desc| Self::Custom(desc.id.clone())) + } + + /// Resolve a media type from file path (built-in types only) + /// Use `from_path_with_registry` for custom types + pub fn from_path(path: &Path) -> Option { + path + .extension() + .and_then(|e| e.to_str()) + .and_then(Self::from_extension) + } + + /// Resolve a media type from file path with registry (includes custom types) + #[must_use] + pub fn from_path_with_registry( + path: &Path, + registry: &MediaTypeRegistry, + ) -> Option { + path + .extension() + .and_then(|e| e.to_str()) + .and_then(|ext| Self::from_extension_with_registry(ext, registry)) + } +} + +// Implement `From` for easier conversion +impl From for MediaType { + fn from(builtin: BuiltinMediaType) -> Self { + Self::Builtin(builtin) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builtin_media_type() { + let mt = MediaType::Builtin(BuiltinMediaType::Mp3); + + assert_eq!(mt.id(), "mp3"); + assert_eq!(mt.mime_type(), "audio/mpeg"); + assert_eq!(mt.category(), MediaCategory::Audio); + } + + #[test] + fn test_custom_media_type() { + let mut registry = MediaTypeRegistry::new(); + + let descriptor = MediaTypeDescriptor { + id: "heif".to_string(), + name: "HEIF Image".to_string(), + category: Some(MediaCategory::Image), + extensions: vec!["heif".to_string()], + mime_types: vec!["image/heif".to_string()], + plugin_id: Some("heif-plugin".to_string()), + }; + + registry.register(descriptor).unwrap(); + + let mt = MediaType::custom("heif"); + assert_eq!(mt.id(), "heif"); + assert_eq!(mt.mime_type_with_registry(®istry), "image/heif"); + assert_eq!(mt.category_with_registry(®istry), MediaCategory::Image); + } + + #[test] + fn test_from_extension_builtin() { + let registry = MediaTypeRegistry::new(); + let mt = MediaType::from_extension_with_registry("mp3", ®istry); + + assert!(mt.is_some()); + assert_eq!(mt.unwrap(), MediaType::Builtin(BuiltinMediaType::Mp3)); + } + + #[test] + fn test_from_extension_custom() { + let mut registry = MediaTypeRegistry::new(); + + let descriptor = MediaTypeDescriptor { + id: "customformat".to_string(), + name: "Custom Format".to_string(), + category: Some(MediaCategory::Image), + extensions: vec!["xyz".to_string()], + mime_types: vec!["application/x-custom".to_string()], + plugin_id: Some("custom-plugin".to_string()), + }; + + registry.register(descriptor).unwrap(); + + let mt = MediaType::from_extension_with_registry("xyz", ®istry); + assert!(mt.is_some()); + assert_eq!(mt.unwrap(), MediaType::custom("customformat")); + } +} diff --git a/crates/pinakes-types/src/media_type/registry.rs b/crates/pinakes-types/src/media_type/registry.rs new file mode 100644 index 0000000..871f12c --- /dev/null +++ b/crates/pinakes-types/src/media_type/registry.rs @@ -0,0 +1,297 @@ +//! Media type registry for managing both built-in and custom media types + +use anyhow::{Result, anyhow}; +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; + +use super::MediaCategory; + +/// Descriptor for a media type (built-in or custom) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaTypeDescriptor { + /// Unique identifier + pub id: String, + + /// Display name + pub name: String, + + /// Category + pub category: Option, + + /// File extensions + pub extensions: Vec, + + /// MIME types + pub mime_types: Vec, + + /// Plugin that registered this type (None for built-in types) + pub plugin_id: Option, +} + +/// Registry for media types +#[derive(Debug, Clone)] +pub struct MediaTypeRegistry { + /// Map of media type ID to descriptor + types: FxHashMap, + + /// Map of extension to media type ID + extension_map: FxHashMap, +} + +impl MediaTypeRegistry { + /// Create a new empty registry + #[must_use] + pub fn new() -> Self { + Self { + types: FxHashMap::default(), + extension_map: FxHashMap::default(), + } + } + + /// Register a new media type + pub fn register(&mut self, descriptor: MediaTypeDescriptor) -> Result<()> { + // Check if ID is already registered + if self.types.contains_key(&descriptor.id) { + return Err(anyhow!("Media type already registered: {}", descriptor.id)); + } + + // Register extensions + for ext in &descriptor.extensions { + let ext_lower = ext.to_lowercase(); + if self.extension_map.contains_key(&ext_lower) { + // Extension already registered - this is OK, we'll use the first one + // In a more sophisticated system, we might track multiple types per + // extension + continue; + } + self.extension_map.insert(ext_lower, descriptor.id.clone()); + } + + // Register the type + self.types.insert(descriptor.id.clone(), descriptor); + + Ok(()) + } + + /// Unregister a media type + pub fn unregister(&mut self, id: &str) -> Result<()> { + let descriptor = self + .types + .remove(id) + .ok_or_else(|| anyhow!("Media type not found: {id}"))?; + + // Remove extensions + for ext in &descriptor.extensions { + let ext_lower = ext.to_lowercase(); + if self.extension_map.get(&ext_lower) == Some(&descriptor.id) { + self.extension_map.remove(&ext_lower); + } + } + + Ok(()) + } + + /// Get a media type descriptor by ID + #[must_use] + pub fn get(&self, id: &str) -> Option<&MediaTypeDescriptor> { + self.types.get(id) + } + + /// Get a media type by file extension + #[must_use] + pub fn get_by_extension(&self, ext: &str) -> Option<&MediaTypeDescriptor> { + let ext_lower = ext.to_lowercase(); + self + .extension_map + .get(&ext_lower) + .and_then(|id| self.types.get(id)) + } + + /// List all registered media types + #[must_use] + pub fn list_all(&self) -> Vec<&MediaTypeDescriptor> { + self.types.values().collect() + } + + /// List media types from a specific plugin + #[must_use] + pub fn list_by_plugin(&self, plugin_id: &str) -> Vec<&MediaTypeDescriptor> { + self + .types + .values() + .filter(|d| d.plugin_id.as_deref() == Some(plugin_id)) + .collect() + } + + /// List built-in media types (`plugin_id` is None) + #[must_use] + pub fn list_builtin(&self) -> Vec<&MediaTypeDescriptor> { + self + .types + .values() + .filter(|d| d.plugin_id.is_none()) + .collect() + } + + /// Get count of registered types + #[must_use] + pub fn count(&self) -> usize { + self.types.len() + } + + /// Check if a media type is registered + #[must_use] + pub fn contains(&self, id: &str) -> bool { + self.types.contains_key(id) + } + + /// Unregister all types from a specific plugin + pub fn unregister_plugin(&mut self, plugin_id: &str) -> Result { + let type_ids: Vec = self + .types + .values() + .filter(|d| d.plugin_id.as_deref() == Some(plugin_id)) + .map(|d| d.id.clone()) + .collect(); + + let count = type_ids.len(); + + for id in type_ids { + self.unregister(&id)?; + } + + Ok(count) + } +} + +impl Default for MediaTypeRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_descriptor(id: &str, ext: &str) -> MediaTypeDescriptor { + MediaTypeDescriptor { + id: id.to_string(), + name: format!("{id} Type"), + category: Some(MediaCategory::Document), + extensions: vec![ext.to_string()], + mime_types: vec![format!("application/{}", id)], + plugin_id: Some("test-plugin".to_string()), + } + } + + #[test] + fn test_register_and_get() { + let mut registry = MediaTypeRegistry::new(); + let descriptor = create_test_descriptor("test", "tst"); + + registry.register(descriptor).unwrap(); + + let retrieved = registry.get("test").unwrap(); + assert_eq!(retrieved.id, "test"); + assert_eq!(retrieved.name, "test Type"); + } + + #[test] + fn test_register_duplicate() { + let mut registry = MediaTypeRegistry::new(); + let descriptor = create_test_descriptor("test", "tst"); + + registry.register(descriptor.clone()).unwrap(); + let result = registry.register(descriptor); + + assert!(result.is_err()); + } + + #[test] + fn test_get_by_extension() { + let mut registry = MediaTypeRegistry::new(); + let descriptor = create_test_descriptor("test", "tst"); + + registry.register(descriptor).unwrap(); + + let retrieved = registry.get_by_extension("tst").unwrap(); + assert_eq!(retrieved.id, "test"); + + // Test case insensitivity + let retrieved = registry.get_by_extension("TST").unwrap(); + assert_eq!(retrieved.id, "test"); + } + + #[test] + fn test_unregister() { + let mut registry = MediaTypeRegistry::new(); + let descriptor = create_test_descriptor("test", "tst"); + + registry.register(descriptor).unwrap(); + assert!(registry.contains("test")); + + registry.unregister("test").unwrap(); + assert!(!registry.contains("test")); + + // Extension should also be removed + assert!(registry.get_by_extension("tst").is_none()); + } + + #[test] + fn test_list_by_plugin() { + let mut registry = MediaTypeRegistry::new(); + + let desc1 = MediaTypeDescriptor { + id: "type1".to_string(), + name: "Type 1".to_string(), + category: Some(MediaCategory::Document), + extensions: vec!["t1".to_string()], + mime_types: vec!["application/type1".to_string()], + plugin_id: Some("plugin1".to_string()), + }; + + let desc2 = MediaTypeDescriptor { + id: "type2".to_string(), + name: "Type 2".to_string(), + category: Some(MediaCategory::Document), + extensions: vec!["t2".to_string()], + mime_types: vec!["application/type2".to_string()], + plugin_id: Some("plugin2".to_string()), + }; + + registry.register(desc1).unwrap(); + registry.register(desc2).unwrap(); + + let plugin1_types = registry.list_by_plugin("plugin1"); + assert_eq!(plugin1_types.len(), 1); + assert_eq!(plugin1_types[0].id, "type1"); + + let plugin2_types = registry.list_by_plugin("plugin2"); + assert_eq!(plugin2_types.len(), 1); + assert_eq!(plugin2_types[0].id, "type2"); + } + + #[test] + fn test_unregister_plugin() { + let mut registry = MediaTypeRegistry::new(); + + for i in 1..=3 { + let desc = MediaTypeDescriptor { + id: format!("type{i}"), + name: format!("Type {i}"), + category: Some(MediaCategory::Document), + extensions: vec![format!("t{}", i)], + mime_types: vec![format!("application/type{}", i)], + plugin_id: Some("test-plugin".to_string()), + }; + registry.register(desc).unwrap(); + } + + assert_eq!(registry.count(), 3); + + let removed = registry.unregister_plugin("test-plugin").unwrap(); + assert_eq!(removed, 3); + assert_eq!(registry.count(), 0); + } +} diff --git a/crates/pinakes-types/src/model.rs b/crates/pinakes-types/src/model.rs new file mode 100644 index 0000000..f2f2863 --- /dev/null +++ b/crates/pinakes-types/src/model.rs @@ -0,0 +1,688 @@ +use std::{fmt, path::PathBuf}; + +use chrono::{DateTime, Utc}; +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::media_type::MediaType; + +/// Unique identifier for a user account. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct UserId(pub Uuid); + +impl UserId { + #[must_use] + pub fn new() -> Self { + Self(Uuid::now_v7()) + } +} + +impl Default for UserId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for UserId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for UserId { + fn from(id: Uuid) -> Self { + Self(id) + } +} + +/// Unique identifier for a media item. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct MediaId(pub Uuid); + +impl MediaId { + /// Creates a new media ID using `UUIDv7`. + #[must_use] + pub fn new() -> Self { + Self(Uuid::now_v7()) + } +} + +impl fmt::Display for MediaId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Default for MediaId { + fn default() -> Self { + Self(uuid::Uuid::nil()) + } +} + +/// BLAKE3 content hash for deduplication. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ContentHash(pub String); + +impl ContentHash { + /// Creates a new content hash from a hex string. + #[must_use] + pub const fn new(hex: String) -> Self { + Self(hex) + } +} + +impl fmt::Display for ContentHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Storage mode for media items +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, +)] +#[serde(rename_all = "lowercase")] +pub enum StorageMode { + /// File exists on disk, referenced by path + #[default] + External, + /// File is stored in managed content-addressable storage + Managed, +} + +impl fmt::Display for StorageMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::External => write!(f, "external"), + Self::Managed => write!(f, "managed"), + } + } +} + +impl std::str::FromStr for StorageMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "external" => Ok(Self::External), + "managed" => Ok(Self::Managed), + _ => Err(format!("unknown storage mode: {s}")), + } + } +} + +/// A blob stored in managed storage (content-addressable) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManagedBlob { + pub content_hash: ContentHash, + pub file_size: u64, + pub mime_type: String, + pub reference_count: u32, + pub stored_at: DateTime, + pub last_verified: Option>, +} + +/// Result of uploading a file to managed storage +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadResult { + pub media_id: MediaId, + pub content_hash: ContentHash, + pub was_duplicate: bool, + pub file_size: u64, +} + +/// Statistics about managed storage +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ManagedStorageStats { + pub total_blobs: u64, + pub total_size_bytes: u64, + pub unique_size_bytes: u64, + pub deduplication_ratio: f64, + pub managed_media_count: u64, + pub orphaned_blobs: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaItem { + pub id: MediaId, + pub path: PathBuf, + pub file_name: String, + pub media_type: MediaType, + pub content_hash: ContentHash, + pub file_size: u64, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + pub thumbnail_path: Option, + pub custom_fields: FxHashMap, + /// File modification time (Unix timestamp in seconds), used for incremental + /// scanning + pub file_mtime: Option, + + // Photo-specific metadata + pub date_taken: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub rating: Option, + pub perceptual_hash: Option, + + // Managed storage fields + /// How the file is stored (external on disk or managed in + /// content-addressable storage) + #[serde(default)] + pub storage_mode: StorageMode, + /// Original filename for uploaded files (preserved separately from + /// `file_name`) + pub original_filename: Option, + /// When the file was uploaded to managed storage + pub uploaded_at: Option>, + /// Storage key for looking up the blob (usually same as `content_hash`) + pub storage_key: Option, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// Soft delete timestamp. If set, the item is in the trash. + pub deleted_at: Option>, + + /// When markdown links were last extracted from this file. + pub links_extracted_at: Option>, +} + +/// A custom field attached to a media item. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomField { + pub field_type: CustomFieldType, + pub value: String, +} + +/// Type of custom field value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CustomFieldType { + Text, + Number, + Date, + Boolean, +} + +impl CustomFieldType { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Text => "text", + Self::Number => "number", + Self::Date => "date", + Self::Boolean => "boolean", + } + } +} + +impl std::fmt::Display for CustomFieldType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A tag that can be applied to media items. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tag { + pub id: Uuid, + pub name: String, + pub parent_id: Option, + pub created_at: DateTime, +} + +/// A collection of media items. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Collection { + pub id: Uuid, + pub name: String, + pub description: Option, + pub kind: CollectionKind, + pub filter_query: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Kind of collection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CollectionKind { + Manual, + Virtual, +} + +impl CollectionKind { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Manual => "manual", + Self::Virtual => "virtual", + } + } +} + +impl std::fmt::Display for CollectionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A member of a collection with position tracking. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectionMember { + pub collection_id: Uuid, + pub media_id: MediaId, + pub position: i32, + pub added_at: DateTime, +} + +/// An audit trail entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEntry { + pub id: Uuid, + pub media_id: Option, + pub action: AuditAction, + pub details: Option, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + // Media actions + Imported, + Updated, + Deleted, + Tagged, + Untagged, + AddedToCollection, + RemovedFromCollection, + Opened, + Scanned, + + // Authentication actions + LoginSuccess, + LoginFailed, + Logout, + SessionExpired, + + // Authorization actions + PermissionDenied, + RoleChanged, + LibraryAccessGranted, + LibraryAccessRevoked, + + // User management + UserCreated, + UserUpdated, + UserDeleted, + + // Plugin actions + PluginInstalled, + PluginUninstalled, + PluginEnabled, + PluginDisabled, + + // Configuration actions + ConfigChanged, + RootDirectoryAdded, + RootDirectoryRemoved, + + // Social/Sharing actions + ShareLinkCreated, + ShareLinkAccessed, + + // System actions + DatabaseVacuumed, + DatabaseCleared, + ExportCompleted, + IntegrityCheckCompleted, +} + +impl fmt::Display for AuditAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + // Media actions + Self::Imported => "imported", + Self::Updated => "updated", + Self::Deleted => "deleted", + Self::Tagged => "tagged", + Self::Untagged => "untagged", + Self::AddedToCollection => "added_to_collection", + Self::RemovedFromCollection => "removed_from_collection", + Self::Opened => "opened", + Self::Scanned => "scanned", + + // Authentication actions + Self::LoginSuccess => "login_success", + Self::LoginFailed => "login_failed", + Self::Logout => "logout", + Self::SessionExpired => "session_expired", + + // Authorization actions + Self::PermissionDenied => "permission_denied", + Self::RoleChanged => "role_changed", + Self::LibraryAccessGranted => "library_access_granted", + Self::LibraryAccessRevoked => "library_access_revoked", + + // User management + Self::UserCreated => "user_created", + Self::UserUpdated => "user_updated", + Self::UserDeleted => "user_deleted", + + // Plugin actions + Self::PluginInstalled => "plugin_installed", + Self::PluginUninstalled => "plugin_uninstalled", + Self::PluginEnabled => "plugin_enabled", + Self::PluginDisabled => "plugin_disabled", + + // Configuration actions + Self::ConfigChanged => "config_changed", + Self::RootDirectoryAdded => "root_directory_added", + Self::RootDirectoryRemoved => "root_directory_removed", + + // Social/Sharing actions + Self::ShareLinkCreated => "share_link_created", + Self::ShareLinkAccessed => "share_link_accessed", + + // System actions + Self::DatabaseVacuumed => "database_vacuumed", + Self::DatabaseCleared => "database_cleared", + Self::ExportCompleted => "export_completed", + Self::IntegrityCheckCompleted => "integrity_check_completed", + }; + write!(f, "{s}") + } +} + +/// Pagination parameters for list queries. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pagination { + pub offset: u64, + pub limit: u64, + pub sort: Option, +} + +impl Pagination { + /// Creates a new pagination instance. + #[must_use] + pub const fn new(offset: u64, limit: u64, sort: Option) -> Self { + Self { + offset, + limit, + sort, + } + } +} + +impl Default for Pagination { + fn default() -> Self { + Self { + offset: 0, + limit: 50, + sort: None, + } + } +} + +/// A saved search query. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedSearch { + pub id: Uuid, + pub name: String, + pub query: String, + pub sort_order: Option, + pub created_at: DateTime, +} + +// Book Management Types + +/// Metadata for book-type media. +/// +/// Used both as a DB record (with populated `media_id`, `created_at`, +/// `updated_at`) and as an extraction result (with placeholder values for +/// those fields when the record has not yet been persisted). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BookMetadata { + pub media_id: MediaId, + pub isbn: Option, + pub isbn13: Option, + pub publisher: Option, + pub language: Option, + pub page_count: Option, + pub publication_date: Option, + pub series_name: Option, + pub series_index: Option, + pub format: Option, + pub authors: Vec, + pub identifiers: FxHashMap>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Default for BookMetadata { + fn default() -> Self { + let now = Utc::now(); + Self { + media_id: MediaId(uuid::Uuid::nil()), + isbn: None, + isbn13: None, + publisher: None, + language: None, + page_count: None, + publication_date: None, + series_name: None, + series_index: None, + format: None, + authors: Vec::new(), + identifiers: FxHashMap::default(), + created_at: now, + updated_at: now, + } + } +} + +/// Information about a book author. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthorInfo { + pub name: String, + pub role: String, + pub file_as: Option, + pub position: i32, +} + +impl AuthorInfo { + /// Creates a new author with the given name. + #[must_use] + pub fn new(name: String) -> Self { + Self { + name, + role: "author".to_string(), + file_as: None, + position: 0, + } + } + + /// Sets the author's role. + #[must_use] + pub fn with_role(mut self, role: String) -> Self { + self.role = role; + self + } + + #[must_use] + pub fn with_file_as(mut self, file_as: String) -> Self { + self.file_as = Some(file_as); + self + } + + #[must_use] + pub const fn with_position(mut self, position: i32) -> Self { + self.position = position; + self + } +} + +/// Reading progress for a book. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReadingProgress { + pub media_id: MediaId, + pub user_id: Uuid, + pub current_page: i32, + pub total_pages: Option, + pub progress_percent: f64, + pub last_read_at: DateTime, +} + +impl ReadingProgress { + /// Creates a new reading progress entry. + #[must_use] + pub fn new( + media_id: MediaId, + user_id: Uuid, + current_page: i32, + total_pages: Option, + ) -> Self { + let progress_percent = total_pages.map_or(0.0, |total| { + if total > 0 { + (f64::from(current_page) / f64::from(total) * 100.0).min(100.0) + } else { + 0.0 + } + }); + + Self { + media_id, + user_id, + current_page, + total_pages, + progress_percent, + last_read_at: Utc::now(), + } + } +} + +/// Reading status for a book. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReadingStatus { + ToRead, + Reading, + Completed, + Abandoned, +} + +impl fmt::Display for ReadingStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ToRead => write!(f, "to_read"), + Self::Reading => write!(f, "reading"), + Self::Completed => write!(f, "completed"), + Self::Abandoned => write!(f, "abandoned"), + } + } +} + +/// Type of markdown link +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LinkType { + /// Wikilink: [[target]] or [[target|display]] + Wikilink, + /// Markdown link: [text](path) + MarkdownLink, + /// Embed: ![[target]] + Embed, +} + +impl fmt::Display for LinkType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Wikilink => write!(f, "wikilink"), + Self::MarkdownLink => write!(f, "markdown_link"), + Self::Embed => write!(f, "embed"), + } + } +} + +impl std::str::FromStr for LinkType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "wikilink" => Ok(Self::Wikilink), + "markdown_link" => Ok(Self::MarkdownLink), + "embed" => Ok(Self::Embed), + _ => Err(format!("unknown link type: {s}")), + } + } +} + +/// A markdown link extracted from a file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarkdownLink { + pub id: Uuid, + pub source_media_id: MediaId, + /// Raw link target as written in the source (wikilink name or path) + pub target_path: String, + /// Resolved target `media_id` (None if unresolved) + pub target_media_id: Option, + pub link_type: LinkType, + /// Display text for the link + pub link_text: Option, + /// Line number in source file (1-indexed) + pub line_number: Option, + /// Surrounding text for backlink preview + pub context: Option, + pub created_at: DateTime, +} + +/// Information about a backlink (incoming link). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacklinkInfo { + pub link_id: Uuid, + pub source_id: MediaId, + pub source_title: Option, + pub source_path: String, + pub link_text: Option, + pub line_number: Option, + pub context: Option, + pub link_type: LinkType, +} + +/// Graph data for visualization. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GraphData { + pub nodes: Vec, + pub edges: Vec, +} + +/// A node in the graph visualization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphNode { + pub id: String, + pub label: String, + pub title: Option, + pub media_type: String, + /// Number of outgoing links from this node + pub link_count: u32, + /// Number of incoming links to this node + pub backlink_count: u32, +} + +/// An edge (link) in the graph visualization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphEdge { + pub source: String, + pub target: String, + pub link_type: LinkType, +} From 011e8edb2828124dcd4e9c6cc4befcec518f6ded Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 May 2026 21:52:08 +0300 Subject: [PATCH 17/22] pinakes-core: remove extracted modules; trim to storage/scan/scheduler domain Signed-off-by: NotAShelf Change-Id: Ibdce07d2626c1a9541eeed26a17716b46a6a6964 --- Cargo.lock | Bin 244939 -> 256995 bytes crates/pinakes-core/Cargo.toml | 26 +- crates/pinakes-core/src/config.rs | 1669 +--------- crates/pinakes-core/src/enrichment/books.rs | 269 -- .../src/enrichment/googlebooks.rs | 295 -- crates/pinakes-core/src/enrichment/lastfm.rs | 116 - crates/pinakes-core/src/enrichment/mod.rs | 79 - .../src/enrichment/musicbrainz.rs | 148 - .../src/enrichment/openlibrary.rs | 308 -- crates/pinakes-core/src/enrichment/tmdb.rs | 125 - crates/pinakes-core/src/error.rs | 149 +- crates/pinakes-core/src/import.rs | 5 +- crates/pinakes-core/src/lib.rs | 5 +- crates/pinakes-core/src/metadata/audio.rs | 91 - crates/pinakes-core/src/metadata/document.rs | 372 --- crates/pinakes-core/src/metadata/image.rs | 299 -- crates/pinakes-core/src/metadata/markdown.rs | 45 - crates/pinakes-core/src/metadata/mod.rs | 70 - crates/pinakes-core/src/metadata/video.rs | 128 - crates/pinakes-core/src/model.rs | 659 ---- crates/pinakes-core/src/plugin/loader.rs | 432 --- crates/pinakes-core/src/plugin/mod.rs | 931 +----- crates/pinakes-core/src/plugin/pipeline.rs | 25 +- crates/pinakes-core/src/plugin/registry.rs | 309 -- crates/pinakes-core/src/plugin/rpc.rs | 240 -- crates/pinakes-core/src/plugin/runtime.rs | 925 ------ crates/pinakes-core/src/plugin/security.rs | 473 --- crates/pinakes-core/src/plugin/signature.rs | 252 -- crates/pinakes-core/src/scheduler.rs | 119 +- crates/pinakes-core/src/storage/migrations.rs | 14 +- crates/pinakes-core/src/storage/mod.rs | 64 +- crates/pinakes-core/src/storage/postgres.rs | 648 ++-- crates/pinakes-core/src/storage/sqlite.rs | 2754 ++++++++++------- crates/pinakes-core/src/sync/chunked.rs | 325 -- crates/pinakes-core/src/sync/conflict.rs | 147 - crates/pinakes-core/src/sync/mod.rs | 9 +- crates/pinakes-core/src/sync/models.rs | 384 --- crates/pinakes-core/src/sync/protocol.rs | 8 +- crates/pinakes-core/src/upload.rs | 4 +- crates/pinakes-core/src/users.rs | 32 +- crates/pinakes-core/tests/book_metadata.rs | 10 +- crates/pinakes-core/tests/integration.rs | 6 +- .../pinakes-core/tests/plugin_integration.rs | 7 +- crates/pinakes-migrations/Cargo.toml | 1 + crates/pinakes-migrations/src/lib.rs | 24 +- 45 files changed, 2163 insertions(+), 10838 deletions(-) delete mode 100644 crates/pinakes-core/src/enrichment/books.rs delete mode 100644 crates/pinakes-core/src/enrichment/googlebooks.rs delete mode 100644 crates/pinakes-core/src/enrichment/lastfm.rs delete mode 100644 crates/pinakes-core/src/enrichment/mod.rs delete mode 100644 crates/pinakes-core/src/enrichment/musicbrainz.rs delete mode 100644 crates/pinakes-core/src/enrichment/openlibrary.rs delete mode 100644 crates/pinakes-core/src/enrichment/tmdb.rs delete mode 100644 crates/pinakes-core/src/metadata/audio.rs delete mode 100644 crates/pinakes-core/src/metadata/document.rs delete mode 100644 crates/pinakes-core/src/metadata/image.rs delete mode 100644 crates/pinakes-core/src/metadata/markdown.rs delete mode 100644 crates/pinakes-core/src/metadata/mod.rs delete mode 100644 crates/pinakes-core/src/metadata/video.rs delete mode 100644 crates/pinakes-core/src/model.rs delete mode 100644 crates/pinakes-core/src/plugin/loader.rs delete mode 100644 crates/pinakes-core/src/plugin/registry.rs delete mode 100644 crates/pinakes-core/src/plugin/rpc.rs delete mode 100644 crates/pinakes-core/src/plugin/runtime.rs delete mode 100644 crates/pinakes-core/src/plugin/security.rs delete mode 100644 crates/pinakes-core/src/plugin/signature.rs delete mode 100644 crates/pinakes-core/src/sync/chunked.rs delete mode 100644 crates/pinakes-core/src/sync/conflict.rs delete mode 100644 crates/pinakes-core/src/sync/models.rs diff --git a/Cargo.lock b/Cargo.lock index 41cca31ebd16b5d289e13063ec8fe7feece93315..fc842dc01ec17784deeae8e53558f2ae6f62f7ca 100644 GIT binary patch delta 17339 zcmb81d3;`FnfE{UIZ3*aF0@SwP1|&pE$!JCY?GiM^0IXkMS*gkt;D8Dnxw6Tk!=*; z85Jl8ddp@DI*2UVl97i|5ehCigIG{NP?n1NI`Xm!I)kGy-}^jCp}zhxuOC11@FY3s zUatFh{jT5jyZyH}C%^FIl->VnHukLV%d@ikQQ4j6o^M5Q6s3WaXNBd-B#W}xiQ=$G zrIk8y>}RnRrn#LJxh&iu&SdI_b|5_~i2PDcZ#6rHdez0pi#d~IZ}sGkEqSWW|DllT z$~8vAdo4He(kQT;!gb>)46;0nEH{lqnfXp!@KZ-7k{1SU>?CfMNGr?i&<=yRwAZ#Y z<(oFk;r?>f`q?9YG}lg?xGwM8ym8&YV1HV6e{QCF`EDF|c^Z0tV9CsLA}^Cp&Z@W{aZGu7|39!eX~T; zWfiVtXJME+R&K{h?4)j-hhAu>MH~f@?W9TK1%V%Djx2(Z8Ks`<+Iis?cI3w8o_}n) zXmmps95bFr-%{gcJ+7?i_3{e80c zz!$@VvS)ax`qu$DG@Ng%KJwT7ebuMo^LmEz!D}WPQzma&R}9LHdG+-+uM{WFo*=i_ z9qV~QpPK)BLv}@Jk!6V$I(FdMmK%nSYsai=;SqyL)Bx#*-02BS?uSz z<5*!>l-nO@Dcg4+5)b~Z(f4m`kCs|Xt?K>%&s^$*EBD+!yFqU0Dawo1%vSBYjFnwE z@fQ`jUxb+#Sc&aMyf8`PEKQt9x-wv}nUfWc&&peo6{Lx8C%*4z4w3BYF*Qm(pz@MW z%^A6LwrSL#>)J^WN2y=L(z2~0@I%*j-NcG>E6aV)iX!6CN+YL;GB(?nQCy^U6oitE z&tkRrbJ)Nzv{Niv5B2;7ADcP&SHg>QL9;>ehz8|JR(+gT74 zuAgNbwA`^|`cB-J19KWiPFT>_*t)Lh$$PT}6Sw4pLu`8ciuMK8Qrjx$|KsYe+)F$w z4PD12+KSYYo}GG06xp7eXFfv@3Z^8TC}Pl_=Q?rfr=HJ=%R(y+O8fn- zSlPu7I4Lm*5CA-`WDz9K$o()$^Te0FlnK)Y5bQ`F*(hg23V1&{pBSu&Msd$y0L6M`W5xUM8<4M8M&$J3eo~OB~$eM*mi;-31cT_zw^M;3q0~E zOEn5xEj!t4RcfM`raGSzlhtqLilfTY{HBphgW6>ElOA(r7lTb~JIp;dNr-cq7J5Tt zi&geA!X;toSrBDK8i1*Z>w!N2bH>!2DEHN-Uy9a|=T10NRFCT{oHPgK88C4jx~`NQ zX9=Pc;`#*V5#S!jY^a~RJ`gHxHr9%4x8U1SKGiz%%!yZ zCwG*`=BJe>X5Xv-+sLu`EPl=p+RGVo-pI!F4;kvFr^M>6B4S@sLQ;lB6v^1Jd>NAC z9M>ThLYbriDZ*zgdhgOg=62|0fsDN%4;@EsI!v^xdFPld|?DSc*upW(OWQp?r12`$k8Z_;DJsqdBOS zhpFpYp_j3ZL<@1_XLjLPzzoMYkMf}KVjFA-6Nl}N^F;0afzeV93{Nay=sj!niH@II zK^%iiz+)b#1yOBjvj@*?t` zAWpqP`sJe==9O(5rkC3`T~~i%^;((uLiSrdc>n&8+f@fl1{Gn2^bA zQc_YlR*)0waTvuiwF5gW^z2*ISHEE%Ql8q}T2AeqSl%_%S|6+rbbw+W(`5F10OI02 z%|XDNae2ApIhoDSZ9C(sRw7dnMRJ@1j($;1U-d3DCaKHb5>r+Gb>^gU*YKM9xU)h! zlCisSoMm1J>Vx}k3M}aqVdXH0j1-nx9LBch62)VJf%&ojpk6OXv$WPzA=&fp_g#(`~lNmdYezzSK?2lMTWGgxGhA`Shs z%6S_nsFxfb`=c#W>jN%gz&#@QSu7|P8AUW^%@MZ!^0i&-7~rE{W`Kdqs@p!P1s;?XS>o{*LE;2Z zFZL9YlYoDOmz{#2fy-L5GpXgkMqQ`e-c<4FS!MIh%Ng8j<5zZ)D%nnm0f^$WcwUl3 zdNrZ{UM_tOd6H%vU>RFhkR*&MWyU$Ao-^jF7x=*3` zGGR&yWH_=SC-#6g_7jd?xQXX@xywNc2`09PDkgF{@@q9`oK$}MOC{j6^2?uQ-p4gD zZ_QKOAaMCzLc`0zJ^qA^AQ=-sk?Vu~tb=BOlr%w{xDEw|8-#(AgqlFKmKXffWuuSl z3Rs$4CN?v&$v}|Xz$u6i*sEWyq8~BnidJS(?po51gPcqSn6nIVx&4-4EMx-UDuntl z5-2`(OCX^zq%UY)d4cBW%mOz)wmfKdb&d0T`>?G@_1ZqaM^EjV{%?H-QE;0%! zeFS7qX5u*JEH8NmWM^L>c9KEKcL(a?@-JT*Umkb+rUv~M_4eM0WYw=Vz6> z?wnG7<&HIDTR@CTodG#Gz;;{|j_a{@8rl=kfWQZ`Y|Y~xqS%EU7i70IfQ`Bw#&Z7m zjw%1^zLt@P@BF$xzB)=)goWk%5$i&hF9>EYgd|Af3{bQJNygR52qoDC;Ryab-2gC~cBae5-(wsI$P z3h6TcD4;&{psnx^$98S{Z8nJZUlOoY5~%6LjlX62}cMTHAw z40yKdv5$EX@D|7?u%&5?wBa>Qpf*h+sF&WogQxAk6c#LPA4Wy^vqO*fH z=suvBLKhe&M?3VBFo;6E{E$|+Ub+K?t>xMGA5va)&y_s&(O<6YgiA3J&mw~cED~6l z=S7+XVHAPUybAkeR`)x6G1l6 z9mX=|#AYm=mHSXCD`K`3YZUpMgFLpV8Dox@<;2u5U~-kJci!1{)i)0%I8=~H$`U{lR+k2(+&H0LVRT@bt~;cTA6-t5rWV!k zvN1zhjmDJn*WWpw#}oXkmc#gpfc=gv5P=ebW#vHtC7TA@M#QoBCUD%sK_YR3B!!Vk z+vN-w>a}Cd*3ri==2$(?Lv=!2QABQ`cPi8K^Miu<0w!Jzeim?`JP2e2MQ1%oVdM{r z46U_fR?Mj_r*_XSKehKlHcDd_Xe&tp;I;JXu~SIq2%M0LZLo=ewE4Hp62#_I>TXH{fY;s@fF@E1qsi_bq zsO$r1R>D3I@)xp4-QNXP>jc2lh@}52A~U#w#Um*#DD5=p@Hq)nnNzd}6!CFxYb8#i z)sf67Yi%8$RnVXMy}{9A#55xKnHIfBTp^p9QcG*9W9JSpfQL}&Li1R=BB4+UQ;!8E zLpoFpl@dUBo?Y(!{tTYDyXPB0 z>Q|e^gADUTzgGUBP0Y4A?qXvV!@pQnmh-By%B(^jn=|OTVOS=5bcL_=@Ipq;6 zSJxdXt?gLI&LB2@Wdh_5+is6?9f4?(5%j< z+>Qu8$Ud9r!#~_a&Hspb*sM`8M(cOy$=;$8v3lXaN{lzAPikLKDQ+EU|6pkkOrLoE z=Dxh6r?2Rh*?!*4iBt z#Gjf7%qmropb$!YxEXRS#|2D+#zM`IX8|eA1^`OW^;jeZj`j!#jHu?*+$)s$KVnwx zlxbq$Ar$EQADmz7oGXqPr%qoaW~x^{ic(8#u+|)GTly9yjzaJfJ6V%I1qOE2v77HgU%i-zAeNC+HM1=w?6H2yNl6yVW zSyVOXmR2M}>gW(q;Umf+#GybNOH-|q<&d%@Qd2)8S`R7ud$aT8(0Q7rXoXgt?uy^3 zl`aMCtMd&+F!l)up$rB)5RfUaG!YBu#)cg9A`?#bRwYc+Z*1SO6HJ3m%Z!b@f6`O`@fM1HqA%qz6btS|}VOOQj`j zKaJr+)IqK4tdEFUrTOgYE>uubx(5VOSa?Vvc5ZuG_5_gZ0EC7f>QW~|>Y>U&9493P zXcDN^q`^wFP_xezZ6Z{=gwa|%{Y>#CquH+J)zjBM+*52*`!^f!sVzB6ylzY$&Ibqj zd*yJxt`G6Jy!;~Bh3pm)C6vc#cZ4P-5wGVPIC*Tt)Jcyig`-;p08tECfd6`sX$PHF zRy9*=dF9N`TI)LTWl_6DinC@;st*9InkK+SBSu^8$gScvQ$yds+;lVvV%WloW;UpxbE40{YJL0u~MVifR#| z2NJ>rB^s)!zYtBe{!fYT8|uR46@M_y{sE|4 zqwMA^kT(fXKcV$xLyTapth}XLIblZrN|`n^Sfb&`98HHro+0)jXhP`8-{#Dwp6aZX z$BDz0bGdj7G;O>>+#>AS9ao4iH>%UG7Kc?>+v!8+sO`y!v;%^OIsx_o6lvgdN$SW- znU?5PAo}md2q>WTTD!fAGukEv83~`Z2uYGs8+n_}wuvj0S=nK> zsEe-^Et*EyOFcDT0h@PTEB1^l*GS|5as+ULA{DS07G)S22|f;^qCm1>T4{!|;1i3e z&>7tv+DhOxdT4@Rx936Hbmq-smT=W2eWGo`M!7A?>3eM}S6w`P$4@DG=t5BrQDGzT z$CRMx?~pSZLXxQ&&HziPOf9_BEXH1Lw*v4;hQR%hpkV zB^)@U7(@h=VaThUP>lI(yNA{v(mO?uBIq27;yefwbo+WpU3i7rUYmZ8XlfkSKk!ad zcG>%l)!i1FNJ9RT6fascb_rA{DXa?dJBvYavLm7|GJT5LO(vnsK&68sOjVTD(Qe>7 zw=aqzanWhxUis%QXIlYNe%4Bu=kz%!KeORO{$OR9J z33Ni#>HEa7wflY~&NujyKb*xs)fFE#W~$qMBHpKNdrbVO{=|0m)?LP->a?$m_vpvY zR{!@W;w3fzSb8T8iY~$tdxf$>)#*W)BJdYFz=29qQa>?AAqzpLq30l3@)MtvL@7bP z*S*Tq?eI{OV&{pvk*-wO-r$=4E12<6iZ6a6tj-or3KuL3n0ffCKQfExh} zmJQ{H3P!Xs@~X|K-_f9+eML0aC!?6mVdWWjA5rex+p5m~59YM&WwECA#vjC=$DNe- z4fdqxZOr?IE5iu6s+&ua^x&roGg;#x~}t8E=c@%W0R+ zo-+2wqhnNdw>kZqT}E5E@hel*{cnp&&11izA3rqqOhm_dYI-9+?^P+aH?20PovxEIIJDjd~r_z`5oO zweQcOZ9GJ|r!ODere`yA%BJ2P*k$!QdacwGlZ~a#2QL*b4`wC3th8Ib3Ht6+0d-`u zH{Vb#WP|<#b<-opj8>bxG&-J5Tl1k|ou}NTI034GG1+6P$(ox7d)1P+#bWidBgr{K zZx6X8rN*<)|4$ISYqw|q&E5VVj(Vk<`~uCU zSKbnhdPu6#Fz#;9aZ_!LI`l81W!&hVsEfY3O z&A(GDR1cZPt(qKod@;~qoOB>~%MD*`8+}ZTaC=^7!=H~cR@F|JU_97Ro-=u6SJm-G zvw?S`k%YRAAb%7#S`6A9<|52U)VtsYX_#I@4A^^G;{qq?JyxPa?as-@6QY#6pb8nB z1p@-z+JJnZa{$5v;FC@7Rb6p(}9K@XmRMW7a( z6Co8~;`iw&?tgUFr1}dww5v+(KgDRD%%^vKSHAX#>Fihija_gODtPqw1Wigm1r=Do zo@7W_Rv>tz575S?)Thk_`66kc<;LJ73UI0wYPDogEUUdb*Vrf2!WM0~fl~qUC?NWG zZS9bu^Mi|u!VB@32i~_~1v(dCK~XKvAj<&o@pGI6{v5 z;F>PR=AckWnklR;G9H5}NDZht9;&qu@@qzgdY)4B(mF!gq(DY~fWXs$(L&q6@JzLJ zp^;aQT-!;92nj0I!W2EHGyvw+?fg^SQ z7tPr_jyHB1TWod7cnoYLRkjHIfog$TmqL!(n65lLg0dOKoxT*3QJ!jJl|)^&SSLg# z+JkT;^oO-A@?DO?3R~r1(u4PGuAV3 z^ZNQn>2lqErylvXv2c22rK{d^;Pk2eN5Di3$7nfZsAprg?!3Hr0Ap6Uf9K&%b@Q5P zzrZ+VW?dxuE7MjfKfk&=r&mKm4&|1!NzF_vEG+UMLC@ChX=o|EQ!RxjNCO4cT>$5j z8A>Qe6^|Ac&z1!!P#uUQJ?zK%bt_-J7_J7}%Uojh+=IrTNivE^LQZo92q-_k}0w_XR_g{Sf6ywbL<7mau<)&ob@D;>H))70aq8t+$&Pc!bS9=w9uD|L7dhCQ+e z`a@NFBA9wo_nA4P^vKYB(2#*(ifTX%c){jC-K*Ah7)#1muWPQU(~XlktLp5n=wB08 z89#4S_qTIcaf(=$gQDip|Drd8cL%(pyMU*SmNzwROs;ZqMgX)fW2R>Ytf4;8?8bwu z&4T5{dmGe`rx=rJ*M8Vo)G%Xxa!?krmPSk2;$17d$$qpQwY?I%7WKUgB2$C`GO6yi zffPhVPF7DZ>3i3x=sU*YnJZU>S${Vp7I5#}^)6uB(2Q3y6b2>X9)jBgm^1?tsKF<0$MjfPryj`2W) zYCDya5$m>sOti@t3m_H`Ha|i1p%({b(YmO96>=y24PJ@1O8{#RCM6Noj=E$4o5Vx! z)AiZ8>kYTLGSV$BuRnRBT6ZD@bZRoL_LE`b6V2*^%hCG+)JGmqk5iX@T3DxZ5$RTW zh9#3)8&#M%)Lt7JOxmnL>Od>j7EJ6Vb>T+4G*$bjjHX)K-y7pZt?Sdq?1|$>!AxEJ zVKeEXC}h{8kX{d`iek7!wtvQ4)v0qWk`6%_ zLPcoz(L6_2(W+(aali=zmI_s!RvcwEoQ9e%MBmh27+*_|YUv5)B=yEE#$%{ybhlT~ zbEf^RJ+~od5&nq_C!~>2Zx=AsYAf9pydQYSaXP_sP+%Y{P$zm%-hl&!VHzh?Zqc}Ts4NqYO!4Gv%1weGu&=8440Y2P-^Z!Y&gxw;bz5B&mQqVl`3 zTtJSNxByOor)u;f(|W9=1DMd&h7JN+h%g+wLdmsamKyk1ohK3;G8>LTm=1D2(oMk63k=C{t;(xgSpd|}e`Gw} zpv$}3J&zcrE`w~f=W%1Yl0P;2tDm0)WKyKFl>%z=79(y zUJBgi4(Zd%aZcq$NC(5Av-Er8u-YG=GcGdKiYJU!U9fsW1E+?zdEh*xTZsgZWs#lN z&2;T8qrZit22}{}Gk_eVL@fpmP{pe!%^#mK+2kmwuXj?>cK^y~FL%5!T}?NPL(9wO zEh)cn|Ge6xzcPL>Mg8UoOqdV6VXRO$zHS_@Zu)}`XiUi1MLBJ31cqH~f;f;ff}Cm) z(m`n%LvO$UT$qW=-s8-L;h=(|x2xA?QvE%*TuiK;_l9xlgxa3BjOQDa@dBw%8)Iw? zX%tE*S+?$x;E;mJgUeinz>j4EqY*}A-F2p2f-prg=Re$kv4eW+kr+EvefBLeqjpY% zxkl8V9B}If7YD?PW*H!hy9m}!nBo(HH)pv&??xmx4@&hU|>75>vw#%ZOVo<_}%H@K+nB425j_Z%!)^5&LzgS?d zR^Odx?qmFWI*iqwz+MiwCLKWRVZ{&y+}T7w(}Hp9hP3t>(2_x_N$DpgNPTA!q5jU(-(F%qwbDFTvqq~H> z1*XC@>2i}WzyhunLW)tWhzqvT6lY4q5fU67v|?&MQmIA#vb< zRv%vLV6Tq!&1tn;k1+2Vm6tlPL2#Qv`@nQ_iK~-%VTnPY9o9$+!!`p+i%7cEsG7`D zchidkPHC26@|nOs5r4JGM|0c{eqq-!<`tteLq5{hHLe_S4T#x95+@5Z8xe)uh?3HW z5|#kbA`9{g{RZ8Uv}}yy+HtHi{8$}0PF>nzzBKw=LW!)zieq4vqCVs9qtvO(%riikeIbFoZkah{^1HA)g3687 zE`)?O7CZ#G0%bzGA7`sGg9D*Q3IMqIRo0a9j>l2y=?fTK)}iAHQ77j?`v`jNI>qvY z`wFh1?|)#xt=}-D?cJ)bJ;I!-rXFcdP#^Zq73#+>pTgVO^wQ;pdmTH;-4piOk;72c!Iu-AELg za!{-41;8Dvo6R1agc1s)vSx(D4h?;7bZ~PDlNXoQ^kEpg%Fw03@=*2Gk!I812<=WX z7_KKy1viVJ+7g*cH-EA0$LvDEH6{)a`fvzUhAu}WJ1obzuy8bawz9Y{Ro&^jeCr`< z4w8ET9B`dyRuIX|h z7239lYnp_{1ks4t8zf|WJ_!^An8vRkK}sME6g-$Xk%6%R;6||^{P^qit*{e$AQ^V9 zTGGjqR;dR})27G_Z@;$B;sM4Ik>JsQ+*0BCIva7}8Dlk{q3{UPm^x6bBy~p09UU@x z?YT~Kw$`NotJ_>e;;yy7&-~Er`eJobWuVVt-O{ZP?WzJ|Xayq)a*cxun&vPA*4MmX zpc-ijKu#$n9u+WFH`PWrsBJ@YIj4ULcciL~N)O(n(PKWN+N;{Qb>?Hs)Ni{GZU`~% zi4dzC7N8${7wvR1D9oSc1QEl8^+k2s8LR>)lZsvwV?=sD2ZG(yQp0TFiOT5MMVo+Q ztH*R#SEkA31=vyWrU8(OGo7M!XFS-plNjH&o_X``pH=g6Vxm&OG)Po5v+kEbg<^^N(>Dy`kXrke5tlz07 zl4w$rHPQ7QsvVkqR7JR85v90Vgpm#38CP8CiZFLnRqYq!2qk12uFKY4NXOo0{#O5g zHrSSS1lN||Zhn92VU^Q*IR7g>-9b;cO)ipgqn#pOTZ^fi&>mn3G#{zj2_kNd(_%q+ i(B@{+lb#;*g5!ck(Z*1#UURW@ZFzBt`pdV>NB$coAap?h delta 12257 zcmZvid7K_qmG?hY)k$Yh8j|kp>104EOaS~EKYr;3hjrHDs>k7g^1HCjg!0(eicS(;U}4& zX+JhyXSm&F&#PYl;Khfm8&+?d|6_CX$L(82VV0z-%oCjzQWk!Z#=Lo2RC$;ODk-ue zNQDTaN|$kz7iGeOlf0_(Oy+^CZ#|*Ap0Hq;`P84?S;N(e4J+ju^U`id_4}dlqp%p6htaeI#UHN%0q%UPl7@hWxxl*T&g6JX3fvto_fxgd+UW_iWAo2 z?Ir!}B~D{rA=YK4a*iwuWE7*S(+xPpBA|el1#{)XUdS1;t;Y#iu%@@C%fy) zT8^Dor@?#mKODNKFG{kqN{cvEVWm=b#@3TE3)55=iWdq4UnhPkqFiu9q7b6sf+`(V zejo!QZ*qF--QfxKzq>OB506%PgJ)$o40C)l;e4-WaAM!^@#f0kI1Brf$U|AhDoHc0 zDoGQmiZqClL~`!J527-Wi7fIolcI{UNENxSvRLPlABoT$@gl43IDVx1&Q{2^D5VE>tRHl_Xgb`I%B$CiUzecMonq@oS#r4=!19 zj>BI&PI{}Gx4!--?%aCX$`j0#SDo>7dhUHgKO3C3axx#YzwfPoyL^rln1`?QRc5bRR;nyim1astIg6D!SDWTVq%#>1 zta(%hL6z{Qapn?>cHw_t_wn|EiAqRQB{|eUgfdon91@HrCrwZ)q9Y6aJkA7Jm4;EC zrdg3BRge~1h^)S4OMAVo8%Ni-U36GIZ$nRO-31v(6f4qAia^CNdFE@sie*L2k(n_E zkojp4su;x3QORj0RT^0k$>RFb8I#SPo7@REKIx6FJI`C(KGrNIo(nRDYY-{n%j${p zvrHtFAInfgt>_8-oG2v`I06ypxnD>vK^r6g+U>q)<6OrP^}O@1X|GvEVOU9-rMyoW zD~>iw@-m8KkN{5+;8Dhz4RXn4`-;%c1P7J~F46b3xqHy*xpA}C-MXerKmYaisxiOi zOhwM^^2sRDG8IASr(`{cQkFuiB*-{UUz9}{t27R)C{6f}FLPCQeR@iL*M%eNXD^s+ z7hJMH+Y89jB#!N=`K1c|3M31(P*uUVE3GQ-DXKE|t$`z5YMz-FX-alG%iYQK)fXPw zez_Uu^6EJNUBJuC0Q)8QWktQEfTHsN6+?UgGb2*)TQDOo@#h0K5hU-M>h2~dC@igf=uUeXTBe#yNMMOB&0#IJm!CrTn+mWe83Dbgwl!pcHTKzxz(d9Gs6he#56 zE(BHL=Jwus)%qJ>IHUEJ16iC$g-+OL#-cI{@}Lr#;1H51&Ph#`hha%DfNMbzfcwOL zNgaw}o$~Oy>z{iDzjf&;;O*caF0W>q6CZIG_EArGQ`VF^O+`$w#AO2RR%IypRUUKy zl!K(=#I?#3>T)3>Fh5f|3`|!JqOZBFb8yVJcQ~yT`!%TsT8IKn)kJzIVm|~+0FlzC zwrH|D6_vJlq;(P}L_i3Ti6|(FDr%L;HMjk4XfdKJ<)*m^k&76_q0$lpx_|^^F_$B3 z-WM@X)nzJ)*C^1ktm1&`Ue)r{?t0Aa$F}!cQC&;UA`()@QIG*TRm_3yw%dCt<9)*5T zKm1g8z3Gm>8hTO6JjJ!3fcY|xwMfG_=9s|`KZCfWIpvRINTVR8_ySf$925m&#@BN5 z?v22QJ=A;uadKC+>_rj0KG#6%WmNyLpkS8)*#ohKorSing($Kxw*sboO9@?-Z|&3GjhP2To3FD>!lAI-`dCm4dg0f zwuGq%B8bA2OyXcF!mfa@!HOu2M0B2)xgWr$AvK@~a2cz5_HQP)LVWO)2i4F!6JnO@ zz;YoG8H|h54JyteCWvxT0Fa5VDX9V)LqB+)XN^ap?%D3m6{xd zU_(;IRC!yqD|VT|z*?v4t5T*R5e?7>5eJZEen7keaD{Fmnmy+4Z11xRE`nbLkf+KI zGaaIM_)E$#t-!=8E|Q{BbrJa-+s}u zpgvWS+%1zdRVi2~b4q>?WE5`g%L46?a@0i#i~y6dPu3GTBCbN2o3$NIx6PP|gTLRo za_A&cTS)+Gj;W||PRwV}9RwMW9C5}(n_mW$M;KPXv4R(tk@#7uVqJg{uxBu$r{4O5 zW9qZ-8eebx{)G0+qHApOhH$h%f{n2FjQkQ2qE3^=8pNSgW?9bm$wZ_^3Hzc>!cN2B z{Zrlb+>H9#`$yLD(YfH;b&uZOUcQ1fOC_j}%J+dEiZAFTg`Xgq1O;C6LX4s}crjn`=+h+@$ z00IkDA_+p+UP5J2a2yzbY>`9irA3PAq})sGRkIMhQDm>AyvihiRB|~``l=wnR9tfN za3s_(lvxNumpt%&$@dB_&>Zn=s_@-UezCn;91@wZShz5Uhz13ODH>!$hPtevGXR&639033eBm| zUy#EhLRyfJQHIx*8ZOtyneJy!<%-rk(*oUv{jf$Ub3~6-8$v7D*By zvw>)+0`-`4r+E2!J6qc~{#t$7qk$s^Uw(dtC1JP^Rok;uFlrm48R>qXrd1~Qi#z?g+)g2C(e~9fd?XqU-&>) zh~^8qI>3bYmV8a=xL$Ndck{Jr&aN>$=ZhD)f$1ITOlt1to^I&khfJ!jO$a^3nBt5@|d=$#|td8X?ecX2=bjuI>hBk5<<#1v&?iwvg@3m~Pz zzyxbTkY|YwV$K8318|~DuyMfG?*6v@vA$Im*2z`HGQE0T+3U|s=0*E|_W9N8m#_K+>a2R|H&s6chEhpY%t9nHTA!>gfqBFl zxExeOvVe8*G(sCmZS@YvLyaYLLt#VEEPK!!X|{aZ9c4b+;r2A&S>>EQiHCMx?(}rD z<5H)PK-`L$Q$|MV#3Ec3mwC|grvxMuHwPCc5EM%r9~zl~IFzsp(C}tP?L6+91Du?Zqr zj)2x^d^8^pj=`2t&qym?01c6+8lGFWc+mAnj%(if8|SyKdFXGQUBvUXH#xU)Nb$|i zw}+X3`g`ZMt&=(&Sy&yWM9sl4^3Et>2AGzIWt0(M0U=Z|2bm5^@WEnfc9ti|yQ)snDhTujAlo(1C zp$91XC>m@U5~#4{V6u7ZcIUp9jKW6UoM zGr%@L0Bbl+io^}2U*g5Wvy|2JJ!bbk2$M-)nbEvE=$tdc?0V1wKteo81*M2m98c+^ z;R=G7v&aCF8$DD6txtUE*IVUuCKJ9c2GZ*Y~p5%HzzS}v&u>m#ReD8&|dAfV>z7NhY&rf%IoXB+9t7v_G^bGe! z`!eHaT0jTOg+FiK8?8ORT))BoD4ZA6M{J$AZ^`#x;^~?0wAR{h%yjQFr}VlToDY0( z{=UafH~uWL<`c8sgH1fgy~VsV$Nd!dXSX_XXscDR{|SefcaC*Ox4zVD{R?-{2G!oz zyoqM%huj6twhz1C?=V-dz}ts);#GuL>?K?j91Tbjd<^&-CIA#zgwzT+PiXxjR6hQY zM6?Kvt5`&qJ@=TU|K@f#^XIxdoch|IF6@J2S|&za6r#+q&x5Mu<0+gg!=5WC2-qIz zwHVo5szP9sg;+OKOcj=9;8=G~bJKix``G5Fe)oCD{NQyD-b5k8n#fTK5(gm7c}w~x z2p)`=vI;T2{8ECRcmP&lV1r`+B~gyW3jJ$g=eCo^5pGW$;U4Wcq1lG}*Kciz%C~=j z_=rWiE1)LmMy-V2WEhq>3fQ&SoeF-zGcapIi_nM)`UR*->-uiGXta6yNVgnXw~u;* zD@)&`to(vEflsBdS!_PQAwj_Am>~jHL2CiS$;!RFC{#?nRCu3Xy&MR#tr*jDw7b38 zc8u$FIHLK#$GTVJu{K8>=N{E*w)NY#0ga3V(^7;*g;fCi!YE7;uLx=@RWLqd#p%nU z$Wg_jgvsL|Vr2X1x%$l6J+v>}TBG=8n_cA6g1==`#2B z4#IKG3Em8I$1)P`_`H*J;VOffMQy5s*IKan+Ss;7yh)d4Y8aWuR81#e(eFAA2m^ouAkGyA%yQsZnie`n> zDJUBq2y`552tGjvO4qsXspcfdzW>lV8 z}DL0{IMerl^4Z;fflxN6z_91;m z&jhrlP4UBwhfP1l=xZR-E!w$~uh=jL`%wRa(hJRV6okl_2JV9Dg!Jg;aE?e(hMEzxq9Q ziaG5|u5aoup)#+zs^`Y5-HztP%iTvh%=MRh3kO2Piyzt|jV(bN5cZQ}1`@Oab<1tz zia|7~M!pqyFc%6t5gO7Vq#06J)3>$ek(=EIZGrjoHEuYTN7I`+*ev)4{BqMb-3iUQ zZ@8ZyYsMWyTL~HpNg`EHj2NPES>hnKIW+D8r-$CB$4A%C_Q^<9>}M)E4mLh&nzqE= zz80r>?sj*0^Wb;fPG@RSF1tXkJokU-34dNb59V^~VeZm?%t-jJz!O#Q5L&!3mM2^X z5sR(~GK;~!0B-1X(Bn`c9}0DVV}h(xEJ8cWT;owzuiN5I8IRMnYCV}T_uLhV=Nd8E zo7U{u;?5k^Y`@nXa7TYYOqj3V>qst@js_AK>`yJdfj$@Tal8R6GtQQ6a*238&^-h= z?jXVjph7~Zun;z*I)^k(J^jlcYW}lv=ZtPX`l$Q$5pBlaY9538tVCb4NRb8BaX^wv ziT4AS!=vL-C}@r$uP}Y^$9++h;A57yInyTLj5e3_IwQ>&e&9aUUKrtr@k?jNx9l0A zio*y10@%2ewz-7ci0zCFz&fS@h`s4!wBer7D+zKVzlSNh`XP66Gv;~seA|#EJbIdM z{K$RN#>^EzaX&SRzn_2Ay>M*1WfIU^jCn1Ly3)t!uK-X8k)W?my3re?qbk4#%p3$W zj2H;c%L07H8w*;7sd?ue_aW2!yt}wBCh58NiqNw?C2A~R#I2!KDexS4f3$(dm*G=P z2hdhluxJ?~LO>Jq?j{Patq@K98+W2(24^}82f!qW;h9mp@WBX{)N*dLsC6?1^^$%H z9yf4-n?NfGInP1yI}{EPADBfuy>5$UZ<^yGXJj+*Tlbn_aFFN*;Y~6>|AV`-MM%0j zyrumG3(-ZkJ?4s3w-A6If+1RE74Zvn<&vWUBkAc?7Uwk41?<7?>1o@PX-Dpze{g3F zJ>x)6%=67w&zob;`I9@XT?97%7ufgUpWGiid`cMm@V*>8^Ggg>F=dSI2T-&I)?!Bz8b%VNEk-<2cUb1uTD~7ol$2m#f)zkv z+}#4{f_21(NIfnD1SfAqiJ?ecg+t*8g%!>x8YpP>A9i+lquU#Jy~BI?{c{_z{Z+{o zV^rYSQ@n6R0b@Ebb`*o3#z-FKJ=hiToy7*NAtf*AO83Vi<-3C>0V9x=sto_6zBsVBx7epoLu3vtKyKgRh!bK8+Sw z`3Qir>mqkl=ZdzzHv<>DGtBQsd5Jl3gg3oeG}?P)eDk^K-r|unKL9oV8{z_P>h1@e zMg4f4To3h>R=;8%ggUKpIN=CHbd}(1QU=+bVqAnz0fRBb1P}-V1q7Mh2eI3KJlNaQ zQD62Si~H%Ukh~iE1q#ZkQ!|6Kq%95YL-2u265j=O#=5XKKfxG~Sy{pt$xwWGifIck zU+;CNG)q6~Epb}w;>K}Y%tBb(S3+W9bS+I$ZW^zNtA%CKA}OQ-Yb-57*zs&!bl%<1 zEDvlK=~Z*R|KpfN_q607@s}|+bFhz+&F#~N zV^tZp!F}Oj&Tun0-&@*@7v75pII)#x{ZQHf_(cfks9WY&s1XUdKpCb?ShGOUiVGCt zoAC<38eR$W=J&#mm$Z(-Hpq-Q%A3%!63ffndX)DO^NU`0JnmbUNAoQ4O|<{G?`Us<(|U`=eb@?Yo^BR}gI(IbF{PVM8L=gyF^TP)(Z!>9!w9WzCU+PK zV&(zQerVq4lhD9LClh16$9ThStfoaSU;KcGd!j2{#V>t?Ug%X+@=M!K`OJP`Q`x&!$q1W^N zZE+-|eTbx>-;d4DCCI8Uc@!FE6oeram4WyQseo~Hd_`AozsnH zdLzuQ7J1|LNf^4-5}Q2dRRzN)s2&s+f}D`Rp@#EkKJ_}qp|Zmr0lrGk01@DcIAZ;F znEl;i?@+4UuEpMZ=l%0){p0O3y+fO?oaEiJUo$Zf0)+8Lj3YyxffHGlhop<3lz8U8 zrb!r;FkFGjjHm%`z@VJghcDX?w9(hR{Ta^ae?G4Fv)*<4cE;dD#4HbxMb?leY(_DB zfSrm_hVM+@&vt?#5=3Bv&05ieu}yX?ZnDfQ`dhf`b&H)*MxW}vwQtq1WfWU#$^;by zQnVYQn9&a=3?yAuV1&yGIZRYW$jlX zFaw|S#*h1;c^vK2nNkxbw#7kbKSu>~LNvRm@bDl&JtewxIEyBWw|+ zd1%&t&6{GbKb=QhbDGz+Z!0*fG_R42ID6Qgw)e*P0uwd(PF5e&r^128s-tjWy-{B% zXtek7^(of2IN0%1g1u|!RTl! zk?zc40P!@x7>Fa_+POVJx?|0%GrZrwA4uk%le}qW+cNLGp$82V49$tw_!*oKs)BWd zoW+Y{h5}wemyBi$y~~u98RsL;$V{5{K(g&%Fo9EJ#Jq5(*O>XQc_;T(3|253MavWI zWUIYKD#FzyKd)#v!UzFgHc{A`1p6foNKAMWNL^T)zzn?Ub(+JKcg?=TfNju#LI!F$VQJb;urp#2w~;LGbovMbc$9V-bu-PA*I9KsRVZ*j7Tb;@9X-^Q`^L{70|Do><$0_ z1#}-XL5arGXC2z@$Q5Q2nAl`f%$mY|;btgLiXE<~z+_EuSkV@AaEOt@?EWQadCSG! z=FgRP%>MBB=gr<2v*|4Fr1oA;>f;=kM)7GDU?c^Yv|JlUR6?;K#SsVuG&Am`>0Qu; z1dLNQM1dxPGMfD-Og5J;2V}51ANFRO`AgV&=)>BgqiNT%AI3T;av|MWpqegus}*V1ybj;HV}*Bkd;MW&dlOlI zkq6bM32ED7xlPlIK(RAL7)lY)MT--#Vv>-FU-}x5a_BNp0Hq9>kBI1K+bnH%p6yMu z?#avNdIwpP*IDJAJgpV+{q&6CI}Dqlx8c2vYtbT0bNER@q9+g{=nu{<$HV$4Y3>5s z9t(o2iRsl{G>d7-ai(Xpcgg;nAW=Wu{Oe|K*ZlhC5ts~o&z2;L9^fQt8#1f`7Gn|8 z0jD?3xCS>5cA<&`J85jqGkgywzW_4ZwrptcM2-{HT_@lzvuCV=2pt=f@i_+xQ-B%x zikGLw$eb5^fta@p3zvcEMvG*aFf<6vyPw5drr4TCb^?jVe~)T7WXWFtq}S1WPl(2l$TolrIE zKOn>iD6z8BTiv>VF^_pu*w4>)g840K)Y=peyv#AXuJo4l;cAt@z8#Q;i`W4xpTfto z;WM-x$oOAU1X|*7~yhVSm~8y%WvOo!-Qe_*T}Zjz4gU=8uniQ%(QT z-V*ctW8O-0%TYl0J5P8YGb4^AOK*DIJAa7ZV9Q!Ns~})e+ya76qpWpN0!PRWu}OL? z=$t5k09zMXGt7cRC6P>We?=W*=0A>=@&1EahcWL-uc!5oeGJsd?HJkiTpG^hqJ3l( z0?aTAbCC=QLUD;u2DS)OPJsN#81ce}V*<3wD;sWfhiR($GzLksJlJ2=Z7sa2z5G8t Y>pcf|_=)$6X3 crate::error::Result { - expand_env_vars(input, |name| { - std::env::var(name).map_err(|_| { - crate::error::PinakesError::Config(format!( - "environment variable not set: {name}" - )) - }) - }) -} - -/// Expand environment variables in a string using the provided lookup function. -/// Supports both `${VAR_NAME}` and `$VAR_NAME` syntax. -fn expand_env_vars( - input: &str, - lookup: impl Fn(&str) -> crate::error::Result, -) -> crate::error::Result { - let mut result = String::new(); - let mut chars = input.chars().peekable(); - - while let Some(ch) = chars.next() { - if ch == '$' { - // Check if it's ${VAR} or $VAR syntax - let use_braces = chars.peek() == Some(&'{'); - if use_braces { - chars.next(); // consume '{' - } - - // Collect variable name - let mut var_name = String::new(); - while let Some(&next_ch) = chars.peek() { - if use_braces { - if next_ch == '}' { - chars.next(); // consume '}' - break; - } - var_name.push(next_ch); - chars.next(); - } else { - // For $VAR syntax, stop at non-alphanumeric/underscore - if next_ch.is_alphanumeric() || next_ch == '_' { - var_name.push(next_ch); - chars.next(); - } else { - break; - } - } - } - - if var_name.is_empty() { - return Err(crate::error::PinakesError::Config( - "empty environment variable name".to_string(), - )); - } - - result.push_str(&lookup(&var_name)?); - } else if ch == '\\' { - // Handle escaped characters - if let Some(&next_ch) = chars.peek() { - if next_ch == '$' { - chars.next(); // consume the escaped $ - result.push('$'); - } else { - result.push(ch); - } - } else { - result.push(ch); - } - } else { - result.push(ch); - } - } - - Ok(result) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - pub storage: StorageConfig, - pub directories: DirectoryConfig, - pub scanning: ScanningConfig, - pub server: ServerConfig, - #[serde(default)] - pub ui: UiConfig, - #[serde(default)] - pub accounts: AccountsConfig, - #[serde(default)] - pub rate_limits: RateLimitConfig, - #[serde(default)] - pub jobs: JobsConfig, - #[serde(default)] - pub thumbnails: ThumbnailConfig, - #[serde(default)] - pub webhooks: Vec, - #[serde(default)] - pub scheduled_tasks: Vec, - #[serde(default)] - pub plugins: PluginsConfig, - #[serde(default)] - pub transcoding: TranscodingConfig, - #[serde(default)] - pub enrichment: EnrichmentConfig, - #[serde(default)] - pub cloud: CloudConfig, - #[serde(default)] - pub analytics: AnalyticsConfig, - #[serde(default)] - pub photos: PhotoConfig, - #[serde(default)] - pub managed_storage: ManagedStorageConfig, - #[serde(default)] - pub sync: SyncConfig, - #[serde(default)] - pub sharing: SharingConfig, - #[serde(default)] - pub trash: TrashConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScheduledTaskConfig { - pub id: String, - pub enabled: bool, - pub schedule: crate::scheduler::Schedule, - pub last_run: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RateLimitConfig { - /// Global rate limit: requests per second (token replenish interval). - /// Default: 1 (combined with `burst_size=100` gives ~100 req/sec) - #[serde(default = "default_global_per_second")] - pub global_per_second: u64, - /// Global rate limit: burst size (max concurrent requests per IP) - #[serde(default = "default_global_burst")] - pub global_burst_size: u32, - /// Login rate limit: seconds between token replenishment. - /// Default: 12 (one token every 12s, combined with burst=5 gives ~5 req/min) - #[serde(default = "default_login_per_second")] - pub login_per_second: u64, - /// Login rate limit: burst size - #[serde(default = "default_login_burst")] - pub login_burst_size: u32, - /// Search rate limit: seconds between token replenishment. - /// Default: 6 (one token every 6s, combined with burst=10 gives ~10 req/min) - #[serde(default = "default_search_per_second")] - pub search_per_second: u64, - /// Search rate limit: burst size - #[serde(default = "default_search_burst")] - pub search_burst_size: u32, - /// Streaming rate limit: seconds between token replenishment. - /// Default: 60 (one per minute) - #[serde(default = "default_stream_per_second")] - pub stream_per_second: u64, - /// Streaming rate limit: burst size (max concurrent streams) - #[serde(default = "default_stream_burst")] - pub stream_burst_size: u32, - /// Share token rate limit: seconds between token replenishment. - /// Default: 2 - #[serde(default = "default_share_per_second")] - pub share_per_second: u64, - /// Share token rate limit: burst size - #[serde(default = "default_share_burst")] - pub share_burst_size: u32, -} - -const fn default_global_per_second() -> u64 { - 1 -} -const fn default_global_burst() -> u32 { - 100 -} -const fn default_login_per_second() -> u64 { - 12 -} -const fn default_login_burst() -> u32 { - 5 -} -const fn default_search_per_second() -> u64 { - 6 -} -const fn default_search_burst() -> u32 { - 10 -} -const fn default_stream_per_second() -> u64 { - 60 -} -const fn default_stream_burst() -> u32 { - 5 -} -const fn default_share_per_second() -> u64 { - 2 -} -const fn default_share_burst() -> u32 { - 20 -} - -impl Default for RateLimitConfig { - fn default() -> Self { - Self { - global_per_second: default_global_per_second(), - global_burst_size: default_global_burst(), - login_per_second: default_login_per_second(), - login_burst_size: default_login_burst(), - search_per_second: default_search_per_second(), - search_burst_size: default_search_burst(), - stream_per_second: default_stream_per_second(), - stream_burst_size: default_stream_burst(), - share_per_second: default_share_per_second(), - share_burst_size: default_share_burst(), - } - } -} - -impl RateLimitConfig { - /// Validate that all rate limit values are positive. - /// - /// # Errors - /// - /// Returns an error string if any rate limit value is zero. - pub fn validate(&self) -> Result<(), String> { - for (name, value) in [ - ("global_per_second", self.global_per_second), - ("global_burst_size", u64::from(self.global_burst_size)), - ("login_per_second", self.login_per_second), - ("login_burst_size", u64::from(self.login_burst_size)), - ("search_per_second", self.search_per_second), - ("search_burst_size", u64::from(self.search_burst_size)), - ("stream_per_second", self.stream_per_second), - ("stream_burst_size", u64::from(self.stream_burst_size)), - ("share_per_second", self.share_per_second), - ("share_burst_size", u64::from(self.share_burst_size)), - ] { - if value == 0 { - return Err(format!("{name} must be > 0")); - } - } - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobsConfig { - #[serde(default = "default_worker_count")] - pub worker_count: usize, - #[serde(default = "default_cache_ttl")] - pub cache_ttl_secs: u64, - /// Maximum time a job is allowed to run before being cancelled (in seconds). - /// Set to 0 to disable timeout. Default: 3600 (1 hour). - #[serde(default = "default_job_timeout")] - pub job_timeout_secs: u64, -} - -const fn default_worker_count() -> usize { - 2 -} -const fn default_cache_ttl() -> u64 { - 60 -} -const fn default_job_timeout() -> u64 { - 3600 -} - -impl Default for JobsConfig { - fn default() -> Self { - Self { - worker_count: default_worker_count(), - cache_ttl_secs: default_cache_ttl(), - job_timeout_secs: default_job_timeout(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbnailConfig { - #[serde(default = "default_thumb_size")] - pub size: u32, - #[serde(default = "default_thumb_quality")] - pub quality: u8, - #[serde(default)] - pub ffmpeg_path: Option, - #[serde(default = "default_video_seek")] - pub video_seek_secs: u32, -} - -const fn default_thumb_size() -> u32 { - 320 -} -const fn default_thumb_quality() -> u8 { - 80 -} -const fn default_video_seek() -> u32 { - 2 -} - -impl Default for ThumbnailConfig { - fn default() -> Self { - Self { - size: default_thumb_size(), - quality: default_thumb_quality(), - ffmpeg_path: None, - video_seek_secs: default_video_seek(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WebhookConfig { - pub url: String, - pub events: Vec, - #[serde(default)] - pub secret: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiConfig { - #[serde(default = "default_theme")] - pub theme: String, - #[serde(default = "default_view")] - pub default_view: String, - #[serde(default = "default_page_size")] - pub default_page_size: usize, - #[serde(default = "default_view_mode")] - pub default_view_mode: String, - #[serde(default)] - pub auto_play_media: bool, - #[serde(default = "default_true")] - pub show_thumbnails: bool, - #[serde(default)] - pub sidebar_collapsed: bool, -} - -fn default_theme() -> String { - "dark".to_string() -} -fn default_view() -> String { - "library".to_string() -} -const fn default_page_size() -> usize { - 50 -} -fn default_view_mode() -> String { - "grid".to_string() -} -const fn default_true() -> bool { - true -} - -impl Default for UiConfig { - fn default() -> Self { - Self { - theme: default_theme(), - default_view: default_view(), - default_page_size: default_page_size(), - default_view_mode: default_view_mode(), - auto_play_media: false, - show_thumbnails: true, - sidebar_collapsed: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccountsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub users: Vec, - /// Session expiry in hours. Defaults to 24. - #[serde(default = "default_session_expiry_hours")] - pub session_expiry_hours: u64, -} - -const fn default_session_expiry_hours() -> u64 { - 24 -} - -impl Default for AccountsConfig { - fn default() -> Self { - Self { - enabled: false, - users: Vec::new(), - session_expiry_hours: default_session_expiry_hours(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserAccount { - pub username: String, - pub password_hash: String, - #[serde(default)] - pub role: UserRole, -} - -#[derive( - Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, -)] -#[serde(rename_all = "lowercase")] -pub enum UserRole { - Admin, - Editor, - #[default] - Viewer, -} - -impl UserRole { - #[must_use] - pub const fn can_read(self) -> bool { - true - } - - #[must_use] - pub const fn can_write(self) -> bool { - matches!(self, Self::Admin | Self::Editor) - } - - #[must_use] - pub const fn can_admin(self) -> bool { - matches!(self, Self::Admin) - } -} - -impl std::fmt::Display for UserRole { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Admin => write!(f, "admin"), - Self::Editor => write!(f, "editor"), - Self::Viewer => write!(f, "viewer"), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginTimeoutConfig { - /// Timeout for capability discovery queries (`supported_types`, - /// `interested_events`) - #[serde(default = "default_capability_query_timeout")] - pub capability_query_secs: u64, - /// Timeout for processing calls (`extract_metadata`, `generate_thumbnail`) - #[serde(default = "default_processing_timeout")] - pub processing_secs: u64, - /// Timeout for event handler calls - #[serde(default = "default_event_handler_timeout")] - pub event_handler_secs: u64, -} - -const fn default_capability_query_timeout() -> u64 { - 2 -} - -const fn default_processing_timeout() -> u64 { - 30 -} - -const fn default_event_handler_timeout() -> u64 { - 10 -} - -impl Default for PluginTimeoutConfig { - fn default() -> Self { - Self { - capability_query_secs: default_capability_query_timeout(), - processing_secs: default_processing_timeout(), - event_handler_secs: default_event_handler_timeout(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_plugin_data_dir")] - pub data_dir: PathBuf, - #[serde(default = "default_plugin_cache_dir")] - pub cache_dir: PathBuf, - #[serde(default)] - pub plugin_dirs: Vec, - #[serde(default)] - pub enable_hot_reload: bool, - #[serde(default)] - pub allow_unsigned: bool, - #[serde(default = "default_max_concurrent_ops")] - pub max_concurrent_ops: usize, - #[serde(default = "default_plugin_timeout")] - pub plugin_timeout_secs: u64, - #[serde(default)] - pub timeouts: PluginTimeoutConfig, - #[serde(default = "default_max_consecutive_failures")] - pub max_consecutive_failures: u32, - - /// Hex-encoded Ed25519 public keys trusted for plugin signature - /// verification. Each entry is 64 hex characters (32 bytes). - #[serde(default)] - pub trusted_keys: Vec, -} - -fn default_plugin_data_dir() -> PathBuf { - Config::default_data_dir().join("plugins").join("data") -} - -fn default_plugin_cache_dir() -> PathBuf { - Config::default_data_dir().join("plugins").join("cache") -} - -const fn default_max_concurrent_ops() -> usize { - 4 -} - -const fn default_plugin_timeout() -> u64 { - 30 -} - -const fn default_max_consecutive_failures() -> u32 { - 5 -} - -impl Default for PluginsConfig { - fn default() -> Self { - Self { - enabled: false, - data_dir: default_plugin_data_dir(), - cache_dir: default_plugin_cache_dir(), - plugin_dirs: vec![], - enable_hot_reload: false, - allow_unsigned: false, - max_concurrent_ops: default_max_concurrent_ops(), - plugin_timeout_secs: default_plugin_timeout(), - timeouts: PluginTimeoutConfig::default(), - max_consecutive_failures: default_max_consecutive_failures(), - trusted_keys: vec![], - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TranscodingConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub cache_dir: Option, - #[serde(default = "default_cache_ttl_hours")] - pub cache_ttl_hours: u64, - #[serde(default = "default_max_concurrent_transcodes")] - pub max_concurrent: usize, - #[serde(default)] - pub hardware_acceleration: Option, - #[serde(default)] - pub profiles: Vec, -} - -const fn default_cache_ttl_hours() -> u64 { - 48 -} - -const fn default_max_concurrent_transcodes() -> usize { - 2 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TranscodeProfile { - pub name: String, - pub video_codec: String, - pub audio_codec: String, - pub max_bitrate_kbps: u32, - pub max_resolution: String, -} - -impl Default for TranscodingConfig { - fn default() -> Self { - Self { - enabled: false, - cache_dir: None, - cache_ttl_hours: default_cache_ttl_hours(), - max_concurrent: default_max_concurrent_transcodes(), - hardware_acceleration: None, - profiles: vec![ - TranscodeProfile { - name: "high".to_string(), - video_codec: "h264".to_string(), - audio_codec: "aac".to_string(), - max_bitrate_kbps: 8000, - max_resolution: "1080p".to_string(), - }, - TranscodeProfile { - name: "medium".to_string(), - video_codec: "h264".to_string(), - audio_codec: "aac".to_string(), - max_bitrate_kbps: 4000, - max_resolution: "720p".to_string(), - }, - ], - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct EnrichmentConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub auto_enrich_on_import: bool, - #[serde(default)] - pub sources: EnrichmentSources, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct EnrichmentSources { - #[serde(default)] - pub musicbrainz: EnrichmentSource, - #[serde(default)] - pub tmdb: EnrichmentSource, - #[serde(default)] - pub lastfm: EnrichmentSource, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct EnrichmentSource { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub api_key: Option, - #[serde(default)] - pub api_endpoint: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CloudConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_auto_sync_interval")] - pub auto_sync_interval_mins: u64, - #[serde(default)] - pub accounts: Vec, -} - -const fn default_auto_sync_interval() -> u64 { - 60 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CloudAccount { - pub id: String, - pub provider: String, - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub sync_rules: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CloudSyncRule { - pub local_path: PathBuf, - pub remote_path: String, - pub direction: CloudSyncDirection, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CloudSyncDirection { - Upload, - Download, - Bidirectional, -} - -impl Default for CloudConfig { - fn default() -> Self { - Self { - enabled: false, - auto_sync_interval_mins: default_auto_sync_interval(), - accounts: vec![], - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnalyticsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_true")] - pub track_usage: bool, - #[serde(default = "default_retention_days")] - pub retention_days: u64, -} - -const fn default_retention_days() -> u64 { - 90 -} - -impl Default for AnalyticsConfig { - fn default() -> Self { - Self { - enabled: false, - track_usage: true, - retention_days: default_retention_days(), - } - } -} - -/// Feature toggles for photo processing (image analysis features). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhotoFeatures { - /// Generate perceptual hashes for image duplicate detection (CPU-intensive) - #[serde(default = "default_true")] - pub generate_perceptual_hash: bool, - - /// Automatically create tags from EXIF keywords - #[serde(default)] - pub auto_tag_from_exif: bool, - - /// Generate multi-resolution thumbnails (tiny, grid, preview) - #[serde(default)] - pub multi_resolution_thumbnails: bool, -} - -impl Default for PhotoFeatures { - fn default() -> Self { - Self { - generate_perceptual_hash: true, - auto_tag_from_exif: false, - multi_resolution_thumbnails: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhotoConfig { - /// Feature toggles for photo processing - #[serde(flatten)] - pub features: PhotoFeatures, - - /// Auto-detect photo events/albums based on time and location - #[serde(default)] - pub enable_event_detection: bool, - - /// Minimum number of photos to form an event - #[serde(default = "default_min_event_photos")] - pub min_event_photos: usize, - - /// Maximum time gap between photos in the same event (in seconds) - #[serde(default = "default_event_time_gap")] - pub event_time_gap_secs: i64, - - /// Maximum distance between photos in the same event (in kilometers) - #[serde(default = "default_event_distance")] - pub event_max_distance_km: f64, -} - -impl PhotoConfig { - /// Returns true if perceptual hashing is enabled. - #[must_use] - pub const fn generate_perceptual_hash(&self) -> bool { - self.features.generate_perceptual_hash - } - - /// Returns true if auto-tagging from EXIF is enabled. - #[must_use] - pub const fn auto_tag_from_exif(&self) -> bool { - self.features.auto_tag_from_exif - } - - /// Returns true if multi-resolution thumbnails are enabled. - #[must_use] - pub const fn multi_resolution_thumbnails(&self) -> bool { - self.features.multi_resolution_thumbnails - } -} - -const fn default_min_event_photos() -> usize { - 5 -} - -const fn default_event_time_gap() -> i64 { - 2 * 60 * 60 // 2 hours -} - -const fn default_event_distance() -> f64 { - 1.0 // 1 km -} - -impl Default for PhotoConfig { - fn default() -> Self { - Self { - features: PhotoFeatures::default(), - enable_event_detection: false, - min_event_photos: default_min_event_photos(), - event_time_gap_secs: default_event_time_gap(), - event_max_distance_km: default_event_distance(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManagedStorageConfig { - /// Enable managed storage for file uploads - #[serde(default)] - pub enabled: bool, - /// Directory where managed files are stored - #[serde(default = "default_managed_storage_dir")] - pub storage_dir: PathBuf, - /// Maximum upload size in bytes (default: 10GB) - #[serde(default = "default_max_upload_size")] - pub max_upload_size: u64, - /// Allowed MIME types for uploads (empty = allow all) - #[serde(default)] - pub allowed_mime_types: Vec, - /// Automatically clean up orphaned blobs - #[serde(default = "default_true")] - pub auto_cleanup: bool, - /// Verify file integrity on read - #[serde(default)] - pub verify_on_read: bool, -} - -fn default_managed_storage_dir() -> PathBuf { - Config::default_data_dir().join("managed") -} - -const fn default_max_upload_size() -> u64 { - 10 * 1024 * 1024 * 1024 // 10GB -} - -impl Default for ManagedStorageConfig { - fn default() -> Self { - Self { - enabled: false, - storage_dir: default_managed_storage_dir(), - max_upload_size: default_max_upload_size(), - allowed_mime_types: vec![], - auto_cleanup: true, - verify_on_read: false, - } - } -} - -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, -)] -#[serde(rename_all = "snake_case")] -pub enum ConflictResolution { - ServerWins, - ClientWins, - #[default] - KeepBoth, - Manual, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncConfig { - /// Enable cross-device sync functionality - #[serde(default)] - pub enabled: bool, - /// Default conflict resolution strategy - #[serde(default)] - pub default_conflict_resolution: ConflictResolution, - /// Maximum file size for sync in MB - #[serde(default = "default_max_sync_file_size")] - pub max_file_size_mb: u64, - /// Chunk size for chunked uploads in KB - #[serde(default = "default_chunk_size")] - pub chunk_size_kb: u64, - /// Upload session timeout in hours - #[serde(default = "default_upload_timeout")] - pub upload_timeout_hours: u64, - /// Maximum concurrent uploads per device - #[serde(default = "default_max_concurrent_uploads")] - pub max_concurrent_uploads: usize, - /// Sync log retention in days - #[serde(default = "default_sync_log_retention")] - pub sync_log_retention_days: u64, - /// Temporary directory for chunked upload storage - #[serde(default = "default_temp_upload_dir")] - pub temp_upload_dir: PathBuf, -} - -const fn default_max_sync_file_size() -> u64 { - 4096 // 4GB -} - -const fn default_chunk_size() -> u64 { - 4096 // 4MB -} - -const fn default_upload_timeout() -> u64 { - 24 // 24 hours -} - -const fn default_max_concurrent_uploads() -> usize { - 3 -} - -const fn default_sync_log_retention() -> u64 { - 90 // 90 days -} - -fn default_temp_upload_dir() -> PathBuf { - Config::default_data_dir().join("temp_uploads") -} - -impl Default for SyncConfig { - fn default() -> Self { - Self { - enabled: false, - default_conflict_resolution: ConflictResolution::default(), - max_file_size_mb: default_max_sync_file_size(), - chunk_size_kb: default_chunk_size(), - upload_timeout_hours: default_upload_timeout(), - max_concurrent_uploads: default_max_concurrent_uploads(), - sync_log_retention_days: default_sync_log_retention(), - temp_upload_dir: default_temp_upload_dir(), - } - } -} - -/// Core permission flags for the sharing subsystem. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SharingPermissions { - /// Enable sharing functionality - #[serde(default = "default_true")] - pub enabled: bool, - /// Allow creating public share links - #[serde(default = "default_true")] - pub allow_public_links: bool, - /// Allow users to reshare content shared with them - #[serde(default = "default_true")] - pub allow_reshare: bool, -} - -impl Default for SharingPermissions { - fn default() -> Self { - Self { - enabled: true, - allow_public_links: true, - allow_reshare: true, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SharingConfig { - /// Core permission flags for sharing - #[serde(flatten)] - pub permissions: SharingPermissions, - /// Require password for public share links - #[serde(default)] - pub require_public_link_password: bool, - /// Enable share notifications - #[serde(default = "default_true")] - pub notifications_enabled: bool, - /// Maximum expiry time for public links in hours (0 = unlimited) - #[serde(default)] - pub max_public_link_expiry_hours: u64, - /// Notification retention in days - #[serde(default = "default_notification_retention")] - pub notification_retention_days: u64, - /// Share activity log retention in days - #[serde(default = "default_activity_retention")] - pub activity_retention_days: u64, -} - -impl SharingConfig { - /// Returns true if sharing is enabled. - #[must_use] - pub const fn enabled(&self) -> bool { - self.permissions.enabled - } - - /// Returns true if public links are allowed. - #[must_use] - pub const fn allow_public_links(&self) -> bool { - self.permissions.allow_public_links - } - - /// Returns true if resharing is allowed. - #[must_use] - pub const fn allow_reshare(&self) -> bool { - self.permissions.allow_reshare - } -} - -const fn default_notification_retention() -> u64 { - 30 -} - -const fn default_activity_retention() -> u64 { - 90 -} - -impl Default for SharingConfig { - fn default() -> Self { - Self { - permissions: SharingPermissions::default(), - require_public_link_password: false, - notifications_enabled: true, - max_public_link_expiry_hours: 0, - notification_retention_days: default_notification_retention(), - activity_retention_days: default_activity_retention(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrashConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_trash_retention_days")] - pub retention_days: u64, - #[serde(default)] - pub auto_empty: bool, -} - -const fn default_trash_retention_days() -> u64 { - 30 -} - -impl Default for TrashConfig { - fn default() -> Self { - Self { - enabled: false, - retention_days: default_trash_retention_days(), - auto_empty: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StorageConfig { - pub backend: StorageBackendType, - pub sqlite: Option, - pub postgres: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum StorageBackendType { - Sqlite, - Postgres, -} - -impl StorageBackendType { - #[must_use] - pub const fn as_str(&self) -> &'static str { - match self { - Self::Sqlite => "sqlite", - Self::Postgres => "postgres", - } - } -} - -impl std::fmt::Display for StorageBackendType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SqliteConfig { - pub path: PathBuf, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PostgresConfig { - pub host: String, - pub port: u16, - pub database: String, - pub username: String, - pub password: String, - pub max_connections: usize, - /// Enable TLS for `PostgreSQL` connections - #[serde(default)] - pub tls_enabled: bool, - /// Verify TLS certificates (default: true) - #[serde(default = "default_true")] - pub tls_verify_ca: bool, - /// Path to custom CA certificate file (PEM format) - #[serde(default)] - pub tls_ca_cert_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DirectoryConfig { - pub roots: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScanningConfig { - pub watch: bool, - pub poll_interval_secs: u64, - pub ignore_patterns: Vec, - #[serde(default = "default_import_concurrency")] - pub import_concurrency: usize, -} - -const fn default_import_concurrency() -> usize { - 8 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerConfig { - pub host: String, - pub port: u16, - /// Optional API key for bearer token authentication. - /// If set, all requests (except /health) must include `Authorization: Bearer - /// `. Can also be set via `PINAKES_API_KEY` environment variable. - pub api_key: Option, - /// Explicitly disable authentication (INSECURE - use only for development). - /// When true, all requests are allowed without authentication. - /// This must be explicitly set to true; empty `api_key` alone is not - /// sufficient. - #[serde(default)] - pub authentication_disabled: bool, - /// Enable CORS (Cross-Origin Resource Sharing). - /// When false, default localhost origins are used. - #[serde(default)] - pub cors_enabled: bool, - /// Allowed CORS origins when `cors_enabled` is true. - /// If empty and `cors_enabled` is true, defaults to localhost origins. - #[serde(default)] - pub cors_origins: Vec, - /// TLS/HTTPS configuration - #[serde(default)] - pub tls: TlsConfig, - /// Enable the Swagger UI at /api/docs. - /// Defaults to true. Set to false to disable in production if desired. - #[serde(default = "default_true")] - pub swagger_ui: bool, -} - -/// TLS/HTTPS configuration for secure connections -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TlsConfig { - /// Enable TLS (HTTPS) - #[serde(default)] - pub enabled: bool, - /// Path to the TLS certificate file (PEM format) - #[serde(default)] - pub cert_path: Option, - /// Path to the TLS private key file (PEM format) - #[serde(default)] - pub key_path: Option, - /// Enable HTTP to HTTPS redirect (starts a second listener on `http_port`) - #[serde(default)] - pub redirect_http: bool, - /// Port for HTTP redirect listener (default: 80) - #[serde(default = "default_http_port")] - pub http_port: u16, - /// Enable HSTS (HTTP Strict Transport Security) header - #[serde(default = "default_true")] - pub hsts_enabled: bool, - /// HSTS max-age in seconds (default: 1 year) - #[serde(default = "default_hsts_max_age")] - pub hsts_max_age: u64, -} - -const fn default_http_port() -> u16 { - 80 -} - -const fn default_hsts_max_age() -> u64 { - 31_536_000 // 1 year in seconds -} - -impl Default for TlsConfig { - fn default() -> Self { - Self { - enabled: false, - cert_path: None, - key_path: None, - redirect_http: false, - http_port: default_http_port(), - hsts_enabled: true, - hsts_max_age: default_hsts_max_age(), - } - } -} - -impl TlsConfig { - /// Validate TLS configuration - /// - /// # Errors - /// - /// Returns an error string if TLS is enabled but required paths are missing - /// or invalid. - pub fn validate(&self) -> Result<(), String> { - if self.enabled { - if self.cert_path.is_none() { - return Err("TLS enabled but cert_path not specified".into()); - } - if self.key_path.is_none() { - return Err("TLS enabled but key_path not specified".into()); - } - if let Some(ref cert_path) = self.cert_path - && !cert_path.exists() - { - return Err(format!( - "TLS certificate file not found: {}", - cert_path.display() - )); - } - if let Some(ref key_path) = self.key_path - && !key_path.exists() - { - return Err(format!("TLS key file not found: {}", key_path.display())); - } - } - Ok(()) - } -} - -impl Config { - /// Load configuration from a TOML file, expanding environment variables in - /// secret fields. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if the file cannot be read, parsed, - /// or contains invalid environment variable references. - pub fn from_file(path: &Path) -> crate::error::Result { - let content = std::fs::read_to_string(path).map_err(|e| { - crate::error::PinakesError::Config(format!( - "failed to read config file: {e}" - )) - })?; - let mut config: Self = toml::from_str(&content).map_err(|e| { - crate::error::PinakesError::Config(format!("failed to parse config: {e}")) - })?; - config.expand_env_vars()?; - Ok(config) - } - - /// Expand environment variables in secret fields. - /// Supports ${`VAR_NAME`} and $`VAR_NAME` syntax. - fn expand_env_vars(&mut self) -> crate::error::Result<()> { - // Postgres password - if let Some(ref mut postgres) = self.storage.postgres { - postgres.password = expand_env_var_string(&postgres.password)?; - } - - // Server API key - if let Some(ref api_key) = self.server.api_key { - self.server.api_key = Some(expand_env_var_string(api_key)?); - } - - // Webhook secrets - for webhook in &mut self.webhooks { - if let Some(ref secret) = webhook.secret { - webhook.secret = Some(expand_env_var_string(secret)?); - } - } - - // Enrichment API keys - if let Some(ref api_key) = self.enrichment.sources.musicbrainz.api_key { - self.enrichment.sources.musicbrainz.api_key = - Some(expand_env_var_string(api_key)?); - } - if let Some(ref api_key) = self.enrichment.sources.tmdb.api_key { - self.enrichment.sources.tmdb.api_key = - Some(expand_env_var_string(api_key)?); - } - if let Some(ref api_key) = self.enrichment.sources.lastfm.api_key { - self.enrichment.sources.lastfm.api_key = - Some(expand_env_var_string(api_key)?); - } - - Ok(()) - } - - /// Try loading from file, falling back to defaults if the file doesn't exist. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if the file exists but cannot be - /// read or parsed. - pub fn load_or_default(path: &Path) -> crate::error::Result { - if path.exists() { - Self::from_file(path) - } else { - let config = Self::default(); - // Ensure the data directory exists for the default SQLite database - config.ensure_dirs()?; - Ok(config) - } - } - - /// Save the current config to a TOML file. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if the file cannot be written or - /// the config cannot be serialized. - pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let content = toml::to_string_pretty(self).map_err(|e| { - crate::error::PinakesError::Config(format!( - "failed to serialize config: {e}" - )) - })?; - std::fs::write(path, content)?; - Ok(()) - } - - /// Ensure all directories needed by this config exist and are writable. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if a required directory cannot be - /// created or is read-only. - pub fn ensure_dirs(&self) -> crate::error::Result<()> { - if let Some(ref sqlite) = self.storage.sqlite - && let Some(parent) = sqlite.path.parent() - { - // Skip if parent is empty string (happens with bare filenames like - // "pinakes.db") - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - let metadata = std::fs::metadata(parent)?; - if metadata.permissions().readonly() { - return Err(crate::error::PinakesError::Config(format!( - "directory is not writable: {}", - parent.display() - ))); - } - } - } - Ok(()) - } - - /// Returns the default config file path following XDG conventions. - #[must_use] - pub fn default_config_path() -> PathBuf { - std::env::var("XDG_CONFIG_HOME").map_or_else( - |_| { - std::env::var("HOME").map_or_else( - |_| PathBuf::from("pinakes.toml"), - |home| { - PathBuf::from(home) - .join(".config") - .join("pinakes") - .join("pinakes.toml") - }, - ) - }, - |xdg| PathBuf::from(xdg).join("pinakes").join("pinakes.toml"), - ) - } - - /// Validate configuration values for correctness. - /// - /// # Errors - /// - /// Returns an error string if any configuration value is invalid. - pub fn validate(&self) -> Result<(), String> { - if self.server.port == 0 { - return Err("server port cannot be 0".into()); - } - if self.server.host.is_empty() { - return Err("server host cannot be empty".into()); - } - if self.scanning.poll_interval_secs == 0 { - return Err("poll interval cannot be 0".into()); - } - if self.scanning.import_concurrency == 0 - || self.scanning.import_concurrency > 256 - { - return Err("import_concurrency must be between 1 and 256".into()); - } - - // Validate authentication configuration - let has_api_key = - self.server.api_key.as_ref().is_some_and(|k| !k.is_empty()); - let has_accounts = !self.accounts.users.is_empty(); - let auth_disabled = self.server.authentication_disabled; - - if !auth_disabled && !has_api_key && !has_accounts { - return Err( - "authentication is not configured: set an api_key, configure user \ - accounts, or explicitly set authentication_disabled = true" - .into(), - ); - } - - // Empty API key is not allowed (must use authentication_disabled flag) - if let Some(ref api_key) = self.server.api_key - && api_key.is_empty() - { - return Err( - "empty api_key is not allowed. To disable authentication, set \ - authentication_disabled = true instead" - .into(), - ); - } - - // Require TLS when authentication is enabled on non-localhost - let is_localhost = self.server.host == "127.0.0.1" - || self.server.host == "localhost" - || self.server.host == "::1"; - - if (has_api_key || has_accounts) - && !auth_disabled - && !is_localhost - && !self.server.tls.enabled - { - return Err( - "TLS must be enabled when authentication is used on non-localhost \ - hosts. Set server.tls.enabled = true or bind to localhost only" - .into(), - ); - } - - // Validate rate limits - self.rate_limits.validate()?; - - // Validate TLS configuration - self.server.tls.validate()?; - Ok(()) - } - - /// Returns the default data directory following XDG conventions. - #[must_use] - pub fn default_data_dir() -> PathBuf { - std::env::var("XDG_DATA_HOME").map_or_else( - |_| { - std::env::var("HOME").map_or_else( - |_| PathBuf::from("pinakes-data"), - |home| { - PathBuf::from(home) - .join(".local") - .join("share") - .join("pinakes") - }, - ) - }, - |xdg| PathBuf::from(xdg).join("pinakes"), - ) - } -} - -impl Default for Config { - fn default() -> Self { - let data_dir = Self::default_data_dir(); - Self { - storage: StorageConfig { - backend: StorageBackendType::Sqlite, - sqlite: Some(SqliteConfig { - path: data_dir.join("pinakes.db"), - }), - postgres: None, - }, - directories: DirectoryConfig { roots: vec![] }, - scanning: ScanningConfig { - watch: false, - poll_interval_secs: 300, - ignore_patterns: vec![ - ".*".to_string(), - "node_modules".to_string(), - "__pycache__".to_string(), - "target".to_string(), - ], - import_concurrency: default_import_concurrency(), - }, - server: ServerConfig { - host: "127.0.0.1".to_string(), - port: 3000, - api_key: None, - authentication_disabled: false, - cors_enabled: false, - cors_origins: vec![], - tls: TlsConfig::default(), - swagger_ui: true, - }, - ui: UiConfig::default(), - accounts: AccountsConfig::default(), - rate_limits: RateLimitConfig::default(), - jobs: JobsConfig::default(), - thumbnails: ThumbnailConfig::default(), - webhooks: vec![], - scheduled_tasks: vec![], - plugins: PluginsConfig::default(), - transcoding: TranscodingConfig::default(), - enrichment: EnrichmentConfig::default(), - cloud: CloudConfig::default(), - analytics: AnalyticsConfig::default(), - photos: PhotoConfig::default(), - managed_storage: ManagedStorageConfig::default(), - sync: SyncConfig::default(), - sharing: SharingConfig::default(), - trash: TrashConfig::default(), - } - } -} - -#[cfg(test)] -mod tests { - use rustc_hash::FxHashMap; - - use super::*; - - fn test_config_with_concurrency(concurrency: usize) -> Config { - let mut config = Config::default(); - config.scanning.import_concurrency = concurrency; - config.server.authentication_disabled = true; // Disable auth for concurrency tests - config - } - - #[test] - fn test_validate_import_concurrency_zero() { - let config = test_config_with_concurrency(0); - assert!(config.validate().is_err()); - assert!( - config - .validate() - .unwrap_err() - .contains("import_concurrency") - ); - } - - #[test] - fn test_validate_import_concurrency_too_high() { - let config = test_config_with_concurrency(257); - assert!(config.validate().is_err()); - assert!( - config - .validate() - .unwrap_err() - .contains("import_concurrency") - ); - } - - #[test] - fn test_validate_import_concurrency_valid() { - let config = test_config_with_concurrency(8); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_import_concurrency_boundary_low() { - let config = test_config_with_concurrency(1); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_import_concurrency_boundary_high() { - let config = test_config_with_concurrency(256); - assert!(config.validate().is_ok()); - } - - // Environment variable expansion tests using expand_env_vars with a - // HashMap lookup. This avoids unsafe std::env::set_var and is - // thread-safe for parallel test execution. - fn test_lookup<'a>( - vars: &'a FxHashMap<&str, &str>, - ) -> impl Fn(&str) -> crate::error::Result + 'a { - move |name| { - vars - .get(name) - .map(std::string::ToString::to_string) - .ok_or_else(|| { - crate::error::PinakesError::Config(format!( - "environment variable not set: {name}" - )) - }) - } - } - - #[test] - fn test_expand_env_var_simple() { - let vars = [("TEST_VAR_SIMPLE", "test_value")] - .into_iter() - .collect::>(); - let result = expand_env_vars("$TEST_VAR_SIMPLE", test_lookup(&vars)); - assert_eq!(result.unwrap(), "test_value"); - } - - #[test] - fn test_expand_env_var_braces() { - let vars = [("TEST_VAR_BRACES", "test_value")] - .into_iter() - .collect::>(); - let result = expand_env_vars("${TEST_VAR_BRACES}", test_lookup(&vars)); - assert_eq!(result.unwrap(), "test_value"); - } - - #[test] - fn test_expand_env_var_embedded() { - let vars = [("TEST_VAR_EMBEDDED", "value")] - .into_iter() - .collect::>(); - let result = - expand_env_vars("prefix_${TEST_VAR_EMBEDDED}_suffix", test_lookup(&vars)); - assert_eq!(result.unwrap(), "prefix_value_suffix"); - } - - #[test] - fn test_expand_env_var_multiple() { - let vars = [("VAR1", "value1"), ("VAR2", "value2")] - .into_iter() - .collect::>(); - let result = expand_env_vars("${VAR1}_${VAR2}", test_lookup(&vars)); - assert_eq!(result.unwrap(), "value1_value2"); - } - - #[test] - fn test_expand_env_var_missing() { - let vars = FxHashMap::default(); - let result = expand_env_vars("${NONEXISTENT_VAR}", test_lookup(&vars)); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("environment variable not set") - ); - } - - #[test] - fn test_expand_env_var_empty_name() { - let vars = FxHashMap::default(); - let result = expand_env_vars("${}", test_lookup(&vars)); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("empty environment variable name") - ); - } - - #[test] - fn test_expand_env_var_escaped() { - let vars = FxHashMap::default(); - let result = expand_env_vars("\\$NOT_A_VAR", test_lookup(&vars)); - assert_eq!(result.unwrap(), "$NOT_A_VAR"); - } - - #[test] - fn test_expand_env_var_no_vars() { - let vars = FxHashMap::default(); - let result = expand_env_vars("plain_text", test_lookup(&vars)); - assert_eq!(result.unwrap(), "plain_text"); - } - - #[test] - fn test_expand_env_var_underscore() { - let vars = [("TEST_VAR_NAME", "value")] - .into_iter() - .collect::>(); - let result = expand_env_vars("$TEST_VAR_NAME", test_lookup(&vars)); - assert_eq!(result.unwrap(), "value"); - } - - #[test] - fn test_expand_env_var_mixed_syntax() { - let vars = [("VAR1_MIXED", "v1"), ("VAR2_MIXED", "v2")] - .into_iter() - .collect::>(); - let result = - expand_env_vars("$VAR1_MIXED and ${VAR2_MIXED}", test_lookup(&vars)); - assert_eq!(result.unwrap(), "v1 and v2"); - } -} +pub use pinakes_types::config::*; diff --git a/crates/pinakes-core/src/enrichment/books.rs b/crates/pinakes-core/src/enrichment/books.rs deleted file mode 100644 index 02226f5..0000000 --- a/crates/pinakes-core/src/enrichment/books.rs +++ /dev/null @@ -1,269 +0,0 @@ -use chrono::Utc; -use uuid::Uuid; - -use super::{ - EnrichmentSourceType, - ExternalMetadata, - MetadataEnricher, - googlebooks::GoogleBooksClient, - openlibrary::OpenLibraryClient, -}; -use crate::{ - error::{PinakesError, Result}, - model::MediaItem, -}; - -/// Book enricher that tries `OpenLibrary` first, then falls back to Google -/// Books -pub struct BookEnricher { - openlibrary: OpenLibraryClient, - googlebooks: GoogleBooksClient, -} - -impl BookEnricher { - #[must_use] - pub fn new(google_api_key: Option) -> Self { - Self { - openlibrary: OpenLibraryClient::new(), - googlebooks: GoogleBooksClient::new(google_api_key), - } - } - - /// Try to enrich from `OpenLibrary` first - /// - /// # Errors - /// - /// Returns an error if the metadata cannot be serialized. - pub async fn try_openlibrary( - &self, - isbn: &str, - ) -> Result> { - match self.openlibrary.fetch_by_isbn(isbn).await { - Ok(book) => { - let metadata_json = serde_json::to_string(&book).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {e}")) - })?; - - Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller - source: EnrichmentSourceType::OpenLibrary, - external_id: None, - metadata_json, - confidence: calculate_openlibrary_confidence(&book), - last_updated: Utc::now(), - })) - }, - Err(_) => Ok(None), - } - } - - /// Try to enrich from Google Books - /// - /// # Errors - /// - /// Returns an error if the metadata cannot be serialized. - pub async fn try_googlebooks( - &self, - isbn: &str, - ) -> Result> { - match self.googlebooks.fetch_by_isbn(isbn).await { - Ok(books) if !books.is_empty() => { - let book = &books[0]; - let metadata_json = serde_json::to_string(book).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {e}")) - })?; - - Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller - source: EnrichmentSourceType::GoogleBooks, - external_id: Some(book.id.clone()), - metadata_json, - confidence: calculate_googlebooks_confidence(&book.volume_info), - last_updated: Utc::now(), - })) - }, - _ => Ok(None), - } - } - - /// Try to enrich by searching with title and author - /// - /// # Errors - /// - /// Returns an error if the metadata cannot be serialized. - pub async fn enrich_by_search( - &self, - title: &str, - author: Option<&str>, - ) -> Result> { - // Try OpenLibrary search first - if let Ok(results) = self.openlibrary.search(title, author).await - && let Some(result) = results.first() - { - let metadata_json = serde_json::to_string(result).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {e}")) - })?; - - return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), - source: EnrichmentSourceType::OpenLibrary, - external_id: result.key.clone(), - metadata_json, - confidence: 0.6, // Lower confidence for search results - last_updated: Utc::now(), - })); - } - - // Fall back to Google Books - if let Ok(results) = self.googlebooks.search(title, author).await - && let Some(book) = results.first() - { - let metadata_json = serde_json::to_string(book).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {e}")) - })?; - - return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), - source: EnrichmentSourceType::GoogleBooks, - external_id: Some(book.id.clone()), - metadata_json, - confidence: 0.6, - last_updated: Utc::now(), - })); - } - - Ok(None) - } -} - -#[async_trait::async_trait] -impl MetadataEnricher for BookEnricher { - fn source(&self) -> EnrichmentSourceType { - // Returns the preferred source - EnrichmentSourceType::OpenLibrary - } - - async fn enrich(&self, item: &MediaItem) -> Result> { - // Try ISBN-based enrichment first by checking title/description for ISBN - // patterns - if let Some(ref title) = item.title { - if let Some(isbn) = crate::books::extract_isbn_from_text(title) { - if let Some(mut metadata) = self.try_openlibrary(&isbn).await? { - metadata.media_id = item.id; - return Ok(Some(metadata)); - } - if let Some(mut metadata) = self.try_googlebooks(&isbn).await? { - metadata.media_id = item.id; - return Ok(Some(metadata)); - } - } - - // Fall back to title/author search - let author = item.artist.as_deref(); - return self.enrich_by_search(title, author).await; - } - - // No title available - Ok(None) - } -} - -/// Calculate confidence score for `OpenLibrary` metadata -#[must_use] -pub fn calculate_openlibrary_confidence( - book: &super::openlibrary::OpenLibraryBook, -) -> f64 { - let mut score: f64 = 0.5; // Base score - - if book.title.is_some() { - score += 0.1; - } - if !book.authors.is_empty() { - score += 0.1; - } - if !book.publishers.is_empty() { - score += 0.05; - } - if book.publish_date.is_some() { - score += 0.05; - } - if book.description.is_some() { - score += 0.1; - } - if !book.covers.is_empty() { - score += 0.1; - } - - score.min(1.0) -} - -/// Calculate confidence score for Google Books metadata -#[must_use] -pub fn calculate_googlebooks_confidence( - info: &super::googlebooks::VolumeInfo, -) -> f64 { - let mut score: f64 = 0.5; // Base score - - if info.title.is_some() { - score += 0.1; - } - if !info.authors.is_empty() { - score += 0.1; - } - if info.publisher.is_some() { - score += 0.05; - } - if info.published_date.is_some() { - score += 0.05; - } - if info.description.is_some() { - score += 0.1; - } - if info.image_links.is_some() { - score += 0.1; - } - - score.min(1.0) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_openlibrary_confidence_calculation() { - let book = super::super::openlibrary::OpenLibraryBook { - title: Some("Test Book".to_string()), - subtitle: None, - authors: vec![], - publishers: vec![], - publish_date: None, - number_of_pages: None, - subjects: vec![], - covers: vec![], - isbn_10: vec![], - isbn_13: vec![], - series: vec![], - description: None, - languages: vec![], - }; - - let confidence = calculate_openlibrary_confidence(&book); - assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title - } - - #[test] - fn test_googlebooks_confidence_calculation() { - let info = super::super::googlebooks::VolumeInfo { - title: Some("Test Book".to_string()), - ..Default::default() - }; - - let confidence = calculate_googlebooks_confidence(&info); - assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title - } -} diff --git a/crates/pinakes-core/src/enrichment/googlebooks.rs b/crates/pinakes-core/src/enrichment/googlebooks.rs deleted file mode 100644 index abfb118..0000000 --- a/crates/pinakes-core/src/enrichment/googlebooks.rs +++ /dev/null @@ -1,295 +0,0 @@ -use std::fmt::Write as _; - -use serde::{Deserialize, Serialize}; - -use crate::error::{PinakesError, Result}; - -/// Google Books API client for book metadata enrichment -pub struct GoogleBooksClient { - client: reqwest::Client, - api_key: Option, -} - -impl GoogleBooksClient { - /// Create a new `GoogleBooksClient`. - #[must_use] - pub fn new(api_key: Option) -> Self { - let client = reqwest::Client::builder() - .user_agent("Pinakes/1.0") - .timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { client, api_key } - } - - /// Fetch book metadata by ISBN - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// parsed. - pub async fn fetch_by_isbn(&self, isbn: &str) -> Result> { - let mut url = - format!("https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}"); - - if let Some(ref key) = self.api_key { - let _ = write!(url, "&key={key}"); - } - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Google Books request failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Google Books returned status: {}", - response.status() - ))); - } - - let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { - PinakesError::External(format!( - "Failed to parse Google Books response: {e}" - )) - })?; - - Ok(volumes.items) - } - - /// Search for books by title and author - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// parsed. - pub async fn search( - &self, - title: &str, - author: Option<&str>, - ) -> Result> { - let mut query = format!("intitle:{}", urlencoding::encode(title)); - - if let Some(author) = author { - let _ = write!(query, "+inauthor:{}", urlencoding::encode(author)); - } - - let mut url = format!( - "https://www.googleapis.com/books/v1/volumes?q={query}&maxResults=5" - ); - - if let Some(ref key) = self.api_key { - let _ = write!(url, "&key={key}"); - } - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Google Books search failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Google Books search returned status: {}", - response.status() - ))); - } - - let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { - PinakesError::External(format!("Failed to parse search results: {e}")) - })?; - - Ok(volumes.items) - } - - /// Download cover image from Google Books - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// read. - pub async fn fetch_cover(&self, image_link: &str) -> Result> { - // Replace thumbnail link with higher resolution if possible - let high_res_link = image_link - .replace("&zoom=1", "&zoom=2") - .replace("&edge=curl", ""); - - let response = - self.client.get(&high_res_link).send().await.map_err(|e| { - PinakesError::External(format!("Cover download failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Cover download returned status: {}", - response.status() - ))); - } - - response.bytes().await.map(|b| b.to_vec()).map_err(|e| { - PinakesError::External(format!("Failed to read cover data: {e}")) - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoogleBooksResponse { - #[serde(default)] - pub items: Vec, - - #[serde(default)] - pub total_items: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoogleBook { - pub id: String, - - #[serde(default)] - pub volume_info: VolumeInfo, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct VolumeInfo { - #[serde(default)] - pub title: Option, - - #[serde(default)] - pub subtitle: Option, - - #[serde(default)] - pub authors: Vec, - - #[serde(default)] - pub publisher: Option, - - #[serde(default)] - pub published_date: Option, - - #[serde(default)] - pub description: Option, - - #[serde(default)] - pub page_count: Option, - - #[serde(default)] - pub categories: Vec, - - #[serde(default)] - pub average_rating: Option, - - #[serde(default)] - pub ratings_count: Option, - - #[serde(default)] - pub image_links: Option, - - #[serde(default)] - pub language: Option, - - #[serde(default)] - pub industry_identifiers: Vec, - - #[serde(default)] - pub main_category: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImageLinks { - #[serde(default)] - pub small_thumbnail: Option, - - #[serde(default)] - pub thumbnail: Option, - - #[serde(default)] - pub small: Option, - - #[serde(default)] - pub medium: Option, - - #[serde(default)] - pub large: Option, - - #[serde(default)] - pub extra_large: Option, -} - -impl ImageLinks { - /// Get the best available image link (highest resolution) - #[must_use] - pub fn best_link(&self) -> Option<&String> { - self - .extra_large - .as_ref() - .or(self.large.as_ref()) - .or(self.medium.as_ref()) - .or(self.small.as_ref()) - .or(self.thumbnail.as_ref()) - .or(self.small_thumbnail.as_ref()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IndustryIdentifier { - #[serde(rename = "type")] - pub identifier_type: String, - - pub identifier: String, -} - -impl IndustryIdentifier { - /// Check if this is an ISBN-13 - #[must_use] - pub fn is_isbn13(&self) -> bool { - self.identifier_type == "ISBN_13" - } - - /// Check if this is an ISBN-10 - #[must_use] - pub fn is_isbn10(&self) -> bool { - self.identifier_type == "ISBN_10" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_googlebooks_client_creation() { - let client = GoogleBooksClient::new(None); - assert!(client.api_key.is_none()); - - let client_with_key = GoogleBooksClient::new(Some("test-key".to_string())); - assert_eq!(client_with_key.api_key, Some("test-key".to_string())); - } - - #[test] - fn test_image_links_best_link() { - let links = ImageLinks { - small_thumbnail: Some("small.jpg".to_string()), - thumbnail: Some("thumb.jpg".to_string()), - small: None, - medium: Some("medium.jpg".to_string()), - large: Some("large.jpg".to_string()), - extra_large: None, - }; - - assert_eq!(links.best_link(), Some(&"large.jpg".to_string())); - } - - #[test] - fn test_industry_identifier_type_checks() { - let isbn13 = IndustryIdentifier { - identifier_type: "ISBN_13".to_string(), - identifier: "9780123456789".to_string(), - }; - assert!(isbn13.is_isbn13()); - assert!(!isbn13.is_isbn10()); - - let isbn10 = IndustryIdentifier { - identifier_type: "ISBN_10".to_string(), - identifier: "0123456789".to_string(), - }; - assert!(!isbn10.is_isbn13()); - assert!(isbn10.is_isbn10()); - } -} diff --git a/crates/pinakes-core/src/enrichment/lastfm.rs b/crates/pinakes-core/src/enrichment/lastfm.rs deleted file mode 100644 index 9bde8c5..0000000 --- a/crates/pinakes-core/src/enrichment/lastfm.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Last.fm metadata enrichment for audio files. - -use std::time::Duration; - -use chrono::Utc; -use uuid::Uuid; - -use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; -use crate::{ - error::{PinakesError, Result}, - model::MediaItem, -}; - -pub struct LastFmEnricher { - client: reqwest::Client, - api_key: String, - base_url: String, -} - -impl LastFmEnricher { - /// Create a new `LastFmEnricher`. - #[must_use] - pub fn new(api_key: String) -> Self { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { - client, - api_key, - base_url: "https://ws.audioscrobbler.com/2.0".to_string(), - } - } -} - -#[async_trait::async_trait] -impl MetadataEnricher for LastFmEnricher { - fn source(&self) -> EnrichmentSourceType { - EnrichmentSourceType::LastFm - } - - async fn enrich(&self, item: &MediaItem) -> Result> { - let artist = match &item.artist { - Some(a) if !a.is_empty() => a, - _ => return Ok(None), - }; - - let title = match &item.title { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; - - let url = format!("{}/", self.base_url); - - let resp = self - .client - .get(&url) - .query(&[ - ("method", "track.getInfo"), - ("api_key", self.api_key.as_str()), - ("artist", artist.as_str()), - ("track", title.as_str()), - ("format", "json"), - ]) - .send() - .await - .map_err(|e| { - PinakesError::MetadataExtraction(format!("Last.fm request failed: {e}")) - })?; - - if !resp.status().is_success() { - return Ok(None); - } - - let body = resp.text().await.map_err(|e| { - PinakesError::MetadataExtraction(format!( - "Last.fm response read failed: {e}" - )) - })?; - - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - PinakesError::MetadataExtraction(format!( - "Last.fm JSON parse failed: {e}" - )) - })?; - - // Check for error response - if json.get("error").is_some() { - return Ok(None); - } - - let Some(track) = json.get("track") else { - return Ok(None); - }; - - let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from); - let listeners = track - .get("listeners") - .and_then(|l| l.as_str()) - .and_then(|l| l.parse::().ok()) - .unwrap_or(0.0); - // Normalize listeners to confidence (arbitrary scale) - let confidence = (listeners / 1_000_000.0).min(1.0); - - Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: item.id, - source: EnrichmentSourceType::LastFm, - external_id: mbid, - metadata_json: body, - confidence, - last_updated: Utc::now(), - })) - } -} diff --git a/crates/pinakes-core/src/enrichment/mod.rs b/crates/pinakes-core/src/enrichment/mod.rs deleted file mode 100644 index 16de3cb..0000000 --- a/crates/pinakes-core/src/enrichment/mod.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Metadata enrichment from external sources. - -pub mod books; -pub mod googlebooks; -pub mod lastfm; -pub mod musicbrainz; -pub mod openlibrary; -pub mod tmdb; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{ - error::Result, - model::{MediaId, MediaItem}, -}; - -/// Externally-sourced metadata for a media item. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExternalMetadata { - pub id: Uuid, - pub media_id: MediaId, - pub source: EnrichmentSourceType, - pub external_id: Option, - pub metadata_json: String, - pub confidence: f64, - pub last_updated: DateTime, -} - -/// Supported enrichment data sources. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum EnrichmentSourceType { - #[serde(rename = "musicbrainz")] - MusicBrainz, - #[serde(rename = "tmdb")] - Tmdb, - #[serde(rename = "lastfm")] - LastFm, - #[serde(rename = "openlibrary")] - OpenLibrary, - #[serde(rename = "googlebooks")] - GoogleBooks, -} - -impl std::fmt::Display for EnrichmentSourceType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Self::MusicBrainz => "musicbrainz", - Self::Tmdb => "tmdb", - Self::LastFm => "lastfm", - Self::OpenLibrary => "openlibrary", - Self::GoogleBooks => "googlebooks", - }; - write!(f, "{s}") - } -} - -impl std::str::FromStr for EnrichmentSourceType { - type Err = String; - - fn from_str(s: &str) -> std::result::Result { - match s { - "musicbrainz" => Ok(Self::MusicBrainz), - "tmdb" => Ok(Self::Tmdb), - "lastfm" => Ok(Self::LastFm), - "openlibrary" => Ok(Self::OpenLibrary), - "googlebooks" => Ok(Self::GoogleBooks), - _ => Err(format!("unknown enrichment source: {s}")), - } - } -} - -/// Trait for metadata enrichment providers. -#[async_trait::async_trait] -pub trait MetadataEnricher: Send + Sync { - fn source(&self) -> EnrichmentSourceType; - async fn enrich(&self, item: &MediaItem) -> Result>; -} diff --git a/crates/pinakes-core/src/enrichment/musicbrainz.rs b/crates/pinakes-core/src/enrichment/musicbrainz.rs deleted file mode 100644 index 9115f38..0000000 --- a/crates/pinakes-core/src/enrichment/musicbrainz.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! `MusicBrainz` metadata enrichment for audio files. - -use std::{fmt::Write as _, time::Duration}; - -use chrono::Utc; -use uuid::Uuid; - -use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; -use crate::{ - error::{PinakesError, Result}, - model::MediaItem, -}; - -pub struct MusicBrainzEnricher { - client: reqwest::Client, - base_url: String, -} - -impl Default for MusicBrainzEnricher { - fn default() -> Self { - Self::new() - } -} - -impl MusicBrainzEnricher { - /// Create a new `MusicBrainzEnricher`. - #[must_use] - pub fn new() -> Self { - let client = reqwest::Client::builder() - .user_agent("Pinakes/0.1 (https://github.com/notashelf/pinakes)") - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { - client, - base_url: "https://musicbrainz.org/ws/2".to_string(), - } - } -} - -fn escape_lucene_query(s: &str) -> String { - let special_chars = [ - '+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', - '?', ':', '\\', '/', - ]; - let mut escaped = String::with_capacity(s.len() * 2); - for c in s.chars() { - if special_chars.contains(&c) { - escaped.push('\\'); - } - escaped.push(c); - } - escaped -} - -#[async_trait::async_trait] -impl MetadataEnricher for MusicBrainzEnricher { - fn source(&self) -> EnrichmentSourceType { - EnrichmentSourceType::MusicBrainz - } - - async fn enrich(&self, item: &MediaItem) -> Result> { - let title = match &item.title { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; - - let mut query = format!("recording:{}", escape_lucene_query(title)); - if let Some(ref artist) = item.artist { - let _ = write!(query, " AND artist:{}", escape_lucene_query(artist)); - } - - let url = format!("{}/recording/", self.base_url); - - let resp = self - .client - .get(&url) - .query(&[ - ("query", &query), - ("fmt", &"json".to_string()), - ("limit", &"1".to_string()), - ]) - .send() - .await - .map_err(|e| { - PinakesError::MetadataExtraction(format!( - "MusicBrainz request failed: {e}" - )) - })?; - - if !resp.status().is_success() { - let status = resp.status(); - if status == reqwest::StatusCode::TOO_MANY_REQUESTS - || status == reqwest::StatusCode::SERVICE_UNAVAILABLE - { - return Err(PinakesError::MetadataExtraction(format!( - "MusicBrainz rate limited (HTTP {})", - status.as_u16() - ))); - } - return Ok(None); - } - - let body = resp.text().await.map_err(|e| { - PinakesError::MetadataExtraction(format!( - "MusicBrainz response read failed: {e}" - )) - })?; - - // Parse to check if we got results - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - PinakesError::MetadataExtraction(format!( - "MusicBrainz JSON parse failed: {e}" - )) - })?; - - let recordings = json.get("recordings").and_then(|r| r.as_array()); - if recordings.is_none_or(std::vec::Vec::is_empty) { - return Ok(None); - } - - let Some(recordings) = recordings else { - return Ok(None); - }; - let recording = &recordings[0]; - let external_id = recording - .get("id") - .and_then(|id| id.as_str()) - .map(String::from); - let score = (recording - .get("score") - .and_then(serde_json::Value::as_f64) - .unwrap_or(0.0) - / 100.0) - .min(1.0); - - Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: item.id, - source: EnrichmentSourceType::MusicBrainz, - external_id, - metadata_json: body, - confidence: score, - last_updated: Utc::now(), - })) - } -} diff --git a/crates/pinakes-core/src/enrichment/openlibrary.rs b/crates/pinakes-core/src/enrichment/openlibrary.rs deleted file mode 100644 index 02ca965..0000000 --- a/crates/pinakes-core/src/enrichment/openlibrary.rs +++ /dev/null @@ -1,308 +0,0 @@ -use std::fmt::Write as _; - -use serde::{Deserialize, Serialize}; - -use crate::error::{PinakesError, Result}; - -/// `OpenLibrary` API client for book metadata enrichment -pub struct OpenLibraryClient { - client: reqwest::Client, - base_url: String, -} - -impl Default for OpenLibraryClient { - fn default() -> Self { - Self::new() - } -} - -impl OpenLibraryClient { - /// Create a new `OpenLibraryClient`. - #[must_use] - pub fn new() -> Self { - let client = reqwest::Client::builder() - .user_agent("Pinakes/1.0") - .timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { - client, - base_url: "https://openlibrary.org".to_string(), - } - } - - /// Fetch book metadata by ISBN - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// parsed. - pub async fn fetch_by_isbn(&self, isbn: &str) -> Result { - let url = format!("{}/isbn/{}.json", self.base_url, isbn); - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("OpenLibrary request failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "OpenLibrary returned status: {}", - response.status() - ))); - } - - response.json::().await.map_err(|e| { - PinakesError::External(format!( - "Failed to parse OpenLibrary response: {e}" - )) - }) - } - - /// Search for books by title and author - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// parsed. - pub async fn search( - &self, - title: &str, - author: Option<&str>, - ) -> Result> { - let mut url = format!( - "{}/search.json?title={}", - self.base_url, - urlencoding::encode(title) - ); - - if let Some(author) = author { - let _ = write!(url, "&author={}", urlencoding::encode(author)); - } - - url.push_str("&limit=5"); - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("OpenLibrary search failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "OpenLibrary search returned status: {}", - response.status() - ))); - } - - let search_response: OpenLibrarySearchResponse = - response.json().await.map_err(|e| { - PinakesError::External(format!("Failed to parse search results: {e}")) - })?; - - Ok(search_response.docs) - } - - /// Fetch cover image by cover ID - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// read. - pub async fn fetch_cover( - &self, - cover_id: i64, - size: CoverSize, - ) -> Result> { - let size_str = match size { - CoverSize::Small => "S", - CoverSize::Medium => "M", - CoverSize::Large => "L", - }; - - let url = - format!("https://covers.openlibrary.org/b/id/{cover_id}-{size_str}.jpg"); - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Cover download failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Cover download returned status: {}", - response.status() - ))); - } - - response.bytes().await.map(|b| b.to_vec()).map_err(|e| { - PinakesError::External(format!("Failed to read cover data: {e}")) - }) - } - - /// Fetch cover by ISBN - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// read. - pub async fn fetch_cover_by_isbn( - &self, - isbn: &str, - size: CoverSize, - ) -> Result> { - let size_str = match size { - CoverSize::Small => "S", - CoverSize::Medium => "M", - CoverSize::Large => "L", - }; - - let url = - format!("https://covers.openlibrary.org/b/isbn/{isbn}-{size_str}.jpg"); - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Cover download failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Cover download returned status: {}", - response.status() - ))); - } - - response.bytes().await.map(|b| b.to_vec()).map_err(|e| { - PinakesError::External(format!("Failed to read cover data: {e}")) - }) - } -} - -#[derive(Debug, Clone, Copy)] -pub enum CoverSize { - Small, // 256x256 - Medium, // 600x800 - Large, // Original -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenLibraryBook { - #[serde(default)] - pub title: Option, - - #[serde(default)] - pub subtitle: Option, - - #[serde(default)] - pub authors: Vec, - - #[serde(default)] - pub publishers: Vec, - - #[serde(default)] - pub publish_date: Option, - - #[serde(default)] - pub number_of_pages: Option, - - #[serde(default)] - pub subjects: Vec, - - #[serde(default)] - pub covers: Vec, - - #[serde(default)] - pub isbn_10: Vec, - - #[serde(default)] - pub isbn_13: Vec, - - #[serde(default)] - pub series: Vec, - - #[serde(default)] - pub description: Option, - - #[serde(default)] - pub languages: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthorRef { - pub key: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LanguageRef { - pub key: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum StringOrObject { - String(String), - Object { value: String }, -} - -impl StringOrObject { - #[must_use] - pub fn as_str(&self) -> &str { - match self { - Self::String(s) => s, - Self::Object { value } => value, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenLibrarySearchResponse { - #[serde(default)] - pub docs: Vec, - - #[serde(default)] - pub num_found: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenLibrarySearchResult { - #[serde(default)] - pub key: Option, - - #[serde(default)] - pub title: Option, - - #[serde(default)] - pub author_name: Vec, - - #[serde(default)] - pub first_publish_year: Option, - - #[serde(default)] - pub publisher: Vec, - - #[serde(default)] - pub isbn: Vec, - - #[serde(default)] - pub cover_i: Option, - - #[serde(default)] - pub subject: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_openlibrary_client_creation() { - let client = OpenLibraryClient::new(); - assert_eq!(client.base_url, "https://openlibrary.org"); - } - - #[test] - fn test_string_or_object_parsing() { - let string_desc: StringOrObject = - serde_json::from_str(r#""Simple description""#).unwrap(); - assert_eq!(string_desc.as_str(), "Simple description"); - - let object_desc: StringOrObject = - serde_json::from_str(r#"{"value": "Object description"}"#).unwrap(); - assert_eq!(object_desc.as_str(), "Object description"); - } -} diff --git a/crates/pinakes-core/src/enrichment/tmdb.rs b/crates/pinakes-core/src/enrichment/tmdb.rs deleted file mode 100644 index 146deb8..0000000 --- a/crates/pinakes-core/src/enrichment/tmdb.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! TMDB (The Movie Database) metadata enrichment for video files. - -use std::time::Duration; - -use chrono::Utc; -use uuid::Uuid; - -use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; -use crate::{ - error::{PinakesError, Result}, - model::MediaItem, -}; - -pub struct TmdbEnricher { - client: reqwest::Client, - api_key: String, - base_url: String, -} - -impl TmdbEnricher { - /// Create a new `TMDb` enricher. - /// - /// # Panics - /// - /// Panics if the HTTP client cannot be built (programming error in client - /// configuration). - #[must_use] - pub fn new(api_key: String) -> Self { - Self { - client: reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .expect("failed to build HTTP client with configured timeouts"), - api_key, - base_url: "https://api.themoviedb.org/3".to_string(), - } - } -} - -#[async_trait::async_trait] -impl MetadataEnricher for TmdbEnricher { - fn source(&self) -> EnrichmentSourceType { - EnrichmentSourceType::Tmdb - } - - async fn enrich(&self, item: &MediaItem) -> Result> { - let title = match &item.title { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; - - let url = format!("{}/search/movie", self.base_url); - - let resp = self - .client - .get(&url) - .query(&[ - ("api_key", &self.api_key), - ("query", &title.clone()), - ("page", &"1".to_string()), - ]) - .send() - .await - .map_err(|e| { - PinakesError::MetadataExtraction(format!("TMDB request failed: {e}")) - })?; - - if !resp.status().is_success() { - let status = resp.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - return Err(PinakesError::MetadataExtraction( - "TMDB API key is invalid (401)".into(), - )); - } - if status == reqwest::StatusCode::TOO_MANY_REQUESTS { - tracing::warn!("TMDB rate limit exceeded (429)"); - return Ok(None); - } - tracing::debug!(status = %status, "TMDB search returned non-success status"); - return Ok(None); - } - - let body = resp.text().await.map_err(|e| { - PinakesError::MetadataExtraction(format!( - "TMDB response read failed: {e}" - )) - })?; - - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - PinakesError::MetadataExtraction(format!("TMDB JSON parse failed: {e}")) - })?; - - let results = json.get("results").and_then(|r| r.as_array()); - if results.is_none_or(std::vec::Vec::is_empty) { - return Ok(None); - } - - let Some(results) = results else { - return Ok(None); - }; - let movie = &results[0]; - let external_id = match movie.get("id").and_then(serde_json::Value::as_i64) - { - Some(id) => id.to_string(), - None => return Ok(None), - }; - let popularity = movie - .get("popularity") - .and_then(serde_json::Value::as_f64) - .unwrap_or(0.0); - // Normalize popularity to 0-1 range (TMDB popularity can be very high) - let confidence = (popularity / 100.0).min(1.0); - - Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: item.id, - source: EnrichmentSourceType::Tmdb, - external_id: Some(external_id), - metadata_json: body, - confidence, - last_updated: Utc::now(), - })) - } -} diff --git a/crates/pinakes-core/src/error.rs b/crates/pinakes-core/src/error.rs index 7e43726..f96beac 100644 --- a/crates/pinakes-core/src/error.rs +++ b/crates/pinakes-core/src/error.rs @@ -1,146 +1,9 @@ -use std::path::PathBuf; +//! Error types for pinakes-core. +//! +//! Re-exports from [`pinakes_types::error`] for use within this crate. +pub use pinakes_types::error::{PinakesError, Result}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum PinakesError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("database error: {0}")] - Database(String), - - #[error("migration error: {0}")] - Migration(String), - - #[error("configuration error: {0}")] - Config(String), - - #[error("media item not found: {0}")] - NotFound(String), - - #[error("duplicate content hash: {0}")] - DuplicateHash(String), - - #[error("unsupported media type for path: {0}")] - UnsupportedMediaType(PathBuf), - - #[error("metadata extraction failed: {0}")] - MetadataExtraction(String), - - #[error("thumbnail generation failed: {0}")] - ThumbnailGeneration(String), - - #[error("search query parse error: {0}")] - SearchParse(String), - - #[error("file not found at path: {0}")] - FileNotFound(PathBuf), - - #[error("tag not found: {0}")] - TagNotFound(String), - - #[error("collection not found: {0}")] - CollectionNotFound(String), - - #[error("invalid operation: {0}")] - InvalidOperation(String), - - #[error("invalid data: {0}")] - InvalidData(String), - - #[error("authentication error: {0}")] - Authentication(String), - - #[error("authorization error: {0}")] - Authorization(String), - - #[error("path not allowed: {0}")] - PathNotAllowed(String), - - #[error("external API error: {0}")] - External(String), - - // Managed Storage errors - #[error("managed storage not enabled")] - ManagedStorageDisabled, - - #[error("upload too large: {0} bytes exceeds limit")] - UploadTooLarge(u64), - - #[error("blob not found: {0}")] - BlobNotFound(String), - - #[error("storage integrity error: {0}")] - StorageIntegrity(String), - - // Sync errors - #[error("sync not enabled")] - SyncDisabled, - - #[error("device not found: {0}")] - DeviceNotFound(String), - - #[error("sync conflict: {0}")] - SyncConflict(String), - - #[error("upload session expired: {0}")] - UploadSessionExpired(String), - - #[error("upload session not found: {0}")] - UploadSessionNotFound(String), - - #[error("chunk out of order: expected {expected}, got {actual}")] - ChunkOutOfOrder { expected: u64, actual: u64 }, - - // Sharing errors - #[error("share not found: {0}")] - ShareNotFound(String), - - #[error("share expired: {0}")] - ShareExpired(String), - - #[error("share password required")] - SharePasswordRequired, - - #[error("share password invalid")] - SharePasswordInvalid, - - #[error("insufficient share permissions")] - InsufficientSharePermissions, - - #[error("serialization error: {0}")] - Serialization(String), - - #[error("external tool `{tool}` failed: {stderr}")] - ExternalTool { tool: String, stderr: String }, - - #[error("subtitle track {index} not found in media")] - SubtitleTrackNotFound { index: u32 }, - - #[error("invalid language code: {0}")] - InvalidLanguageCode(String), -} - -impl From for PinakesError { - fn from(e: rusqlite::Error) -> Self { - Self::Database(e.to_string()) - } -} - -impl From for PinakesError { - fn from(e: tokio_postgres::Error) -> Self { - Self::Database(e.to_string()) - } -} - -impl From for PinakesError { - fn from(e: serde_json::Error) -> Self { - Self::Serialization(e.to_string()) - } -} - -/// Build a closure that wraps a database error with operation context. +/// Create a curried error mapper with operation context. /// /// Usage: `stmt.execute(params).map_err(db_ctx("insert_media", media_id))?;` pub fn db_ctx( @@ -150,5 +13,3 @@ pub fn db_ctx( let context = format!("{operation} [{entity}]"); move |e| PinakesError::Database(format!("{context}: {e}")) } - -pub type Result = std::result::Result; diff --git a/crates/pinakes-core/src/import.rs b/crates/pinakes-core/src/import.rs index 7bae8a3..0981a96 100644 --- a/crates/pinakes-core/src/import.rs +++ b/crates/pinakes-core/src/import.rs @@ -12,7 +12,6 @@ use crate::{ hash::compute_file_hash, links, media_type::{BuiltinMediaType, MediaType}, - metadata, model::{ AuditAction, CustomField, @@ -183,7 +182,7 @@ pub async fn import_file_with_options( let path_clone = path.clone(); let media_type_clone = media_type.clone(); tokio::task::spawn_blocking(move || { - metadata::extract_metadata(&path_clone, &media_type_clone) + pinakes_metadata::extract_metadata(&path_clone, &media_type_clone) }) .await .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? @@ -227,7 +226,7 @@ pub async fn import_file_with_options( let perceptual_hash = if options.photo_config.generate_perceptual_hash() && media_type.category() == crate::media_type::MediaCategory::Image { - crate::metadata::image::generate_perceptual_hash(&path) + pinakes_metadata::image::generate_perceptual_hash(&path) } else { None }; diff --git a/crates/pinakes-core/src/lib.rs b/crates/pinakes-core/src/lib.rs index 17fdb19..3920f0f 100644 --- a/crates/pinakes-core/src/lib.rs +++ b/crates/pinakes-core/src/lib.rs @@ -4,7 +4,6 @@ pub mod books; pub mod cache; pub mod collections; pub mod config; -pub mod enrichment; pub mod error; pub mod events; pub mod export; @@ -14,9 +13,7 @@ pub mod integrity; pub mod jobs; pub mod links; pub mod managed_storage; -pub mod media_type; -pub mod metadata; -pub mod model; +pub use pinakes_types::{media_type, model}; pub mod opener; pub mod path_validation; pub mod playlists; diff --git a/crates/pinakes-core/src/metadata/audio.rs b/crates/pinakes-core/src/metadata/audio.rs deleted file mode 100644 index 576f511..0000000 --- a/crates/pinakes-core/src/metadata/audio.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::path::Path; - -use lofty::{ - file::{AudioFile, TaggedFileExt}, - tag::Accessor, -}; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::{PinakesError, Result}, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct AudioExtractor; - -impl MetadataExtractor for AudioExtractor { - fn extract(&self, path: &Path) -> Result { - let tagged_file = lofty::read_from_path(path).map_err(|e| { - PinakesError::MetadataExtraction(format!("audio metadata: {e}")) - })?; - - let mut meta = ExtractedMetadata::default(); - - if let Some(tag) = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()) - { - meta.title = tag.title().map(|s| s.to_string()); - meta.artist = tag.artist().map(|s| s.to_string()); - meta.album = tag.album().map(|s| s.to_string()); - meta.genre = tag.genre().map(|s| s.to_string()); - meta.year = tag.date().map(|ts| i32::from(ts.year)); - } - - if let Some(tag) = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()) - { - if let Some(track) = tag.track() { - meta - .extra - .insert("track_number".to_string(), track.to_string()); - } - if let Some(disc) = tag.disk() { - meta - .extra - .insert("disc_number".to_string(), disc.to_string()); - } - if let Some(comment) = tag.comment() { - meta - .extra - .insert("comment".to_string(), comment.to_string()); - } - } - - let properties = tagged_file.properties(); - let duration = properties.duration(); - if !duration.is_zero() { - meta.duration_secs = Some(duration.as_secs_f64()); - } - - if let Some(bitrate) = properties.audio_bitrate() { - meta - .extra - .insert("bitrate".to_string(), format!("{bitrate} kbps")); - } - if let Some(sample_rate) = properties.sample_rate() { - meta - .extra - .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); - } - if let Some(channels) = properties.channels() { - meta - .extra - .insert("channels".to_string(), channels.to_string()); - } - - Ok(meta) - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Mp3), - MediaType::Builtin(BuiltinMediaType::Flac), - MediaType::Builtin(BuiltinMediaType::Ogg), - MediaType::Builtin(BuiltinMediaType::Wav), - MediaType::Builtin(BuiltinMediaType::Aac), - MediaType::Builtin(BuiltinMediaType::Opus), - ] - } -} diff --git a/crates/pinakes-core/src/metadata/document.rs b/crates/pinakes-core/src/metadata/document.rs deleted file mode 100644 index 395e18b..0000000 --- a/crates/pinakes-core/src/metadata/document.rs +++ /dev/null @@ -1,372 +0,0 @@ -use std::path::Path; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::{PinakesError, Result}, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct DocumentExtractor; - -impl MetadataExtractor for DocumentExtractor { - fn extract(&self, path: &Path) -> Result { - match MediaType::from_path(path) { - Some(MediaType::Builtin(BuiltinMediaType::Pdf)) => extract_pdf(path), - Some(MediaType::Builtin(BuiltinMediaType::Epub)) => extract_epub(path), - Some(MediaType::Builtin(BuiltinMediaType::Djvu)) => extract_djvu(path), - _ => Ok(ExtractedMetadata::default()), - } - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Pdf), - MediaType::Builtin(BuiltinMediaType::Epub), - MediaType::Builtin(BuiltinMediaType::Djvu), - ] - } -} - -fn extract_pdf(path: &Path) -> Result { - let doc = lopdf::Document::load(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?; - - let mut meta = ExtractedMetadata::default(); - let mut book_meta = crate::model::BookMetadata::default(); - - // Find the Info dictionary via the trailer - if let Ok(info_ref) = doc.trailer.get(b"Info") { - let info_obj = info_ref - .as_reference() - .map_or(Some(info_ref), |reference| doc.get_object(reference).ok()); - - if let Some(obj) = info_obj - && let Ok(dict) = obj.as_dict() - { - if let Ok(title) = dict.get(b"Title") { - meta.title = pdf_object_to_string(title); - } - if let Ok(author) = dict.get(b"Author") { - let author_str = pdf_object_to_string(author); - meta.artist.clone_from(&author_str); - - // Parse multiple authors if separated by semicolon, comma, or "and" - if let Some(authors_str) = author_str { - book_meta.authors = authors_str - .split(&[';', ','][..]) - .flat_map(|part| part.split(" and ")) - .map(|name| name.trim().to_string()) - .filter(|name| !name.is_empty()) - .enumerate() - .map(|(pos, name)| { - let mut author = crate::model::AuthorInfo::new(name); - author.position = i32::try_from(pos).unwrap_or(i32::MAX); - author - }) - .collect(); - } - } - if let Ok(subject) = dict.get(b"Subject") { - meta.description = pdf_object_to_string(subject); - } - if let Ok(creator) = dict.get(b"Creator") { - meta.extra.insert( - "creator".to_string(), - pdf_object_to_string(creator).unwrap_or_default(), - ); - } - if let Ok(producer) = dict.get(b"Producer") { - meta.extra.insert( - "producer".to_string(), - pdf_object_to_string(producer).unwrap_or_default(), - ); - } - } - } - - // Page count - let pages = doc.get_pages(); - let page_count = pages.len(); - if page_count > 0 { - book_meta.page_count = Some(i32::try_from(page_count).unwrap_or(i32::MAX)); - } - - // Try to extract ISBN from first few pages - // Extract text from up to the first 5 pages and search for ISBN patterns - let mut extracted_text = String::new(); - let max_pages = page_count.min(5); - - for (_page_num, page_id) in pages.iter().take(max_pages) { - if let Ok(content) = doc.get_page_content(*page_id) { - // PDF content streams contain raw operators, but may have text strings - if let Ok(text) = std::str::from_utf8(&content) { - extracted_text.push_str(text); - extracted_text.push(' '); - } - } - } - - // Extract ISBN from the text - if let Some(isbn) = crate::books::extract_isbn_from_text(&extracted_text) - && let Ok(normalized) = crate::books::normalize_isbn(&isbn) - { - book_meta.isbn13 = Some(normalized); - book_meta.isbn = Some(isbn); - } - - // Set format - book_meta.format = Some("pdf".to_string()); - - meta.book_metadata = Some(book_meta); - Ok(meta) -} - -fn pdf_object_to_string(obj: &lopdf::Object) -> Option { - match obj { - lopdf::Object::String(bytes, _) => { - Some(String::from_utf8_lossy(bytes).into_owned()) - }, - lopdf::Object::Name(name) => { - Some(String::from_utf8_lossy(name).into_owned()) - }, - _ => None, - } -} - -fn extract_epub(path: &Path) -> Result { - let mut doc = epub::doc::EpubDoc::new(path).map_err(|e| { - PinakesError::MetadataExtraction(format!("EPUB parse: {e}")) - })?; - - let mut meta = ExtractedMetadata { - title: doc.mdata("title").map(|item| item.value.clone()), - artist: doc.mdata("creator").map(|item| item.value.clone()), - description: doc.mdata("description").map(|item| item.value.clone()), - ..Default::default() - }; - - let mut book_meta = crate::model::BookMetadata::default(); - - // Extract basic metadata - if let Some(lang) = doc.mdata("language") { - book_meta.language = Some(lang.value.clone()); - } - if let Some(publisher) = doc.mdata("publisher") { - book_meta.publisher = Some(publisher.value.clone()); - } - if let Some(date) = doc.mdata("date") { - // Try to parse as YYYY-MM-DD or just YYYY - if let Ok(parsed_date) = - chrono::NaiveDate::parse_from_str(&date.value, "%Y-%m-%d") - { - book_meta.publication_date = Some(parsed_date); - } else if let Ok(year) = date.value.parse::() { - book_meta.publication_date = chrono::NaiveDate::from_ymd_opt(year, 1, 1); - } - } - - // Extract authors - iterate through all metadata items - let mut authors = Vec::new(); - let mut position = 0; - for item in &doc.metadata { - if item.property == "creator" || item.property == "dc:creator" { - let mut author = crate::model::AuthorInfo::new(item.value.clone()); - author.position = position; - position += 1; - - // Check for file-as in refinements - if let Some(file_as_ref) = item.refinement("file-as") { - author.file_as = Some(file_as_ref.value.clone()); - } - - // Check for role in refinements - if let Some(role_ref) = item.refinement("role") { - author.role.clone_from(&role_ref.value); - } - - authors.push(author); - } - } - book_meta.authors = authors; - - // Extract ISBNs from identifiers - let mut identifiers = rustc_hash::FxHashMap::default(); - for item in &doc.metadata { - if item.property == "identifier" || item.property == "dc:identifier" { - // Try to get scheme from refinements - let scheme = item - .refinement("identifier-type") - .map(|r| r.value.to_lowercase()); - - let id_type = match scheme.as_deref() { - Some("isbn" | "isbn-10" | "isbn10") => "isbn", - Some("isbn-13" | "isbn13") => "isbn13", - Some("asin") => "asin", - Some("doi") => "doi", - _ => { - // Fallback: detect from value pattern. - // ISBN-10 = 10 chars bare, ISBN-13 = 13 chars bare, - // hyphenated ISBN-13 = 17 chars (e.g. 978-0-123-45678-9). - // Parentheses required: && binds tighter than ||. - if (item.value.len() == 10 || item.value.len() == 13) - || (item.value.contains('-') - && (item.value.len() == 13 || item.value.len() == 17)) - { - "isbn" - } else { - "other" - } - }, - }; - - // Try to normalize ISBN - if (id_type == "isbn" || id_type == "isbn13") - && let Ok(normalized) = crate::books::normalize_isbn(&item.value) - { - book_meta.isbn13 = Some(normalized.clone()); - book_meta.isbn = Some(item.value.clone()); - } - - identifiers - .entry(id_type.to_string()) - .or_insert_with(Vec::new) - .push(item.value.clone()); - } - } - book_meta.identifiers = identifiers; - - // Extract Calibre series metadata by parsing the content.opf file - // Try common OPF locations - let opf_paths = vec!["OEBPS/content.opf", "content.opf", "OPS/content.opf"]; - let mut opf_data = None; - for path in opf_paths { - if let Some(data) = doc.get_resource_str_by_path(path) { - opf_data = Some(data); - break; - } - } - - if let Some(opf_content) = opf_data { - // Look for - if let Some(series_start) = opf_content.find("name=\"calibre:series\"") - && let Some(content_start) = - opf_content[series_start..].find("content=\"") - { - let after_content = &opf_content[series_start + content_start + 9..]; - if let Some(quote_end) = after_content.find('"') { - book_meta.series_name = Some(after_content[..quote_end].to_string()); - } - } - - // Look for - if let Some(index_start) = opf_content.find("name=\"calibre:series_index\"") - && let Some(content_start) = opf_content[index_start..].find("content=\"") - { - let after_content = &opf_content[index_start + content_start + 9..]; - if let Some(quote_end) = after_content.find('"') - && let Ok(index) = after_content[..quote_end].parse::() - { - book_meta.series_index = Some(index); - } - } - } - - // Set format - book_meta.format = Some("epub".to_string()); - - meta.book_metadata = Some(book_meta); - Ok(meta) -} - -fn extract_djvu(path: &Path) -> Result { - // DjVu files contain metadata in SEXPR (S-expression) format within - // ANTa/ANTz chunks, or in the DIRM chunk. We parse the raw bytes to - // extract any metadata fields we can find. - - // Guard against loading very large DjVu files into memory. - const MAX_DJVU_SIZE: u64 = 50 * 1024 * 1024; // 50 MB - let file_meta = std::fs::metadata(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("DjVu stat: {e}")))?; - if file_meta.len() > MAX_DJVU_SIZE { - return Ok(ExtractedMetadata::default()); - } - - let data = std::fs::read(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("DjVu read: {e}")))?; - - let mut meta = ExtractedMetadata::default(); - - // DjVu files start with "AT&T" magic followed by FORM:DJVU or FORM:DJVM - if data.len() < 16 { - return Ok(meta); - } - - // Search for metadata annotations in the file. DjVu metadata is stored - // as S-expressions like (metadata (key "value") ...) within ANTa chunks. - let content = String::from_utf8_lossy(&data); - - // Look for (metadata ...) blocks - if let Some(meta_start) = content.find("(metadata") { - let remainder = &content[meta_start..]; - // Extract key-value pairs like (title "Some Title") - extract_djvu_field(remainder, "title", &mut meta.title); - extract_djvu_field(remainder, "author", &mut meta.artist); - - let mut desc = None; - extract_djvu_field(remainder, "subject", &mut desc); - if desc.is_none() { - extract_djvu_field(remainder, "description", &mut desc); - } - meta.description = desc; - - let mut year_str = None; - extract_djvu_field(remainder, "year", &mut year_str); - if let Some(ref y) = year_str { - meta.year = y.parse().ok(); - } - - let mut creator = None; - extract_djvu_field(remainder, "creator", &mut creator); - if let Some(c) = creator { - meta.extra.insert("creator".to_string(), c); - } - } - - // Also check for booklet-style metadata that some DjVu encoders write - // outside the metadata SEXPR - if meta.title.is_none() - && let Some(title_start) = content.find("(bookmarks") - { - let remainder = &content[title_start..]; - // First bookmark title is often the document title - if let Some(q1) = remainder.find('"') { - let after_q1 = &remainder[q1 + 1..]; - if let Some(q2) = after_q1.find('"') { - let val = &after_q1[..q2]; - if !val.is_empty() { - meta.title = Some(val.to_string()); - } - } - } - } - - Ok(meta) -} - -fn extract_djvu_field(sexpr: &str, key: &str, out: &mut Option) { - // Look for patterns like (key "value") in the S-expression - let pattern = format!("({key}"); - if let Some(start) = sexpr.find(&pattern) { - let remainder = &sexpr[start + pattern.len()..]; - // Find the quoted value - if let Some(q1) = remainder.find('"') { - let after_q1 = &remainder[q1 + 1..]; - if let Some(q2) = after_q1.find('"') { - let val = &after_q1[..q2]; - if !val.is_empty() { - *out = Some(val.to_string()); - } - } - } - } -} diff --git a/crates/pinakes-core/src/metadata/image.rs b/crates/pinakes-core/src/metadata/image.rs deleted file mode 100644 index 6652a82..0000000 --- a/crates/pinakes-core/src/metadata/image.rs +++ /dev/null @@ -1,299 +0,0 @@ -use std::path::Path; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::Result, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct ImageExtractor; - -impl MetadataExtractor for ImageExtractor { - fn extract(&self, path: &Path) -> Result { - let mut meta = ExtractedMetadata::default(); - - let file = std::fs::File::open(path)?; - let mut buf_reader = std::io::BufReader::new(&file); - - let Ok(exif_data) = - exif::Reader::new().read_from_container(&mut buf_reader) - else { - return Ok(meta); - }; - - // Image dimensions - if let Some(width) = exif_data - .get_field(exif::Tag::PixelXDimension, exif::In::PRIMARY) - .or_else(|| exif_data.get_field(exif::Tag::ImageWidth, exif::In::PRIMARY)) - && let Some(w) = field_to_u32(width) - { - meta.extra.insert("width".to_string(), w.to_string()); - } - if let Some(height) = exif_data - .get_field(exif::Tag::PixelYDimension, exif::In::PRIMARY) - .or_else(|| { - exif_data.get_field(exif::Tag::ImageLength, exif::In::PRIMARY) - }) - && let Some(h) = field_to_u32(height) - { - meta.extra.insert("height".to_string(), h.to_string()); - } - - // Camera make and model - set both in top-level fields and extra - if let Some(make) = exif_data.get_field(exif::Tag::Make, exif::In::PRIMARY) - { - let val = make.display_value().to_string().trim().to_string(); - if !val.is_empty() { - meta.camera_make = Some(val.clone()); - meta.extra.insert("camera_make".to_string(), val); - } - } - if let Some(model) = - exif_data.get_field(exif::Tag::Model, exif::In::PRIMARY) - { - let val = model.display_value().to_string().trim().to_string(); - if !val.is_empty() { - meta.camera_model = Some(val.clone()); - meta.extra.insert("camera_model".to_string(), val); - } - } - - // Date taken - parse EXIF date format (YYYY:MM:DD HH:MM:SS) - if let Some(date) = exif_data - .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) - .or_else(|| exif_data.get_field(exif::Tag::DateTime, exif::In::PRIMARY)) - { - let val = date.display_value().to_string(); - if !val.is_empty() { - // Try parsing EXIF format: "YYYY:MM:DD HH:MM:SS" - if let Some(dt) = parse_exif_datetime(&val) { - meta.date_taken = Some(dt); - } - meta.extra.insert("date_taken".to_string(), val); - } - } - - // GPS coordinates - set both in top-level fields and extra - if let (Some(lat), Some(lat_ref), Some(lon), Some(lon_ref)) = ( - exif_data.get_field(exif::Tag::GPSLatitude, exif::In::PRIMARY), - exif_data.get_field(exif::Tag::GPSLatitudeRef, exif::In::PRIMARY), - exif_data.get_field(exif::Tag::GPSLongitude, exif::In::PRIMARY), - exif_data.get_field(exif::Tag::GPSLongitudeRef, exif::In::PRIMARY), - ) && let (Some(lat_val), Some(lon_val)) = - (dms_to_decimal(lat, lat_ref), dms_to_decimal(lon, lon_ref)) - { - meta.latitude = Some(lat_val); - meta.longitude = Some(lon_val); - meta - .extra - .insert("gps_latitude".to_string(), format!("{lat_val:.6}")); - meta - .extra - .insert("gps_longitude".to_string(), format!("{lon_val:.6}")); - } - - // Exposure info - if let Some(iso) = - exif_data.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY) - { - let val = iso.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("iso".to_string(), val); - } - } - if let Some(exposure) = - exif_data.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY) - { - let val = exposure.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("exposure_time".to_string(), val); - } - } - if let Some(aperture) = - exif_data.get_field(exif::Tag::FNumber, exif::In::PRIMARY) - { - let val = aperture.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("f_number".to_string(), val); - } - } - if let Some(focal) = - exif_data.get_field(exif::Tag::FocalLength, exif::In::PRIMARY) - { - let val = focal.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("focal_length".to_string(), val); - } - } - - // Lens model - if let Some(lens) = - exif_data.get_field(exif::Tag::LensModel, exif::In::PRIMARY) - { - let val = lens.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta - .extra - .insert("lens_model".to_string(), val.trim_matches('"').to_string()); - } - } - - // Flash - if let Some(flash) = - exif_data.get_field(exif::Tag::Flash, exif::In::PRIMARY) - { - let val = flash.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("flash".to_string(), val); - } - } - - // Orientation - if let Some(orientation) = - exif_data.get_field(exif::Tag::Orientation, exif::In::PRIMARY) - { - let val = orientation.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("orientation".to_string(), val); - } - } - - // Software - if let Some(software) = - exif_data.get_field(exif::Tag::Software, exif::In::PRIMARY) - { - let val = software.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("software".to_string(), val); - } - } - - // Image description as title - if let Some(desc) = - exif_data.get_field(exif::Tag::ImageDescription, exif::In::PRIMARY) - { - let val = desc.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.title = Some(val.trim_matches('"').to_string()); - } - } - - // Artist - if let Some(artist) = - exif_data.get_field(exif::Tag::Artist, exif::In::PRIMARY) - { - let val = artist.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.artist = Some(val.trim_matches('"').to_string()); - } - } - - // Copyright as description - if let Some(copyright) = - exif_data.get_field(exif::Tag::Copyright, exif::In::PRIMARY) - { - let val = copyright.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.description = Some(val.trim_matches('"').to_string()); - } - } - - Ok(meta) - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Jpeg), - MediaType::Builtin(BuiltinMediaType::Png), - MediaType::Builtin(BuiltinMediaType::Gif), - MediaType::Builtin(BuiltinMediaType::Webp), - MediaType::Builtin(BuiltinMediaType::Avif), - MediaType::Builtin(BuiltinMediaType::Tiff), - MediaType::Builtin(BuiltinMediaType::Bmp), - // RAW formats (TIFF-based, kamadak-exif handles these) - MediaType::Builtin(BuiltinMediaType::Cr2), - MediaType::Builtin(BuiltinMediaType::Nef), - MediaType::Builtin(BuiltinMediaType::Arw), - MediaType::Builtin(BuiltinMediaType::Dng), - MediaType::Builtin(BuiltinMediaType::Orf), - MediaType::Builtin(BuiltinMediaType::Rw2), - // HEIC - MediaType::Builtin(BuiltinMediaType::Heic), - ] - } -} - -fn field_to_u32(field: &exif::Field) -> Option { - match &field.value { - exif::Value::Long(v) => v.first().copied(), - exif::Value::Short(v) => v.first().map(|&x| u32::from(x)), - _ => None, - } -} - -fn dms_to_decimal( - dms_field: &exif::Field, - ref_field: &exif::Field, -) -> Option { - if let exif::Value::Rational(ref rationals) = dms_field.value - && rationals.len() >= 3 - { - let degrees = rationals[0].to_f64(); - let minutes = rationals[1].to_f64(); - let seconds = rationals[2].to_f64(); - let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0; - - let ref_str = ref_field.display_value().to_string(); - if ref_str.contains('S') || ref_str.contains('W') { - decimal = -decimal; - } - - return Some(decimal); - } - None -} - -/// Parse EXIF datetime format: "YYYY:MM:DD HH:MM:SS" -fn parse_exif_datetime(s: &str) -> Option> { - use chrono::NaiveDateTime; - - // EXIF format is "YYYY:MM:DD HH:MM:SS" - let s = s.trim().trim_matches('"'); - - // Try standard EXIF format - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S") { - return Some(dt.and_utc()); - } - - // Try ISO format as fallback - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { - return Some(dt.and_utc()); - } - - None -} - -/// Generate a perceptual hash for an image file. -/// -/// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity -/// detection. Returns a hex-encoded hash string, or None if the image cannot be -/// processed. -#[must_use] -pub fn generate_perceptual_hash(path: &Path) -> Option { - use image_hasher::{HashAlg, HasherConfig}; - - // Open and decode the image - let img = image::open(path).ok()?; - - // Create hasher with DCT algorithm (good for finding similar images) - let hasher = HasherConfig::new() - .hash_alg(HashAlg::DoubleGradient) - .hash_size(8, 8) // 64-bit hash - .to_hasher(); - - // Generate hash - let hash = hasher.hash_image(&img); - - // Convert to hex string for storage - Some(hash.to_base64()) -} diff --git a/crates/pinakes-core/src/metadata/markdown.rs b/crates/pinakes-core/src/metadata/markdown.rs deleted file mode 100644 index 155a7e6..0000000 --- a/crates/pinakes-core/src/metadata/markdown.rs +++ /dev/null @@ -1,45 +0,0 @@ -use std::path::Path; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::Result, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct MarkdownExtractor; - -impl MetadataExtractor for MarkdownExtractor { - fn extract(&self, path: &Path) -> Result { - let content = std::fs::read_to_string(path)?; - let parsed = - gray_matter::Matter::::new().parse(&content); - - let mut meta = ExtractedMetadata::default(); - - if let Some(data) = parsed.ok().and_then(|p| p.data) - && let gray_matter::Pod::Hash(map) = data - { - if let Some(gray_matter::Pod::String(title)) = map.get("title") { - meta.title = Some(title.clone()); - } - if let Some(gray_matter::Pod::String(author)) = map.get("author") { - meta.artist = Some(author.clone()); - } - if let Some(gray_matter::Pod::String(desc)) = map.get("description") { - meta.description = Some(desc.clone()); - } - if let Some(gray_matter::Pod::String(date)) = map.get("date") { - meta.extra.insert("date".to_string(), date.clone()); - } - } - - Ok(meta) - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Markdown), - MediaType::Builtin(BuiltinMediaType::PlainText), - ] - } -} diff --git a/crates/pinakes-core/src/metadata/mod.rs b/crates/pinakes-core/src/metadata/mod.rs deleted file mode 100644 index b4e91e5..0000000 --- a/crates/pinakes-core/src/metadata/mod.rs +++ /dev/null @@ -1,70 +0,0 @@ -pub mod audio; -pub mod document; -pub mod image; -pub mod markdown; -pub mod video; - -use std::path::Path; - -use rustc_hash::FxHashMap; - -use crate::{error::Result, media_type::MediaType, model::BookMetadata}; - -#[derive(Debug, Clone, Default)] -pub struct ExtractedMetadata { - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub duration_secs: Option, - pub description: Option, - pub extra: FxHashMap, - pub book_metadata: Option, - - // Photo-specific metadata - pub date_taken: Option>, - pub latitude: Option, - pub longitude: Option, - pub camera_make: Option, - pub camera_model: Option, - pub rating: Option, -} - -pub trait MetadataExtractor: Send + Sync { - /// Extract metadata from a file at the given path. - /// - /// # Errors - /// - /// Returns an error if the file cannot be read or parsed. - fn extract(&self, path: &Path) -> Result; - fn supported_types(&self) -> Vec; -} - -/// Extract metadata from a file using the appropriate extractor for the given -/// media type. -/// -/// # Errors -/// -/// Returns an error if no extractor supports the media type, or if extraction -/// fails. -pub fn extract_metadata( - path: &Path, - media_type: &MediaType, -) -> Result { - let extractors: Vec> = vec![ - Box::new(audio::AudioExtractor), - Box::new(document::DocumentExtractor), - Box::new(video::VideoExtractor), - Box::new(markdown::MarkdownExtractor), - Box::new(image::ImageExtractor), - ]; - - for extractor in &extractors { - if extractor.supported_types().contains(media_type) { - return extractor.extract(path); - } - } - - Ok(ExtractedMetadata::default()) -} diff --git a/crates/pinakes-core/src/metadata/video.rs b/crates/pinakes-core/src/metadata/video.rs deleted file mode 100644 index a0c26f5..0000000 --- a/crates/pinakes-core/src/metadata/video.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::path::Path; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::{PinakesError, Result}, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct VideoExtractor; - -impl MetadataExtractor for VideoExtractor { - fn extract(&self, path: &Path) -> Result { - match MediaType::from_path(path) { - Some(MediaType::Builtin(BuiltinMediaType::Mkv)) => extract_mkv(path), - Some(MediaType::Builtin(BuiltinMediaType::Mp4)) => extract_mp4(path), - _ => Ok(ExtractedMetadata::default()), - } - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Mp4), - MediaType::Builtin(BuiltinMediaType::Mkv), - ] - } -} - -fn extract_mkv(path: &Path) -> Result { - let file = std::fs::File::open(path)?; - let mkv = matroska::Matroska::open(file) - .map_err(|e| PinakesError::MetadataExtraction(format!("MKV parse: {e}")))?; - - let mut meta = ExtractedMetadata { - title: mkv.info.title.clone(), - duration_secs: mkv.info.duration.map(|dur| dur.as_secs_f64()), - ..Default::default() - }; - - // Extract resolution and codec info from tracks - for track in &mkv.tracks { - match &track.settings { - matroska::Settings::Video(v) => { - meta.extra.insert( - "resolution".to_string(), - format!("{}x{}", v.pixel_width, v.pixel_height), - ); - if !track.codec_id.is_empty() { - meta - .extra - .insert("video_codec".to_string(), track.codec_id.clone()); - } - }, - matroska::Settings::Audio(a) => { - meta.extra.insert( - "sample_rate".to_string(), - format!("{:.0} Hz", a.sample_rate), - ); - meta - .extra - .insert("channels".to_string(), a.channels.to_string()); - if !track.codec_id.is_empty() { - meta - .extra - .insert("audio_codec".to_string(), track.codec_id.clone()); - } - }, - matroska::Settings::None => {}, - } - } - - Ok(meta) -} - -fn extract_mp4(path: &Path) -> Result { - use lofty::{ - file::{AudioFile, TaggedFileExt}, - tag::Accessor, - }; - - let tagged_file = lofty::read_from_path(path).map_err(|e| { - PinakesError::MetadataExtraction(format!("MP4 metadata: {e}")) - })?; - - let mut meta = ExtractedMetadata::default(); - - if let Some(tag) = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()) - { - meta.title = tag - .title() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.artist = tag - .artist() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.album = tag - .album() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.genre = tag - .genre() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.year = tag.date().map(|ts| i32::from(ts.year)); - } - - let properties = tagged_file.properties(); - let duration = properties.duration(); - if !duration.is_zero() { - meta.duration_secs = Some(duration.as_secs_f64()); - } - - if let Some(bitrate) = properties.audio_bitrate() { - meta - .extra - .insert("audio_bitrate".to_string(), format!("{bitrate} kbps")); - } - if let Some(sample_rate) = properties.sample_rate() { - meta - .extra - .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); - } - if let Some(channels) = properties.channels() { - meta - .extra - .insert("channels".to_string(), channels.to_string()); - } - - Ok(meta) -} diff --git a/crates/pinakes-core/src/model.rs b/crates/pinakes-core/src/model.rs deleted file mode 100644 index f37e7a1..0000000 --- a/crates/pinakes-core/src/model.rs +++ /dev/null @@ -1,659 +0,0 @@ -use std::{fmt, path::PathBuf}; - -use chrono::{DateTime, Utc}; -use rustc_hash::FxHashMap; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::media_type::MediaType; - -/// Unique identifier for a media item. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct MediaId(pub Uuid); - -impl MediaId { - /// Creates a new media ID using `UUIDv7`. - #[must_use] - pub fn new() -> Self { - Self(Uuid::now_v7()) - } -} - -impl fmt::Display for MediaId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Default for MediaId { - fn default() -> Self { - Self(uuid::Uuid::nil()) - } -} - -/// BLAKE3 content hash for deduplication. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ContentHash(pub String); - -impl ContentHash { - /// Creates a new content hash from a hex string. - #[must_use] - pub const fn new(hex: String) -> Self { - Self(hex) - } -} - -impl fmt::Display for ContentHash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Storage mode for media items -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, -)] -#[serde(rename_all = "lowercase")] -pub enum StorageMode { - /// File exists on disk, referenced by path - #[default] - External, - /// File is stored in managed content-addressable storage - Managed, -} - -impl fmt::Display for StorageMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::External => write!(f, "external"), - Self::Managed => write!(f, "managed"), - } - } -} - -impl std::str::FromStr for StorageMode { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "external" => Ok(Self::External), - "managed" => Ok(Self::Managed), - _ => Err(format!("unknown storage mode: {s}")), - } - } -} - -/// A blob stored in managed storage (content-addressable) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManagedBlob { - pub content_hash: ContentHash, - pub file_size: u64, - pub mime_type: String, - pub reference_count: u32, - pub stored_at: DateTime, - pub last_verified: Option>, -} - -/// Result of uploading a file to managed storage -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UploadResult { - pub media_id: MediaId, - pub content_hash: ContentHash, - pub was_duplicate: bool, - pub file_size: u64, -} - -/// Statistics about managed storage -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ManagedStorageStats { - pub total_blobs: u64, - pub total_size_bytes: u64, - pub unique_size_bytes: u64, - pub deduplication_ratio: f64, - pub managed_media_count: u64, - pub orphaned_blobs: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MediaItem { - pub id: MediaId, - pub path: PathBuf, - pub file_name: String, - pub media_type: MediaType, - pub content_hash: ContentHash, - pub file_size: u64, - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub duration_secs: Option, - pub description: Option, - pub thumbnail_path: Option, - pub custom_fields: FxHashMap, - /// File modification time (Unix timestamp in seconds), used for incremental - /// scanning - pub file_mtime: Option, - - // Photo-specific metadata - pub date_taken: Option>, - pub latitude: Option, - pub longitude: Option, - pub camera_make: Option, - pub camera_model: Option, - pub rating: Option, - pub perceptual_hash: Option, - - // Managed storage fields - /// How the file is stored (external on disk or managed in - /// content-addressable storage) - #[serde(default)] - pub storage_mode: StorageMode, - /// Original filename for uploaded files (preserved separately from - /// `file_name`) - pub original_filename: Option, - /// When the file was uploaded to managed storage - pub uploaded_at: Option>, - /// Storage key for looking up the blob (usually same as `content_hash`) - pub storage_key: Option, - - pub created_at: DateTime, - pub updated_at: DateTime, - - /// Soft delete timestamp. If set, the item is in the trash. - pub deleted_at: Option>, - - /// When markdown links were last extracted from this file. - pub links_extracted_at: Option>, -} - -/// A custom field attached to a media item. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CustomField { - pub field_type: CustomFieldType, - pub value: String, -} - -/// Type of custom field value. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CustomFieldType { - Text, - Number, - Date, - Boolean, -} - -impl CustomFieldType { - #[must_use] - pub const fn as_str(&self) -> &'static str { - match self { - Self::Text => "text", - Self::Number => "number", - Self::Date => "date", - Self::Boolean => "boolean", - } - } -} - -impl std::fmt::Display for CustomFieldType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// A tag that can be applied to media items. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Tag { - pub id: Uuid, - pub name: String, - pub parent_id: Option, - pub created_at: DateTime, -} - -/// A collection of media items. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Collection { - pub id: Uuid, - pub name: String, - pub description: Option, - pub kind: CollectionKind, - pub filter_query: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// Kind of collection. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CollectionKind { - Manual, - Virtual, -} - -impl CollectionKind { - #[must_use] - pub const fn as_str(&self) -> &'static str { - match self { - Self::Manual => "manual", - Self::Virtual => "virtual", - } - } -} - -impl std::fmt::Display for CollectionKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// A member of a collection with position tracking. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CollectionMember { - pub collection_id: Uuid, - pub media_id: MediaId, - pub position: i32, - pub added_at: DateTime, -} - -/// An audit trail entry. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuditEntry { - pub id: Uuid, - pub media_id: Option, - pub action: AuditAction, - pub details: Option, - pub timestamp: DateTime, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AuditAction { - // Media actions - Imported, - Updated, - Deleted, - Tagged, - Untagged, - AddedToCollection, - RemovedFromCollection, - Opened, - Scanned, - - // Authentication actions - LoginSuccess, - LoginFailed, - Logout, - SessionExpired, - - // Authorization actions - PermissionDenied, - RoleChanged, - LibraryAccessGranted, - LibraryAccessRevoked, - - // User management - UserCreated, - UserUpdated, - UserDeleted, - - // Plugin actions - PluginInstalled, - PluginUninstalled, - PluginEnabled, - PluginDisabled, - - // Configuration actions - ConfigChanged, - RootDirectoryAdded, - RootDirectoryRemoved, - - // Social/Sharing actions - ShareLinkCreated, - ShareLinkAccessed, - - // System actions - DatabaseVacuumed, - DatabaseCleared, - ExportCompleted, - IntegrityCheckCompleted, -} - -impl fmt::Display for AuditAction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - // Media actions - Self::Imported => "imported", - Self::Updated => "updated", - Self::Deleted => "deleted", - Self::Tagged => "tagged", - Self::Untagged => "untagged", - Self::AddedToCollection => "added_to_collection", - Self::RemovedFromCollection => "removed_from_collection", - Self::Opened => "opened", - Self::Scanned => "scanned", - - // Authentication actions - Self::LoginSuccess => "login_success", - Self::LoginFailed => "login_failed", - Self::Logout => "logout", - Self::SessionExpired => "session_expired", - - // Authorization actions - Self::PermissionDenied => "permission_denied", - Self::RoleChanged => "role_changed", - Self::LibraryAccessGranted => "library_access_granted", - Self::LibraryAccessRevoked => "library_access_revoked", - - // User management - Self::UserCreated => "user_created", - Self::UserUpdated => "user_updated", - Self::UserDeleted => "user_deleted", - - // Plugin actions - Self::PluginInstalled => "plugin_installed", - Self::PluginUninstalled => "plugin_uninstalled", - Self::PluginEnabled => "plugin_enabled", - Self::PluginDisabled => "plugin_disabled", - - // Configuration actions - Self::ConfigChanged => "config_changed", - Self::RootDirectoryAdded => "root_directory_added", - Self::RootDirectoryRemoved => "root_directory_removed", - - // Social/Sharing actions - Self::ShareLinkCreated => "share_link_created", - Self::ShareLinkAccessed => "share_link_accessed", - - // System actions - Self::DatabaseVacuumed => "database_vacuumed", - Self::DatabaseCleared => "database_cleared", - Self::ExportCompleted => "export_completed", - Self::IntegrityCheckCompleted => "integrity_check_completed", - }; - write!(f, "{s}") - } -} - -/// Pagination parameters for list queries. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Pagination { - pub offset: u64, - pub limit: u64, - pub sort: Option, -} - -impl Pagination { - /// Creates a new pagination instance. - #[must_use] - pub const fn new(offset: u64, limit: u64, sort: Option) -> Self { - Self { - offset, - limit, - sort, - } - } -} - -impl Default for Pagination { - fn default() -> Self { - Self { - offset: 0, - limit: 50, - sort: None, - } - } -} - -/// A saved search query. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SavedSearch { - pub id: Uuid, - pub name: String, - pub query: String, - pub sort_order: Option, - pub created_at: DateTime, -} - -// Book Management Types - -/// Metadata for book-type media. -/// -/// Used both as a DB record (with populated `media_id`, `created_at`, -/// `updated_at`) and as an extraction result (with placeholder values for -/// those fields when the record has not yet been persisted). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BookMetadata { - pub media_id: MediaId, - pub isbn: Option, - pub isbn13: Option, - pub publisher: Option, - pub language: Option, - pub page_count: Option, - pub publication_date: Option, - pub series_name: Option, - pub series_index: Option, - pub format: Option, - pub authors: Vec, - pub identifiers: FxHashMap>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl Default for BookMetadata { - fn default() -> Self { - let now = Utc::now(); - Self { - media_id: MediaId(uuid::Uuid::nil()), - isbn: None, - isbn13: None, - publisher: None, - language: None, - page_count: None, - publication_date: None, - series_name: None, - series_index: None, - format: None, - authors: Vec::new(), - identifiers: FxHashMap::default(), - created_at: now, - updated_at: now, - } - } -} - -/// Information about a book author. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AuthorInfo { - pub name: String, - pub role: String, - pub file_as: Option, - pub position: i32, -} - -impl AuthorInfo { - /// Creates a new author with the given name. - #[must_use] - pub fn new(name: String) -> Self { - Self { - name, - role: "author".to_string(), - file_as: None, - position: 0, - } - } - - /// Sets the author's role. - #[must_use] - pub fn with_role(mut self, role: String) -> Self { - self.role = role; - self - } - - #[must_use] - pub fn with_file_as(mut self, file_as: String) -> Self { - self.file_as = Some(file_as); - self - } - - #[must_use] - pub const fn with_position(mut self, position: i32) -> Self { - self.position = position; - self - } -} - -/// Reading progress for a book. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReadingProgress { - pub media_id: MediaId, - pub user_id: Uuid, - pub current_page: i32, - pub total_pages: Option, - pub progress_percent: f64, - pub last_read_at: DateTime, -} - -impl ReadingProgress { - /// Creates a new reading progress entry. - #[must_use] - pub fn new( - media_id: MediaId, - user_id: Uuid, - current_page: i32, - total_pages: Option, - ) -> Self { - let progress_percent = total_pages.map_or(0.0, |total| { - if total > 0 { - (f64::from(current_page) / f64::from(total) * 100.0).min(100.0) - } else { - 0.0 - } - }); - - Self { - media_id, - user_id, - current_page, - total_pages, - progress_percent, - last_read_at: Utc::now(), - } - } -} - -/// Reading status for a book. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReadingStatus { - ToRead, - Reading, - Completed, - Abandoned, -} - -impl fmt::Display for ReadingStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ToRead => write!(f, "to_read"), - Self::Reading => write!(f, "reading"), - Self::Completed => write!(f, "completed"), - Self::Abandoned => write!(f, "abandoned"), - } - } -} - -/// Type of markdown link -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum LinkType { - /// Wikilink: [[target]] or [[target|display]] - Wikilink, - /// Markdown link: [text](path) - MarkdownLink, - /// Embed: ![[target]] - Embed, -} - -impl fmt::Display for LinkType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Wikilink => write!(f, "wikilink"), - Self::MarkdownLink => write!(f, "markdown_link"), - Self::Embed => write!(f, "embed"), - } - } -} - -impl std::str::FromStr for LinkType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "wikilink" => Ok(Self::Wikilink), - "markdown_link" => Ok(Self::MarkdownLink), - "embed" => Ok(Self::Embed), - _ => Err(format!("unknown link type: {s}")), - } - } -} - -/// A markdown link extracted from a file. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MarkdownLink { - pub id: Uuid, - pub source_media_id: MediaId, - /// Raw link target as written in the source (wikilink name or path) - pub target_path: String, - /// Resolved target `media_id` (None if unresolved) - pub target_media_id: Option, - pub link_type: LinkType, - /// Display text for the link - pub link_text: Option, - /// Line number in source file (1-indexed) - pub line_number: Option, - /// Surrounding text for backlink preview - pub context: Option, - pub created_at: DateTime, -} - -/// Information about a backlink (incoming link). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BacklinkInfo { - pub link_id: Uuid, - pub source_id: MediaId, - pub source_title: Option, - pub source_path: String, - pub link_text: Option, - pub line_number: Option, - pub context: Option, - pub link_type: LinkType, -} - -/// Graph data for visualization. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GraphData { - pub nodes: Vec, - pub edges: Vec, -} - -/// A node in the graph visualization. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GraphNode { - pub id: String, - pub label: String, - pub title: Option, - pub media_type: String, - /// Number of outgoing links from this node - pub link_count: u32, - /// Number of incoming links to this node - pub backlink_count: u32, -} - -/// An edge (link) in the graph visualization. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GraphEdge { - pub source: String, - pub target: String, - pub link_type: LinkType, -} diff --git a/crates/pinakes-core/src/plugin/loader.rs b/crates/pinakes-core/src/plugin/loader.rs deleted file mode 100644 index f8242e8..0000000 --- a/crates/pinakes-core/src/plugin/loader.rs +++ /dev/null @@ -1,432 +0,0 @@ -//! Plugin loader for discovering and loading plugins from the filesystem - -use std::path::{Path, PathBuf}; - -use anyhow::{Result, anyhow}; -use pinakes_plugin_api::PluginManifest; -use tracing::{debug, info, warn}; -use walkdir::WalkDir; - -/// Plugin loader handles discovery and loading of plugins from directories -pub struct PluginLoader { - /// Directories to search for plugins - plugin_dirs: Vec, -} - -impl PluginLoader { - /// Create a new plugin loader - #[must_use] - pub const fn new(plugin_dirs: Vec) -> Self { - Self { plugin_dirs } - } - - /// Discover all plugins in configured directories - /// - /// # Errors - /// - /// Returns an error if a plugin directory cannot be searched. - pub fn discover_plugins(&self) -> Result> { - let mut manifests = Vec::new(); - - for dir in &self.plugin_dirs { - if !dir.exists() { - warn!("Plugin directory does not exist: {:?}", dir); - continue; - } - - info!("Discovering plugins in: {:?}", dir); - - let found = Self::discover_in_directory(dir); - info!("Found {} plugins in {:?}", found.len(), dir); - manifests.extend(found); - } - - Ok(manifests) - } - - /// Discover plugins in a specific directory - fn discover_in_directory(dir: &Path) -> Vec { - let mut manifests = Vec::new(); - - // Walk the directory looking for plugin.toml files - for entry in WalkDir::new(dir) - .max_depth(3) // Don't go too deep - .follow_links(false) - { - let entry = match entry { - Ok(e) => e, - Err(e) => { - warn!("Error reading directory entry: {}", e); - continue; - }, - }; - - let path = entry.path(); - - // Look for plugin.toml files - if path.file_name() == Some(std::ffi::OsStr::new("plugin.toml")) { - debug!("Found plugin manifest: {:?}", path); - - match PluginManifest::from_file(path) { - Ok(manifest) => { - info!("Loaded manifest for plugin: {}", manifest.plugin.name); - manifests.push(manifest); - }, - Err(e) => { - warn!("Failed to load manifest from {:?}: {}", path, e); - }, - } - } - } - - manifests - } - - /// Resolve the WASM binary path from a manifest - /// - /// # Errors - /// - /// Returns an error if the WASM binary is not found or its path escapes the - /// plugin directory. - pub fn resolve_wasm_path( - &self, - manifest: &PluginManifest, - ) -> Result { - // The WASM path in the manifest is relative to the manifest file - // We need to search for it in the plugin directories - - for dir in &self.plugin_dirs { - // Look for a directory matching the plugin name - let plugin_dir = dir.join(&manifest.plugin.name); - if !plugin_dir.exists() { - continue; - } - - // Check for plugin.toml in this directory - let manifest_path = plugin_dir.join("plugin.toml"); - if !manifest_path.exists() { - continue; - } - - // Resolve WASM path relative to this directory - let wasm_path = plugin_dir.join(&manifest.plugin.binary.wasm); - if wasm_path.exists() { - // Verify the resolved path is within the plugin directory (prevent path - // traversal) - let canonical_wasm = wasm_path - .canonicalize() - .map_err(|e| anyhow!("Failed to canonicalize WASM path: {e}"))?; - let canonical_plugin_dir = plugin_dir - .canonicalize() - .map_err(|e| anyhow!("Failed to canonicalize plugin dir: {e}"))?; - if !canonical_wasm.starts_with(&canonical_plugin_dir) { - return Err(anyhow!( - "WASM binary path escapes plugin directory: {}", - wasm_path.display() - )); - } - return Ok(canonical_wasm); - } - } - - Err(anyhow!( - "WASM binary not found for plugin: {}", - manifest.plugin.name - )) - } - - /// Download a plugin from a URL - /// - /// # Errors - /// - /// Returns an error if the URL is not HTTPS, no plugin directories are - /// configured, the download fails, the archive is too large, or extraction - /// fails. - pub async fn download_plugin(&self, url: &str) -> Result { - const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB - - // Only allow HTTPS downloads - if !url.starts_with("https://") { - return Err(anyhow!( - "Only HTTPS URLs are allowed for plugin downloads: {url}" - )); - } - - let dest_dir = self - .plugin_dirs - .first() - .ok_or_else(|| anyhow!("No plugin directories configured"))?; - - std::fs::create_dir_all(dest_dir)?; - - // Download the archive with timeout and size limits - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_mins(5)) - .build() - .map_err(|e| anyhow!("Failed to build HTTP client: {e}"))?; - - let response = client - .get(url) - .send() - .await - .map_err(|e| anyhow!("Failed to download plugin: {e}"))?; - - if !response.status().is_success() { - return Err(anyhow!( - "Plugin download failed with status: {}", - response.status() - )); - } - - // Check content-length header before downloading - if let Some(content_length) = response.content_length() - && content_length > MAX_PLUGIN_SIZE - { - return Err(anyhow!( - "Plugin archive too large: {content_length} bytes (max \ - {MAX_PLUGIN_SIZE} bytes)" - )); - } - - let bytes = response - .bytes() - .await - .map_err(|e| anyhow!("Failed to read plugin response: {e}"))?; - - // Check actual size after download - if bytes.len() as u64 > MAX_PLUGIN_SIZE { - return Err(anyhow!( - "Plugin archive too large: {} bytes (max {} bytes)", - bytes.len(), - MAX_PLUGIN_SIZE - )); - } - - // Write archive to a unique temp file - let temp_archive = - dest_dir.join(format!(".download-{}.tar.gz", uuid::Uuid::now_v7())); - std::fs::write(&temp_archive, &bytes)?; - - // Extract using tar with -C to target directory - let canonical_dest = dest_dir - .canonicalize() - .map_err(|e| anyhow!("Failed to canonicalize dest dir: {e}"))?; - let output = std::process::Command::new("tar") - .args([ - "xzf", - &temp_archive.to_string_lossy(), - "-C", - &canonical_dest.to_string_lossy(), - ]) - .output() - .map_err(|e| anyhow!("Failed to extract plugin archive: {e}"))?; - - // Clean up the archive - let _ = std::fs::remove_file(&temp_archive); - - if !output.status.success() { - return Err(anyhow!( - "Failed to extract plugin archive: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - - // Validate that all extracted files are within dest_dir - for entry in WalkDir::new(&canonical_dest).follow_links(false) { - let entry = entry?; - let entry_canonical = entry.path().canonicalize()?; - if !entry_canonical.starts_with(&canonical_dest) { - return Err(anyhow!( - "Extracted file escapes destination directory: {}", - entry.path().display() - )); - } - } - - // Find the extracted plugin directory by looking for plugin.toml - for entry in WalkDir::new(dest_dir).max_depth(2).follow_links(false) { - let entry = entry?; - if entry.file_name() == "plugin.toml" { - let plugin_dir = entry - .path() - .parent() - .ok_or_else(|| anyhow!("Invalid plugin.toml location"))?; - - // Validate the manifest - let manifest = PluginManifest::from_file(entry.path())?; - info!("Downloaded and extracted plugin: {}", manifest.plugin.name); - - return Ok(plugin_dir.to_path_buf()); - } - } - - Err(anyhow!( - "No plugin.toml found after extracting archive from: {url}" - )) - } - - /// Validate a plugin package - /// - /// # Errors - /// - /// Returns an error if the path does not exist, is missing `plugin.toml`, - /// the WASM binary is not found, or the WASM file is invalid. - pub fn validate_plugin_package(&self, path: &Path) -> Result<()> { - // Check that the path exists - if !path.exists() { - return Err(anyhow!("Plugin path does not exist: {}", path.display())); - } - - // Check for plugin.toml - let manifest_path = path.join("plugin.toml"); - if !manifest_path.exists() { - return Err(anyhow!("Missing plugin.toml in {}", path.display())); - } - - // Parse and validate manifest - let manifest = PluginManifest::from_file(&manifest_path)?; - - // Check that WASM binary exists - let wasm_path = path.join(&manifest.plugin.binary.wasm); - if !wasm_path.exists() { - return Err(anyhow!( - "WASM binary not found: {}", - manifest.plugin.binary.wasm - )); - } - - // Verify the WASM path is within the plugin directory (prevent path - // traversal) - let canonical_wasm = wasm_path.canonicalize()?; - let canonical_path = path.canonicalize()?; - if !canonical_wasm.starts_with(&canonical_path) { - return Err(anyhow!( - "WASM binary path escapes plugin directory: {}", - wasm_path.display() - )); - } - - // Validate WASM file - let wasm_bytes = std::fs::read(&wasm_path)?; - if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" { - return Err(anyhow!("Invalid WASM file: {}", wasm_path.display())); - } - - Ok(()) - } - - /// Get plugin directory path for a given plugin name - #[must_use] - pub fn get_plugin_dir(&self, plugin_name: &str) -> Option { - for dir in &self.plugin_dirs { - let plugin_dir = dir.join(plugin_name); - if plugin_dir.exists() { - return Some(plugin_dir); - } - } - None - } -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - - #[test] - fn test_discover_plugins_empty() { - let temp_dir = TempDir::new().unwrap(); - let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - - let manifests = loader.discover_plugins().unwrap(); - assert_eq!(manifests.len(), 0); - } - - #[test] - fn test_discover_plugins_with_manifest() { - let temp_dir = TempDir::new().unwrap(); - let plugin_dir = temp_dir.path().join("test-plugin"); - std::fs::create_dir(&plugin_dir).unwrap(); - - // Create a valid manifest - let manifest_content = r#" -[plugin] -name = "test-plugin" -version = "1.0.0" -api_version = "1.0" -kind = ["media_type"] - -[plugin.binary] -wasm = "plugin.wasm" -"#; - std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - - // Create dummy WASM file - std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") - .unwrap(); - - let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - let manifests = loader.discover_plugins().unwrap(); - - assert_eq!(manifests.len(), 1); - assert_eq!(manifests[0].plugin.name, "test-plugin"); - } - - #[test] - fn test_validate_plugin_package() { - let temp_dir = TempDir::new().unwrap(); - let plugin_dir = temp_dir.path().join("test-plugin"); - std::fs::create_dir(&plugin_dir).unwrap(); - - // Create a valid manifest - let manifest_content = r#" -[plugin] -name = "test-plugin" -version = "1.0.0" -api_version = "1.0" -kind = ["media_type"] - -[plugin.binary] -wasm = "plugin.wasm" -"#; - std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - - let loader = PluginLoader::new(vec![]); - - // Should fail without WASM file - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); - - // Create valid WASM file (magic number only) - std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") - .unwrap(); - - // Should succeed now - assert!(loader.validate_plugin_package(&plugin_dir).is_ok()); - } - - #[test] - fn test_validate_invalid_wasm() { - let temp_dir = TempDir::new().unwrap(); - let plugin_dir = temp_dir.path().join("test-plugin"); - std::fs::create_dir(&plugin_dir).unwrap(); - - let manifest_content = r#" -[plugin] -name = "test-plugin" -version = "1.0.0" -api_version = "1.0" -kind = ["media_type"] - -[plugin.binary] -wasm = "plugin.wasm" -"#; - std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - - // Create invalid WASM file - std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap(); - - let loader = PluginLoader::new(vec![]); - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); - } -} diff --git a/crates/pinakes-core/src/plugin/mod.rs b/crates/pinakes-core/src/plugin/mod.rs index f8ae6cc..0a8286f 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -1,932 +1,3 @@ -//! Plugin system for Pinakes -//! -//! This module provides a comprehensive plugin architecture that allows -//! extending Pinakes with custom media types, metadata extractors, search -//! backends, and more. -//! -//! # Architecture -//! -//! - Plugins are compiled to WASM and run in a sandboxed environment -//! - Capability-based security controls what plugins can access -//! - Hot-reload support for development -//! - Automatic plugin discovery from configured directories - -use std::{path::PathBuf, sync::Arc}; - -use anyhow::Result; -use pinakes_plugin_api::{PluginContext, PluginMetadata}; -use tokio::sync::RwLock; -use tracing::{debug, error, info, warn}; - -pub mod loader; +//! Plugin pipeline for Pinakes. pub mod pipeline; -pub mod registry; -pub mod rpc; -pub mod runtime; -pub mod security; -pub mod signature; - -pub use loader::PluginLoader; pub use pipeline::PluginPipeline; -pub use registry::{PluginRegistry, RegisteredPlugin}; -pub use runtime::{WasmPlugin, WasmRuntime}; -pub use security::CapabilityEnforcer; -pub use signature::{SignatureStatus, verify_plugin_signature}; - -/// Plugin manager coordinates plugin lifecycle and operations -pub struct PluginManager { - /// Plugin registry - registry: Arc>, - - /// WASM runtime for executing plugins - runtime: Arc, - - /// Plugin loader for discovery and loading - loader: PluginLoader, - - /// Capability enforcer for security - enforcer: CapabilityEnforcer, - - /// Plugin data directory - data_dir: PathBuf, - - /// Plugin cache directory - cache_dir: PathBuf, - - /// Configuration - config: PluginManagerConfig, -} - -/// Configuration for the plugin manager -#[derive(Debug, Clone)] -pub struct PluginManagerConfig { - /// Directories to search for plugins - pub plugin_dirs: Vec, - - /// Whether to enable hot-reload (for development) - pub enable_hot_reload: bool, - - /// Whether to allow unsigned plugins - pub allow_unsigned: bool, - - /// Maximum number of concurrent plugin operations - pub max_concurrent_ops: usize, - - /// Plugin timeout in seconds - pub plugin_timeout_secs: u64, - - /// Timeout configuration for different call types - pub timeouts: crate::config::PluginTimeoutConfig, - - /// Max consecutive failures before circuit breaker disables plugin - pub max_consecutive_failures: u32, - - /// Trusted Ed25519 public keys for signature verification (hex-encoded) - pub trusted_keys: Vec, -} - -impl Default for PluginManagerConfig { - fn default() -> Self { - Self { - plugin_dirs: vec![], - enable_hot_reload: false, - allow_unsigned: false, - max_concurrent_ops: 4, - plugin_timeout_secs: 30, - timeouts: crate::config::PluginTimeoutConfig::default(), - max_consecutive_failures: 5, - trusted_keys: vec![], - } - } -} - -impl From for PluginManagerConfig { - fn from(cfg: crate::config::PluginsConfig) -> Self { - Self { - plugin_dirs: cfg.plugin_dirs, - enable_hot_reload: cfg.enable_hot_reload, - allow_unsigned: cfg.allow_unsigned, - max_concurrent_ops: cfg.max_concurrent_ops, - plugin_timeout_secs: cfg.plugin_timeout_secs, - timeouts: cfg.timeouts, - max_consecutive_failures: cfg.max_consecutive_failures, - trusted_keys: cfg.trusted_keys, - } - } -} - -impl PluginManager { - /// Create a new plugin manager - /// - /// # Errors - /// - /// Returns an error if the data or cache directories cannot be created, or - /// if the WASM runtime cannot be initialized. - pub fn new( - data_dir: PathBuf, - cache_dir: PathBuf, - config: PluginManagerConfig, - ) -> Result { - // Ensure directories exist - std::fs::create_dir_all(&data_dir)?; - std::fs::create_dir_all(&cache_dir)?; - - let runtime = Arc::new(WasmRuntime::new()?); - let registry = Arc::new(RwLock::new(PluginRegistry::new())); - let loader = PluginLoader::new(config.plugin_dirs.clone()); - let enforcer = CapabilityEnforcer::new(); - - Ok(Self { - registry, - runtime, - loader, - enforcer, - data_dir, - cache_dir, - config, - }) - } - - /// Discover and load all plugins from configured directories. - /// - /// Plugins are loaded in dependency order: if plugin A declares a - /// dependency on plugin B, B is loaded first. Cycles and missing - /// dependencies are detected and reported as warnings; affected plugins - /// are skipped rather than causing a hard failure. - /// - /// # Errors - /// - /// Returns an error if plugin discovery fails. - pub async fn discover_and_load_all(&self) -> Result> { - info!("Discovering plugins from {:?}", self.config.plugin_dirs); - - let manifests = self.loader.discover_plugins()?; - let ordered = Self::resolve_load_order(&manifests); - let mut loaded_plugins = Vec::new(); - - for manifest in ordered { - match self.load_plugin_from_manifest(&manifest).await { - Ok(plugin_id) => { - info!("Loaded plugin: {}", plugin_id); - loaded_plugins.push(plugin_id); - }, - Err(e) => { - warn!("Failed to load plugin {}: {}", manifest.plugin.name, e); - }, - } - } - - Ok(loaded_plugins) - } - - /// Topological sort of manifests by their declared `dependencies`. - /// - /// Uses Kahn's algorithm. Plugins whose dependencies are missing or form - /// a cycle are logged as warnings and excluded from the result. - fn resolve_load_order( - manifests: &[pinakes_plugin_api::PluginManifest], - ) -> Vec { - use std::collections::VecDeque; - - use rustc_hash::{FxHashMap, FxHashSet}; - - // Index manifests by name for O(1) lookup - let by_name: FxHashMap<&str, usize> = manifests - .iter() - .enumerate() - .map(|(i, m)| (m.plugin.name.as_str(), i)) - .collect(); - - // Check for missing dependencies and warn early - let known: FxHashSet<&str> = by_name.keys().copied().collect(); - for manifest in manifests { - for dep in &manifest.plugin.dependencies { - if !known.contains(dep.as_str()) { - warn!( - "Plugin '{}' depends on '{}' which was not discovered; it will be \ - skipped", - manifest.plugin.name, dep - ); - } - } - } - - // Build adjacency: in_degree[i] = number of deps that must load before i - let mut in_degree = vec![0usize; manifests.len()]; - // dependents[i] = indices that depend on i (i must load before them) - let mut dependents: Vec> = vec![vec![]; manifests.len()]; - - for (i, manifest) in manifests.iter().enumerate() { - for dep in &manifest.plugin.dependencies { - if let Some(&dep_idx) = by_name.get(dep.as_str()) { - in_degree[i] += 1; - dependents[dep_idx].push(i); - } else { - // Missing dep: set in_degree impossibly high so it never resolves - in_degree[i] = usize::MAX; - } - } - } - - // Kahn's algorithm - let mut queue: VecDeque = VecDeque::new(); - for (i, °) in in_degree.iter().enumerate() { - if deg == 0 { - queue.push_back(i); - } - } - - let mut result = Vec::with_capacity(manifests.len()); - while let Some(idx) = queue.pop_front() { - result.push(manifests[idx].clone()); - for &dependent in &dependents[idx] { - if in_degree[dependent] == usize::MAX { - continue; // already poisoned by missing dep - } - in_degree[dependent] -= 1; - if in_degree[dependent] == 0 { - queue.push_back(dependent); - } - } - } - - // Anything not in `result` is part of a cycle or has a missing dep - if result.len() < manifests.len() { - let loaded: FxHashSet<&str> = - result.iter().map(|m| m.plugin.name.as_str()).collect(); - for manifest in manifests { - if !loaded.contains(manifest.plugin.name.as_str()) { - warn!( - "Plugin '{}' was skipped due to unresolved dependencies or a \ - dependency cycle", - manifest.plugin.name - ); - } - } - } - - result - } - - /// Load a plugin from a manifest file - /// - /// # Errors - /// - /// Returns an error if the plugin ID is invalid, capability validation - /// fails, the WASM binary cannot be loaded, or the plugin cannot be - /// registered. - async fn load_plugin_from_manifest( - &self, - manifest: &pinakes_plugin_api::PluginManifest, - ) -> Result { - let plugin_id = manifest.plugin_id(); - - // Validate plugin_id to prevent path traversal - if plugin_id.contains('/') - || plugin_id.contains('\\') - || plugin_id.contains("..") - { - return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}")); - } - - // Check if already loaded - { - let registry = self.registry.read().await; - if registry.is_loaded(&plugin_id) { - return Ok(plugin_id); - } - } - - // Validate capabilities - let capabilities = manifest.to_capabilities(); - self.enforcer.validate_capabilities(&capabilities)?; - - // Create plugin context - let plugin_data_dir = self.data_dir.join(&plugin_id); - let plugin_cache_dir = self.cache_dir.join(&plugin_id); - tokio::fs::create_dir_all(&plugin_data_dir).await?; - tokio::fs::create_dir_all(&plugin_cache_dir).await?; - - let context = PluginContext { - data_dir: plugin_data_dir, - cache_dir: plugin_cache_dir, - config: manifest - .config - .iter() - .map(|(k, v)| { - ( - k.clone(), - serde_json::to_value(v).unwrap_or_else(|e| { - tracing::warn!( - "failed to serialize config value for key {}: {}", - k, - e - ); - serde_json::Value::Null - }), - ) - }) - .collect(), - capabilities: capabilities.clone(), - }; - - // Load WASM binary - let wasm_path = self.loader.resolve_wasm_path(manifest)?; - - // Verify plugin signature unless unsigned plugins are allowed - if !self.config.allow_unsigned { - let plugin_dir = wasm_path - .parent() - .ok_or_else(|| anyhow::anyhow!("WASM path has no parent directory"))?; - - let trusted_keys: Vec = self - .config - .trusted_keys - .iter() - .filter_map(|hex| { - signature::parse_public_key(hex) - .map_err(|e| warn!("Ignoring malformed trusted key: {e}")) - .ok() - }) - .collect(); - - match signature::verify_plugin_signature( - plugin_dir, - &wasm_path, - &trusted_keys, - )? { - SignatureStatus::Valid => { - debug!("Plugin '{plugin_id}' signature verified"); - }, - SignatureStatus::Unsigned => { - return Err(anyhow::anyhow!( - "Plugin '{plugin_id}' is unsigned and allow_unsigned is false" - )); - }, - SignatureStatus::Invalid(reason) => { - return Err(anyhow::anyhow!( - "Plugin '{plugin_id}' has an invalid signature: {reason}" - )); - }, - } - } - - let wasm_plugin = self.runtime.load_plugin(&wasm_path, context)?; - - // Initialize plugin - let init_succeeded = match wasm_plugin - .call_function("initialize", &[]) - .await - { - Ok(_) => true, - Err(e) => { - tracing::warn!(plugin_id = %plugin_id, "plugin initialization failed: {}", e); - false - }, - }; - - // Register plugin - let metadata = PluginMetadata { - id: plugin_id.clone(), - name: manifest.plugin.name.clone(), - version: manifest.plugin.version.clone(), - author: manifest.plugin.author.clone().unwrap_or_default(), - description: manifest - .plugin - .description - .clone() - .unwrap_or_default(), - api_version: manifest.plugin.api_version.clone(), - capabilities_required: capabilities, - }; - - // Derive manifest_path from the loader's plugin directories - let manifest_path = self - .loader - .get_plugin_dir(&manifest.plugin.name) - .map(|dir| dir.join("plugin.toml")); - - let registered = RegisteredPlugin { - id: plugin_id.clone(), - metadata, - wasm_plugin, - manifest: manifest.clone(), - manifest_path, - enabled: init_succeeded, - }; - - { - let mut registry = self.registry.write().await; - registry.register(registered)?; - } - - Ok(plugin_id) - } - - /// Install a plugin from a file or URL - /// - /// # Errors - /// - /// Returns an error if the plugin cannot be downloaded, the manifest cannot - /// be read, or the plugin cannot be loaded. - pub async fn install_plugin(&self, source: &str) -> Result { - info!("Installing plugin from: {}", source); - - // Download/copy plugin to plugins directory - let plugin_path = - if source.starts_with("http://") || source.starts_with("https://") { - // Download from URL - self.loader.download_plugin(source).await? - } else { - // Copy from local file - PathBuf::from(source) - }; - - // Load the manifest - let manifest_path = plugin_path.join("plugin.toml"); - let manifest = - pinakes_plugin_api::PluginManifest::from_file(&manifest_path)?; - - // Load the plugin - self.load_plugin_from_manifest(&manifest).await - } - - /// Uninstall a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is invalid, the plugin cannot be shut - /// down, cannot be unregistered, or its data directories cannot be removed. - pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { - // Validate plugin_id to prevent path traversal - if plugin_id.contains('/') - || plugin_id.contains('\\') - || plugin_id.contains("..") - { - return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}")); - } - - info!("Uninstalling plugin: {}", plugin_id); - - // Shutdown plugin first - self.shutdown_plugin(plugin_id).await?; - - // Remove from registry - { - let mut registry = self.registry.write().await; - registry.unregister(plugin_id)?; - } - - // Remove plugin data and cache - let plugin_data_dir = self.data_dir.join(plugin_id); - let plugin_cache_dir = self.cache_dir.join(plugin_id); - - if plugin_data_dir.exists() { - std::fs::remove_dir_all(&plugin_data_dir)?; - } - if plugin_cache_dir.exists() { - std::fs::remove_dir_all(&plugin_cache_dir)?; - } - - Ok(()) - } - - /// Enable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found in the registry. - pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> { - let mut registry = self.registry.write().await; - registry.enable(plugin_id) - } - - /// Disable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found in the registry. - pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> { - let mut registry = self.registry.write().await; - registry.disable(plugin_id) - } - - /// Shutdown a specific plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found in the registry. - pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> { - debug!("Shutting down plugin: {}", plugin_id); - - let registry = self.registry.read().await; - if let Some(plugin) = registry.get(plugin_id) { - let _ = plugin.wasm_plugin.call_function("shutdown", &[]).await; - Ok(()) - } else { - Err(anyhow::anyhow!("Plugin not found: {plugin_id}")) - } - } - - /// Shutdown all plugins - /// - /// # Errors - /// - /// This function always returns `Ok(())`. Individual plugin shutdown errors - /// are logged but do not cause the overall operation to fail. - pub async fn shutdown_all(&self) -> Result<()> { - info!("Shutting down all plugins"); - - let plugin_ids: Vec = { - let registry = self.registry.read().await; - registry.list_all().iter().map(|p| p.id.clone()).collect() - }; - - for plugin_id in plugin_ids { - if let Err(e) = self.shutdown_plugin(&plugin_id).await { - error!("Failed to shutdown plugin {}: {}", plugin_id, e); - } - } - - Ok(()) - } - - /// Get list of all registered plugins - pub async fn list_plugins(&self) -> Vec { - let registry = self.registry.read().await; - registry - .list_all() - .iter() - .map(|p| p.metadata.clone()) - .collect() - } - - /// Get plugin metadata by ID - pub async fn get_plugin(&self, plugin_id: &str) -> Option { - let registry = self.registry.read().await; - registry.get(plugin_id).map(|p| p.metadata.clone()) - } - - /// Get enabled plugins of a specific kind, sorted by priority (ascending). - /// - /// # Returns - /// - /// `(plugin_id, priority, kinds, wasm_plugin)` tuples. - pub async fn get_enabled_by_kind_sorted( - &self, - kind: &str, - ) -> Vec<(String, u16, Vec, WasmPlugin)> { - let registry = self.registry.read().await; - let mut plugins: Vec<_> = registry - .get_by_kind(kind) - .into_iter() - .filter(|p| p.enabled) - .map(|p| { - ( - p.id.clone(), - p.manifest.plugin.priority, - p.manifest.plugin.kind.clone(), - p.wasm_plugin.clone(), - ) - }) - .collect(); - drop(registry); - plugins.sort_by_key(|(_, priority, ..)| *priority); - plugins - } - - /// Get a reference to the capability enforcer. - #[must_use] - pub const fn enforcer(&self) -> &CapabilityEnforcer { - &self.enforcer - } - - /// List all UI pages provided by loaded plugins. - /// - /// Returns a vector of `(plugin_id, page)` tuples for all enabled plugins - /// that provide pages in their manifests. Both inline and file-referenced - /// page entries are resolved. - pub async fn list_ui_pages( - &self, - ) -> Vec<(String, pinakes_plugin_api::UiPage)> { - self - .list_ui_pages_with_endpoints() - .await - .into_iter() - .map(|(id, page, _)| (id, page)) - .collect() - } - - /// List all UI pages provided by loaded plugins, including each plugin's - /// declared endpoint allowlist. - /// - /// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples. The - /// `allowed_endpoints` list mirrors the `required_endpoints` field from the - /// plugin manifest's `[ui]` section. - pub async fn list_ui_pages_with_endpoints( - &self, - ) -> Vec<(String, pinakes_plugin_api::UiPage, Vec)> { - let registry = self.registry.read().await; - let mut pages = Vec::new(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - let allowed = plugin.manifest.ui.required_endpoints.clone(); - let plugin_dir = plugin - .manifest_path - .as_ref() - .and_then(|p| p.parent()) - .map(std::path::Path::to_path_buf); - let Some(plugin_dir) = plugin_dir else { - for entry in &plugin.manifest.ui.pages { - if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry - { - pages.push((plugin.id.clone(), (**page).clone(), allowed.clone())); - } - } - continue; - }; - match plugin.manifest.load_ui_pages(&plugin_dir) { - Ok(loaded) => { - for page in loaded { - pages.push((plugin.id.clone(), page, allowed.clone())); - } - }, - Err(e) => { - tracing::warn!( - "Failed to load UI pages for plugin '{}': {e}", - plugin.id - ); - }, - } - } - pages - } - - /// Collect CSS custom property overrides declared by all enabled plugins. - /// - /// When multiple plugins declare the same property name, later-loaded plugins - /// overwrite earlier ones. Returns an empty map if no plugins are loaded or - /// none declare theme extensions. - pub async fn list_ui_theme_extensions( - &self, - ) -> rustc_hash::FxHashMap { - let registry = self.registry.read().await; - let mut merged = rustc_hash::FxHashMap::default(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - for (k, v) in &plugin.manifest.ui.theme_extensions { - merged.insert(k.clone(), v.clone()); - } - } - merged - } - - /// List all UI widgets provided by loaded plugins. - /// - /// Returns a vector of `(plugin_id, widget)` tuples for all enabled plugins - /// that provide widgets in their manifests. - pub async fn list_ui_widgets( - &self, - ) -> Vec<(String, pinakes_plugin_api::UiWidget)> { - let registry = self.registry.read().await; - let mut widgets = Vec::new(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - for widget in &plugin.manifest.ui.widgets { - widgets.push((plugin.id.clone(), widget.clone())); - } - } - widgets - } - - /// Check if a plugin is loaded and enabled - pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool { - let registry = self.registry.read().await; - registry.is_enabled(plugin_id).unwrap_or(false) - } - - /// Reload a plugin (for hot-reload during development) - /// - /// # Errors - /// - /// Returns an error if hot-reload is disabled, the plugin is not found, it - /// cannot be shut down, or the reloaded plugin cannot be registered. - pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> { - if !self.config.enable_hot_reload { - return Err(anyhow::anyhow!("Hot-reload is disabled")); - } - - info!("Reloading plugin: {}", plugin_id); - - // Re-read the manifest from disk if possible, falling back to cached - // version - let manifest = { - let registry = self.registry.read().await; - let plugin = registry - .get(plugin_id) - .ok_or_else(|| anyhow::anyhow!("Plugin not found"))?; - let manifest = plugin.manifest_path.as_ref().map_or_else( - || plugin.manifest.clone(), - |manifest_path| { - pinakes_plugin_api::PluginManifest::from_file(manifest_path) - .unwrap_or_else(|e| { - warn!( - "Failed to re-read manifest from disk, using cached: {}", - e - ); - plugin.manifest.clone() - }) - }, - ); - drop(registry); - manifest - }; - - // Shutdown and unload current version - self.shutdown_plugin(plugin_id).await?; - { - let mut registry = self.registry.write().await; - registry.unregister(plugin_id)?; - } - - // Reload from manifest - self.load_plugin_from_manifest(&manifest).await?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - - #[tokio::test] - async fn test_plugin_manager_creation() { - let temp_dir = TempDir::new().unwrap(); - let data_dir = temp_dir.path().join("data"); - let cache_dir = temp_dir.path().join("cache"); - - let config = PluginManagerConfig::default(); - let manager = - PluginManager::new(data_dir.clone(), cache_dir.clone(), config); - - assert!(manager.is_ok()); - assert!(data_dir.exists()); - assert!(cache_dir.exists()); - } - - #[tokio::test] - async fn test_list_plugins_empty() { - let temp_dir = TempDir::new().unwrap(); - let data_dir = temp_dir.path().join("data"); - let cache_dir = temp_dir.path().join("cache"); - - let config = PluginManagerConfig::default(); - let manager = PluginManager::new(data_dir, cache_dir, config).unwrap(); - - let plugins = manager.list_plugins().await; - assert_eq!(plugins.len(), 0); - } - - /// Build a minimal manifest for dependency resolution tests - fn test_manifest( - name: &str, - deps: Vec, - ) -> pinakes_plugin_api::PluginManifest { - use pinakes_plugin_api::manifest::{PluginBinary, PluginInfo}; - - pinakes_plugin_api::PluginManifest { - plugin: PluginInfo { - name: name.to_string(), - version: "1.0.0".to_string(), - api_version: "1.0".to_string(), - author: None, - description: None, - homepage: None, - license: None, - priority: 500, - kind: vec!["media_type".to_string()], - binary: PluginBinary { - wasm: "plugin.wasm".to_string(), - entrypoint: None, - }, - dependencies: deps, - }, - capabilities: Default::default(), - config: Default::default(), - ui: Default::default(), - } - } - - #[test] - fn test_resolve_load_order_no_deps() { - let manifests = vec![ - test_manifest("alpha", vec![]), - test_manifest("beta", vec![]), - test_manifest("gamma", vec![]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 3); - } - - #[test] - fn test_resolve_load_order_linear_chain() { - // gamma depends on beta, beta depends on alpha - let manifests = vec![ - test_manifest("gamma", vec!["beta".to_string()]), - test_manifest("alpha", vec![]), - test_manifest("beta", vec!["alpha".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 3); - - let names: Vec<&str> = - ordered.iter().map(|m| m.plugin.name.as_str()).collect(); - let alpha_pos = names.iter().position(|&n| n == "alpha").unwrap(); - let beta_pos = names.iter().position(|&n| n == "beta").unwrap(); - let gamma_pos = names.iter().position(|&n| n == "gamma").unwrap(); - assert!(alpha_pos < beta_pos, "alpha must load before beta"); - assert!(beta_pos < gamma_pos, "beta must load before gamma"); - } - - #[test] - fn test_resolve_load_order_cycle_detected() { - // A -> B -> C -> A (cycle) - let manifests = vec![ - test_manifest("a", vec!["c".to_string()]), - test_manifest("b", vec!["a".to_string()]), - test_manifest("c", vec!["b".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - // All three should be excluded due to cycle - assert_eq!(ordered.len(), 0); - } - - #[test] - fn test_resolve_load_order_missing_dependency() { - let manifests = vec![ - test_manifest("good", vec![]), - test_manifest("bad", vec!["nonexistent".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - // Only "good" should be loaded; "bad" depends on something missing - assert_eq!(ordered.len(), 1); - assert_eq!(ordered[0].plugin.name, "good"); - } - - #[test] - fn test_resolve_load_order_partial_cycle() { - // "ok" has no deps, "cycle_a" and "cycle_b" form a cycle - let manifests = vec![ - test_manifest("ok", vec![]), - test_manifest("cycle_a", vec!["cycle_b".to_string()]), - test_manifest("cycle_b", vec!["cycle_a".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 1); - assert_eq!(ordered[0].plugin.name, "ok"); - } - - #[test] - fn test_resolve_load_order_diamond() { - // Man look at how beautiful my diamond is... - // A - // / \ - // B C - // \ / - // D - let manifests = vec![ - test_manifest("d", vec!["b".to_string(), "c".to_string()]), - test_manifest("b", vec!["a".to_string()]), - test_manifest("c", vec!["a".to_string()]), - test_manifest("a", vec![]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 4); - - let names: Vec<&str> = - ordered.iter().map(|m| m.plugin.name.as_str()).collect(); - let a_pos = names.iter().position(|&n| n == "a").unwrap(); - let b_pos = names.iter().position(|&n| n == "b").unwrap(); - let c_pos = names.iter().position(|&n| n == "c").unwrap(); - let d_pos = names.iter().position(|&n| n == "d").unwrap(); - assert!(a_pos < b_pos); - assert!(a_pos < c_pos); - assert!(b_pos < d_pos); - assert!(c_pos < d_pos); - } -} diff --git a/crates/pinakes-core/src/plugin/pipeline.rs b/crates/pinakes-core/src/plugin/pipeline.rs index f094e73..3a8439e 100644 --- a/crates/pinakes-core/src/plugin/pipeline.rs +++ b/crates/pinakes-core/src/plugin/pipeline.rs @@ -18,17 +18,10 @@ use std::{ time::{Duration, Instant}, }; -use rustc_hash::FxHashMap; -use tokio::sync::RwLock; -use tracing::{debug, info, warn}; - -use super::PluginManager; -use crate::{ - config::PluginTimeoutConfig, - media_type::MediaType, - metadata::ExtractedMetadata, - model::MediaId, - plugin::rpc::{ +use pinakes_metadata::ExtractedMetadata; +use pinakes_plugin::{ + PluginManager, + rpc::{ CanHandleRequest, CanHandleResponse, ExtractMetadataRequest, @@ -46,6 +39,12 @@ use crate::{ SearchResultItem, }, }; +use pinakes_types::config::PluginTimeoutConfig; +use rustc_hash::FxHashMap; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +use crate::{media_type::MediaType, model::MediaId}; /// Built-in handlers run at this implicit priority. const BUILTIN_PRIORITY: u16 = 100; @@ -529,7 +528,7 @@ impl PluginPipeline { let path = path.to_path_buf(); let mt = media_type.clone(); let builtin = tokio::task::spawn_blocking(move || { - crate::metadata::extract_metadata(&path, &mt) + pinakes_metadata::extract_metadata(&path, &mt) }) .await .map_err(|e| { @@ -1174,10 +1173,10 @@ fn merge_extracted(base: &mut ExtractedMetadata, source: ExtractedMetadata) { #[cfg(test)] mod tests { + use pinakes_plugin::{PluginManager, PluginManagerConfig}; use tempfile::TempDir; use super::*; - use crate::plugin::{PluginManager, PluginManagerConfig}; /// Create a `PluginPipeline` backed by an empty `PluginManager`. fn create_test_pipeline() -> (TempDir, Arc) { diff --git a/crates/pinakes-core/src/plugin/registry.rs b/crates/pinakes-core/src/plugin/registry.rs deleted file mode 100644 index ce13d86..0000000 --- a/crates/pinakes-core/src/plugin/registry.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Plugin registry for managing loaded plugins - -use std::path::PathBuf; - -use anyhow::{Result, anyhow}; -use pinakes_plugin_api::{PluginManifest, PluginMetadata}; -use rustc_hash::FxHashMap; - -use super::runtime::WasmPlugin; - -/// A registered plugin with its metadata and runtime state -#[derive(Clone)] -pub struct RegisteredPlugin { - pub id: String, - pub metadata: PluginMetadata, - pub wasm_plugin: WasmPlugin, - pub manifest: PluginManifest, - pub manifest_path: Option, - pub enabled: bool, -} - -/// Plugin registry maintains the state of all loaded plugins -pub struct PluginRegistry { - /// Map of plugin ID to registered plugin - plugins: FxHashMap, -} - -impl PluginRegistry { - /// Create a new empty registry - #[must_use] - pub fn new() -> Self { - Self { - plugins: FxHashMap::default(), - } - } - - /// Register a new plugin - /// - /// # Errors - /// - /// Returns an error if a plugin with the same ID is already registered. - pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> { - if self.plugins.contains_key(&plugin.id) { - return Err(anyhow!("Plugin already registered: {}", plugin.id)); - } - - self.plugins.insert(plugin.id.clone(), plugin); - Ok(()) - } - - /// Unregister a plugin by ID - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found. - pub fn unregister(&mut self, plugin_id: &str) -> Result<()> { - self - .plugins - .remove(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?; - Ok(()) - } - - /// Get a plugin by ID - #[must_use] - pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> { - self.plugins.get(plugin_id) - } - - /// Get a mutable reference to a plugin by ID - pub fn get_mut(&mut self, plugin_id: &str) -> Option<&mut RegisteredPlugin> { - self.plugins.get_mut(plugin_id) - } - - /// Check if a plugin is loaded - #[must_use] - pub fn is_loaded(&self, plugin_id: &str) -> bool { - self.plugins.contains_key(plugin_id) - } - - /// Check if a plugin is enabled. Returns `None` if the plugin is not found. - #[must_use] - pub fn is_enabled(&self, plugin_id: &str) -> Option { - self.plugins.get(plugin_id).map(|p| p.enabled) - } - - /// Enable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found. - pub fn enable(&mut self, plugin_id: &str) -> Result<()> { - let plugin = self - .plugins - .get_mut(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?; - - plugin.enabled = true; - Ok(()) - } - - /// Disable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found. - pub fn disable(&mut self, plugin_id: &str) -> Result<()> { - let plugin = self - .plugins - .get_mut(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?; - - plugin.enabled = false; - Ok(()) - } - - /// List all registered plugins - #[must_use] - pub fn list_all(&self) -> Vec<&RegisteredPlugin> { - self.plugins.values().collect() - } - - /// List all enabled plugins - #[must_use] - pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> { - self.plugins.values().filter(|p| p.enabled).collect() - } - - /// Get plugins by kind (e.g., "`media_type`", "`metadata_extractor`") - #[must_use] - pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> { - self - .plugins - .values() - .filter(|p| p.manifest.plugin.kind.iter().any(|k| k == kind)) - .collect() - } - - /// Get count of registered plugins - #[must_use] - pub fn count(&self) -> usize { - self.plugins.len() - } - - /// Get count of enabled plugins - #[must_use] - pub fn count_enabled(&self) -> usize { - self.plugins.values().filter(|p| p.enabled).count() - } -} - -impl Default for PluginRegistry { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use pinakes_plugin_api::{Capabilities, manifest::ManifestCapabilities}; - use rustc_hash::FxHashMap; - - use super::*; - - fn create_test_plugin(id: &str, kind: Vec) -> RegisteredPlugin { - let manifest = PluginManifest { - plugin: pinakes_plugin_api::manifest::PluginInfo { - name: id.to_string(), - version: "1.0.0".to_string(), - api_version: "1.0".to_string(), - author: Some("Test".to_string()), - description: Some("Test plugin".to_string()), - homepage: None, - license: None, - kind, - binary: pinakes_plugin_api::manifest::PluginBinary { - wasm: "test.wasm".to_string(), - entrypoint: None, - }, - dependencies: vec![], - priority: 0, - }, - capabilities: ManifestCapabilities::default(), - config: FxHashMap::default(), - ui: Default::default(), - }; - - RegisteredPlugin { - id: id.to_string(), - metadata: PluginMetadata { - id: id.to_string(), - name: id.to_string(), - version: "1.0.0".to_string(), - author: "Test".to_string(), - description: "Test plugin".to_string(), - api_version: "1.0".to_string(), - capabilities_required: Capabilities::default(), - }, - wasm_plugin: WasmPlugin::default(), - manifest, - manifest_path: None, - enabled: true, - } - } - - #[test] - fn test_registry_register_and_get() { - let mut registry = PluginRegistry::new(); - let plugin = - create_test_plugin("test-plugin", vec!["media_type".to_string()]); - - registry.register(plugin).unwrap(); - - assert!(registry.is_loaded("test-plugin")); - assert!(registry.get("test-plugin").is_some()); - } - - #[test] - fn test_registry_duplicate_register() { - let mut registry = PluginRegistry::new(); - let plugin = - create_test_plugin("test-plugin", vec!["media_type".to_string()]); - - registry.register(plugin.clone()).unwrap(); - let result = registry.register(plugin); - - assert!(result.is_err()); - } - - #[test] - fn test_registry_unregister() { - let mut registry = PluginRegistry::new(); - let plugin = - create_test_plugin("test-plugin", vec!["media_type".to_string()]); - - registry.register(plugin).unwrap(); - registry.unregister("test-plugin").unwrap(); - - assert!(!registry.is_loaded("test-plugin")); - } - - #[test] - fn test_registry_enable_disable() { - let mut registry = PluginRegistry::new(); - let plugin = - create_test_plugin("test-plugin", vec!["media_type".to_string()]); - - registry.register(plugin).unwrap(); - assert_eq!(registry.is_enabled("test-plugin"), Some(true)); - - registry.disable("test-plugin").unwrap(); - assert_eq!(registry.is_enabled("test-plugin"), Some(false)); - - registry.enable("test-plugin").unwrap(); - assert_eq!(registry.is_enabled("test-plugin"), Some(true)); - - assert_eq!(registry.is_enabled("nonexistent"), None); - } - - #[test] - fn test_registry_get_by_kind() { - let mut registry = PluginRegistry::new(); - - registry - .register(create_test_plugin("plugin1", vec![ - "media_type".to_string(), - ])) - .unwrap(); - registry - .register(create_test_plugin("plugin2", vec![ - "metadata_extractor".to_string(), - ])) - .unwrap(); - registry - .register(create_test_plugin("plugin3", vec![ - "media_type".to_string(), - ])) - .unwrap(); - - let media_type_plugins = registry.get_by_kind("media_type"); - assert_eq!(media_type_plugins.len(), 2); - - let extractor_plugins = registry.get_by_kind("metadata_extractor"); - assert_eq!(extractor_plugins.len(), 1); - } - - #[test] - fn test_registry_counts() { - let mut registry = PluginRegistry::new(); - - registry - .register(create_test_plugin("plugin1", vec![ - "media_type".to_string(), - ])) - .unwrap(); - registry - .register(create_test_plugin("plugin2", vec![ - "media_type".to_string(), - ])) - .unwrap(); - - assert_eq!(registry.count(), 2); - assert_eq!(registry.count_enabled(), 2); - - registry.disable("plugin1").unwrap(); - assert_eq!(registry.count(), 2); - assert_eq!(registry.count_enabled(), 1); - } -} diff --git a/crates/pinakes-core/src/plugin/rpc.rs b/crates/pinakes-core/src/plugin/rpc.rs deleted file mode 100644 index e875d11..0000000 --- a/crates/pinakes-core/src/plugin/rpc.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! JSON RPC types for structured plugin function calls. -//! -//! Each extension point maps to well-known exported function names. -//! Requests are serialized to JSON, passed to the plugin, and responses -//! are deserialized from JSON written by the plugin via `host_set_result`. - -use std::path::PathBuf; - -use rustc_hash::FxHashMap; -use serde::{Deserialize, Serialize}; - -/// Request to check if a plugin can handle a file -#[derive(Debug, Serialize)] -pub struct CanHandleRequest { - pub path: PathBuf, - pub mime_type: Option, -} - -/// Response from `can_handle` -#[derive(Debug, Deserialize)] -pub struct CanHandleResponse { - pub can_handle: bool, -} - -/// Media type definition returned by `supported_media_types` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginMediaTypeDefinition { - pub id: String, - pub name: String, - pub category: Option, - pub extensions: Vec, - pub mime_types: Vec, -} - -/// Request to extract metadata from a file -#[derive(Debug, Serialize)] -pub struct ExtractMetadataRequest { - pub path: PathBuf, -} - -/// Metadata response from a plugin (all fields optional for partial results) -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -pub struct ExtractMetadataResponse { - #[serde(default)] - pub title: Option, - #[serde(default)] - pub artist: Option, - #[serde(default)] - pub album: Option, - #[serde(default)] - pub genre: Option, - #[serde(default)] - pub year: Option, - #[serde(default)] - pub duration_secs: Option, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub extra: FxHashMap, -} - -/// Request to generate a thumbnail -#[derive(Debug, Serialize)] -pub struct GenerateThumbnailRequest { - pub source_path: PathBuf, - pub output_path: PathBuf, - pub max_width: u32, - pub max_height: u32, - pub format: String, -} - -/// Response from thumbnail generation -#[derive(Debug, Deserialize)] -pub struct GenerateThumbnailResponse { - pub path: PathBuf, - pub width: u32, - pub height: u32, - pub format: String, -} - -/// Event sent to event handler plugins -#[derive(Debug, Serialize)] -pub struct HandleEventRequest { - pub event_type: String, - pub payload: serde_json::Value, -} - -/// Search request for search backend plugins -#[derive(Debug, Serialize)] -pub struct SearchRequest { - pub query: String, - pub limit: usize, - pub offset: usize, -} - -/// Search response -#[derive(Debug, Clone, Deserialize)] -pub struct SearchResponse { - pub results: Vec, - #[serde(default)] - pub total_count: Option, -} - -/// Individual search result -#[derive(Debug, Clone, Deserialize)] -pub struct SearchResultItem { - pub id: String, - pub score: f64, - pub snippet: Option, -} - -/// Request to index a media item in a search backend -#[derive(Debug, Serialize)] -pub struct IndexItemRequest { - pub id: String, - pub title: Option, - pub artist: Option, - pub album: Option, - pub description: Option, - pub tags: Vec, - pub media_type: String, - pub path: PathBuf, -} - -/// Request to remove a media item from a search backend -#[derive(Debug, Serialize)] -pub struct RemoveItemRequest { - pub id: String, -} - -/// A theme definition returned by a theme provider plugin -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginThemeDefinition { - pub id: String, - pub name: String, - pub description: Option, - pub dark: bool, -} - -/// Response from `load_theme` -#[derive(Debug, Clone, Deserialize)] -pub struct LoadThemeResponse { - pub css: Option, - pub colors: FxHashMap, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_metadata_request_serialization() { - let req = ExtractMetadataRequest { - path: "/tmp/test.mp3".into(), - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("/tmp/test.mp3")); - } - - #[test] - fn test_extract_metadata_response_partial() { - let json = r#"{"title":"My Song","extra":{"bpm":"120"}}"#; - let resp: ExtractMetadataResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.title.as_deref(), Some("My Song")); - assert_eq!(resp.artist, None); - assert_eq!(resp.extra.get("bpm").map(String::as_str), Some("120")); - } - - #[test] - fn test_extract_metadata_response_empty() { - let json = "{}"; - let resp: ExtractMetadataResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.title, None); - assert!(resp.extra.is_empty()); - } - - #[test] - fn test_can_handle_response() { - let json = r#"{"can_handle":true}"#; - let resp: CanHandleResponse = serde_json::from_str(json).unwrap(); - assert!(resp.can_handle); - } - - #[test] - fn test_can_handle_response_false() { - let json = r#"{"can_handle":false}"#; - let resp: CanHandleResponse = serde_json::from_str(json).unwrap(); - assert!(!resp.can_handle); - } - - #[test] - fn test_plugin_media_type_definition_round_trip() { - let def = PluginMediaTypeDefinition { - id: "heif".to_string(), - name: "HEIF Image".to_string(), - category: Some("image".to_string()), - extensions: vec!["heif".to_string(), "heic".to_string()], - mime_types: vec!["image/heif".to_string()], - }; - let json = serde_json::to_string(&def).unwrap(); - let parsed: PluginMediaTypeDefinition = - serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.id, "heif"); - assert_eq!(parsed.extensions.len(), 2); - } - - #[test] - fn test_search_response() { - let json = - r#"{"results":[{"id":"abc","score":0.95,"snippet":"match here"}]}"#; - let resp: SearchResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.results.len(), 1); - assert_eq!(resp.results[0].id, "abc"); - } - - #[test] - fn test_generate_thumbnail_request_serialization() { - let req = GenerateThumbnailRequest { - source_path: "/media/photo.heif".into(), - output_path: "/tmp/thumb.jpg".into(), - max_width: 256, - max_height: 256, - format: "jpeg".to_string(), - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("photo.heif")); - assert!(json.contains("256")); - } - - #[test] - fn test_handle_event_request_serialization() { - let req = HandleEventRequest { - event_type: "MediaImported".to_string(), - payload: serde_json::json!({"id": "abc-123"}), - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("MediaImported")); - assert!(json.contains("abc-123")); - } -} diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs deleted file mode 100644 index e07a1c4..0000000 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ /dev/null @@ -1,925 +0,0 @@ -//! WASM runtime for executing plugins - -use std::{path::Path, sync::Arc}; - -use anyhow::{Result, anyhow}; -use pinakes_plugin_api::PluginContext; -use wasmtime::{ - Caller, - Config, - Engine, - Linker, - Module, - Store, - StoreLimitsBuilder, - Val, - anyhow, -}; - -/// WASM runtime wrapper for executing plugins -pub struct WasmRuntime { - engine: Engine, -} - -impl WasmRuntime { - /// Create a new WASM runtime - /// - /// # Errors - /// - /// Returns an error if the WASM engine cannot be created with the given - /// configuration. - pub fn new() -> Result { - let mut config = Config::new(); - config.wasm_component_model(true); - config.max_wasm_stack(1024 * 1024); // 1MB stack - config.consume_fuel(true); // enable fuel metering for CPU limits - - let engine = Engine::new(&config)?; - - Ok(Self { engine }) - } - - /// Load a plugin from a WASM file - /// - /// # Errors - /// - /// Returns an error if the WASM file does not exist, cannot be read, or - /// cannot be compiled. - pub fn load_plugin( - &self, - wasm_path: &Path, - context: PluginContext, - ) -> Result { - if !wasm_path.exists() { - return Err(anyhow!("WASM file not found: {}", wasm_path.display())); - } - - let wasm_bytes = std::fs::read(wasm_path)?; - let module = Module::new(&self.engine, &wasm_bytes)?; - - Ok(WasmPlugin { - module: Arc::new(module), - context, - }) - } -} - -/// Store data passed to each WASM invocation -pub struct PluginStoreData { - pub context: PluginContext, - pub exchange_buffer: Vec, - pub pending_events: Vec<(String, String)>, - pub limiter: wasmtime::StoreLimits, -} - -/// A loaded WASM plugin instance -#[derive(Clone)] -pub struct WasmPlugin { - module: Arc, - context: PluginContext, -} - -impl WasmPlugin { - /// Get the plugin context - #[must_use] - pub const fn context(&self) -> &PluginContext { - &self.context - } - - /// Execute a plugin function, returning both the result bytes and any - /// events the plugin queued via `host_emit_event`. - /// - /// Creates a fresh store and instance per invocation with host functions - /// linked, calls the requested exported function, drains both the exchange - /// buffer and the pending events list before the store is dropped, and - /// returns both. - /// - /// # Errors - /// - /// Returns an error if the function cannot be found, instantiation fails, - /// or the function call returns an error. - pub async fn call_function_with_events( - &self, - function_name: &str, - params: &[u8], - ) -> Result<(Vec, Vec<(String, String)>)> { - let engine = self.module.engine(); - - // Build memory limiter from capabilities - let memory_limit = self - .context - .capabilities - .max_memory_bytes - .unwrap_or(512 * 1024 * 1024); // default 512 MB - - let limiter = StoreLimitsBuilder::new().memory_size(memory_limit).build(); - - let store_data = PluginStoreData { - context: self.context.clone(), - exchange_buffer: Vec::new(), - pending_events: Vec::new(), - limiter, - }; - let mut store = Store::new(engine, store_data); - store.limiter(|data| &mut data.limiter); - - // Set fuel limit based on capabilities - if let Some(max_cpu_time_ms) = self.context.capabilities.max_cpu_time_ms { - let fuel = max_cpu_time_ms * 100_000; - store.set_fuel(fuel)?; - } else { - store.set_fuel(1_000_000_000)?; - } - - let mut linker = Linker::new(engine); - HostFunctions::setup_linker(&mut linker)?; - - let instance = linker.instantiate_async(&mut store, &self.module).await?; - - let memory = instance.get_memory(&mut store, "memory"); - - // If there are params and memory is available, write them to the module - let mut alloc_offset: i32 = 0; - if !params.is_empty() - && let Some(mem) = &memory - { - // Call the plugin's alloc function if available, otherwise write at - // offset 0 - let offset = if let Ok(alloc) = - instance.get_typed_func::(&mut store, "alloc") - { - let result = alloc - .call_async( - &mut store, - i32::try_from(params.len()).unwrap_or(i32::MAX), - ) - .await?; - if result < 0 { - return Err(anyhow!( - "plugin alloc returned negative offset: {result}" - )); - } - u32::try_from(result).unwrap_or(0) as usize - } else { - 0 - }; - - alloc_offset = i32::try_from(offset).unwrap_or(i32::MAX); - let mem_data = mem.data_mut(&mut store); - if offset + params.len() <= mem_data.len() { - mem_data[offset..offset + params.len()].copy_from_slice(params); - } - } - - let func = - instance - .get_func(&mut store, function_name) - .ok_or_else(|| { - anyhow!("exported function '{function_name}' not found") - })?; - - let func_ty = func.ty(&store); - let param_count = func_ty.params().len(); - let result_count = func_ty.results().len(); - - let mut results = vec![Val::I32(0); result_count]; - - // Call with appropriate params based on function signature; convention: - // (ptr, len) - if param_count == 2 && !params.is_empty() { - func - .call_async( - &mut store, - &[ - Val::I32(alloc_offset), - Val::I32(i32::try_from(params.len()).unwrap_or(i32::MAX)), - ], - &mut results, - ) - .await?; - } else if param_count == 0 { - func.call_async(&mut store, &[], &mut results).await?; - } else { - // Generic: fill with zeroes - let params_vals: Vec = - std::iter::repeat_n(Val::I32(0), param_count).collect(); - func - .call_async(&mut store, ¶ms_vals, &mut results) - .await?; - } - - // Drain both buffers before the store is dropped. - let pending_events = std::mem::take(&mut store.data_mut().pending_events); - let exchange = std::mem::take(&mut store.data_mut().exchange_buffer); - - let result = if !exchange.is_empty() { - exchange - } else if let Some(Val::I32(ret)) = results.first() { - ret.to_le_bytes().to_vec() - } else { - Vec::new() - }; - - Ok((result, pending_events)) - } - - /// Execute a plugin function, discarding any events the plugin queued. - /// - /// This is a thin wrapper around [`Self::call_function_with_events`]. - /// - /// # Errors - /// - /// Returns an error if the function cannot be found, instantiation fails, - /// or the function call returns an error. - pub async fn call_function( - &self, - function_name: &str, - params: &[u8], - ) -> Result> { - let (data, _events) = self - .call_function_with_events(function_name, params) - .await?; - Ok(data) - } - - /// Call a plugin function with JSON request/response serialization. - /// - /// Serializes `request` to JSON, calls the named function, deserializes - /// the response. Wraps the call with `tokio::time::timeout`. - /// - /// # Errors - /// - /// Returns an error if serialization fails, the call times out, the plugin - /// traps, or the response is malformed JSON. - #[allow(clippy::future_not_send)] // Req doesn't need Sync; called within local tasks - pub async fn call_function_json( - &self, - function_name: &str, - request: &Req, - timeout: std::time::Duration, - ) -> anyhow::Result - where - Req: serde::Serialize, - Resp: serde::de::DeserializeOwned, - { - let request_bytes = serde_json::to_vec(request) - .map_err(|e| anyhow::anyhow!("failed to serialize request: {e}"))?; - - let result = tokio::time::timeout( - timeout, - self.call_function(function_name, &request_bytes), - ) - .await - .map_err(|_| { - anyhow::anyhow!( - "plugin call '{function_name}' timed out after {timeout:?}" - ) - })??; - - serde_json::from_slice(&result).map_err(|e| { - anyhow::anyhow!( - "failed to deserialize response from '{function_name}': {e}" - ) - }) - } - - /// Call a plugin function with JSON serialization, also returning any - /// events the plugin queued via `host_emit_event`. - /// - /// Mirrors [`Self::call_function_json`] but delegates to - /// [`Self::call_function_with_events`] so the pending events list is not - /// discarded before returning. - /// - /// # Errors - /// - /// Returns an error if serialization fails, the call times out, the plugin - /// traps, or the response is malformed JSON. - #[allow(clippy::future_not_send)] // Req doesn't need Sync; called within local tasks - pub async fn call_function_json_with_events( - &self, - function_name: &str, - request: &Req, - timeout: std::time::Duration, - ) -> anyhow::Result<(Resp, Vec<(String, String)>)> - where - Req: serde::Serialize, - Resp: serde::de::DeserializeOwned, - { - let request_bytes = serde_json::to_vec(request) - .map_err(|e| anyhow::anyhow!("failed to serialize request: {e}"))?; - - let (result, pending_events) = tokio::time::timeout( - timeout, - self.call_function_with_events(function_name, &request_bytes), - ) - .await - .map_err(|_| { - anyhow::anyhow!( - "plugin call '{function_name}' timed out after {timeout:?}" - ) - })??; - - let resp = serde_json::from_slice(&result).map_err(|e| { - anyhow::anyhow!( - "failed to deserialize response from '{function_name}': {e}" - ) - })?; - - Ok((resp, pending_events)) - } -} - -#[cfg(test)] -impl Default for WasmPlugin { - fn default() -> Self { - let engine = Engine::default(); - let module = Module::new(&engine, br"(module)").unwrap(); - - Self { - module: Arc::new(module), - context: PluginContext { - data_dir: std::env::temp_dir(), - cache_dir: std::env::temp_dir(), - config: Default::default(), - capabilities: Default::default(), - }, - } - } -} - -/// Host functions that plugins can call -pub struct HostFunctions; - -impl HostFunctions { - /// Registers all host ABI functions (`host_log`, `host_read_file`, - /// `host_write_file`, `host_http_request`, `host_get_config`, - /// `host_get_env`, `host_get_buffer`, `host_set_result`, - /// `host_emit_event`) into the given linker. - /// - /// # Errors - /// - /// Returns an error if any host function cannot be registered in the linker. - pub fn setup_linker(linker: &mut Linker) -> Result<()> { - linker.func_wrap( - "env", - "host_log", - |mut caller: Caller<'_, PluginStoreData>, - level: i32, - ptr: i32, - len: i32| { - if ptr < 0 || len < 0 { - return; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - if let Some(mem) = memory { - let data = mem.data(&caller); - let start = u32::try_from(ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(len).unwrap_or(0) as usize; - if end <= data.len() - && let Ok(msg) = std::str::from_utf8(&data[start..end]) - { - match level { - 0 => tracing::error!(plugin = true, "{}", msg), - 1 => tracing::warn!(plugin = true, "{}", msg), - 2 => tracing::info!(plugin = true, "{}", msg), - _ => tracing::debug!(plugin = true, "{}", msg), - } - } - } - }, - )?; - - linker.func_wrap( - "env", - "host_read_file", - |mut caller: Caller<'_, PluginStoreData>, - path_ptr: i32, - path_len: i32| - -> i32 { - if path_ptr < 0 || path_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = u32::try_from(path_ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(path_len).unwrap_or(0) as usize; - if end > data.len() { - return -1; - } - - let path_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - // Canonicalize path before checking permissions to prevent traversal - let Ok(path) = std::path::Path::new(&path_str).canonicalize() else { - return -1; - }; - - // Check read permission against canonicalized path - let can_read = caller - .data() - .context - .capabilities - .filesystem - .read - .iter() - .any(|allowed| { - allowed.canonicalize().is_ok_and(|a| path.starts_with(a)) - }); - - if !can_read { - tracing::warn!(path = %path_str, "plugin read access denied"); - return -2; - } - - std::fs::read(&path).map_or(-1, |contents| { - let len = i32::try_from(contents.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = contents; - len - }) - }, - )?; - - linker.func_wrap( - "env", - "host_write_file", - |mut caller: Caller<'_, PluginStoreData>, - path_ptr: i32, - path_len: i32, - data_ptr: i32, - data_len: i32| - -> i32 { - if path_ptr < 0 || path_len < 0 || data_ptr < 0 || data_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let mem_data = mem.data(&caller); - let path_start = u32::try_from(path_ptr).unwrap_or(0) as usize; - let path_end = - path_start + u32::try_from(path_len).unwrap_or(0) as usize; - let data_start = u32::try_from(data_ptr).unwrap_or(0) as usize; - let data_end = - data_start + u32::try_from(data_len).unwrap_or(0) as usize; - - if path_end > mem_data.len() || data_end > mem_data.len() { - return -1; - } - - let path_str = - match std::str::from_utf8(&mem_data[path_start..path_end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - let file_data = mem_data[data_start..data_end].to_vec(); - - // Canonicalize path for write (file may not exist yet) - let path = std::path::Path::new(&path_str); - let canonical = if path.exists() { - path.canonicalize().ok() - } else { - path - .parent() - .and_then(|p| p.canonicalize().ok()) - .map(|p| p.join(path.file_name().unwrap_or_default())) - }; - let Some(canonical) = canonical else { - return -1; - }; - - // Check write permission against canonicalized path - let can_write = caller - .data() - .context - .capabilities - .filesystem - .write - .iter() - .any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| canonical.starts_with(a)) - }); - - if !can_write { - tracing::warn!(path = %path_str, "plugin write access denied"); - return -2; - } - - match std::fs::write(&canonical, &file_data) { - Ok(()) => 0, - Err(_) => -1, - } - }, - )?; - - linker.func_wrap( - "env", - "host_http_request", - |mut caller: Caller<'_, PluginStoreData>, - url_ptr: i32, - url_len: i32| - -> i32 { - if url_ptr < 0 || url_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = u32::try_from(url_ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(url_len).unwrap_or(0) as usize; - if end > data.len() { - return -1; - } - - let url_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - // Check network permission - if !caller.data().context.capabilities.network.enabled { - tracing::warn!(url = %url_str, "plugin network access denied"); - return -2; - } - - // Check domain whitelist if configured - if let Some(ref allowed) = - caller.data().context.capabilities.network.allowed_domains - { - let parsed = if let Ok(u) = url::Url::parse(&url_str) { - u - } else { - tracing::warn!(url = %url_str, "plugin provided invalid URL"); - return -1; - }; - let domain = parsed.host_str().unwrap_or(""); - - if !allowed.iter().any(|d| d.eq_ignore_ascii_case(domain)) { - tracing::warn!( - url = %url_str, - domain = domain, - "plugin domain not in allowlist" - ); - return -3; - } - } - - // Use block_in_place to avoid blocking the async runtime's thread pool. - // Falls back to a blocking client with timeout if block_in_place is - // unavailable. - let result = std::panic::catch_unwind(|| { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| e.to_string())?; - let resp = client - .get(&url_str) - .send() - .await - .map_err(|e| e.to_string())?; - let bytes = resp.bytes().await.map_err(|e| e.to_string())?; - Ok::<_, String>(bytes) - }) - }) - }); - - match result { - Ok(Ok(bytes)) => { - let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = bytes.to_vec(); - len - }, - Ok(Err(_)) => -1, - Err(_) => { - // block_in_place panicked (e.g. current-thread runtime); - // fall back to blocking client with timeout - let Ok(client) = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - else { - return -1; - }; - client.get(&url_str).send().map_or(-1, |resp| { - resp.bytes().map_or(-1, |bytes| { - let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = bytes.to_vec(); - len - }) - }) - }, - } - }, - )?; - - linker.func_wrap( - "env", - "host_get_config", - |mut caller: Caller<'_, PluginStoreData>, - key_ptr: i32, - key_len: i32| - -> i32 { - if key_ptr < 0 || key_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = u32::try_from(key_ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(key_len).unwrap_or(0) as usize; - if end > data.len() { - return -1; - } - - let key_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - let bytes = caller - .data() - .context - .config - .get(&key_str) - .map(|value| value.to_string().into_bytes()); - bytes.map_or(-1, |b| { - let len = i32::try_from(b.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = b; - len - }) - }, - )?; - - linker.func_wrap( - "env", - "host_get_env", - |mut caller: Caller<'_, PluginStoreData>, - key_ptr: i32, - key_len: i32| - -> i32 { - if key_ptr < 0 || key_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = u32::try_from(key_ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(key_len).unwrap_or(0) as usize; - if end > data.len() { - return -1; - } - - let key_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - // Check environment capability - let env_cap = &caller.data().context.capabilities.environment; - if !env_cap.enabled { - tracing::warn!( - var = %key_str, - "plugin environment access denied" - ); - return -2; - } - - // Check against allowed variables list if configured - if let Some(ref allowed) = env_cap.allowed_vars - && !allowed.iter().any(|v| v == &key_str) - { - tracing::warn!( - var = %key_str, - "plugin env var not in allowlist" - ); - return -2; - } - - match std::env::var(&key_str) { - Ok(value) => { - let bytes = value.into_bytes(); - let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = bytes; - len - }, - Err(_) => -1, - } - }, - )?; - - linker.func_wrap( - "env", - "host_get_buffer", - |mut caller: Caller<'_, PluginStoreData>, - dest_ptr: i32, - dest_len: i32| - -> i32 { - if dest_ptr < 0 || dest_len < 0 { - return -1; - } - let buf = caller.data().exchange_buffer.clone(); - let copy_len = - buf.len().min(u32::try_from(dest_len).unwrap_or(0) as usize); - - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let mem_data = mem.data_mut(&mut caller); - let start = u32::try_from(dest_ptr).unwrap_or(0) as usize; - if start + copy_len > mem_data.len() { - return -1; - } - - mem_data[start..start + copy_len].copy_from_slice(&buf[..copy_len]); - i32::try_from(copy_len).unwrap_or(i32::MAX) - }, - )?; - - linker.func_wrap( - "env", - "host_set_result", - |mut caller: Caller<'_, PluginStoreData>, ptr: i32, len: i32| { - if ptr < 0 || len < 0 { - return; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return }; - - let data = mem.data(&caller); - let start = u32::try_from(ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(len).unwrap_or(0) as usize; - if end <= data.len() { - caller.data_mut().exchange_buffer = data[start..end].to_vec(); - } - }, - )?; - - linker.func_wrap( - "env", - "host_emit_event", - |mut caller: Caller<'_, PluginStoreData>, - type_ptr: i32, - type_len: i32, - payload_ptr: i32, - payload_len: i32| - -> i32 { - const MAX_PENDING_EVENTS: usize = 1000; - - if type_ptr < 0 || type_len < 0 || payload_ptr < 0 || payload_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let type_start = u32::try_from(type_ptr).unwrap_or(0) as usize; - let type_end = - type_start + u32::try_from(type_len).unwrap_or(0) as usize; - let payload_start = u32::try_from(payload_ptr).unwrap_or(0) as usize; - let payload_end = - payload_start + u32::try_from(payload_len).unwrap_or(0) as usize; - - // Extract owned strings in a block so the immutable borrow of - // `caller` (via `mem.data`) is dropped before `caller.data_mut()`. - let (event_type, payload) = { - let data = mem.data(&caller); - if type_end > data.len() || payload_end > data.len() { - return -1; - } - let event_type = - match std::str::from_utf8(&data[type_start..type_end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - let payload = - match std::str::from_utf8(&data[payload_start..payload_end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - (event_type, payload) - }; - - if caller.data().pending_events.len() >= MAX_PENDING_EVENTS { - tracing::warn!("plugin exceeded max pending events limit"); - return -4; - } - - caller.data_mut().pending_events.push((event_type, payload)); - 0 - }, - )?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use pinakes_plugin_api::PluginContext; - use rustc_hash::FxHashMap; - - use super::*; - - #[test] - fn test_wasm_runtime_creation() { - let runtime = WasmRuntime::new(); - assert!(runtime.is_ok()); - } - - #[test] - fn test_host_functions_file_access() { - let mut capabilities = pinakes_plugin_api::Capabilities::default(); - capabilities.filesystem.read.push("/tmp".into()); - capabilities.filesystem.write.push("/tmp/output".into()); - - let context = PluginContext { - data_dir: "/tmp/data".into(), - cache_dir: "/tmp/cache".into(), - config: Default::default(), - capabilities, - }; - - // Verify capability checks work via context fields - let can_read = context - .capabilities - .filesystem - .read - .iter() - .any(|p| Path::new("/tmp/test.txt").starts_with(p)); - assert!(can_read); - - let cant_read = context - .capabilities - .filesystem - .read - .iter() - .any(|p| Path::new("/etc/passwd").starts_with(p)); - assert!(!cant_read); - - let can_write = context - .capabilities - .filesystem - .write - .iter() - .any(|p| Path::new("/tmp/output/file.txt").starts_with(p)); - assert!(can_write); - - let cant_write = context - .capabilities - .filesystem - .write - .iter() - .any(|p| Path::new("/tmp/file.txt").starts_with(p)); - assert!(!cant_write); - } - - #[test] - fn test_host_functions_network_access() { - let mut context = PluginContext { - data_dir: "/tmp/data".into(), - cache_dir: "/tmp/cache".into(), - config: FxHashMap::default(), - capabilities: Default::default(), - }; - - assert!(!context.capabilities.network.enabled); - - context.capabilities.network.enabled = true; - assert!(context.capabilities.network.enabled); - } - - #[test] - fn test_linker_setup() { - let engine = Engine::default(); - let mut linker = Linker::::new(&engine); - let result = HostFunctions::setup_linker(&mut linker); - assert!(result.is_ok()); - } -} diff --git a/crates/pinakes-core/src/plugin/security.rs b/crates/pinakes-core/src/plugin/security.rs deleted file mode 100644 index 6bebb94..0000000 --- a/crates/pinakes-core/src/plugin/security.rs +++ /dev/null @@ -1,473 +0,0 @@ -//! Capability-based security for plugins - -use std::path::{Path, PathBuf}; - -use anyhow::{Result, anyhow}; -use pinakes_plugin_api::Capabilities; - -/// Capability enforcer validates and enforces plugin capabilities -pub struct CapabilityEnforcer { - /// Maximum allowed memory per plugin (bytes) - max_memory_limit: usize, - - /// Maximum allowed CPU time per plugin (milliseconds) - max_cpu_time_limit: u64, - - /// Allowed filesystem read paths (system-wide) - allowed_read_paths: Vec, - - /// Allowed filesystem write paths (system-wide) - allowed_write_paths: Vec, - - /// Whether to allow network access by default - allow_network_default: bool, -} - -impl CapabilityEnforcer { - /// Create a new capability enforcer with default limits - #[must_use] - pub const fn new() -> Self { - Self { - max_memory_limit: 512 * 1024 * 1024, // 512 MB - max_cpu_time_limit: 60 * 1000, // 60 seconds - allowed_read_paths: vec![], - allowed_write_paths: vec![], - allow_network_default: false, - } - } - - /// Set maximum memory limit - #[must_use] - pub const fn with_max_memory(mut self, bytes: usize) -> Self { - self.max_memory_limit = bytes; - self - } - - /// Set maximum CPU time limit - #[must_use] - pub const fn with_max_cpu_time(mut self, milliseconds: u64) -> Self { - self.max_cpu_time_limit = milliseconds; - self - } - - /// Add allowed read path - #[must_use] - pub fn allow_read_path(mut self, path: PathBuf) -> Self { - self.allowed_read_paths.push(path); - self - } - - /// Add allowed write path - #[must_use] - pub fn allow_write_path(mut self, path: PathBuf) -> Self { - self.allowed_write_paths.push(path); - self - } - - /// Set default network access policy - #[must_use] - pub const fn with_network_default(mut self, allow: bool) -> Self { - self.allow_network_default = allow; - self - } - - /// Validate capabilities requested by a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin requests capabilities that exceed the - /// configured system limits, such as memory, CPU time, filesystem paths, or - /// network access. - pub fn validate_capabilities( - &self, - capabilities: &Capabilities, - ) -> Result<()> { - // Validate memory limit - if let Some(memory) = capabilities.max_memory_bytes - && memory > self.max_memory_limit - { - return Err(anyhow!( - "Requested memory ({} bytes) exceeds limit ({} bytes)", - memory, - self.max_memory_limit - )); - } - - // Validate CPU time limit - if let Some(cpu_time) = capabilities.max_cpu_time_ms - && cpu_time > self.max_cpu_time_limit - { - return Err(anyhow!( - "Requested CPU time ({} ms) exceeds limit ({} ms)", - cpu_time, - self.max_cpu_time_limit - )); - } - - // Validate filesystem access - self.validate_filesystem_access(capabilities)?; - - // Validate network access - if capabilities.network.enabled && !self.allow_network_default { - return Err(anyhow!( - "Plugin requests network access, but network access is disabled by \ - policy" - )); - } - - Ok(()) - } - - /// Validate filesystem access capabilities - fn validate_filesystem_access( - &self, - capabilities: &Capabilities, - ) -> Result<()> { - // Check read paths - for path in &capabilities.filesystem.read { - if !self.is_read_allowed(path) { - return Err(anyhow!( - "Plugin requests read access to {} which is not in allowed paths", - path.display() - )); - } - } - - // Check write paths - for path in &capabilities.filesystem.write { - if !self.is_write_allowed(path) { - return Err(anyhow!( - "Plugin requests write access to {} which is not in allowed paths", - path.display() - )); - } - } - - Ok(()) - } - - /// Check if a path is allowed for reading - #[must_use] - pub fn is_read_allowed(&self, path: &Path) -> bool { - if self.allowed_read_paths.is_empty() { - return false; // deny-all when unconfigured - } - let Ok(canonical) = path.canonicalize() else { - return false; - }; - self.allowed_read_paths.iter().any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| canonical.starts_with(a)) - }) - } - - /// Check if a path is allowed for writing - #[must_use] - pub fn is_write_allowed(&self, path: &Path) -> bool { - if self.allowed_write_paths.is_empty() { - return false; // deny-all when unconfigured - } - let canonical = if path.exists() { - path.canonicalize().ok() - } else { - path - .parent() - .and_then(|p| p.canonicalize().ok()) - .map(|p| p.join(path.file_name().unwrap_or_default())) - }; - let Some(canonical) = canonical else { - return false; - }; - self.allowed_write_paths.iter().any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| canonical.starts_with(a)) - }) - } - - /// Check if network access is allowed for a plugin - #[must_use] - pub const fn is_network_allowed(&self, capabilities: &Capabilities) -> bool { - capabilities.network.enabled && self.allow_network_default - } - - /// Check if a specific domain is allowed - #[must_use] - pub fn is_domain_allowed( - &self, - capabilities: &Capabilities, - domain: &str, - ) -> bool { - if !capabilities.network.enabled { - return false; - } - - // If no domain restrictions, allow all domains - if capabilities.network.allowed_domains.is_none() { - return self.allow_network_default; - } - - // Check against allowed domains list - capabilities - .network - .allowed_domains - .as_ref() - .is_some_and(|domains| { - domains.iter().any(|d| d.eq_ignore_ascii_case(domain)) - }) - } - - /// Get effective memory limit for a plugin - #[must_use] - pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize { - capabilities - .max_memory_bytes - .unwrap_or(self.max_memory_limit) - .min(self.max_memory_limit) - } - - /// Get effective CPU time limit for a plugin - #[must_use] - pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 { - capabilities - .max_cpu_time_ms - .unwrap_or(self.max_cpu_time_limit) - .min(self.max_cpu_time_limit) - } - - /// Validate that a function call is allowed for a plugin's declared kinds. - /// - /// Defense-in-depth: even though the pipeline filters by kind, this prevents - /// bugs from calling wrong functions on plugins. Returns `true` if allowed. - #[must_use] - pub fn validate_function_call( - &self, - plugin_kinds: &[String], - function_name: &str, - ) -> bool { - match function_name { - // Lifecycle functions are always allowed - "initialize" | "shutdown" | "health_check" => true, - // MediaTypeProvider - "supported_media_types" | "can_handle" => { - plugin_kinds.iter().any(|k| k == "media_type") - }, - // supported_types is shared by metadata_extractor and thumbnail_generator - "supported_types" => { - plugin_kinds - .iter() - .any(|k| k == "metadata_extractor" || k == "thumbnail_generator") - }, - // MetadataExtractor - "extract_metadata" => { - plugin_kinds.iter().any(|k| k == "metadata_extractor") - }, - // ThumbnailGenerator - "generate_thumbnail" => { - plugin_kinds.iter().any(|k| k == "thumbnail_generator") - }, - // SearchBackend - "search" | "index_item" | "remove_item" | "get_stats" => { - plugin_kinds.iter().any(|k| k == "search_backend") - }, - // EventHandler - "interested_events" | "handle_event" => { - plugin_kinds.iter().any(|k| k == "event_handler") - }, - // ThemeProvider - "get_themes" | "load_theme" => { - plugin_kinds.iter().any(|k| k == "theme_provider") - }, - // Unknown function names are not allowed - _ => false, - } - } -} - -impl Default for CapabilityEnforcer { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - #[allow(unused_imports)] - use pinakes_plugin_api::{FilesystemCapability, NetworkCapability}; - - use super::*; - - #[test] - fn test_validate_memory_limit() { - let enforcer = CapabilityEnforcer::new().with_max_memory(100 * 1024 * 1024); // 100 MB - - let mut caps = Capabilities::default(); - caps.max_memory_bytes = Some(50 * 1024 * 1024); // 50 MB - OK - assert!(enforcer.validate_capabilities(&caps).is_ok()); - - caps.max_memory_bytes = Some(200 * 1024 * 1024); // 200 MB - exceeds limit - assert!(enforcer.validate_capabilities(&caps).is_err()); - } - - #[test] - fn test_validate_cpu_time_limit() { - let enforcer = CapabilityEnforcer::new().with_max_cpu_time(30_000); // 30 seconds - - let mut caps = Capabilities::default(); - caps.max_cpu_time_ms = Some(10_000); // 10 seconds - OK - assert!(enforcer.validate_capabilities(&caps).is_ok()); - - caps.max_cpu_time_ms = Some(60_000); // 60 seconds - exceeds limit - assert!(enforcer.validate_capabilities(&caps).is_err()); - } - - #[test] - fn test_filesystem_read_allowed() { - // Use real temp directories so canonicalize works - let tmp = tempfile::tempdir().unwrap(); - let allowed_dir = tmp.path().join("allowed"); - std::fs::create_dir_all(&allowed_dir).unwrap(); - let test_file = allowed_dir.join("test.txt"); - std::fs::write(&test_file, "test").unwrap(); - - let enforcer = CapabilityEnforcer::new().allow_read_path(allowed_dir); - - assert!(enforcer.is_read_allowed(&test_file)); - assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd"))); - } - - #[test] - fn test_filesystem_read_denied_when_empty() { - let enforcer = CapabilityEnforcer::new(); - assert!(!enforcer.is_read_allowed(Path::new("/tmp/test.txt"))); - } - - #[test] - fn test_filesystem_write_allowed() { - let tmp = tempfile::tempdir().unwrap(); - let output_dir = tmp.path().join("output"); - std::fs::create_dir_all(&output_dir).unwrap(); - // Existing file in allowed dir - let existing = output_dir.join("file.txt"); - std::fs::write(&existing, "test").unwrap(); - - let enforcer = - CapabilityEnforcer::new().allow_write_path(output_dir.clone()); - - assert!(enforcer.is_write_allowed(&existing)); - // New file in allowed dir (parent exists) - assert!(enforcer.is_write_allowed(&output_dir.join("new_file.txt"))); - assert!(!enforcer.is_write_allowed(Path::new("/etc/config"))); - } - - #[test] - fn test_filesystem_write_denied_when_empty() { - let enforcer = CapabilityEnforcer::new(); - assert!(!enforcer.is_write_allowed(Path::new("/tmp/file.txt"))); - } - - #[test] - fn test_network_allowed() { - let enforcer = CapabilityEnforcer::new().with_network_default(true); - - let mut caps = Capabilities::default(); - caps.network.enabled = true; - - assert!(enforcer.is_network_allowed(&caps)); - - caps.network.enabled = false; - assert!(!enforcer.is_network_allowed(&caps)); - } - - #[test] - fn test_domain_restrictions() { - let enforcer = CapabilityEnforcer::new().with_network_default(true); - - let mut caps = Capabilities::default(); - caps.network.enabled = true; - caps.network.allowed_domains = Some(vec![ - "api.example.com".to_string(), - "cdn.example.com".to_string(), - ]); - - assert!(enforcer.is_domain_allowed(&caps, "api.example.com")); - assert!(enforcer.is_domain_allowed(&caps, "cdn.example.com")); - assert!(!enforcer.is_domain_allowed(&caps, "evil.com")); - } - - #[test] - fn test_get_effective_limits() { - let enforcer = CapabilityEnforcer::new() - .with_max_memory(100 * 1024 * 1024) - .with_max_cpu_time(30_000); - - let mut caps = Capabilities::default(); - - // No limits specified, use the defaults - assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); - assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); - - // Plugin requests lower limits, use plugin's - caps.max_memory_bytes = Some(50 * 1024 * 1024); - caps.max_cpu_time_ms = Some(10_000); - assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024); - assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000); - - // Plugin requests higher limits, cap at system max - caps.max_memory_bytes = Some(200 * 1024 * 1024); - caps.max_cpu_time_ms = Some(60_000); - assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); - assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); - } - - #[test] - fn test_validate_function_call_lifecycle_always_allowed() { - let enforcer = CapabilityEnforcer::new(); - let kinds = vec!["metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "initialize")); - assert!(enforcer.validate_function_call(&kinds, "shutdown")); - assert!(enforcer.validate_function_call(&kinds, "health_check")); - } - - #[test] - fn test_validate_function_call_metadata_extractor() { - let enforcer = CapabilityEnforcer::new(); - let kinds = vec!["metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); - assert!(enforcer.validate_function_call(&kinds, "supported_types")); - assert!(!enforcer.validate_function_call(&kinds, "search")); - assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail")); - assert!(!enforcer.validate_function_call(&kinds, "can_handle")); - } - - #[test] - fn test_validate_function_call_multi_kind() { - let enforcer = CapabilityEnforcer::new(); - let kinds = - vec!["media_type".to_string(), "metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "can_handle")); - assert!(enforcer.validate_function_call(&kinds, "supported_media_types")); - assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); - assert!(!enforcer.validate_function_call(&kinds, "search")); - } - - #[test] - fn test_validate_function_call_unknown_function() { - let enforcer = CapabilityEnforcer::new(); - let kinds = vec!["metadata_extractor".to_string()]; - assert!(!enforcer.validate_function_call(&kinds, "unknown_func")); - assert!(!enforcer.validate_function_call(&kinds, "")); - } - - #[test] - fn test_validate_function_call_shared_supported_types() { - let enforcer = CapabilityEnforcer::new(); - let extractor = vec!["metadata_extractor".to_string()]; - let generator = vec!["thumbnail_generator".to_string()]; - let search = vec!["search_backend".to_string()]; - assert!(enforcer.validate_function_call(&extractor, "supported_types")); - assert!(enforcer.validate_function_call(&generator, "supported_types")); - assert!(!enforcer.validate_function_call(&search, "supported_types")); - } -} diff --git a/crates/pinakes-core/src/plugin/signature.rs b/crates/pinakes-core/src/plugin/signature.rs deleted file mode 100644 index 64f9dc5..0000000 --- a/crates/pinakes-core/src/plugin/signature.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Plugin signature verification using Ed25519 + BLAKE3 -//! -//! Each plugin directory may contain a `plugin.sig` file alongside its -//! `plugin.toml`. The signature covers the BLAKE3 hash of the WASM binary -//! referenced by the manifest. Verification uses Ed25519 public keys -//! configured as trusted in the server's plugin settings. -//! -//! When `allow_unsigned` is false, plugins _must_ carry a valid signature -//! from one of the trusted keys or they will be rejected at load time. - -use std::path::Path; - -use anyhow::{Result, anyhow}; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - -/// Outcome of a signature check on a plugin package. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SignatureStatus { - /// Signature is present and valid against a trusted key. - Valid, - /// No signature file found. - Unsigned, - /// Signature file exists but does not match any trusted key. - Invalid(String), -} - -/// Verify the signature of a plugin's WASM binary. -/// -/// Reads `plugin.sig` from `plugin_dir`, computes the BLAKE3 hash of the -/// WASM binary at `wasm_path`, and verifies the signature against each of -/// the `trusted_keys`. The signature file is raw 64-byte Ed25519 signature -/// over the 32-byte BLAKE3 digest. -/// -/// # Errors -/// -/// Returns an error only on I/O failures, never for cryptographic rejection, -/// which is reported via [`SignatureStatus`] instead. -pub fn verify_plugin_signature( - plugin_dir: &Path, - wasm_path: &Path, - trusted_keys: &[VerifyingKey], -) -> Result { - let sig_path = plugin_dir.join("plugin.sig"); - if !sig_path.exists() { - return Ok(SignatureStatus::Unsigned); - } - - let sig_bytes = std::fs::read(&sig_path) - .map_err(|e| anyhow!("failed to read plugin.sig: {e}"))?; - - let signature = Signature::from_slice(&sig_bytes).map_err(|e| { - // Malformed signature file is an invalid signature, not an I/O error - tracing::warn!(path = %sig_path.display(), "malformed plugin.sig: {e}"); - anyhow!("malformed plugin.sig: {e}") - }); - let Ok(signature) = signature else { - return Ok(SignatureStatus::Invalid( - "malformed signature file".to_string(), - )); - }; - - // BLAKE3 hash of the WASM binary is the signed message - let wasm_bytes = std::fs::read(wasm_path) - .map_err(|e| anyhow!("failed to read WASM binary for verification: {e}"))?; - let digest = blake3::hash(&wasm_bytes); - let message = digest.as_bytes(); - - for key in trusted_keys { - if key.verify(message, &signature).is_ok() { - return Ok(SignatureStatus::Valid); - } - } - - Ok(SignatureStatus::Invalid( - "signature did not match any trusted key".to_string(), - )) -} - -/// Parse a hex-encoded Ed25519 public key (64 hex characters = 32 bytes). -/// -/// # Errors -/// -/// Returns an error if the string is not valid hex or is the wrong length. -pub fn parse_public_key(hex_str: &str) -> Result { - let hex_str = hex_str.trim(); - if hex_str.len() != 64 { - return Err(anyhow!( - "expected 64 hex characters for Ed25519 public key, got {}", - hex_str.len() - )); - } - - let mut bytes = [0u8; 32]; - for (i, byte) in bytes.iter_mut().enumerate() { - *byte = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16) - .map_err(|e| anyhow!("invalid hex in public key: {e}"))?; - } - - VerifyingKey::from_bytes(&bytes) - .map_err(|e| anyhow!("invalid Ed25519 public key: {e}")) -} - -#[cfg(test)] -mod tests { - use ed25519_dalek::{Signer, SigningKey}; - use rand::RngExt; - - use super::*; - - fn make_keypair() -> (SigningKey, VerifyingKey) { - let secret_bytes: [u8; 32] = rand::rng().random(); - let signing = SigningKey::from_bytes(&secret_bytes); - let verifying = signing.verifying_key(); - (signing, verifying) - } - - #[test] - fn test_verify_unsigned_plugin() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap(); - - let (_, vk) = make_keypair(); - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); - assert_eq!(status, SignatureStatus::Unsigned); - } - - #[test] - fn test_verify_valid_signature() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code_here"; - std::fs::write(&wasm_path, wasm_bytes).unwrap(); - - let (sk, vk) = make_keypair(); - - // Sign the BLAKE3 hash of the WASM binary - let digest = blake3::hash(wasm_bytes); - let signature = sk.sign(digest.as_bytes()); - std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) - .unwrap(); - - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); - assert_eq!(status, SignatureStatus::Valid); - } - - #[test] - fn test_verify_wrong_key() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code"; - std::fs::write(&wasm_path, wasm_bytes).unwrap(); - - let (sk, _) = make_keypair(); - let (_, wrong_vk) = make_keypair(); - - let digest = blake3::hash(wasm_bytes); - let signature = sk.sign(digest.as_bytes()); - std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) - .unwrap(); - - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[wrong_vk]).unwrap(); - assert!(matches!(status, SignatureStatus::Invalid(_))); - } - - #[test] - fn test_verify_tampered_wasm() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - let original = b"\0asm\x01\x00\x00\x00original"; - std::fs::write(&wasm_path, original).unwrap(); - - let (sk, vk) = make_keypair(); - let digest = blake3::hash(original); - let signature = sk.sign(digest.as_bytes()); - std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) - .unwrap(); - - // Tamper with the WASM file after signing - std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00tampered").unwrap(); - - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); - assert!(matches!(status, SignatureStatus::Invalid(_))); - } - - #[test] - fn test_verify_malformed_sig_file() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap(); - - // Write garbage to plugin.sig (wrong length) - std::fs::write(dir.path().join("plugin.sig"), b"not a signature").unwrap(); - - let (_, vk) = make_keypair(); - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); - assert!(matches!(status, SignatureStatus::Invalid(_))); - } - - #[test] - fn test_verify_multiple_trusted_keys() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - let wasm_bytes = b"\0asm\x01\x00\x00\x00multi_key_test"; - std::fs::write(&wasm_path, wasm_bytes).unwrap(); - - let (sk2, vk2) = make_keypair(); - let (_, vk1) = make_keypair(); - let (_, vk3) = make_keypair(); - - // Sign with key 2 - let digest = blake3::hash(wasm_bytes); - let signature = sk2.sign(digest.as_bytes()); - std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) - .unwrap(); - - // Verify against [vk1, vk2, vk3]; should find vk2 - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk1, vk2, vk3]) - .unwrap(); - assert_eq!(status, SignatureStatus::Valid); - } - - #[test] - fn test_parse_public_key_valid() { - let (_, vk) = make_keypair(); - let hex = hex_encode(vk.as_bytes()); - let parsed = parse_public_key(&hex).unwrap(); - assert_eq!(parsed, vk); - } - - #[test] - fn test_parse_public_key_wrong_length() { - assert!(parse_public_key("abcdef").is_err()); - } - - #[test] - fn test_parse_public_key_invalid_hex() { - let bad = - "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; - assert!(parse_public_key(bad).is_err()); - } - - fn hex_encode(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() - } -} diff --git a/crates/pinakes-core/src/scheduler.rs b/crates/pinakes-core/src/scheduler.rs index fa66f37..0dc4b18 100644 --- a/crates/pinakes-core/src/scheduler.rs +++ b/crates/pinakes-core/src/scheduler.rs @@ -1,6 +1,7 @@ use std::{path::PathBuf, sync::Arc}; -use chrono::{DateTime, Datelike, Utc}; +use chrono::{DateTime, Utc}; +pub use pinakes_types::config::Schedule; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; @@ -11,102 +12,6 @@ use crate::{ jobs::{JobKind, JobQueue}, }; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "type")] -pub enum Schedule { - Interval { - secs: u64, - }, - Daily { - hour: u32, - minute: u32, - }, - Weekly { - day: u32, - hour: u32, - minute: u32, - }, -} - -impl Schedule { - #[must_use] - pub fn next_run(&self, from: DateTime) -> DateTime { - match self { - Self::Interval { secs } => { - from - + chrono::Duration::seconds(i64::try_from(*secs).unwrap_or(i64::MAX)) - }, - Self::Daily { hour, minute } => { - let today = from - .date_naive() - .and_hms_opt(*hour, *minute, 0) - .unwrap_or_default(); - let today_utc = today.and_utc(); - if today_utc > from { - today_utc - } else { - today_utc + chrono::Duration::days(1) - } - }, - Self::Weekly { day, hour, minute } => { - let current_day = from.weekday().num_days_from_monday(); - let target_day = *day; - let days_ahead = match target_day.cmp(¤t_day) { - std::cmp::Ordering::Greater => target_day - current_day, - std::cmp::Ordering::Less => 7 - (current_day - target_day), - std::cmp::Ordering::Equal => { - let today = from - .date_naive() - .and_hms_opt(*hour, *minute, 0) - .unwrap_or_default() - .and_utc(); - if today > from { - return today; - } - 7 - }, - }; - let target_date = - from.date_naive() + chrono::Duration::days(i64::from(days_ahead)); - target_date - .and_hms_opt(*hour, *minute, 0) - .unwrap_or_default() - .and_utc() - }, - } - } - - #[must_use] - pub fn display_string(&self) -> String { - match self { - Self::Interval { secs } => { - if *secs >= 3600 { - format!("Every {}h", secs / 3600) - } else if *secs >= 60 { - format!("Every {}m", secs / 60) - } else { - format!("Every {secs}s") - } - }, - Self::Daily { hour, minute } => { - format!("Daily {hour:02}:{minute:02}") - }, - Self::Weekly { day, hour, minute } => { - let day_name = match day { - 0 => "Mon", - 1 => "Tue", - 2 => "Wed", - 3 => "Thu", - 4 => "Fri", - 5 => "Sat", - _ => "Sun", - }; - format!("{day_name} {hour:02}:{minute:02}") - }, - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduledTask { pub id: String, @@ -251,7 +156,7 @@ impl TaskScheduler { } if task.enabled { let from = task.last_run.unwrap_or_else(Utc::now); - task.next_run = Some(task.schedule.next_run(from)); + task.next_run = task.schedule.next_run(from); } else { task.next_run = None; } @@ -298,7 +203,7 @@ impl TaskScheduler { if let Some(task) = tasks.iter_mut().find(|t| t.id == id) { task.enabled = !task.enabled; if task.enabled { - task.next_run = Some(task.schedule.next_run(Utc::now())); + task.next_run = task.schedule.next_run(Utc::now()); } else { task.next_run = None; } @@ -331,7 +236,7 @@ impl TaskScheduler { task.running = true; task.last_job_id = Some(job_id); if task.enabled { - task.next_run = Some(task.schedule.next_run(Utc::now())); + task.next_run = task.schedule.next_run(Utc::now()); } drop(tasks); } @@ -403,7 +308,7 @@ impl TaskScheduler { task.last_run = Some(now); task.last_status = Some("running".to_string()); task.running = true; - task.next_run = Some(task.schedule.next_run(now)); + task.next_run = task.schedule.next_run(now); } } } @@ -431,7 +336,7 @@ mod tests { fn test_interval_next_run() { let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(); let schedule = Schedule::Interval { secs: 3600 }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap()); } @@ -443,7 +348,7 @@ mod tests { hour: 14, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); } @@ -455,7 +360,7 @@ mod tests { hour: 14, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 14, 0, 0).unwrap()); } @@ -468,7 +373,7 @@ mod tests { hour: 3, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 3, 0, 0).unwrap()); } @@ -482,7 +387,7 @@ mod tests { hour: 14, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); } @@ -496,7 +401,7 @@ mod tests { hour: 8, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 22, 8, 0, 0).unwrap()); } diff --git a/crates/pinakes-core/src/storage/migrations.rs b/crates/pinakes-core/src/storage/migrations.rs index 196397f..ac78968 100644 --- a/crates/pinakes-core/src/storage/migrations.rs +++ b/crates/pinakes-core/src/storage/migrations.rs @@ -1,17 +1,19 @@ -use crate::error::{PinakesError, Result}; - -pub fn run_sqlite_migrations(conn: &mut rusqlite::Connection) -> Result<()> { +#[cfg(feature = "sqlite")] +pub fn run_sqlite_migrations( + conn: &mut rusqlite::Connection, +) -> crate::error::Result<()> { pinakes_migrations::sqlite_migrations() .to_latest(conn) - .map_err(|e| PinakesError::Migration(e.to_string())) + .map_err(|e| crate::error::PinakesError::Migration(e.to_string())) } +#[cfg(feature = "postgres")] pub async fn run_postgres_migrations( client: &mut tokio_postgres::Client, -) -> Result<()> { +) -> crate::error::Result<()> { pinakes_migrations::postgres_runner() .run_async(client) .await .map(|_| ()) - .map_err(|e| PinakesError::Migration(e.to_string())) + .map_err(|e| crate::error::PinakesError::Migration(e.to_string())) } diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs index e1d93bc..968e3f2 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -1,16 +1,16 @@ pub mod migrations; -pub mod postgres; -pub mod sqlite; +#[cfg(feature = "postgres")] pub mod postgres; +#[cfg(feature = "sqlite")] pub mod sqlite; use std::{path::PathBuf, sync::Arc}; use chrono::{DateTime, Utc}; +use pinakes_enrichment::ExternalMetadata; use rustc_hash::FxHashMap; use uuid::Uuid; use crate::{ analytics::UsageEvent, - enrichment::ExternalMetadata, error::Result, model::{ AuditEntry, @@ -412,7 +412,7 @@ pub trait StorageBackend: Send + Sync + 'static { } } - Err(crate::error::PinakesError::Authorization(format!( + Err(pinakes_types::error::PinakesError::Authorization(format!( "user {user_id} has no access to media {media_id}" ))) } @@ -841,42 +841,44 @@ pub trait StorageBackend: Send + Sync + 'static { /// Register a new sync device async fn register_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, token_hash: &str, - ) -> Result; + ) -> Result; /// Get a sync device by ID async fn get_device( &self, - id: crate::sync::DeviceId, - ) -> Result; + id: pinakes_sync::DeviceId, + ) -> Result; /// Get a sync device by its token hash async fn get_device_by_token( &self, token_hash: &str, - ) -> Result>; + ) -> Result>; /// List all devices for a user async fn list_user_devices( &self, user_id: UserId, - ) -> Result>; + ) -> Result>; /// Update a sync device - async fn update_device(&self, device: &crate::sync::SyncDevice) - -> Result<()>; + async fn update_device( + &self, + device: &pinakes_sync::SyncDevice, + ) -> Result<()>; /// Delete a sync device - async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()>; + async fn delete_device(&self, id: pinakes_sync::DeviceId) -> Result<()>; /// Update the `last_seen_at` timestamp for a device - async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()>; + async fn touch_device(&self, id: pinakes_sync::DeviceId) -> Result<()>; /// Record a change in the sync log async fn record_sync_change( &self, - change: &crate::sync::SyncLogEntry, + change: &pinakes_sync::SyncLogEntry, ) -> Result<()>; /// Get changes since a cursor position @@ -884,7 +886,7 @@ pub trait StorageBackend: Send + Sync + 'static { &self, cursor: i64, limit: u64, - ) -> Result>; + ) -> Result>; /// Get the current sync cursor (highest sequence number) async fn get_current_sync_cursor(&self) -> Result; @@ -895,52 +897,52 @@ pub trait StorageBackend: Send + Sync + 'static { /// Get sync state for a device and path async fn get_device_sync_state( &self, - device_id: crate::sync::DeviceId, + device_id: pinakes_sync::DeviceId, path: &str, - ) -> Result>; + ) -> Result>; /// Insert or update device sync state async fn upsert_device_sync_state( &self, - state: &crate::sync::DeviceSyncState, + state: &pinakes_sync::DeviceSyncState, ) -> Result<()>; /// List all pending sync items for a device async fn list_pending_sync( &self, - device_id: crate::sync::DeviceId, - ) -> Result>; + device_id: pinakes_sync::DeviceId, + ) -> Result>; /// Create a new upload session async fn create_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()>; /// Get an upload session by ID async fn get_upload_session( &self, id: Uuid, - ) -> Result; + ) -> Result; /// Update an upload session async fn update_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()>; /// Record a received chunk async fn record_chunk( &self, upload_id: Uuid, - chunk: &crate::sync::ChunkInfo, + chunk: &pinakes_sync::ChunkInfo, ) -> Result<()>; /// Get all chunks for an upload async fn get_upload_chunks( &self, upload_id: Uuid, - ) -> Result>; + ) -> Result>; /// Clean up expired upload sessions async fn cleanup_expired_uploads(&self) -> Result; @@ -948,20 +950,20 @@ pub trait StorageBackend: Send + Sync + 'static { /// Record a sync conflict async fn record_conflict( &self, - conflict: &crate::sync::SyncConflict, + conflict: &pinakes_sync::SyncConflict, ) -> Result<()>; /// Get unresolved conflicts for a device async fn get_unresolved_conflicts( &self, - device_id: crate::sync::DeviceId, - ) -> Result>; + device_id: pinakes_sync::DeviceId, + ) -> Result>; /// Resolve a conflict async fn resolve_conflict( &self, id: Uuid, - resolution: crate::config::ConflictResolution, + resolution: pinakes_types::config::ConflictResolution, ) -> Result<()>; /// Create a new share @@ -1176,7 +1178,7 @@ pub trait StorageBackend: Send + Sync + 'static { /// deployments should use `pg_dump` directly; this method returns /// `PinakesError::InvalidOperation` for unsupported backends. async fn backup(&self, _dest: &std::path::Path) -> Result<()> { - Err(crate::error::PinakesError::InvalidOperation( + Err(pinakes_types::error::PinakesError::InvalidOperation( "backup not supported for this storage backend; use pg_dump for \ PostgreSQL" .to_string(), diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index dbeb0e2..6935ff7 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::{ config::PostgresConfig, - error::{PinakesError, Result}, + error::{PinakesError, Result, db_ctx}, media_type::MediaType, model::{ AuditAction, @@ -629,7 +629,8 @@ impl StorageBackend for PostgresBackend { NOTHING", &[&path.to_string_lossy().as_ref()], ) - .await?; + .await + .map_err(db_ctx("insert_root_dirs", path.display()))?; Ok(()) } @@ -643,7 +644,8 @@ impl StorageBackend for PostgresBackend { let rows = client .query("SELECT path FROM root_dirs ORDER BY path", &[]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows @@ -664,7 +666,8 @@ impl StorageBackend for PostgresBackend { .execute("DELETE FROM root_dirs WHERE path = $1", &[&path .to_string_lossy() .as_ref()]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -724,7 +727,8 @@ impl StorageBackend for PostgresBackend { &item.updated_at, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Insert custom fields for (name, field) in &item.custom_fields { @@ -739,7 +743,8 @@ impl StorageBackend for PostgresBackend { EXCLUDED.field_value", &[&item.id.0, &name, &ft, &field.value], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) @@ -756,7 +761,8 @@ impl StorageBackend for PostgresBackend { "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let count: i64 = row.get(0); Ok(count.cast_unsigned()) } @@ -782,7 +788,8 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE id = $1", &[&id.0], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::NotFound(format!("media item {id}")))?; let mut item = row_to_media_item(&row)?; @@ -814,7 +821,8 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE content_hash = $1", &[&hash.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; match row { Some(r) => { @@ -851,7 +859,8 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE path = $1", &[&path_str], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; match row { Some(r) => { @@ -904,7 +913,8 @@ impl StorageBackend for PostgresBackend { &(pagination.limit.cast_signed()), &(pagination.offset.cast_signed()), ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -921,7 +931,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -1008,7 +1019,8 @@ impl StorageBackend for PostgresBackend { &item.updated_at, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!("media item {}", item.id))); @@ -1019,7 +1031,8 @@ impl StorageBackend for PostgresBackend { .execute("DELETE FROM custom_fields WHERE media_id = $1", &[&item .id .0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; for (name, field) in &item.custom_fields { let ft = custom_field_type_to_string(field.field_type); @@ -1030,7 +1043,8 @@ impl StorageBackend for PostgresBackend { VALUES ($1, $2, $3, $4)", &[&item.id.0, &name, &ft, &field.value], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } txn.commit().await.map_err(|e| { @@ -1049,7 +1063,8 @@ impl StorageBackend for PostgresBackend { let rows_affected = client .execute("DELETE FROM media_items WHERE id = $1", &[&id.0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); @@ -1067,10 +1082,14 @@ impl StorageBackend for PostgresBackend { let count: i64 = client .query_one("SELECT COUNT(*) FROM media_items", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); - client.execute("DELETE FROM media_items", &[]).await?; + client + .execute("DELETE FROM media_items", &[]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(count.cast_unsigned()) } @@ -1090,7 +1109,8 @@ impl StorageBackend for PostgresBackend { let uuids: Vec = ids.iter().map(|id| id.0).collect(); let rows = client .execute("DELETE FROM media_items WHERE id = ANY($1)", &[&uuids]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows) } @@ -1126,7 +1146,8 @@ impl StorageBackend for PostgresBackend { ON CONFLICT DO NOTHING", &[&media_uuids, &tag_uuids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows) } @@ -1209,7 +1230,10 @@ impl StorageBackend for PostgresBackend { .iter() .map(|p| p.as_ref() as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - let rows = client.execute(&sql, ¶m_refs).await?; + let rows = client + .execute(&sql, ¶m_refs) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows) } @@ -1234,7 +1258,8 @@ impl StorageBackend for PostgresBackend { $3, $4)", &[&id, &name, &parent_id, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(Tag { id, @@ -1256,7 +1281,8 @@ impl StorageBackend for PostgresBackend { "SELECT id, name, parent_id, created_at FROM tags WHERE id = $1", &[&id], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::TagNotFound(id.to_string()))?; Ok(row_to_tag(&row)) @@ -1274,7 +1300,8 @@ impl StorageBackend for PostgresBackend { "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.iter().map(row_to_tag).collect()) } @@ -1288,7 +1315,8 @@ impl StorageBackend for PostgresBackend { let rows_affected = client .execute("DELETE FROM tags WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::TagNotFound(id.to_string())); @@ -1310,7 +1338,8 @@ impl StorageBackend for PostgresBackend { ON CONFLICT (media_id, tag_id) DO NOTHING", &[&media_id.0, &tag_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1327,7 +1356,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM media_tags WHERE media_id = $1 AND tag_id = $2", &[&media_id.0, &tag_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1348,7 +1378,8 @@ impl StorageBackend for PostgresBackend { ORDER BY t.name", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.iter().map(row_to_tag).collect()) } @@ -1375,7 +1406,8 @@ impl StorageBackend for PostgresBackend { BY name", &[&tag_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.iter().map(row_to_tag).collect()) } @@ -1413,7 +1445,8 @@ impl StorageBackend for PostgresBackend { &now, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(Collection { id, @@ -1440,7 +1473,8 @@ impl StorageBackend for PostgresBackend { FROM collections WHERE id = $1", &[&id], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::CollectionNotFound(id.to_string()))?; row_to_collection(&row) @@ -1460,7 +1494,8 @@ impl StorageBackend for PostgresBackend { FROM collections ORDER BY name", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; rows.iter().map(row_to_collection).collect() } @@ -1474,7 +1509,8 @@ impl StorageBackend for PostgresBackend { let rows_affected = client .execute("DELETE FROM collections WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); @@ -1506,7 +1542,8 @@ impl StorageBackend for PostgresBackend { = EXCLUDED.position", &[&collection_id, &media_id.0, &position, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Update the collection's updated_at timestamp client @@ -1514,7 +1551,8 @@ impl StorageBackend for PostgresBackend { &collection_id, &now, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1536,7 +1574,8 @@ impl StorageBackend for PostgresBackend { = $2", &[&collection_id, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let now = Utc::now(); client @@ -1544,7 +1583,8 @@ impl StorageBackend for PostgresBackend { &collection_id, &now, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1579,7 +1619,8 @@ impl StorageBackend for PostgresBackend { ORDER BY cm.position ASC", &[&collection_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -1595,7 +1636,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -1730,7 +1772,10 @@ impl StorageBackend for PostgresBackend { .map(|p| p.as_ref() as &(dyn ToSql + Sync)) .collect(); - let count_row = client.query_one(&count_sql, &count_params).await?; + let count_row = client + .query_one(&count_sql, &count_params) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let total_count: i64 = count_row.get(0); // Add pagination params @@ -1742,7 +1787,10 @@ impl StorageBackend for PostgresBackend { .map(|p| p.as_ref() as &(dyn ToSql + Sync)) .collect(); - let rows = client.query(&select_sql, &select_params).await?; + let rows = client + .query(&select_sql, &select_params) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -1758,7 +1806,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -1810,7 +1859,8 @@ impl StorageBackend for PostgresBackend { &entry.timestamp, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1841,7 +1891,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, None => { client @@ -1855,7 +1906,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, }; @@ -1887,7 +1939,8 @@ impl StorageBackend for PostgresBackend { EXCLUDED.field_value", &[&media_id.0, &name, &ft, &field.value], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1908,7 +1961,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut map = FxHashMap::default(); for row in &rows { @@ -1938,7 +1992,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM custom_fields WHERE media_id = $1 AND field_name = $2", &[&media_id.0, &name], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1971,7 +2026,8 @@ impl StorageBackend for PostgresBackend { ORDER BY content_hash, created_at", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -1987,7 +2043,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -2049,7 +2106,8 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE perceptual_hash IS NOT NULL ORDER BY id", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -2065,7 +2123,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -2152,23 +2211,28 @@ impl StorageBackend for PostgresBackend { let media_count: i64 = client .query_one("SELECT COUNT(*) FROM media_items", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let tag_count: i64 = client .query_one("SELECT COUNT(*) FROM tags", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let collection_count: i64 = client .query_one("SELECT COUNT(*) FROM collections", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let audit_count: i64 = client .query_one("SELECT COUNT(*) FROM audit_log", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let database_size_bytes: i64 = client .query_one("SELECT pg_database_size(current_database())", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); Ok(crate::storage::DatabaseStats { @@ -2188,7 +2252,10 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - client.execute("VACUUM ANALYZE", &[]).await?; + client + .execute("VACUUM ANALYZE", &[]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2206,7 +2273,8 @@ impl StorageBackend for PostgresBackend { media_items, tags, collections CASCADE", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2221,7 +2289,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query("SELECT id, path, content_hash FROM media_items", &[]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::with_capacity(rows.len()); for row in rows { let id: Uuid = row.get(0); @@ -2253,7 +2322,8 @@ impl StorageBackend for PostgresBackend { sort_order = $4", &[&id, &name, &query, &sort_order, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2271,7 +2341,8 @@ impl StorageBackend for PostgresBackend { ORDER BY created_at DESC", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::with_capacity(rows.len()); for row in rows { results.push(crate::model::SavedSearch { @@ -2300,7 +2371,8 @@ impl StorageBackend for PostgresBackend { WHERE id = $1", &[&id], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::NotFound(format!("saved search {id}")))?; Ok(crate::model::SavedSearch { id: row.get(0), @@ -2319,7 +2391,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM saved_searches WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2338,7 +2411,10 @@ impl StorageBackend for PostgresBackend { } else { "SELECT id FROM media_items ORDER BY created_at DESC" }; - let rows = client.query(sql, &[]).await?; + let rows = client + .query(sql, &[]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids = rows .iter() .map(|r| { @@ -2372,7 +2448,8 @@ impl StorageBackend for PostgresBackend { FROM users ORDER BY created_at DESC", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut users = Vec::with_capacity(rows.len()); for row in rows { let user_id: uuid::Uuid = row.get::<_, uuid::Uuid>(0); @@ -2406,7 +2483,8 @@ impl StorageBackend for PostgresBackend { FROM users WHERE id = $1", &[&id.0], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::NotFound(format!("user {}", id.0)))?; let profile = self.load_user_profile(id.0).await?; Ok(crate::users::User { @@ -2436,7 +2514,8 @@ impl StorageBackend for PostgresBackend { FROM users WHERE username = $1", &[&username], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| { PinakesError::NotFound(format!("user with username {username}")) })?; @@ -2476,7 +2555,8 @@ impl StorageBackend for PostgresBackend { updated_at) VALUES ($1, $2, $3, $4, $5, $6)", &[&id, &username, &password_hash, &role_json, &now, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let user_profile = if let Some(prof) = profile.clone() { let prefs_json = serde_json::to_value(&prof.preferences)?; @@ -2487,7 +2567,8 @@ impl StorageBackend for PostgresBackend { $5, $6)", &[&id, &prof.avatar_path, &prof.bio, &prefs_json, &now, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; prof } else { crate::users::UserProfile { @@ -2564,7 +2645,10 @@ impl StorageBackend for PostgresBackend { } params.push(&id.0); - client.execute(&sql, ¶ms).await?; + client + .execute(&sql, ¶ms) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Update profile if provided @@ -2579,7 +2663,8 @@ impl StorageBackend for PostgresBackend { = $3, preferences_json = $4, updated_at = $6", &[&id.0, &prof.avatar_path, &prof.bio, &prefs_json, &now, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Fetch updated user @@ -2596,15 +2681,18 @@ impl StorageBackend for PostgresBackend { // Delete profile first due to foreign key client .execute("DELETE FROM user_profiles WHERE user_id = $1", &[&id.0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete library access client .execute("DELETE FROM user_libraries WHERE user_id = $1", &[&id.0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete user let affected = client .execute("DELETE FROM users WHERE id = $1", &[&id.0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if affected == 0 { return Err(PinakesError::NotFound(format!("user {}", id.0))); } @@ -2626,7 +2714,8 @@ impl StorageBackend for PostgresBackend { user_libraries WHERE user_id = $1", &[&user_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut libraries = Vec::with_capacity(rows.len()); for row in rows { libraries.push(crate::users::UserLibraryAccess { @@ -2661,7 +2750,8 @@ impl StorageBackend for PostgresBackend { $3, granted_at = $4", &[&user_id.0, &root_path, &perm_json, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2680,7 +2770,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM user_libraries WHERE user_id = $1 AND root_path = $2", &[&user_id.0, &root_path], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2707,7 +2798,8 @@ impl StorageBackend for PostgresBackend { created_at", &[&id, &user_id.0, &media_id.0, &stars_i32, &review, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let actual_id: Uuid = row.get(0); let actual_created_at: chrono::DateTime = row.get(1); Ok(crate::social::Rating { @@ -2735,7 +2827,8 @@ impl StorageBackend for PostgresBackend { ratings WHERE media_id = $1 ORDER BY created_at DESC", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() @@ -2769,7 +2862,8 @@ impl StorageBackend for PostgresBackend { ratings WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.first().map(|row| { crate::social::Rating { id: row.get("id"), @@ -2790,7 +2884,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM ratings WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2814,7 +2909,8 @@ impl StorageBackend for PostgresBackend { text, created_at) VALUES ($1, $2, $3, $4, $5, $6)", &[&id, &user_id.0, &media_id.0, &parent_id, &text, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::Comment { id, user_id, @@ -2840,7 +2936,8 @@ impl StorageBackend for PostgresBackend { FROM comments WHERE media_id = $1 ORDER BY created_at ASC", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() @@ -2866,7 +2963,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM comments WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2887,7 +2985,8 @@ impl StorageBackend for PostgresBackend { $2, $3) ON CONFLICT DO NOTHING", &[&user_id.0, &media_id.0, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2906,7 +3005,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM favorites WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2937,7 +3037,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = rows .iter() .map(row_to_media_item) @@ -2952,7 +3053,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -2991,7 +3093,8 @@ impl StorageBackend for PostgresBackend { "SELECT COUNT(*) FROM favorites WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let count: i64 = row.get(0); Ok(count > 0) } @@ -3028,7 +3131,8 @@ impl StorageBackend for PostgresBackend { &now, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::ShareLink { id, media_id, @@ -3056,7 +3160,8 @@ impl StorageBackend for PostgresBackend { view_count, created_at FROM share_links WHERE token = $1", &[&token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let row = rows .first() .ok_or_else(|| PinakesError::NotFound("share link not found".into()))?; @@ -3085,7 +3190,8 @@ impl StorageBackend for PostgresBackend { "UPDATE share_links SET view_count = view_count + 1 WHERE token = $1", &[&token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3097,7 +3203,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM share_links WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3134,7 +3241,8 @@ impl StorageBackend for PostgresBackend { &now, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::playlists::Playlist { id, owner_id, @@ -3160,7 +3268,8 @@ impl StorageBackend for PostgresBackend { filter_query, created_at, updated_at FROM playlists WHERE id = $1", &[&id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let row = rows .first() .ok_or_else(|| PinakesError::NotFound(format!("playlist {id}")))?; @@ -3195,7 +3304,8 @@ impl StorageBackend for PostgresBackend { owner_id = $1 OR is_public = true ORDER BY updated_at DESC", &[&uid.0], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, None => { client @@ -3205,7 +3315,8 @@ impl StorageBackend for PostgresBackend { updated_at DESC", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, }; Ok( @@ -3270,7 +3381,10 @@ impl StorageBackend for PostgresBackend { .iter() .map(|p| &**p as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - client.execute(&sql, ¶m_refs).await?; + client + .execute(&sql, ¶m_refs) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; self.get_playlist(id).await } @@ -3282,7 +3396,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM playlists WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3305,7 +3420,8 @@ impl StorageBackend for PostgresBackend { media_id) DO UPDATE SET position = $3", &[&playlist_id, &media_id.0, &position, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3324,7 +3440,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM playlist_items WHERE playlist_id = $1 AND media_id = $2", &[&playlist_id, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3350,7 +3467,8 @@ impl StorageBackend for PostgresBackend { $1 ORDER BY pi.position ASC", &[&playlist_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = rows .iter() .map(row_to_media_item) @@ -3365,7 +3483,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3406,7 +3525,8 @@ impl StorageBackend for PostgresBackend { media_id = $3", &[&new_position, &playlist_id, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3441,7 +3561,8 @@ impl StorageBackend for PostgresBackend { &context, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3485,7 +3606,10 @@ impl StorageBackend for PostgresBackend { .iter() .map(|p| &**p as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - let rows = client.query(&sql, ¶m_refs).await?; + let rows = client + .query(&sql, ¶m_refs) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() @@ -3536,7 +3660,8 @@ impl StorageBackend for PostgresBackend { m.deleted_at, m.links_extracted_at ORDER BY view_count DESC LIMIT $1", &[&limit.cast_signed()], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); for row in &rows { let item = row_to_media_item(row)?; @@ -3553,7 +3678,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3608,7 +3734,8 @@ impl StorageBackend for PostgresBackend { LIMIT $2", &[&user_id.0, &limit.cast_signed()], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = rows .iter() .map(row_to_media_item) @@ -3623,7 +3750,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3667,7 +3795,8 @@ impl StorageBackend for PostgresBackend { media_id) DO UPDATE SET progress_secs = $4, last_watched = $5", &[&id, &user_id.0, &media_id.0, &progress_secs, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3687,7 +3816,8 @@ impl StorageBackend for PostgresBackend { media_id = $2", &[&user_id.0, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.first().map(|row| row.get("progress_secs"))) } @@ -3702,7 +3832,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let affected = client .execute("DELETE FROM usage_events WHERE timestamp < $1", &[&before]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected) } @@ -3753,7 +3884,8 @@ impl StorageBackend for PostgresBackend { &subtitle.created_at, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3772,7 +3904,8 @@ impl StorageBackend for PostgresBackend { track_index, offset_ms, created_at FROM subtitles WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() @@ -3808,7 +3941,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM subtitles WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3831,13 +3965,14 @@ impl StorageBackend for PostgresBackend { .execute("UPDATE subtitles SET offset_ms = $1 WHERE id = $2", &[ &offset, &id, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn store_external_metadata( &self, - meta: &crate::enrichment::ExternalMetadata, + meta: &pinakes_enrichment::ExternalMetadata, ) -> Result<()> { let client = self .pool @@ -3870,14 +4005,15 @@ impl StorageBackend for PostgresBackend { &meta.last_updated, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_external_metadata( &self, media_id: MediaId, - ) -> Result> { + ) -> Result> { let client = self .pool .get() @@ -3889,19 +4025,20 @@ impl StorageBackend for PostgresBackend { last_updated FROM external_metadata WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() .map(|row| { let source_str: String = row.get("source"); let metadata_json: serde_json::Value = row.get("metadata_json"); - crate::enrichment::ExternalMetadata { + pinakes_enrichment::ExternalMetadata { id: row.get("id"), media_id: MediaId(row.get("media_id")), source: source_str .parse() - .unwrap_or(crate::enrichment::EnrichmentSourceType::MusicBrainz), + .unwrap_or(pinakes_enrichment::EnrichmentSourceType::MusicBrainz), external_id: row.get("external_id"), metadata_json: metadata_json.to_string(), confidence: row.get("confidence"), @@ -3920,7 +4057,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM external_metadata WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3956,7 +4094,8 @@ impl StorageBackend for PostgresBackend { &session.expires_at, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3976,7 +4115,8 @@ impl StorageBackend for PostgresBackend { transcode_sessions WHERE id = $1", &[&id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let row = rows.first().ok_or_else(|| { PinakesError::NotFound(format!("transcode session {id}")) })?; @@ -4022,7 +4162,8 @@ impl StorageBackend for PostgresBackend { transcode_sessions WHERE media_id = $1 ORDER BY created_at DESC", &[&mid.0], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, None => { client @@ -4032,7 +4173,8 @@ impl StorageBackend for PostgresBackend { transcode_sessions ORDER BY created_at DESC", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, }; Ok( @@ -4086,7 +4228,8 @@ impl StorageBackend for PostgresBackend { error_message = $3 WHERE id = $4", &[&status_str, &progress_f64, &error_message, &id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4105,7 +4248,8 @@ impl StorageBackend for PostgresBackend { expires_at < $1", &[&before], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected) } @@ -4134,7 +4278,8 @@ impl StorageBackend for PostgresBackend { &session.last_accessed, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4155,7 +4300,8 @@ impl StorageBackend for PostgresBackend { FROM sessions WHERE session_token = $1", &[&session_token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { crate::storage::SessionData { @@ -4183,7 +4329,8 @@ impl StorageBackend for PostgresBackend { "UPDATE sessions SET last_accessed = $1 WHERE session_token = $2", &[&now, &session_token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4205,7 +4352,8 @@ impl StorageBackend for PostgresBackend { session_token = $3 AND expires_at > NOW()", &[&new_expires_at, &now, &session_token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows > 0 { Ok(Some(new_expires_at)) } else { @@ -4224,7 +4372,8 @@ impl StorageBackend for PostgresBackend { .execute("DELETE FROM sessions WHERE session_token = $1", &[ &session_token, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4237,7 +4386,8 @@ impl StorageBackend for PostgresBackend { let affected = client .execute("DELETE FROM sessions WHERE username = $1", &[&username]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected) } @@ -4251,7 +4401,8 @@ impl StorageBackend for PostgresBackend { let now = chrono::Utc::now(); let affected = client .execute("DELETE FROM sessions WHERE expires_at < $1", &[&now]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected) } @@ -4275,7 +4426,8 @@ impl StorageBackend for PostgresBackend { ORDER BY last_accessed DESC", &[&now, &user], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } else { client .query( @@ -4285,7 +4437,8 @@ impl StorageBackend for PostgresBackend { ORDER BY last_accessed DESC", &[&now], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }; Ok( @@ -4323,7 +4476,10 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - let tx = client.transaction().await?; + let tx = client + .transaction() + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Upsert book_metadata tx.execute( @@ -4348,17 +4504,20 @@ impl StorageBackend for PostgresBackend { &metadata.format, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Clear existing authors and identifiers tx.execute("DELETE FROM book_authors WHERE media_id = $1", &[&metadata .media_id .0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; tx.execute("DELETE FROM book_identifiers WHERE media_id = $1", &[ &metadata.media_id.0, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Insert authors for author in &metadata.authors { @@ -4374,7 +4533,8 @@ impl StorageBackend for PostgresBackend { &author.position, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Insert identifiers @@ -4386,11 +4546,14 @@ impl StorageBackend for PostgresBackend { VALUES ($1, $2, $3)", &[&metadata.media_id.0, &id_type, &value], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } } - tx.commit().await?; + tx.commit() + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4413,7 +4576,8 @@ impl StorageBackend for PostgresBackend { FROM book_metadata WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let Some(row) = row else { return Ok(None); @@ -4426,7 +4590,8 @@ impl StorageBackend for PostgresBackend { FROM book_authors WHERE media_id = $1 ORDER BY position", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let authors: Vec = author_rows .iter() @@ -4447,7 +4612,8 @@ impl StorageBackend for PostgresBackend { FROM book_identifiers WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut identifiers: FxHashMap> = FxHashMap::default(); for r in id_rows { @@ -4500,7 +4666,8 @@ impl StorageBackend for PostgresBackend { &author.position, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4520,7 +4687,8 @@ impl StorageBackend for PostgresBackend { FROM book_authors WHERE media_id = $1 ORDER BY position", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows @@ -4559,7 +4727,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows @@ -4585,7 +4754,8 @@ impl StorageBackend for PostgresBackend { ORDER BY series_name", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows @@ -4625,7 +4795,8 @@ impl StorageBackend for PostgresBackend { ORDER BY b.series_index, m.title", &[&series_name], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; rows.iter().map(row_to_media_item).collect() } @@ -4651,7 +4822,8 @@ impl StorageBackend for PostgresBackend { progress_secs = $3, last_watched_at = NOW()", &[&user_id, &media_id.0, &f64::from(current_page)], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4675,7 +4847,8 @@ impl StorageBackend for PostgresBackend { WHERE wh.user_id = $1 AND wh.media_id = $2", &[&user_id, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { let current_page: i32 = r.get(0); @@ -4821,7 +4994,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } else if isbn.is_none() && author.is_none() && series.is_none() @@ -4852,7 +5026,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } else { // For other combinations, use dynamic query (simplified - just filter by // what's provided) @@ -4879,14 +5054,16 @@ impl StorageBackend for PostgresBackend { &(pagination.limit.cast_signed()), &(pagination.offset.cast_signed()), ]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } else { client .query(&query, &[ &(pagination.limit.cast_signed()), &(pagination.offset.cast_signed()), ]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } }; @@ -5181,9 +5358,9 @@ impl StorageBackend for PostgresBackend { async fn register_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, token_hash: &str, - ) -> Result { + ) -> Result { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5218,8 +5395,8 @@ impl StorageBackend for PostgresBackend { async fn get_device( &self, - id: crate::sync::DeviceId, - ) -> Result { + id: pinakes_sync::DeviceId, + ) -> Result { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5235,9 +5412,9 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(row.get(0)), - user_id: crate::users::UserId(row.get(1)), + Ok(pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(row.get(0)), + user_id: pinakes_types::model::UserId(row.get(1)), name: row.get(2), device_type: row.get::<_, String>(3).parse().unwrap_or_default(), client_version: row.get(4), @@ -5254,7 +5431,7 @@ impl StorageBackend for PostgresBackend { async fn get_device_by_token( &self, token_hash: &str, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5271,9 +5448,9 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { - crate::sync::SyncDevice { - id: crate::sync::DeviceId(r.get(0)), - user_id: crate::users::UserId(r.get(1)), + pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(r.get(0)), + user_id: pinakes_types::model::UserId(r.get(1)), name: r.get(2), device_type: r.get::<_, String>(3).parse().unwrap_or_default(), client_version: r.get(4), @@ -5291,7 +5468,7 @@ impl StorageBackend for PostgresBackend { async fn list_user_devices( &self, user_id: crate::users::UserId, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5312,9 +5489,9 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::SyncDevice { - id: crate::sync::DeviceId(r.get(0)), - user_id: crate::users::UserId(r.get(1)), + pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(r.get(0)), + user_id: pinakes_types::model::UserId(r.get(1)), name: r.get(2), device_type: r.get::<_, String>(3).parse().unwrap_or_default(), client_version: r.get(4), @@ -5333,7 +5510,7 @@ impl StorageBackend for PostgresBackend { async fn update_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5365,7 +5542,7 @@ impl StorageBackend for PostgresBackend { Ok(()) } - async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()> { + async fn delete_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5378,7 +5555,7 @@ impl StorageBackend for PostgresBackend { Ok(()) } - async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()> { + async fn touch_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5398,7 +5575,7 @@ impl StorageBackend for PostgresBackend { async fn record_sync_change( &self, - change: &crate::sync::SyncLogEntry, + change: &pinakes_sync::SyncLogEntry, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5444,7 +5621,7 @@ impl StorageBackend for PostgresBackend { &self, cursor: i64, limit: u64, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5463,13 +5640,13 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::SyncLogEntry { + pinakes_sync::SyncLogEntry { id: r.get(0), sequence: r.get(1), change_type: r .get::<_, String>(2) .parse() - .unwrap_or(crate::sync::SyncChangeType::Modified), + .unwrap_or(pinakes_sync::SyncChangeType::Modified), media_id: r.get::<_, Option>(3).map(MediaId), path: r.get(4), content_hash: r.get::<_, Option>(5).map(ContentHash), @@ -5479,7 +5656,7 @@ impl StorageBackend for PostgresBackend { metadata_json: r.get(7), changed_by_device: r .get::<_, Option>(8) - .map(crate::sync::DeviceId), + .map(pinakes_sync::DeviceId), timestamp: r.get(9), } }) @@ -5518,9 +5695,9 @@ impl StorageBackend for PostgresBackend { async fn get_device_sync_state( &self, - device_id: crate::sync::DeviceId, + device_id: pinakes_sync::DeviceId, path: &str, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5537,8 +5714,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { - crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(r.get(0)), + pinakes_sync::DeviceSyncState { + device_id: pinakes_sync::DeviceId(r.get(0)), path: r.get(1), local_hash: r.get(2), server_hash: r.get(3), @@ -5547,7 +5724,7 @@ impl StorageBackend for PostgresBackend { sync_status: r .get::<_, String>(6) .parse() - .unwrap_or(crate::sync::FileSyncStatus::Synced), + .unwrap_or(pinakes_sync::FileSyncStatus::Synced), last_synced_at: r.get(7), conflict_info_json: r.get(8), } @@ -5556,7 +5733,7 @@ impl StorageBackend for PostgresBackend { async fn upsert_device_sync_state( &self, - state: &crate::sync::DeviceSyncState, + state: &pinakes_sync::DeviceSyncState, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5597,8 +5774,8 @@ impl StorageBackend for PostgresBackend { async fn list_pending_sync( &self, - device_id: crate::sync::DeviceId, - ) -> Result> { + device_id: pinakes_sync::DeviceId, + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5620,8 +5797,8 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(r.get(0)), + pinakes_sync::DeviceSyncState { + device_id: pinakes_sync::DeviceId(r.get(0)), path: r.get(1), local_hash: r.get(2), server_hash: r.get(3), @@ -5630,7 +5807,7 @@ impl StorageBackend for PostgresBackend { sync_status: r .get::<_, String>(6) .parse() - .unwrap_or(crate::sync::FileSyncStatus::Synced), + .unwrap_or(pinakes_sync::FileSyncStatus::Synced), last_synced_at: r.get(7), conflict_info_json: r.get(8), } @@ -5641,7 +5818,7 @@ impl StorageBackend for PostgresBackend { async fn create_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5677,7 +5854,7 @@ impl StorageBackend for PostgresBackend { async fn get_upload_session( &self, id: Uuid, - ) -> Result { + ) -> Result { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5694,9 +5871,9 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(crate::sync::UploadSession { + Ok(pinakes_sync::UploadSession { id: row.get(0), - device_id: crate::sync::DeviceId(row.get(1)), + device_id: pinakes_sync::DeviceId(row.get(1)), target_path: row.get(2), expected_hash: ContentHash(row.get(3)), expected_size: row.get::<_, i64>(4).cast_unsigned(), @@ -5705,7 +5882,7 @@ impl StorageBackend for PostgresBackend { status: row .get::<_, String>(7) .parse() - .unwrap_or(crate::sync::UploadStatus::Pending), + .unwrap_or(pinakes_sync::UploadStatus::Pending), created_at: row.get(8), expires_at: row.get(9), last_activity: row.get(10), @@ -5714,7 +5891,7 @@ impl StorageBackend for PostgresBackend { async fn update_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5739,7 +5916,7 @@ impl StorageBackend for PostgresBackend { async fn record_chunk( &self, upload_id: Uuid, - chunk: &crate::sync::ChunkInfo, + chunk: &pinakes_sync::ChunkInfo, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5771,7 +5948,7 @@ impl StorageBackend for PostgresBackend { async fn get_upload_chunks( &self, upload_id: Uuid, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5789,7 +5966,7 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::ChunkInfo { + pinakes_sync::ChunkInfo { upload_id: r.get(0), chunk_index: r.get::<_, i64>(1).cast_unsigned(), offset: r.get::<_, i64>(2).cast_unsigned(), @@ -5818,7 +5995,7 @@ impl StorageBackend for PostgresBackend { async fn record_conflict( &self, - conflict: &crate::sync::SyncConflict, + conflict: &pinakes_sync::SyncConflict, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5849,8 +6026,8 @@ impl StorageBackend for PostgresBackend { async fn get_unresolved_conflicts( &self, - device_id: crate::sync::DeviceId, - ) -> Result> { + device_id: pinakes_sync::DeviceId, + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5871,9 +6048,9 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::SyncConflict { + pinakes_sync::SyncConflict { id: r.get(0), - device_id: crate::sync::DeviceId(r.get(1)), + device_id: pinakes_sync::DeviceId(r.get(1)), path: r.get(2), local_hash: r.get(3), local_mtime: r.get(4), @@ -5884,15 +6061,17 @@ impl StorageBackend for PostgresBackend { resolution: r.get::<_, Option>(9).and_then(|s| { match s.as_str() { "server_wins" => { - Some(crate::config::ConflictResolution::ServerWins) + Some(pinakes_types::config::ConflictResolution::ServerWins) }, "client_wins" => { - Some(crate::config::ConflictResolution::ClientWins) + Some(pinakes_types::config::ConflictResolution::ClientWins) }, "keep_both" => { - Some(crate::config::ConflictResolution::KeepBoth) + Some(pinakes_types::config::ConflictResolution::KeepBoth) + }, + "manual" => { + Some(pinakes_types::config::ConflictResolution::Manual) }, - "manual" => Some(crate::config::ConflictResolution::Manual), _ => None, } }), @@ -5905,7 +6084,7 @@ impl StorageBackend for PostgresBackend { async fn resolve_conflict( &self, id: Uuid, - resolution: crate::config::ConflictResolution, + resolution: pinakes_types::config::ConflictResolution, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5913,10 +6092,10 @@ impl StorageBackend for PostgresBackend { let now = chrono::Utc::now(); let resolution_str = match resolution { - crate::config::ConflictResolution::ServerWins => "server_wins", - crate::config::ConflictResolution::ClientWins => "client_wins", - crate::config::ConflictResolution::KeepBoth => "keep_both", - crate::config::ConflictResolution::Manual => "manual", + pinakes_types::config::ConflictResolution::ServerWins => "server_wins", + pinakes_types::config::ConflictResolution::ClientWins => "client_wins", + pinakes_types::config::ConflictResolution::KeepBoth => "keep_both", + pinakes_types::config::ConflictResolution::Manual => "manual", }; client @@ -7302,7 +7481,8 @@ impl PostgresBackend { user_id = $1", &[&user_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map_or_else( || { crate::users::UserProfile { @@ -7337,7 +7517,8 @@ impl PostgresBackend { deleted_at IS NULL", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let total_media: i64 = row.get(0); let total_size: i64 = row.get(1); let avg_size = if total_media > 0 { @@ -7352,7 +7533,8 @@ impl PostgresBackend { NULL GROUP BY media_type ORDER BY COUNT(*) DESC", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let media_by_type: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7369,7 +7551,8 @@ impl PostgresBackend { DESC", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let storage_by_type: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7385,7 +7568,8 @@ impl PostgresBackend { ORDER BY created_at DESC LIMIT 1", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .map(|r| r.get(0)); let oldest: Option = client .query_opt( @@ -7393,7 +7577,8 @@ impl PostgresBackend { ORDER BY created_at ASC LIMIT 1", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .map(|r| r.get(0)); let rows = client @@ -7402,7 +7587,8 @@ impl PostgresBackend { mt.tag_id = t.id GROUP BY t.id, t.name ORDER BY cnt DESC LIMIT 10", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let top_tags: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7419,7 +7605,8 @@ impl PostgresBackend { BY cnt DESC LIMIT 10", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let top_collections: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7431,11 +7618,13 @@ impl PostgresBackend { let total_tags: i64 = client .query_one("SELECT COUNT(*) FROM tags", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let total_collections: i64 = client .query_one("SELECT COUNT(*) FROM collections", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let total_duplicates: i64 = client .query_one( @@ -7443,7 +7632,8 @@ impl PostgresBackend { content_hash HAVING COUNT(*) > 1) sub", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); Ok(super::LibraryStatistics { diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index 4b72f95..82987f0 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -9,7 +9,7 @@ use rustc_hash::FxHashMap; use uuid::Uuid; use crate::{ - error::{PinakesError, Result}, + error::{PinakesError, Result, db_ctx}, media_type::MediaType, model::{ AuditAction, @@ -58,7 +58,8 @@ impl SqliteBackend { /// /// Returns an error if the database cannot be opened or configured. pub fn new(path: &Path) -> Result { - let conn = Connection::open(path)?; + let conn = Connection::open(path) + .map_err(|e| PinakesError::Database(e.to_string()))?; Self::configure(conn) } @@ -69,13 +70,15 @@ impl SqliteBackend { /// Returns an error if the in-memory database cannot be created or /// configured. pub fn in_memory() -> Result { - let conn = Connection::open_in_memory()?; + let conn = Connection::open_in_memory() + .map_err(|e| PinakesError::Database(e.to_string()))?; Self::configure(conn) } fn configure(conn: Connection) -> Result { conn - .execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?; + .execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;") + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(Self { conn: Arc::new(Mutex::new(conn)), }) @@ -692,7 +695,8 @@ impl StorageBackend for SqliteBackend { db.execute( "INSERT OR IGNORE INTO root_dirs (path) VALUES (?1)", params![path.to_string_lossy().as_ref()], - )?; + ) + .map_err(db_ctx("insert_root_dirs", "path"))?; } Ok(()) }) @@ -709,14 +713,17 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = - db.prepare("SELECT path FROM root_dirs ORDER BY path")?; + let mut stmt = db + .prepare("SELECT path FROM root_dirs ORDER BY path") + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt .query_map([], |row| { let p: String = row.get(0)?; Ok(PathBuf::from(p)) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -738,7 +745,8 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; db.execute("DELETE FROM root_dirs WHERE path = ?1", params![ path.to_string_lossy().as_ref() - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -794,7 +802,7 @@ impl StorageBackend for SqliteBackend { item.updated_at.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx("insert_media", &item.id))?; + .map_err(db_ctx("insert_media", &item.id))?; } Ok(()) }) @@ -809,11 +817,13 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let count: i64 = db.query_row( - "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", - [], - |row| row.get(0), - )?; + let count: i64 = db + .query_row( + "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", + [], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; count }; Ok(count.cast_unsigned()) @@ -829,15 +839,17 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, \ - title, artist, album, genre, year, duration_secs, description, \ - thumbnail_path, file_mtime, date_taken, latitude, longitude, \ - camera_make, camera_model, rating, perceptual_hash, storage_mode, \ - original_filename, uploaded_at, storage_key, created_at, \ - updated_at, deleted_at, links_extracted_at FROM media_items WHERE \ - id = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, \ + title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, file_mtime, date_taken, latitude, longitude, \ + camera_make, camera_model, rating, perceptual_hash, \ + storage_mode, original_filename, uploaded_at, storage_key, \ + created_at, updated_at, deleted_at, links_extracted_at FROM \ + media_items WHERE id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut item = stmt .query_row(params![id.0.to_string()], row_to_media_item) .map_err(|e| { @@ -845,11 +857,12 @@ impl StorageBackend for SqliteBackend { rusqlite::Error::QueryReturnedNoRows => { PinakesError::NotFound(format!("media item {id}")) }, - other => PinakesError::from(other), + other => PinakesError::Database(other.to_string()), } })?; drop(stmt); - item.custom_fields = load_custom_fields_sync(&db, item.id)?; + item.custom_fields = load_custom_fields_sync(&db, item.id) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); item }; @@ -870,21 +883,25 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, \ - title, artist, album, genre, year, duration_secs, description, \ - thumbnail_path, file_mtime, date_taken, latitude, longitude, \ - camera_make, camera_model, rating, perceptual_hash, storage_mode, \ - original_filename, uploaded_at, storage_key, created_at, \ - updated_at, deleted_at, links_extracted_at FROM media_items WHERE \ - content_hash = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, \ + title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, file_mtime, date_taken, latitude, longitude, \ + camera_make, camera_model, rating, perceptual_hash, \ + storage_mode, original_filename, uploaded_at, storage_key, \ + created_at, updated_at, deleted_at, links_extracted_at FROM \ + media_items WHERE content_hash = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let result = stmt .query_row(params![hash.0], row_to_media_item) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); if let Some(mut item) = result { - item.custom_fields = load_custom_fields_sync(&db, item.id)?; + item.custom_fields = load_custom_fields_sync(&db, item.id) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); Some(item) } else { @@ -909,21 +926,25 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, \ - title, artist, album, genre, year, duration_secs, description, \ - thumbnail_path, file_mtime, date_taken, latitude, longitude, \ - camera_make, camera_model, rating, perceptual_hash, storage_mode, \ - original_filename, uploaded_at, storage_key, created_at, \ - updated_at, deleted_at, links_extracted_at FROM media_items WHERE \ - path = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, \ + title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, file_mtime, date_taken, latitude, longitude, \ + camera_make, camera_model, rating, perceptual_hash, \ + storage_mode, original_filename, uploaded_at, storage_key, \ + created_at, updated_at, deleted_at, links_extracted_at FROM \ + media_items WHERE path = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let result = stmt .query_row(params![path_str], row_to_media_item) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); if let Some(mut item) = result { - item.custom_fields = load_custom_fields_sync(&db, item.id)?; + item.custom_fields = load_custom_fields_sync(&db, item.id) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); Some(item) } else { @@ -968,7 +989,9 @@ impl StorageBackend for SqliteBackend { updated_at, deleted_at, links_extracted_at FROM media_items WHERE \ deleted_at IS NULL ORDER BY {order_by} LIMIT ?1 OFFSET ?2" ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut rows = stmt .query_map( params![ @@ -976,10 +999,13 @@ impl StorageBackend for SqliteBackend { pagination.offset.cast_signed() ], row_to_media_item, - )? - .collect::>>()?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - load_custom_fields_batch(&db, &mut rows)?; + load_custom_fields_batch(&db, &mut rows) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); rows }; @@ -1035,7 +1061,7 @@ impl StorageBackend for SqliteBackend { item.updated_at.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx("update_media", &item.id))?; + .map_err(db_ctx("update_media", &item.id))?; drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!( @@ -1061,7 +1087,7 @@ impl StorageBackend for SqliteBackend { .execute("DELETE FROM media_items WHERE id = ?1", params![ id.0.to_string() ]) - .map_err(crate::error::db_ctx("delete_media", id))?; + .map_err(db_ctx("delete_media", id))?; drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); @@ -1080,11 +1106,14 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let count: u64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |row| { + let count: u64 = db + .query_row("SELECT COUNT(*) FROM media_items", [], |row| { row.get::<_, i64>(0) - })?.cast_unsigned(); - db.execute("DELETE FROM media_items", [])?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); + db.execute("DELETE FROM media_items", []) + .map_err(|e| PinakesError::Database(e.to_string()))?; count }; Ok(count) @@ -1117,7 +1146,7 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx("create_tag", &name))?; + .map_err(db_ctx("create_tag", &name))?; drop(db); Tag { id, @@ -1139,9 +1168,11 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, parent_id, created_at FROM tags WHERE id = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, name, parent_id, created_at FROM tags WHERE id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let tag = stmt .query_row(params![id.to_string()], row_to_tag) .map_err(|e| { @@ -1149,7 +1180,7 @@ impl StorageBackend for SqliteBackend { rusqlite::Error::QueryReturnedNoRows => { PinakesError::TagNotFound(id.to_string()) }, - other => PinakesError::from(other), + other => PinakesError::Database(other.to_string()), } })?; drop(stmt); @@ -1169,12 +1200,16 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", - )?; + let mut stmt = db + .prepare( + "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt - .query_map([], row_to_tag)? - .collect::>>()?; + .query_map([], row_to_tag) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -1194,7 +1229,7 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; let changed = db .execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()]) - .map_err(crate::error::db_ctx("delete_tag", id))?; + .map_err(db_ctx("delete_tag", id))?; drop(db); if changed == 0 { return Err(PinakesError::TagNotFound(id.to_string())); @@ -1217,10 +1252,7 @@ impl StorageBackend for SqliteBackend { "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", params![media_id.0.to_string(), tag_id.to_string()], ) - .map_err(crate::error::db_ctx( - "tag_media", - format!("{media_id} x {tag_id}"), - ))?; + .map_err(db_ctx("tag_media", format!("{media_id} x {tag_id}")))?; } Ok(()) }) @@ -1239,10 +1271,7 @@ impl StorageBackend for SqliteBackend { "DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2", params![media_id.0.to_string(), tag_id.to_string()], ) - .map_err(crate::error::db_ctx( - "untag_media", - format!("{media_id} x {tag_id}"), - ))?; + .map_err(db_ctx("untag_media", format!("{media_id} x {tag_id}")))?; } Ok(()) }) @@ -1257,14 +1286,18 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT t.id, t.name, t.parent_id, t.created_at FROM tags t JOIN \ - media_tags mt ON mt.tag_id = t.id WHERE mt.media_id = ?1 ORDER BY \ - t.name", - )?; + let mut stmt = db + .prepare( + "SELECT t.id, t.name, t.parent_id, t.created_at FROM tags t JOIN \ + media_tags mt ON mt.tag_id = t.id WHERE mt.media_id = ?1 ORDER \ + BY t.name", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt - .query_map(params![media_id.0.to_string()], row_to_tag)? - .collect::>>()?; + .query_map(params![media_id.0.to_string()], row_to_tag) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -1282,16 +1315,20 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "WITH RECURSIVE descendants(id, name, parent_id, created_at) AS ( \ - SELECT id, name, parent_id, created_at FROM tags WHERE parent_id = \ - ?1 UNION ALL SELECT t.id, t.name, t.parent_id, t.created_at FROM \ - tags t JOIN descendants d ON t.parent_id = d.id ) SELECT id, name, \ - parent_id, created_at FROM descendants ORDER BY name", - )?; + let mut stmt = db + .prepare( + "WITH RECURSIVE descendants(id, name, parent_id, created_at) AS ( \ + SELECT id, name, parent_id, created_at FROM tags WHERE parent_id \ + = ?1 UNION ALL SELECT t.id, t.name, t.parent_id, t.created_at \ + FROM tags t JOIN descendants d ON t.parent_id = d.id ) SELECT \ + id, name, parent_id, created_at FROM descendants ORDER BY name", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt - .query_map(params![tag_id.to_string()], row_to_tag)? - .collect::>>()?; + .query_map(params![tag_id.to_string()], row_to_tag) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -1334,7 +1371,7 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx("create_collection", &name))?; + .map_err(db_ctx("create_collection", &name))?; drop(db); Collection { id, @@ -1359,10 +1396,12 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, description, kind, filter_query, created_at, \ - updated_at FROM collections WHERE id = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, name, description, kind, filter_query, created_at, \ + updated_at FROM collections WHERE id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let collection = stmt .query_row(params![id.to_string()], row_to_collection) .map_err(|e| { @@ -1370,7 +1409,7 @@ impl StorageBackend for SqliteBackend { rusqlite::Error::QueryReturnedNoRows => { PinakesError::CollectionNotFound(id.to_string()) }, - other => PinakesError::from(other), + other => PinakesError::Database(other.to_string()), } })?; drop(stmt); @@ -1390,13 +1429,17 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, description, kind, filter_query, created_at, \ - updated_at FROM collections ORDER BY name", - )?; + let mut stmt = db + .prepare( + "SELECT id, name, description, kind, filter_query, created_at, \ + updated_at FROM collections ORDER BY name", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt - .query_map([], row_to_collection)? - .collect::>>()?; + .query_map([], row_to_collection) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -1418,7 +1461,7 @@ impl StorageBackend for SqliteBackend { .execute("DELETE FROM collections WHERE id = ?1", params![ id.to_string() ]) - .map_err(crate::error::db_ctx("delete_collection", id))?; + .map_err(db_ctx("delete_collection", id))?; drop(db); if changed == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); @@ -1453,7 +1496,7 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx( + .map_err(db_ctx( "add_to_collection", format!("{collection_id} <- {media_id}"), ))?; @@ -1480,7 +1523,7 @@ impl StorageBackend for SqliteBackend { media_id = ?2", params![collection_id.to_string(), media_id.0.to_string()], ) - .map_err(crate::error::db_ctx( + .map_err(db_ctx( "remove_from_collection", format!("{collection_id} <- {media_id}"), ))?; @@ -1501,22 +1544,27 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, \ - m.date_taken, m.latitude, m.longitude, m.camera_make, \ - m.camera_model, m.rating, m.perceptual_hash, m.storage_mode, \ - m.original_filename, m.uploaded_at, m.storage_key, m.created_at, \ - m.updated_at, m.deleted_at, m.links_extracted_at FROM media_items \ - m JOIN collection_members cm ON cm.media_id = m.id WHERE \ - cm.collection_id = ?1 ORDER BY cm.position", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, \ + m.date_taken, m.latitude, m.longitude, m.camera_make, \ + m.camera_model, m.rating, m.perceptual_hash, m.storage_mode, \ + m.original_filename, m.uploaded_at, m.storage_key, m.created_at, \ + m.updated_at, m.deleted_at, m.links_extracted_at FROM \ + media_items m JOIN collection_members cm ON cm.media_id = m.id \ + WHERE cm.collection_id = ?1 ORDER BY cm.position", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut rows = stmt - .query_map(params![collection_id.to_string()], row_to_media_item)? - .collect::>>()?; + .query_map(params![collection_id.to_string()], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - load_custom_fields_batch(&db, &mut rows)?; + load_custom_fields_batch(&db, &mut rows) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); rows }; @@ -1584,16 +1632,21 @@ impl StorageBackend for SqliteBackend { all_params.push(request.pagination.limit.to_string()); all_params.push(request.pagination.offset.to_string()); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let param_refs: Vec<&dyn rusqlite::types::ToSql> = all_params .iter() .map(|s| s as &dyn rusqlite::types::ToSql) .collect(); let mut items = stmt - .query_map(param_refs.as_slice(), row_to_media_item)? - .collect::>>()?; + .query_map(param_refs.as_slice(), row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Count query (same filters, no LIMIT/OFFSET) let mut count_sql = String::from("SELECT COUNT(*) FROM media_items m "); @@ -1619,10 +1672,9 @@ impl StorageBackend for SqliteBackend { .iter() .map(|s| s as &dyn rusqlite::types::ToSql) .collect(); - let total_count: i64 = - db.query_row(&count_sql, count_param_refs.as_slice(), |row| { - row.get(0) - })?; + let total_count: i64 = db + .query_row(&count_sql, count_param_refs.as_slice(), |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); SearchResults { @@ -1654,7 +1706,8 @@ impl StorageBackend for SqliteBackend { entry.details, entry.timestamp.to_rfc3339(), ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -1694,7 +1747,9 @@ impl StorageBackend for SqliteBackend { }, ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = if let Some(ref mid_str) = bind_media_id { stmt .query_map( @@ -1704,8 +1759,10 @@ impl StorageBackend for SqliteBackend { pagination.offset.cast_signed() ], row_to_audit_entry, - )? - .collect::>>()? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))? } else { stmt .query_map( @@ -1714,8 +1771,10 @@ impl StorageBackend for SqliteBackend { pagination.offset.cast_signed() ], row_to_audit_entry, - )? - .collect::>>()? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))? }; drop(stmt); drop(db); @@ -1751,7 +1810,8 @@ impl StorageBackend for SqliteBackend { custom_field_type_to_str(field.field_type), field.value, ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -1769,23 +1829,28 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT field_name, field_type, field_value FROM custom_fields \ - WHERE media_id = ?1", - )?; - let rows = stmt.query_map(params![media_id.0.to_string()], |row| { - let name: String = row.get(0)?; - let ft_str: String = row.get(1)?; - let value: String = row.get(2)?; - Ok((name, CustomField { - field_type: str_to_custom_field_type(&ft_str), - value, - })) - })?; + let mut stmt = db + .prepare( + "SELECT field_name, field_type, field_value FROM custom_fields \ + WHERE media_id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt + .query_map(params![media_id.0.to_string()], |row| { + let name: String = row.get(0)?; + let ft_str: String = row.get(1)?; + let value: String = row.get(2)?; + Ok((name, CustomField { + field_type: str_to_custom_field_type(&ft_str), + value, + })) + }) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut map = FxHashMap::default(); for r in rows { - let (name, field) = r?; + let (name, field) = + r.map_err(|e| PinakesError::Database(e.to_string()))?; map.insert(name, field); } drop(stmt); @@ -1813,7 +1878,8 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM custom_fields WHERE media_id = ?1 AND field_name = ?2", params![media_id.0.to_string(), name], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -1837,7 +1903,7 @@ impl StorageBackend for SqliteBackend { let ctx = format!("{n} items"); let tx = db .unchecked_transaction() - .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; + .map_err(db_ctx("batch_delete_media", &ctx))?; let mut count = 0u64; for chunk in ids.chunks(CHUNK_SIZE) { let placeholders: Vec = @@ -1850,11 +1916,10 @@ impl StorageBackend for SqliteBackend { chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); let rows = tx .execute(&sql, params.as_slice()) - .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; + .map_err(db_ctx("batch_delete_media", &ctx))?; count += rows as u64; } - tx.commit() - .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; + tx.commit().map_err(db_ctx("batch_delete_media", &ctx))?; count }; Ok(count) @@ -1886,26 +1951,25 @@ impl StorageBackend for SqliteBackend { let ctx = format!("{} media x {} tags", media_ids.len(), tag_ids.len()); let tx = db .unchecked_transaction() - .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; + .map_err(db_ctx("batch_tag_media", &ctx))?; // Prepare statement once for reuse let mut stmt = tx .prepare_cached( "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, \ ?2)", ) - .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; + .map_err(db_ctx("batch_tag_media", &ctx))?; let mut count = 0u64; for mid in &media_ids { for tid in &tag_ids { let rows = stmt .execute(params![mid, tid]) - .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; + .map_err(db_ctx("batch_tag_media", &ctx))?; count += rows as u64; // INSERT OR IGNORE: rows=1 if new, 0 if existed } } drop(stmt); - tx.commit() - .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; + tx.commit().map_err(db_ctx("batch_tag_media", &ctx))?; count }; Ok(count) @@ -1991,7 +2055,7 @@ impl StorageBackend for SqliteBackend { let ctx = format!("{} items", ids.len()); let tx = db .unchecked_transaction() - .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; + .map_err(db_ctx("batch_update_media", &ctx))?; let mut count = 0u64; for chunk in ids.chunks(CHUNK_SIZE) { @@ -2011,12 +2075,11 @@ impl StorageBackend for SqliteBackend { let rows = tx .execute(&sql, all_params.as_slice()) - .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; + .map_err(db_ctx("batch_update_media", &ctx))?; count += rows as u64; } - tx.commit() - .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; + tx.commit().map_err(db_ctx("batch_update_media", &ctx))?; count }; Ok(count) @@ -2032,19 +2095,24 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT * FROM media_items WHERE deleted_at IS NULL AND \ - content_hash IN ( + let mut stmt = db + .prepare( + "SELECT * FROM media_items WHERE deleted_at IS NULL AND \ + content_hash IN ( SELECT content_hash FROM media_items WHERE deleted_at IS \ - NULL + NULL GROUP BY content_hash HAVING COUNT(*) > 1 ) ORDER BY content_hash, created_at", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut rows: Vec = stmt - .query_map([], row_to_media_item)? - .collect::>>()?; + .query_map([], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; - load_custom_fields_batch(&db, &mut rows)?; + load_custom_fields_batch(&db, &mut rows) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Group by content_hash let mut groups: Vec> = Vec::new(); @@ -2079,15 +2147,20 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; // Get all images with perceptual hashes - let mut stmt = db.prepare( - "SELECT * FROM media_items WHERE perceptual_hash IS NOT NULL ORDER \ - BY id", - )?; + let mut stmt = db + .prepare( + "SELECT * FROM media_items WHERE perceptual_hash IS NOT NULL \ + ORDER BY id", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = stmt - .query_map([], row_to_media_item)? - .collect::>>()?; + .query_map([], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; items }; @@ -2159,22 +2232,24 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let media_count: i64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |row| { - row.get(0) - })?; - let tag_count: i64 = - db.query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0))?; - let collection_count: i64 = - db.query_row("SELECT COUNT(*) FROM collections", [], |row| { - row.get(0) - })?; - let audit_count: i64 = - db.query_row("SELECT COUNT(*) FROM audit_log", [], |row| row.get(0))?; - let page_count: i64 = - db.query_row("PRAGMA page_count", [], |row| row.get(0))?; - let page_size: i64 = - db.query_row("PRAGMA page_size", [], |row| row.get(0))?; + let media_count: i64 = db + .query_row("SELECT COUNT(*) FROM media_items", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let tag_count: i64 = db + .query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let collection_count: i64 = db + .query_row("SELECT COUNT(*) FROM collections", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let audit_count: i64 = db + .query_row("SELECT COUNT(*) FROM audit_log", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let page_count: i64 = db + .query_row("PRAGMA page_count", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let page_size: i64 = db + .query_row("PRAGMA page_size", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; let database_size_bytes = (page_count * page_size).cast_unsigned(); crate::storage::DatabaseStats { media_count: media_count.cast_unsigned(), @@ -2198,7 +2273,8 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute_batch("VACUUM")?; + db.execute_batch("VACUUM") + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -2221,7 +2297,8 @@ impl StorageBackend for SqliteBackend { DELETE FROM media_items; DELETE FROM tags; DELETE FROM collections;", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -2238,24 +2315,28 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, path, content_hash FROM media_items WHERE deleted_at IS \ - NULL", - )?; - let rows = stmt.query_map([], |row| { - let id_str: String = row.get(0)?; - let path_str: String = row.get(1)?; - let hash_str: String = row.get(2)?; - let id = parse_uuid(&id_str)?; - Ok(( - MediaId(id), - PathBuf::from(path_str), - ContentHash::new(hash_str), - )) - })?; + let mut stmt = db + .prepare( + "SELECT id, path, content_hash FROM media_items WHERE deleted_at \ + IS NULL", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + let id_str: String = row.get(0)?; + let path_str: String = row.get(1)?; + let hash_str: String = row.get(2)?; + let id = parse_uuid(&id_str)?; + Ok(( + MediaId(id), + PathBuf::from(path_str), + ContentHash::new(hash_str), + )) + }) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); for row in rows { - results.push(row?); + results.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } results }; @@ -2287,7 +2368,8 @@ impl StorageBackend for SqliteBackend { "INSERT OR REPLACE INTO saved_searches (id, name, query, \ sort_order, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", params![id_str, name, query, sort_order, now], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -2304,28 +2386,32 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, query, sort_order, created_at FROM saved_searches \ - ORDER BY created_at DESC", - )?; - let rows = stmt.query_map([], |row| { - let id_str: String = row.get(0)?; - let name: String = row.get(1)?; - let query: String = row.get(2)?; - let sort_order: Option = row.get(3)?; - let created_at_str: String = row.get(4)?; - let id = parse_uuid(&id_str)?; - Ok(crate::model::SavedSearch { - id, - name, - query, - sort_order, - created_at: parse_datetime(&created_at_str), + let mut stmt = db + .prepare( + "SELECT id, name, query, sort_order, created_at FROM \ + saved_searches ORDER BY created_at DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + let id_str: String = row.get(0)?; + let name: String = row.get(1)?; + let query: String = row.get(2)?; + let sort_order: Option = row.get(3)?; + let created_at_str: String = row.get(4)?; + let id = parse_uuid(&id_str)?; + Ok(crate::model::SavedSearch { + id, + name, + query, + sort_order, + created_at: parse_datetime(&created_at_str), + }) }) - })?; + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); for row in rows { - results.push(row?); + results.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } results }; @@ -2389,9 +2475,8 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute("DELETE FROM saved_searches WHERE id = ?1", params![ - id_str - ])?; + db.execute("DELETE FROM saved_searches WHERE id = ?1", params![id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -2415,14 +2500,23 @@ impl StorageBackend for SqliteBackend { } else { "SELECT id FROM media_items ORDER BY created_at DESC" }; - let mut stmt = db.prepare(sql)?; + let mut stmt = db + .prepare(sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids: Vec = stmt .query_map([], |r| { let s: String = r.get(0)?; - Ok(MediaId(uuid::Uuid::parse_str(&s).unwrap_or_default())) - })? - .filter_map(std::result::Result::ok) - .collect(); + uuid::Uuid::parse_str(&s).map(MediaId).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(e), + ) + }) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::, _>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(ids) }) .await @@ -2440,32 +2534,49 @@ impl StorageBackend for SqliteBackend { .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let total_media: u64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |r| r.get::<_, i64>(0))?.cast_unsigned(); - let total_size: u64 = db.query_row( - "SELECT COALESCE(SUM(file_size), 0) FROM media_items", - [], - |r| r.get::<_, i64>(0), - )?.cast_unsigned(); + let total_media: u64 = db + .query_row("SELECT COUNT(*) FROM media_items", [], |r| { + r.get::<_, i64>(0) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); + let total_size: u64 = db + .query_row( + "SELECT COALESCE(SUM(file_size), 0) FROM media_items", + [], + |r| r.get::<_, i64>(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); let avg_size: u64 = total_size.checked_div(total_media).unwrap_or(0); // Media count by type - let mut stmt = db.prepare( - "SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type \ - ORDER BY COUNT(*) DESC", - )?; + let mut stmt = db + .prepare( + "SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type \ + ORDER BY COUNT(*) DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let media_by_type: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Storage by type - let mut stmt = db.prepare( - "SELECT media_type, COALESCE(SUM(file_size), 0) FROM media_items \ - GROUP BY media_type ORDER BY SUM(file_size) DESC", - )?; + let mut stmt = db + .prepare( + "SELECT media_type, COALESCE(SUM(file_size), 0) FROM media_items \ + GROUP BY media_type ORDER BY SUM(file_size) DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let storage_by_type: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); @@ -2476,48 +2587,69 @@ impl StorageBackend for SqliteBackend { [], |r| r.get(0), ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; let oldest: Option = db .query_row( "SELECT created_at FROM media_items ORDER BY created_at ASC LIMIT 1", [], |r| r.get(0), ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Top tags - let mut stmt = db.prepare( - "SELECT t.name, COUNT(*) as cnt FROM media_tags mt JOIN tags t ON \ - mt.tag_id = t.id GROUP BY t.id ORDER BY cnt DESC LIMIT 10", - )?; + let mut stmt = db + .prepare( + "SELECT t.name, COUNT(*) as cnt FROM media_tags mt JOIN tags t ON \ + mt.tag_id = t.id GROUP BY t.id ORDER BY cnt DESC LIMIT 10", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let top_tags: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Top collections - let mut stmt = db.prepare( - "SELECT c.name, COUNT(*) as cnt FROM collection_members cm JOIN \ - collections c ON cm.collection_id = c.id GROUP BY c.id ORDER BY cnt \ - DESC LIMIT 10", - )?; + let mut stmt = db + .prepare( + "SELECT c.name, COUNT(*) as cnt FROM collection_members cm JOIN \ + collections c ON cm.collection_id = c.id GROUP BY c.id ORDER BY \ + cnt DESC LIMIT 10", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let top_collections: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); - let total_tags: u64 = - db.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get::<_, i64>(0))?.cast_unsigned(); - let total_collections: u64 = - db.query_row("SELECT COUNT(*) FROM collections", [], |r| r.get::<_, i64>(0))?.cast_unsigned(); + let total_tags: u64 = db + .query_row("SELECT COUNT(*) FROM tags", [], |r| r.get::<_, i64>(0)) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); + let total_collections: u64 = db + .query_row("SELECT COUNT(*) FROM collections", [], |r| { + r.get::<_, i64>(0) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); // Duplicates: count of hashes that appear more than once - let total_duplicates: u64 = db.query_row( - "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP BY \ - content_hash HAVING COUNT(*) > 1)", - [], - |r| r.get::<_, i64>(0), - )?.cast_unsigned(); + let total_duplicates: u64 = db + .query_row( + "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP \ + BY content_hash HAVING COUNT(*) > 1)", + [], + |r| r.get::<_, i64>(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); Ok(super::LibraryStatistics { total_media, @@ -2548,10 +2680,12 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, username, password_hash, role, created_at, updated_at \ - FROM users ORDER BY created_at DESC", - )?; + let mut stmt = db + .prepare( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users ORDER BY created_at DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let users = stmt .query_map([], |row| { let id_str: String = row.get(0)?; @@ -2574,8 +2708,10 @@ impl StorageBackend for SqliteBackend { .unwrap_or_else(|_| chrono::Utc::now().into()) .with_timezone(&chrono::Utc), }) - })? - .collect::, _>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::, _>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(users) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -2627,7 +2763,8 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; opt.ok_or_else(|| PinakesError::NotFound(format!("user {id_str}"))) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -2681,7 +2818,8 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; opt.ok_or_else(|| { PinakesError::NotFound(format!("user with username {username}")) }) @@ -2718,7 +2856,9 @@ impl StorageBackend for SqliteBackend { )) })?; - let tx = db.unchecked_transaction()?; + let tx = db + .unchecked_transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; let id = crate::users::UserId(uuid::Uuid::now_v7()); let id_str = id.0.to_string(); @@ -2738,7 +2878,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let user_profile = if let Some(prof) = profile.clone() { let prefs_json = @@ -2759,7 +2900,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; prof } else { crate::users::UserProfile { @@ -2769,7 +2911,8 @@ impl StorageBackend for SqliteBackend { } }; - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::users::User { id, @@ -2809,7 +2952,9 @@ impl StorageBackend for SqliteBackend { )) })?; - let tx = db.unchecked_transaction()?; + let tx = db + .unchecked_transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; let now = chrono::Utc::now(); // Update password and/or role if provided @@ -2838,7 +2983,8 @@ impl StorageBackend for SqliteBackend { format!("UPDATE users SET {} WHERE id = ?", updates.join(", ")); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(std::convert::AsRef::as_ref).collect(); - tx.execute(&sql, param_refs.as_slice())?; + tx.execute(&sql, param_refs.as_slice()) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Update profile if provided @@ -2863,39 +3009,44 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Fetch updated user - Ok(db.query_row( - "SELECT id, username, password_hash, role, created_at, updated_at \ - FROM users WHERE id = ?", - [&id_str], - |row| { - let id_str: String = row.get(0)?; - let profile = load_user_profile_sync(&db, &id_str)?; - Ok(crate::users::User { - id: crate::users::UserId(parse_uuid(&id_str)?), - username: row.get(1)?, - password_hash: row.get(2)?, - role: serde_json::from_str(&row.get::<_, String>(3)?) - .unwrap_or(crate::config::UserRole::Viewer), - profile, - created_at: chrono::DateTime::parse_from_rfc3339( - &row.get::<_, String>(4)?, - ) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339( - &row.get::<_, String>(5)?, - ) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - }) - }, - )?) + Ok( + db.query_row( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users WHERE id = ?", + [&id_str], + |row| { + let id_str: String = row.get(0)?; + let profile = load_user_profile_sync(&db, &id_str)?; + Ok(crate::users::User { + id: crate::users::UserId(parse_uuid(&id_str)?), + username: row.get(1)?, + password_hash: row.get(2)?, + role: serde_json::from_str(&row.get::<_, String>(3)?) + .unwrap_or(crate::config::UserRole::Viewer), + profile, + created_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(4)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(5)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + }) + }, + ) + .map_err(|e| PinakesError::Database(e.to_string()))?, + ) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) .await @@ -2915,19 +3066,26 @@ impl StorageBackend for SqliteBackend { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let tx = db.unchecked_transaction()?; + let tx = db + .unchecked_transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete profile first due to foreign key - tx.execute("DELETE FROM user_profiles WHERE user_id = ?", [&id_str])?; + tx.execute("DELETE FROM user_profiles WHERE user_id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete library access - tx.execute("DELETE FROM user_libraries WHERE user_id = ?", [&id_str])?; + tx.execute("DELETE FROM user_libraries WHERE user_id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete user - let affected = tx.execute("DELETE FROM users WHERE id = ?", [&id_str])?; + let affected = tx + .execute("DELETE FROM users WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; if affected == 0 { return Err(PinakesError::NotFound(format!("user {id_str}"))); } - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -2950,10 +3108,12 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT user_id, root_path, permission, granted_at FROM \ - user_libraries WHERE user_id = ?", - )?; + let mut stmt = db + .prepare( + "SELECT user_id, root_path, permission, granted_at FROM \ + user_libraries WHERE user_id = ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let libraries = stmt .query_map([&user_id_str], |row| { let id_str: String = row.get(0)?; @@ -2968,7 +3128,8 @@ impl StorageBackend for SqliteBackend { .unwrap_or_else(|_| chrono::Utc::now().into()) .with_timezone(&chrono::Utc), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect::>(); Ok(libraries) @@ -3011,7 +3172,8 @@ impl StorageBackend for SqliteBackend { &perm_str, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3041,7 +3203,8 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM user_libraries WHERE user_id = ? AND root_path = ?", rusqlite::params![&user_id_str, &root_path], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3085,18 +3248,22 @@ impl StorageBackend for SqliteBackend { &review, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; // SELECT the actual row to get the real id and created_at (INSERT OR // REPLACE may have kept existing values) - let (actual_id, actual_created_at) = db.query_row( - "SELECT id, created_at FROM ratings WHERE user_id = ? AND media_id = ?", - params![&user_id_str, &media_id_str], - |row| { - let rid_str: String = row.get(0)?; - let created_str: String = row.get(1)?; - Ok((parse_uuid(&rid_str)?, parse_datetime(&created_str))) - }, - )?; + let (actual_id, actual_created_at) = db + .query_row( + "SELECT id, created_at FROM ratings WHERE user_id = ? AND media_id \ + = ?", + params![&user_id_str, &media_id_str], + |row| { + let rid_str: String = row.get(0)?; + let created_str: String = row.get(1)?; + Ok((parse_uuid(&rid_str)?, parse_datetime(&created_str))) + }, + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::Rating { id: actual_id, user_id, @@ -3124,10 +3291,12 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, user_id, media_id, stars, review_text, created_at FROM \ - ratings WHERE media_id = ? ORDER BY created_at DESC", - )?; + let mut stmt = db + .prepare( + "SELECT id, user_id, media_id, stars, review_text, created_at FROM \ + ratings WHERE media_id = ? ORDER BY created_at DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let ratings = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; @@ -3142,7 +3311,8 @@ impl StorageBackend for SqliteBackend { review_text: row.get(4)?, created_at: parse_datetime(&created_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(ratings) @@ -3189,7 +3359,8 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3207,7 +3378,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM ratings WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM ratings WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3248,7 +3420,8 @@ impl StorageBackend for SqliteBackend { &text, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::Comment { id, user_id, @@ -3276,10 +3449,12 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, user_id, media_id, parent_comment_id, text, created_at \ - FROM comments WHERE media_id = ? ORDER BY created_at ASC", - )?; + let mut stmt = db + .prepare( + "SELECT id, user_id, media_id, parent_comment_id, text, created_at \ + FROM comments WHERE media_id = ? ORDER BY created_at ASC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let comments = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; @@ -3296,7 +3471,8 @@ impl StorageBackend for SqliteBackend { text: row.get(4)?, created_at: parse_datetime(&created_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(comments) @@ -3318,7 +3494,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM comments WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM comments WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3346,7 +3523,8 @@ impl StorageBackend for SqliteBackend { "INSERT OR IGNORE INTO favorites (user_id, media_id, created_at) \ VALUES (?, ?, ?)", params![&user_id_str, &media_id_str, now.to_rfc3339()], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3372,7 +3550,8 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM favorites WHERE user_id = ? AND media_id = ?", params![&user_id_str, &media_id_str], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3396,19 +3575,23 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ - m.updated_at FROM media_items m JOIN favorites f ON m.id = \ - f.media_id WHERE f.user_id = ? ORDER BY f.created_at DESC LIMIT ? \ - OFFSET ?", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at FROM media_items m JOIN favorites f ON m.id = \ + f.media_id WHERE f.user_id = ? ORDER BY f.created_at DESC LIMIT ? \ + OFFSET ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = stmt - .query_map(params![&user_id_str, limit, offset], row_to_media_item)? + .query_map(params![&user_id_str, limit, offset], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(items) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3433,11 +3616,13 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let count: i64 = db.query_row( - "SELECT COUNT(*) FROM favorites WHERE user_id = ? AND media_id = ?", - params![&user_id_str, &media_id_str], - |row| row.get(0), - )?; + let count: i64 = db + .query_row( + "SELECT COUNT(*) FROM favorites WHERE user_id = ? AND media_id = ?", + params![&user_id_str, &media_id_str], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(count > 0) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3482,7 +3667,8 @@ impl StorageBackend for SqliteBackend { &expires_str, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::ShareLink { id, media_id, @@ -3563,7 +3749,8 @@ impl StorageBackend for SqliteBackend { db.execute( "UPDATE share_links SET view_count = view_count + 1 WHERE token = ?", [&token], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3583,7 +3770,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM share_links WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM share_links WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3632,7 +3820,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::playlists::Playlist { id, owner_id, @@ -3728,7 +3917,9 @@ impl StorageBackend for SqliteBackend { ) }, ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = if let Some(ref p) = param { stmt .query_map([p], |row| { @@ -3747,7 +3938,8 @@ impl StorageBackend for SqliteBackend { created_at: parse_datetime(&created_str), updated_at: parse_datetime(&updated_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect() } else { @@ -3768,7 +3960,8 @@ impl StorageBackend for SqliteBackend { created_at: parse_datetime(&created_str), updated_at: parse_datetime(&updated_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect() }; @@ -3818,7 +4011,8 @@ impl StorageBackend for SqliteBackend { format!("UPDATE playlists SET {} WHERE id = ?", updates.join(", ")); let param_refs: Vec<&dyn rusqlite::ToSql> = sql_params.iter().map(std::convert::AsRef::as_ref).collect(); - db.execute(&sql, param_refs.as_slice())?; + db.execute(&sql, param_refs.as_slice()) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Fetch updated db.query_row( "SELECT id, owner_id, name, description, is_public, is_smart, \ @@ -3866,7 +4060,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM playlists WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM playlists WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3895,7 +4090,8 @@ impl StorageBackend for SqliteBackend { "INSERT OR REPLACE INTO playlist_items (playlist_id, media_id, \ position, added_at) VALUES (?, ?, ?, ?)", params![&playlist_id_str, &media_id_str, position, now.to_rfc3339()], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3921,7 +4117,8 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM playlist_items WHERE playlist_id = ? AND media_id = ?", params![&playlist_id_str, &media_id_str], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3944,18 +4141,22 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ - m.updated_at FROM media_items m JOIN playlist_items pi ON m.id = \ - pi.media_id WHERE pi.playlist_id = ? ORDER BY pi.position ASC", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at FROM media_items m JOIN playlist_items pi ON m.id = \ + pi.media_id WHERE pi.playlist_id = ? ORDER BY pi.position ASC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = stmt - .query_map([&playlist_id_str], row_to_media_item)? + .query_map([&playlist_id_str], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(items) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3985,7 +4186,8 @@ impl StorageBackend for SqliteBackend { "UPDATE playlist_items SET position = ? WHERE playlist_id = ? AND \ media_id = ?", params![new_position, &playlist_id_str, &media_id_str], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4024,7 +4226,8 @@ impl StorageBackend for SqliteBackend { &duration, &context ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4069,7 +4272,9 @@ impl StorageBackend for SqliteBackend { context_json FROM usage_events {where_clause} ORDER BY timestamp \ DESC LIMIT ?" ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let param_refs: Vec<&dyn rusqlite::ToSql> = sql_params.iter().map(std::convert::AsRef::as_ref).collect(); let events = stmt @@ -4094,7 +4299,8 @@ impl StorageBackend for SqliteBackend { duration_secs: row.get(5)?, context_json: row.get(6)?, }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(events) @@ -4113,26 +4319,30 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ - m.updated_at, COUNT(ue.id) as view_count FROM media_items m JOIN \ - usage_events ue ON m.id = ue.media_id WHERE ue.event_type IN \ - ('view', 'play') GROUP BY m.id ORDER BY view_count DESC LIMIT ?", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at, COUNT(ue.id) as view_count FROM media_items m JOIN \ + usage_events ue ON m.id = ue.media_id WHERE ue.event_type IN \ + ('view', 'play') GROUP BY m.id ORDER BY view_count DESC LIMIT ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec<(MediaItem, u64)> = stmt .query_map([limit.cast_signed()], |row| { let item = row_to_media_item(row)?; let count: i64 = row.get(16)?; Ok((item, count.cast_unsigned())) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Load custom fields for each item let mut media_items: Vec = items.iter().map(|(i, _)| i.clone()).collect(); - load_custom_fields_batch(&db, &mut media_items)?; + load_custom_fields_batch(&db, &mut media_items) + .map_err(|e| PinakesError::Database(e.to_string()))?; for (i, (item, _)) in items.iter_mut().enumerate() { item.custom_fields = std::mem::take(&mut media_items[i].custom_fields); } @@ -4157,22 +4367,26 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ - m.updated_at FROM media_items m JOIN usage_events ue ON m.id = \ - ue.media_id WHERE ue.user_id = ? AND ue.event_type IN ('view', \ - 'play') GROUP BY m.id ORDER BY MAX(ue.timestamp) DESC LIMIT ?", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at FROM media_items m JOIN usage_events ue ON m.id = \ + ue.media_id WHERE ue.user_id = ? AND ue.event_type IN ('view', \ + 'play') GROUP BY m.id ORDER BY MAX(ue.timestamp) DESC LIMIT ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = stmt .query_map( params![&user_id_str, limit.cast_signed()], row_to_media_item, - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(items) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4212,7 +4426,8 @@ impl StorageBackend for SqliteBackend { progress_secs, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4244,7 +4459,8 @@ impl StorageBackend for SqliteBackend { params![&user_id_str, &media_id_str], |row| row.get(0), ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4270,7 +4486,8 @@ impl StorageBackend for SqliteBackend { let affected = db .execute("DELETE FROM usage_events WHERE timestamp < ?", [ &before_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4319,7 +4536,8 @@ impl StorageBackend for SqliteBackend { offset_ms, &now ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4340,10 +4558,13 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, media_id, language, format, file_path, is_embedded, \ - track_index, offset_ms, created_at FROM subtitles WHERE media_id = ?", - )?; + let mut stmt = db + .prepare( + "SELECT id, media_id, language, format, file_path, is_embedded, \ + track_index, offset_ms, created_at FROM subtitles WHERE media_id = \ + ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let subtitles = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; @@ -4367,7 +4588,8 @@ impl StorageBackend for SqliteBackend { offset_ms: row.get(7)?, created_at: parse_datetime(&created_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(subtitles) @@ -4389,7 +4611,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM subtitles WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM subtitles WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4413,7 +4636,8 @@ impl StorageBackend for SqliteBackend { })?; db.execute("UPDATE subtitles SET offset_ms = ? WHERE id = ?", params![ offset_ms, &id_str - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4428,7 +4652,7 @@ impl StorageBackend for SqliteBackend { async fn store_external_metadata( &self, - meta: &crate::enrichment::ExternalMetadata, + meta: &pinakes_enrichment::ExternalMetadata, ) -> Result<()> { let conn = Arc::clone(&self.conn); let id_str = meta.id.to_string(); @@ -4455,7 +4679,8 @@ impl StorageBackend for SqliteBackend { confidence, &last_updated ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4471,35 +4696,38 @@ impl StorageBackend for SqliteBackend { async fn get_external_metadata( &self, media_id: MediaId, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); let media_id_str = media_id.0.to_string(); let fut = tokio::task::spawn_blocking(move || { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, media_id, source, external_id, metadata_json, confidence, \ - last_updated FROM external_metadata WHERE media_id = ?", - )?; + let mut stmt = db + .prepare( + "SELECT id, media_id, source, external_id, metadata_json, \ + confidence, last_updated FROM external_metadata WHERE media_id = ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let metas = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; let mid_str: String = row.get(1)?; let source_str: String = row.get(2)?; let updated_str: String = row.get(6)?; - Ok(crate::enrichment::ExternalMetadata { + Ok(pinakes_enrichment::ExternalMetadata { id: parse_uuid(&id_str)?, media_id: MediaId(parse_uuid(&mid_str)?), source: source_str .parse() - .unwrap_or(crate::enrichment::EnrichmentSourceType::MusicBrainz), + .unwrap_or(pinakes_enrichment::EnrichmentSourceType::MusicBrainz), external_id: row.get(3)?, metadata_json: row.get(4)?, confidence: row.get(5)?, last_updated: parse_datetime(&updated_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(metas) @@ -4521,7 +4749,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM external_metadata WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM external_metadata WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4569,7 +4798,8 @@ impl StorageBackend for SqliteBackend { &created_at, &expires_at ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4673,7 +4903,9 @@ impl StorageBackend for SqliteBackend { ) }, ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let parse_row = |row: &Row| -> rusqlite::Result { let id_str: String = row.get(0)?; @@ -4704,12 +4936,14 @@ impl StorageBackend for SqliteBackend { }; let sessions: Vec<_> = if let Some(ref p) = param { stmt - .query_map([p], parse_row)? + .query_map([p], parse_row) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect() } else { stmt - .query_map([], parse_row)? + .query_map([], parse_row) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect() }; @@ -4743,7 +4977,8 @@ impl StorageBackend for SqliteBackend { "UPDATE transcode_sessions SET status = ?, progress = ?, \ error_message = ? WHERE id = ?", params![&status_str, progress, &error_message, &id_str], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4766,11 +5001,13 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let affected = db.execute( - "DELETE FROM transcode_sessions WHERE expires_at IS NOT NULL AND \ - expires_at < ?", - [&before_str], - )?; + let affected = db + .execute( + "DELETE FROM transcode_sessions WHERE expires_at IS NOT NULL AND \ + expires_at < ?", + [&before_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4813,7 +5050,8 @@ impl StorageBackend for SqliteBackend { &expires_at, &last_accessed ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4876,7 +5114,8 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) }); @@ -4900,7 +5139,8 @@ impl StorageBackend for SqliteBackend { db.execute( "UPDATE sessions SET last_accessed = ? WHERE session_token = ?", params![&now, &token], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4925,11 +5165,13 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let rows = db.execute( - "UPDATE sessions SET expires_at = ?, last_accessed = ? WHERE \ - session_token = ? AND expires_at > datetime('now')", - params![&expires, &now, &token], - )?; + let rows = db + .execute( + "UPDATE sessions SET expires_at = ?, last_accessed = ? WHERE \ + session_token = ? AND expires_at > datetime('now')", + params![&expires, &now, &token], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows > 0 { Ok(Some(new_expires_at)) } else { @@ -4952,7 +5194,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM sessions WHERE session_token = ?", [&token])?; + db.execute("DELETE FROM sessions WHERE session_token = ?", [&token]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4971,8 +5214,9 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let affected = - db.execute("DELETE FROM sessions WHERE username = ?", [&user])?; + let affected = db + .execute("DELETE FROM sessions WHERE username = ?", [&user]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4993,8 +5237,9 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let affected = - db.execute("DELETE FROM sessions WHERE expires_at < ?", [&now])?; + let affected = db + .execute("DELETE FROM sessions WHERE expires_at < ?", [&now]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -5039,36 +5284,44 @@ impl StorageBackend for SqliteBackend { ) }; - let mut stmt = db.prepare(query)?; + let mut stmt = db + .prepare(query) + .map_err(|e| PinakesError::Database(e.to_string()))?; let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); - let rows = stmt.query_map(¶m_refs[..], |row| { - let created_at_str: String = row.get(4)?; - let expires_at_str: String = row.get(5)?; - let last_accessed_str: String = row.get(6)?; + let rows = stmt + .query_map(¶m_refs[..], |row| { + let created_at_str: String = row.get(4)?; + let expires_at_str: String = row.get(5)?; + let last_accessed_str: String = row.get(6)?; - Ok(crate::storage::SessionData { - session_token: row.get(0)?, - user_id: row.get(1)?, - username: row.get(2)?, - role: row.get(3)?, - created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str) + Ok(crate::storage::SessionData { + session_token: row.get(0)?, + user_id: row.get(1)?, + username: row.get(2)?, + role: row.get(3)?, + created_at: chrono::DateTime::parse_from_rfc3339( + &created_at_str, + ) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? .with_timezone(&chrono::Utc), - expires_at: chrono::DateTime::parse_from_rfc3339(&expires_at_str) + expires_at: chrono::DateTime::parse_from_rfc3339( + &expires_at_str, + ) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? .with_timezone(&chrono::Utc), - last_accessed: chrono::DateTime::parse_from_rfc3339( - &last_accessed_str, - ) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? - .with_timezone(&chrono::Utc), + last_accessed: chrono::DateTime::parse_from_rfc3339( + &last_accessed_str, + ) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? + .with_timezone(&chrono::Utc), + }) }) - })?; + .map_err(|e| PinakesError::Database(e.to_string()))?; rows .collect::, _>>() - .map_err(std::convert::Into::into) + .map_err(|e| PinakesError::Database(e.to_string())) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) .await @@ -5109,7 +5362,9 @@ impl StorageBackend for SqliteBackend { let mut conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let tx = conn.transaction()?; + let tx = conn + .transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Upsert book_metadata tx.execute( @@ -5134,15 +5389,18 @@ impl StorageBackend for SqliteBackend { series_index, format ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Clear existing authors and identifiers tx.execute("DELETE FROM book_authors WHERE media_id = ?1", [ &media_id_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; tx.execute("DELETE FROM book_identifiers WHERE media_id = ?1", [ &media_id_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Insert authors for author in &authors { @@ -5157,7 +5415,8 @@ impl StorageBackend for SqliteBackend { author.role, author.position ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Insert identifiers @@ -5168,11 +5427,13 @@ impl StorageBackend for SqliteBackend { identifier_value) VALUES (?1, ?2, ?3)", rusqlite::params![media_id_str, id_type, value], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } } - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }); @@ -5223,7 +5484,8 @@ impl StorageBackend for SqliteBackend { )) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; let Some(( isbn, @@ -5243,10 +5505,12 @@ impl StorageBackend for SqliteBackend { }; // Get authors - let mut stmt = conn.prepare( - "SELECT author_name, author_sort, role, position + let mut stmt = conn + .prepare( + "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = ?1 ORDER BY position", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let authors: Vec = stmt .query_map([&media_id_str], |row| { Ok(crate::model::AuthorInfo { @@ -5255,20 +5519,28 @@ impl StorageBackend for SqliteBackend { role: row.get(2)?, position: row.get(3)?, }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Get identifiers - let mut stmt = conn.prepare( - "SELECT identifier_type, identifier_value + let mut stmt = conn + .prepare( + "SELECT identifier_type, identifier_value FROM book_identifiers WHERE media_id = ?1", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut identifiers: FxHashMap> = FxHashMap::default(); - for row in stmt.query_map([&media_id_str], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })? { - let (id_type, value) = row?; + for row in stmt + .query_map([&media_id_str], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + { + let (id_type, value) = + row.map_err(|e| PinakesError::Database(e.to_string()))?; identifiers.entry(id_type).or_default().push(value); } @@ -5326,20 +5598,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO book_authors (media_id, author_name, author_sort, role, \ - position) + conn + .execute( + "INSERT INTO book_authors (media_id, author_name, author_sort, \ + role, position) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(media_id, author_name, role) DO UPDATE SET author_sort = ?3, position = ?5", - rusqlite::params![ - media_id_str, - author_clone.name, - author_clone.file_as, - author_clone.role, - author_clone.position - ], - )?; + rusqlite::params![ + media_id_str, + author_clone.name, + author_clone.file_as, + author_clone.role, + author_clone.position + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }); @@ -5363,10 +5637,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT author_name, author_sort, role, position + let mut stmt = conn + .prepare( + "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = ?1 ORDER BY position", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let authors: Vec = stmt .query_map([&media_id_str], |row| { Ok(crate::model::AuthorInfo { @@ -5375,8 +5651,10 @@ impl StorageBackend for SqliteBackend { role: row.get(2)?, position: row.get(3)?, }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(authors) }); @@ -5404,18 +5682,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT author_name, COUNT(DISTINCT media_id) as book_count + let mut stmt = conn + .prepare( + "SELECT author_name, COUNT(DISTINCT media_id) as book_count FROM book_authors GROUP BY author_name ORDER BY book_count DESC, author_name LIMIT ?1 OFFSET ?2", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let authors: Vec<(String, u64)> = stmt .query_map([limit.cast_signed(), offset.cast_signed()], |row| { Ok((row.get(0)?, row.get::<_, i64>(1)?.cast_unsigned())) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(authors) }); @@ -5438,18 +5720,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT series_name, COUNT(*) as book_count + let mut stmt = conn + .prepare( + "SELECT series_name, COUNT(*) as book_count FROM book_metadata WHERE series_name IS NOT NULL GROUP BY series_name ORDER BY series_name", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let series: Vec<(String, u64)> = stmt .query_map([], |row| { Ok((row.get(0)?, row.get::<_, i64>(1)?.cast_unsigned())) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(series) }); @@ -5474,21 +5760,25 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, + let mut stmt = conn + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, \ - m.year, + m.year, m.duration_secs, m.description, m.thumbnail_path, \ - m.file_mtime, + m.file_mtime, m.created_at, m.updated_at FROM media_items m INNER JOIN book_metadata b ON m.id = b.media_id WHERE b.series_name = ?1 ORDER BY b.series_index, m.title", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let items = stmt - .query_map([&series], row_to_media_item)? - .collect::>>()?; + .query_map([&series], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(items) }); @@ -5519,14 +5809,16 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO watch_history (user_id, media_id, progress_secs, \ - last_watched) + conn + .execute( + "INSERT INTO watch_history (user_id, media_id, progress_secs, \ + last_watched) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(user_id, media_id) DO UPDATE SET progress_secs = ?3, last_watched = datetime('now')", - rusqlite::params![user_id_str, media_id_str, f64::from(current_page)], - )?; + rusqlite::params![user_id_str, media_id_str, f64::from(current_page)], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }); @@ -5570,7 +5862,8 @@ impl StorageBackend for SqliteBackend { Ok((current_page, total_pages, last_read_str)) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; let progress = match result { Some((current_page, total_pages, last_read_str)) => { @@ -5630,26 +5923,30 @@ impl StorageBackend for SqliteBackend { // Query books with reading progress for this user // Join with book_metadata to get page counts and media_items for the // items - let mut stmt = conn.prepare( - "SELECT m.*, wh.progress_secs, bm.page_count + let mut stmt = conn + .prepare( + "SELECT m.*, wh.progress_secs, bm.page_count FROM media_items m INNER JOIN watch_history wh ON m.id = wh.media_id LEFT JOIN book_metadata bm ON m.id = bm.media_id WHERE wh.user_id = ?1 ORDER BY wh.last_watched DESC", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; - let rows = stmt.query_map([&user_id_str], |row| { - // Parse the media item - let item = row_to_media_item(row)?; - // Read the extra columns by name, this is safe *regardless* of column - // count. - let current_page = row - .get::<_, Option>("progress_secs")? - .map_or(0, |v| i32::try_from(v).unwrap_or(0)); - let total_pages = row.get::<_, Option>("page_count")?; - Ok((item, current_page, total_pages)) - })?; + let rows = stmt + .query_map([&user_id_str], |row| { + // Parse the media item + let item = row_to_media_item(row)?; + // Read the extra columns by name, this is safe *regardless* of column + // count. + let current_page = row + .get::<_, Option>("progress_secs")? + .map_or(0, |v| i32::try_from(v).unwrap_or(0)); + let total_pages = row.get::<_, Option>("page_count")?; + Ok((item, current_page, total_pages)) + }) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); for row in rows { @@ -5776,10 +6073,14 @@ impl StorageBackend for SqliteBackend { let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(std::convert::AsRef::as_ref).collect(); - let mut stmt = conn.prepare(&query)?; + let mut stmt = conn + .prepare(&query) + .map_err(|e| PinakesError::Database(e.to_string()))?; let items = stmt - .query_map(&*params_refs, row_to_media_item)? - .collect::>>()?; + .query_map(&*params_refs, row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(items) }); @@ -5800,41 +6101,43 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO media_items (id, path, file_name, media_type, \ - content_hash, file_size, + conn + .execute( + "INSERT INTO media_items (id, path, file_name, media_type, \ + content_hash, file_size, title, artist, album, genre, year, duration_secs, \ - description, thumbnail_path, + description, thumbnail_path, storage_mode, original_filename, uploaded_at, storage_key, \ - created_at, updated_at) + created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, \ - ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", - params![ - item.id.0.to_string(), - item.path.to_string_lossy().to_string(), - item.file_name, - media_type_to_str(&item.media_type), - item.content_hash.0, - item.file_size.cast_signed(), - item.title, - item.artist, - item.album, - item.genre, - item.year, - item.duration_secs, - item.description, - item - .thumbnail_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()), - item.storage_mode.to_string(), - item.original_filename, - item.uploaded_at.map(|dt| dt.to_rfc3339()), - item.storage_key, - item.created_at.to_rfc3339(), - item.updated_at.to_rfc3339(), - ], - )?; + ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + params![ + item.id.0.to_string(), + item.path.to_string_lossy().to_string(), + item.file_name, + media_type_to_str(&item.media_type), + item.content_hash.0, + item.file_size.cast_signed(), + item.title, + item.artist, + item.album, + item.genre, + item.year, + item.duration_secs, + item.description, + item + .thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + item.storage_mode.to_string(), + item.original_filename, + item.uploaded_at.map(|dt| dt.to_rfc3339()), + item.storage_key, + item.created_at.to_rfc3339(), + item.updated_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -5880,19 +6183,22 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; if let Some(blob) = existing { return Ok(blob); } // Create new blob - conn.execute( - "INSERT INTO managed_blobs (content_hash, file_size, mime_type, \ - reference_count, stored_at) + conn + .execute( + "INSERT INTO managed_blobs (content_hash, file_size, mime_type, \ + reference_count, stored_at) VALUES (?1, ?2, ?3, 1, ?4)", - params![&hash_str, size.cast_signed(), &mime, &now], - )?; + params![&hash_str, size.cast_signed(), &mime, &now], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(ManagedBlob { content_hash: ContentHash(hash_str), @@ -5949,11 +6255,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE managed_blobs SET reference_count = reference_count + 1 WHERE \ - content_hash = ?1", - params![&hash_str], - )?; + conn + .execute( + "UPDATE managed_blobs SET reference_count = reference_count + 1 \ + WHERE content_hash = ?1", + params![&hash_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -5971,11 +6279,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE managed_blobs SET reference_count = reference_count - 1 WHERE \ - content_hash = ?1", - params![&hash_str], - )?; + conn + .execute( + "UPDATE managed_blobs SET reference_count = reference_count - 1 \ + WHERE content_hash = ?1", + params![&hash_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Check if reference count is now 0 let count: i32 = conn @@ -6006,10 +6316,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE managed_blobs SET last_verified = ?1 WHERE content_hash = ?2", - params![&now, &hash_str], - )?; + conn + .execute( + "UPDATE managed_blobs SET last_verified = ?1 WHERE content_hash = ?2", + params![&now, &hash_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6026,11 +6338,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT content_hash, file_size, mime_type, reference_count, \ - stored_at, last_verified + let mut stmt = conn + .prepare( + "SELECT content_hash, file_size, mime_type, reference_count, \ + stored_at, last_verified FROM managed_blobs WHERE reference_count <= 0", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let blobs = stmt .query_map([], |row| { Ok(ManagedBlob { @@ -6043,8 +6357,10 @@ impl StorageBackend for SqliteBackend { .get::<_, Option>(5)? .map(|s| parse_datetime(&s)), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(blobs) }) .await @@ -6062,10 +6378,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "DELETE FROM managed_blobs WHERE content_hash = ?1", - params![&hash_str], - )?; + conn + .execute( + "DELETE FROM managed_blobs WHERE content_hash = ?1", + params![&hash_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6084,7 +6402,8 @@ impl StorageBackend for SqliteBackend { let total_blobs: u64 = conn .query_row("SELECT COUNT(*) FROM managed_blobs", [], |row| { row.get::<_, i64>(0) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let total_size: u64 = conn @@ -6092,7 +6411,8 @@ impl StorageBackend for SqliteBackend { "SELECT COALESCE(SUM(file_size), 0) FROM managed_blobs", [], |row| row.get::<_, i64>(0), - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let unique_size: u64 = conn @@ -6101,7 +6421,8 @@ impl StorageBackend for SqliteBackend { reference_count = 1", [], |row| row.get::<_, i64>(0), - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let managed_media_count: u64 = conn @@ -6109,7 +6430,8 @@ impl StorageBackend for SqliteBackend { "SELECT COUNT(*) FROM media_items WHERE storage_mode = 'managed'", [], |row| row.get::<_, i64>(0), - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let orphaned_blobs: u64 = conn @@ -6117,7 +6439,8 @@ impl StorageBackend for SqliteBackend { "SELECT COUNT(*) FROM managed_blobs WHERE reference_count <= 0", [], |row| row.get::<_, i64>(0), - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let dedup_ratio = if total_size > 0 { @@ -6149,9 +6472,9 @@ impl StorageBackend for SqliteBackend { async fn register_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, token_hash: &str, - ) -> Result { + ) -> Result { let conn = Arc::clone(&self.conn); let device = device.clone(); let token_hash = token_hash.to_string(); @@ -6160,27 +6483,29 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO sync_devices (id, user_id, name, device_type, \ - client_version, os_info, + conn + .execute( + "INSERT INTO sync_devices (id, user_id, name, device_type, \ + client_version, os_info, device_token_hash, last_seen_at, sync_cursor, enabled, \ - created_at, updated_at) + created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - params![ - device.id.0.to_string(), - device.user_id.0.to_string(), - device.name, - device.device_type.to_string(), - device.client_version, - device.os_info, - token_hash, - device.last_seen_at.to_rfc3339(), - device.sync_cursor, - device.enabled, - device.created_at.to_rfc3339(), - device.updated_at.to_rfc3339(), - ], - )?; + params![ + device.id.0.to_string(), + device.user_id.0.to_string(), + device.name, + device.device_type.to_string(), + device.client_version, + device.os_info, + token_hash, + device.last_seen_at.to_rfc3339(), + device.sync_cursor, + device.enabled, + device.created_at.to_rfc3339(), + device.updated_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(device) }) .await @@ -6190,8 +6515,8 @@ impl StorageBackend for SqliteBackend { async fn get_device( &self, - id: crate::sync::DeviceId, - ) -> Result { + id: pinakes_sync::DeviceId, + ) -> Result { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { @@ -6206,11 +6531,11 @@ impl StorageBackend for SqliteBackend { FROM sync_devices WHERE id = ?1", params![id.0.to_string()], |row| { - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), - user_id: crate::users::UserId(parse_uuid( + user_id: pinakes_types::model::UserId(parse_uuid( &row.get::<_, String>(1)?, )?), name: row.get(2)?, @@ -6240,7 +6565,7 @@ impl StorageBackend for SqliteBackend { async fn get_device_by_token( &self, token_hash: &str, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); let token_hash = token_hash.to_string(); @@ -6256,11 +6581,11 @@ impl StorageBackend for SqliteBackend { FROM sync_devices WHERE device_token_hash = ?1", params![&token_hash], |row| { - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), - user_id: crate::users::UserId(parse_uuid( + user_id: pinakes_types::model::UserId(parse_uuid( &row.get::<_, String>(1)?, )?), name: row.get(2)?, @@ -6293,27 +6618,29 @@ impl StorageBackend for SqliteBackend { async fn list_user_devices( &self, user_id: crate::users::UserId, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, user_id, name, device_type, client_version, os_info, + let mut stmt = conn + .prepare( + "SELECT id, user_id, name, device_type, client_version, os_info, last_sync_at, last_seen_at, sync_cursor, enabled, \ - created_at, updated_at + created_at, updated_at FROM sync_devices WHERE user_id = ?1 ORDER BY last_seen_at \ - DESC", - )?; + DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let devices = stmt .query_map(params![user_id.0.to_string()], |row| { - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), - user_id: crate::users::UserId(parse_uuid( + user_id: pinakes_types::model::UserId(parse_uuid( &row.get::<_, String>(1)?, )?), name: row.get(2)?, @@ -6332,8 +6659,10 @@ impl StorageBackend for SqliteBackend { created_at: parse_datetime(&row.get::<_, String>(10)?), updated_at: parse_datetime(&row.get::<_, String>(11)?), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(devices) }) .await @@ -6345,7 +6674,7 @@ impl StorageBackend for SqliteBackend { async fn update_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, ) -> Result<()> { let conn = Arc::clone(&self.conn); let device = device.clone(); @@ -6354,25 +6683,27 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE sync_devices SET name = ?1, device_type = ?2, client_version \ - = ?3, + conn + .execute( + "UPDATE sync_devices SET name = ?1, device_type = ?2, \ + client_version = ?3, os_info = ?4, last_sync_at = ?5, last_seen_at = ?6, \ - sync_cursor = ?7, + sync_cursor = ?7, enabled = ?8, updated_at = ?9 WHERE id = ?10", - params![ - device.name, - device.device_type.to_string(), - device.client_version, - device.os_info, - device.last_sync_at.map(|dt| dt.to_rfc3339()), - device.last_seen_at.to_rfc3339(), - device.sync_cursor, - device.enabled, - device.updated_at.to_rfc3339(), - device.id.0.to_string(), - ], - )?; + params![ + device.name, + device.device_type.to_string(), + device.client_version, + device.os_info, + device.last_sync_at.map(|dt| dt.to_rfc3339()), + device.last_seen_at.to_rfc3339(), + device.sync_cursor, + device.enabled, + device.updated_at.to_rfc3339(), + device.id.0.to_string(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6380,16 +6711,18 @@ impl StorageBackend for SqliteBackend { Ok(()) } - async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()> { + async fn delete_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute("DELETE FROM sync_devices WHERE id = ?1", params![ - id.0.to_string() - ])?; + conn + .execute("DELETE FROM sync_devices WHERE id = ?1", params![ + id.0.to_string() + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6397,7 +6730,7 @@ impl StorageBackend for SqliteBackend { Ok(()) } - async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()> { + async fn touch_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { let conn = Arc::clone(&self.conn); let now = chrono::Utc::now().to_rfc3339(); @@ -6405,11 +6738,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE sync_devices SET last_seen_at = ?1, updated_at = ?1 WHERE id \ - = ?2", - params![&now, id.0.to_string()], - )?; + conn + .execute( + "UPDATE sync_devices SET last_seen_at = ?1, updated_at = ?1 WHERE \ + id = ?2", + params![&now, id.0.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6419,7 +6754,7 @@ impl StorageBackend for SqliteBackend { async fn record_sync_change( &self, - change: &crate::sync::SyncLogEntry, + change: &pinakes_sync::SyncLogEntry, ) -> Result<()> { let conn = Arc::clone(&self.conn); let change = change.clone(); @@ -6430,31 +6765,35 @@ impl StorageBackend for SqliteBackend { })?; // Get and increment sequence - let seq: i64 = conn.query_row( - "UPDATE sync_sequence SET current_value = current_value + 1 WHERE id \ - = 1 RETURNING current_value", - [], - |row| row.get(0), - )?; + let seq: i64 = conn + .query_row( + "UPDATE sync_sequence SET current_value = current_value + 1 WHERE \ + id = 1 RETURNING current_value", + [], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; - conn.execute( - "INSERT INTO sync_log (id, sequence, change_type, media_id, path, \ - content_hash, + conn + .execute( + "INSERT INTO sync_log (id, sequence, change_type, media_id, path, \ + content_hash, file_size, metadata_json, changed_by_device, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - change.id.to_string(), - seq, - change.change_type.to_string(), - change.media_id.map(|m| m.0.to_string()), - change.path, - change.content_hash.as_ref().map(|h| h.0.clone()), - change.file_size.map(u64::cast_signed), - change.metadata_json, - change.changed_by_device.map(|d| d.0.to_string()), - change.timestamp.to_rfc3339(), - ], - )?; + params![ + change.id.to_string(), + seq, + change.change_type.to_string(), + change.media_id.map(|m| m.0.to_string()), + change.path, + change.content_hash.as_ref().map(|h| h.0.clone()), + change.file_size.map(u64::cast_signed), + change.metadata_json, + change.changed_by_device.map(|d| d.0.to_string()), + change.timestamp.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6468,27 +6807,29 @@ impl StorageBackend for SqliteBackend { &self, cursor: i64, limit: u64, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, sequence, change_type, media_id, path, content_hash, + let mut stmt = conn + .prepare( + "SELECT id, sequence, change_type, media_id, path, content_hash, file_size, metadata_json, changed_by_device, timestamp FROM sync_log WHERE sequence > ?1 ORDER BY sequence LIMIT ?2", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let entries = stmt .query_map(params![cursor, limit.cast_signed()], |row| { - Ok(crate::sync::SyncLogEntry { + Ok(pinakes_sync::SyncLogEntry { id: parse_uuid(&row.get::<_, String>(0)?)?, sequence: row.get(1)?, change_type: row .get::<_, String>(2)? .parse() - .unwrap_or(crate::sync::SyncChangeType::Modified), + .unwrap_or(pinakes_sync::SyncChangeType::Modified), media_id: row .get::<_, Option>(3)? .and_then(|s| Uuid::parse_str(&s).ok().map(MediaId)), @@ -6501,12 +6842,14 @@ impl StorageBackend for SqliteBackend { .map(i64::cast_unsigned), metadata_json: row.get(7)?, changed_by_device: row.get::<_, Option>(8)?.and_then(|s| { - Uuid::parse_str(&s).ok().map(crate::sync::DeviceId) + Uuid::parse_str(&s).ok().map(pinakes_sync::DeviceId) }), timestamp: parse_datetime(&row.get::<_, String>(9)?), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(entries) }) .await @@ -6562,9 +6905,9 @@ impl StorageBackend for SqliteBackend { async fn get_device_sync_state( &self, - device_id: crate::sync::DeviceId, + device_id: pinakes_sync::DeviceId, path: &str, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); let path = path.to_string(); @@ -6580,8 +6923,8 @@ impl StorageBackend for SqliteBackend { FROM device_sync_state WHERE device_id = ?1 AND path = ?2", params![device_id.0.to_string(), &path], |row| { - Ok(crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::DeviceSyncState { + device_id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), path: row.get(1)?, @@ -6592,7 +6935,7 @@ impl StorageBackend for SqliteBackend { sync_status: row .get::<_, String>(6)? .parse() - .unwrap_or(crate::sync::FileSyncStatus::Synced), + .unwrap_or(pinakes_sync::FileSyncStatus::Synced), last_synced_at: row .get::<_, Option>(7)? .map(|s| parse_datetime(&s)), @@ -6613,7 +6956,7 @@ impl StorageBackend for SqliteBackend { async fn upsert_device_sync_state( &self, - state: &crate::sync::DeviceSyncState, + state: &pinakes_sync::DeviceSyncState, ) -> Result<()> { let conn = Arc::clone(&self.conn); let state = state.clone(); @@ -6622,11 +6965,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO device_sync_state (device_id, path, local_hash, \ - server_hash, + conn + .execute( + "INSERT INTO device_sync_state (device_id, path, local_hash, \ + server_hash, local_mtime, server_mtime, sync_status, last_synced_at, \ - conflict_info_json) + conflict_info_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(device_id, path) DO UPDATE SET local_hash = excluded.local_hash, @@ -6636,18 +6980,19 @@ impl StorageBackend for SqliteBackend { sync_status = excluded.sync_status, last_synced_at = excluded.last_synced_at, conflict_info_json = excluded.conflict_info_json", - params![ - state.device_id.0.to_string(), - state.path, - state.local_hash, - state.server_hash, - state.local_mtime, - state.server_mtime, - state.sync_status.to_string(), - state.last_synced_at.map(|dt| dt.to_rfc3339()), - state.conflict_info_json, - ], - )?; + params![ + state.device_id.0.to_string(), + state.path, + state.local_hash, + state.server_hash, + state.local_mtime, + state.server_mtime, + state.sync_status.to_string(), + state.last_synced_at.map(|dt| dt.to_rfc3339()), + state.conflict_info_json, + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6659,26 +7004,28 @@ impl StorageBackend for SqliteBackend { async fn list_pending_sync( &self, - device_id: crate::sync::DeviceId, - ) -> Result> { + device_id: pinakes_sync::DeviceId, + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT device_id, path, local_hash, server_hash, local_mtime, \ - server_mtime, + let mut stmt = conn + .prepare( + "SELECT device_id, path, local_hash, server_hash, local_mtime, \ + server_mtime, sync_status, last_synced_at, conflict_info_json FROM device_sync_state WHERE device_id = ?1 AND sync_status IN ('pending_upload', \ - 'pending_download', 'conflict')", - )?; + 'pending_download', 'conflict')", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let states = stmt .query_map(params![device_id.0.to_string()], |row| { - Ok(crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::DeviceSyncState { + device_id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), path: row.get(1)?, @@ -6689,14 +7036,16 @@ impl StorageBackend for SqliteBackend { sync_status: row .get::<_, String>(6)? .parse() - .unwrap_or(crate::sync::FileSyncStatus::Synced), + .unwrap_or(pinakes_sync::FileSyncStatus::Synced), last_synced_at: row .get::<_, Option>(7)? .map(|s| parse_datetime(&s)), conflict_info_json: row.get(8)?, }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(states) }) .await @@ -6708,7 +7057,7 @@ impl StorageBackend for SqliteBackend { async fn create_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()> { let conn = Arc::clone(&self.conn); let session = session.clone(); @@ -6717,26 +7066,28 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO upload_sessions (id, device_id, target_path, \ - expected_hash, + conn + .execute( + "INSERT INTO upload_sessions (id, device_id, target_path, \ + expected_hash, expected_size, chunk_size, chunk_count, status, \ - created_at, expires_at, last_activity) + created_at, expires_at, last_activity) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - params![ - session.id.to_string(), - session.device_id.0.to_string(), - session.target_path, - session.expected_hash.0, - session.expected_size.cast_signed(), - session.chunk_size.cast_signed(), - session.chunk_count.cast_signed(), - session.status.to_string(), - session.created_at.to_rfc3339(), - session.expires_at.to_rfc3339(), - session.last_activity.to_rfc3339(), - ], - )?; + params![ + session.id.to_string(), + session.device_id.0.to_string(), + session.target_path, + session.expected_hash.0, + session.expected_size.cast_signed(), + session.chunk_size.cast_signed(), + session.chunk_count.cast_signed(), + session.status.to_string(), + session.created_at.to_rfc3339(), + session.expires_at.to_rfc3339(), + session.last_activity.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6749,7 +7100,7 @@ impl StorageBackend for SqliteBackend { async fn get_upload_session( &self, id: Uuid, - ) -> Result { + ) -> Result { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { @@ -6765,9 +7116,9 @@ impl StorageBackend for SqliteBackend { FROM upload_sessions WHERE id = ?1", params![id.to_string()], |row| { - Ok(crate::sync::UploadSession { + Ok(pinakes_sync::UploadSession { id: parse_uuid(&row.get::<_, String>(0)?)?, - device_id: crate::sync::DeviceId(parse_uuid( + device_id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(1)?, )?), target_path: row.get(2)?, @@ -6778,7 +7129,7 @@ impl StorageBackend for SqliteBackend { status: row .get::<_, String>(7)? .parse() - .unwrap_or(crate::sync::UploadStatus::Pending), + .unwrap_or(pinakes_sync::UploadStatus::Pending), created_at: parse_datetime(&row.get::<_, String>(8)?), expires_at: parse_datetime(&row.get::<_, String>(9)?), last_activity: parse_datetime(&row.get::<_, String>(10)?), @@ -6795,7 +7146,7 @@ impl StorageBackend for SqliteBackend { async fn update_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()> { let conn = Arc::clone(&self.conn); let session = session.clone(); @@ -6804,15 +7155,17 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE upload_sessions SET status = ?1, last_activity = ?2 WHERE id \ - = ?3", - params![ - session.status.to_string(), - session.last_activity.to_rfc3339(), - session.id.to_string(), - ], - )?; + conn + .execute( + "UPDATE upload_sessions SET status = ?1, last_activity = ?2 WHERE \ + id = ?3", + params![ + session.status.to_string(), + session.last_activity.to_rfc3339(), + session.id.to_string(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6825,7 +7178,7 @@ impl StorageBackend for SqliteBackend { async fn record_chunk( &self, upload_id: Uuid, - chunk: &crate::sync::ChunkInfo, + chunk: &pinakes_sync::ChunkInfo, ) -> Result<()> { let conn = Arc::clone(&self.conn); let chunk = chunk.clone(); @@ -6834,22 +7187,24 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO upload_chunks (upload_id, chunk_index, offset, size, \ - hash, received_at) + conn + .execute( + "INSERT INTO upload_chunks (upload_id, chunk_index, offset, size, \ + hash, received_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(upload_id, chunk_index) DO UPDATE SET offset = excluded.offset, size = excluded.size, hash = excluded.hash, received_at = excluded.received_at", - params![ - upload_id.to_string(), - chunk.chunk_index.cast_signed(), - chunk.offset.cast_signed(), - chunk.size.cast_signed(), - chunk.hash, - chunk.received_at.to_rfc3339(), - ], - )?; + params![ + upload_id.to_string(), + chunk.chunk_index.cast_signed(), + chunk.offset.cast_signed(), + chunk.size.cast_signed(), + chunk.hash, + chunk.received_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6860,20 +7215,22 @@ impl StorageBackend for SqliteBackend { async fn get_upload_chunks( &self, upload_id: Uuid, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT upload_id, chunk_index, offset, size, hash, received_at + let mut stmt = conn + .prepare( + "SELECT upload_id, chunk_index, offset, size, hash, received_at FROM upload_chunks WHERE upload_id = ?1 ORDER BY chunk_index", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let chunks = stmt .query_map(params![upload_id.to_string()], |row| { - Ok(crate::sync::ChunkInfo { + Ok(pinakes_sync::ChunkInfo { upload_id: parse_uuid(&row.get::<_, String>(0)?)?, chunk_index: row.get::<_, i64>(1)?.cast_unsigned(), offset: row.get::<_, i64>(2)?.cast_unsigned(), @@ -6881,8 +7238,10 @@ impl StorageBackend for SqliteBackend { hash: row.get(4)?, received_at: parse_datetime(&row.get::<_, String>(5)?), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(chunks) }) .await @@ -6918,7 +7277,7 @@ impl StorageBackend for SqliteBackend { async fn record_conflict( &self, - conflict: &crate::sync::SyncConflict, + conflict: &pinakes_sync::SyncConflict, ) -> Result<()> { let conn = Arc::clone(&self.conn); let conflict = conflict.clone(); @@ -6927,22 +7286,24 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO sync_conflicts (id, device_id, path, local_hash, \ - local_mtime, + conn + .execute( + "INSERT INTO sync_conflicts (id, device_id, path, local_hash, \ + local_mtime, server_hash, server_mtime, detected_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - conflict.id.to_string(), - conflict.device_id.0.to_string(), - conflict.path, - conflict.local_hash, - conflict.local_mtime, - conflict.server_hash, - conflict.server_mtime, - conflict.detected_at.to_rfc3339(), - ], - )?; + params![ + conflict.id.to_string(), + conflict.device_id.0.to_string(), + conflict.path, + conflict.local_hash, + conflict.local_mtime, + conflict.server_hash, + conflict.server_mtime, + conflict.detected_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6952,26 +7313,28 @@ impl StorageBackend for SqliteBackend { async fn get_unresolved_conflicts( &self, - device_id: crate::sync::DeviceId, - ) -> Result> { + device_id: pinakes_sync::DeviceId, + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, device_id, path, local_hash, local_mtime, server_hash, \ - server_mtime, + let mut stmt = conn + .prepare( + "SELECT id, device_id, path, local_hash, local_mtime, server_hash, \ + server_mtime, detected_at, resolved_at, resolution FROM sync_conflicts WHERE device_id = ?1 AND resolved_at IS \ - NULL", - )?; + NULL", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let conflicts = stmt .query_map(params![device_id.0.to_string()], |row| { - Ok(crate::sync::SyncConflict { + Ok(pinakes_sync::SyncConflict { id: parse_uuid(&row.get::<_, String>(0)?)?, - device_id: crate::sync::DeviceId(parse_uuid( + device_id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(1)?, )?), path: row.get(2)?, @@ -6986,21 +7349,25 @@ impl StorageBackend for SqliteBackend { resolution: row.get::<_, Option>(9)?.and_then(|s| { match s.as_str() { "server_wins" => { - Some(crate::config::ConflictResolution::ServerWins) + Some(pinakes_types::config::ConflictResolution::ServerWins) }, "client_wins" => { - Some(crate::config::ConflictResolution::ClientWins) + Some(pinakes_types::config::ConflictResolution::ClientWins) }, "keep_both" => { - Some(crate::config::ConflictResolution::KeepBoth) + Some(pinakes_types::config::ConflictResolution::KeepBoth) + }, + "manual" => { + Some(pinakes_types::config::ConflictResolution::Manual) }, - "manual" => Some(crate::config::ConflictResolution::Manual), _ => None, } }), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(conflicts) }) .await @@ -7015,26 +7382,28 @@ impl StorageBackend for SqliteBackend { async fn resolve_conflict( &self, id: Uuid, - resolution: crate::config::ConflictResolution, + resolution: pinakes_types::config::ConflictResolution, ) -> Result<()> { let conn = Arc::clone(&self.conn); let now = chrono::Utc::now().to_rfc3339(); let resolution_str = match resolution { - crate::config::ConflictResolution::ServerWins => "server_wins", - crate::config::ConflictResolution::ClientWins => "client_wins", - crate::config::ConflictResolution::KeepBoth => "keep_both", - crate::config::ConflictResolution::Manual => "manual", + pinakes_types::config::ConflictResolution::ServerWins => "server_wins", + pinakes_types::config::ConflictResolution::ClientWins => "client_wins", + pinakes_types::config::ConflictResolution::KeepBoth => "keep_both", + pinakes_types::config::ConflictResolution::Manual => "manual", }; tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE sync_conflicts SET resolved_at = ?1, resolution = ?2 WHERE id \ - = ?3", - params![&now, resolution_str, id.to_string()], - )?; + conn + .execute( + "UPDATE sync_conflicts SET resolved_at = ?1, resolution = ?2 WHERE \ + id = ?3", + params![&now, resolution_str, id.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7078,41 +7447,43 @@ impl StorageBackend for SqliteBackend { }, }; - conn.execute( - "INSERT INTO shares (id, target_type, target_id, owner_id, \ - recipient_type, + conn + .execute( + "INSERT INTO shares (id, target_type, target_id, owner_id, \ + recipient_type, recipient_user_id, public_token, public_password_hash, perm_view, perm_download, perm_edit, perm_delete, \ - perm_reshare, perm_add, + perm_reshare, perm_add, note, expires_at, access_count, inherit_to_children, \ - parent_share_id, + parent_share_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, \ - ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", - params![ - share.id.0.to_string(), - share.target.target_type(), - share.target.target_id().to_string(), - share.owner_id.0.to_string(), - recipient_type, - recipient_user_id, - public_token, - password_hash, - share.permissions.view.can_view, - share.permissions.view.can_download, - share.permissions.mutate.can_edit, - share.permissions.mutate.can_delete, - share.permissions.view.can_reshare, - share.permissions.mutate.can_add, - share.note, - share.expires_at.map(|dt| dt.to_rfc3339()), - share.access_count.cast_signed(), - share.inherit_to_children, - share.parent_share_id.map(|s| s.0.to_string()), - share.created_at.to_rfc3339(), - share.updated_at.to_rfc3339(), - ], - )?; + ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", + params![ + share.id.0.to_string(), + share.target.target_type(), + share.target.target_id().to_string(), + share.owner_id.0.to_string(), + recipient_type, + recipient_user_id, + public_token, + password_hash, + share.permissions.view.can_view, + share.permissions.view.can_download, + share.permissions.mutate.can_edit, + share.permissions.mutate.can_delete, + share.permissions.view.can_reshare, + share.permissions.mutate.can_add, + share.note, + share.expires_at.map(|dt| dt.to_rfc3339()), + share.access_count.cast_signed(), + share.inherit_to_children, + share.parent_share_id.map(|s| s.0.to_string()), + share.created_at.to_rfc3339(), + share.updated_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(share) }) .await @@ -7196,18 +7567,20 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, target_type, target_id, owner_id, recipient_type, \ - recipient_user_id, + let mut stmt = conn + .prepare( + "SELECT id, target_type, target_id, owner_id, recipient_type, \ + recipient_user_id, public_token, public_password_hash, perm_view, \ - perm_download, perm_edit, + perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, \ - access_count, + access_count, last_accessed, inherit_to_children, parent_share_id, \ - created_at, updated_at + created_at, updated_at FROM shares WHERE owner_id = ?1 ORDER BY created_at DESC \ - LIMIT ?2 OFFSET ?3", - )?; + LIMIT ?2 OFFSET ?3", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let shares = stmt .query_map( params![ @@ -7216,8 +7589,10 @@ impl StorageBackend for SqliteBackend { offset.cast_signed() ], row_to_share, - )? - .collect::>>()?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(shares) }) .await @@ -7240,18 +7615,20 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, target_type, target_id, owner_id, recipient_type, \ - recipient_user_id, + let mut stmt = conn + .prepare( + "SELECT id, target_type, target_id, owner_id, recipient_type, \ + recipient_user_id, public_token, public_password_hash, perm_view, \ - perm_download, perm_edit, + perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, \ - access_count, + access_count, last_accessed, inherit_to_children, parent_share_id, \ - created_at, updated_at + created_at, updated_at FROM shares WHERE recipient_user_id = ?1 ORDER BY created_at \ - DESC LIMIT ?2 OFFSET ?3", - )?; + DESC LIMIT ?2 OFFSET ?3", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let shares = stmt .query_map( params![ @@ -7260,8 +7637,10 @@ impl StorageBackend for SqliteBackend { offset.cast_signed() ], row_to_share, - )? - .collect::>>()?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(shares) }) .await @@ -7283,20 +7662,24 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, target_type, target_id, owner_id, recipient_type, \ - recipient_user_id, + let mut stmt = conn + .prepare( + "SELECT id, target_type, target_id, owner_id, recipient_type, \ + recipient_user_id, public_token, public_password_hash, perm_view, \ - perm_download, perm_edit, + perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, \ - access_count, + access_count, last_accessed, inherit_to_children, parent_share_id, \ - created_at, updated_at + created_at, updated_at FROM shares WHERE target_type = ?1 AND target_id = ?2", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let shares = stmt - .query_map(params![&target_type, &target_id], row_to_share)? - .collect::>>()?; + .query_map(params![&target_type, &target_id], row_to_share) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(shares) }) .await @@ -7319,28 +7702,30 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE shares SET + conn + .execute( + "UPDATE shares SET perm_view = ?1, perm_download = ?2, perm_edit = ?3, \ - perm_delete = ?4, + perm_delete = ?4, perm_reshare = ?5, perm_add = ?6, note = ?7, expires_at = \ - ?8, + ?8, inherit_to_children = ?9, updated_at = ?10 WHERE id = ?11", - params![ - share.permissions.view.can_view, - share.permissions.view.can_download, - share.permissions.mutate.can_edit, - share.permissions.mutate.can_delete, - share.permissions.view.can_reshare, - share.permissions.mutate.can_add, - share.note, - share.expires_at.map(|dt| dt.to_rfc3339()), - share.inherit_to_children, - share.updated_at.to_rfc3339(), - share.id.0.to_string(), - ], - )?; + params![ + share.permissions.view.can_view, + share.permissions.view.can_download, + share.permissions.mutate.can_edit, + share.permissions.mutate.can_delete, + share.permissions.view.can_reshare, + share.permissions.mutate.can_add, + share.note, + share.expires_at.map(|dt| dt.to_rfc3339()), + share.inherit_to_children, + share.updated_at.to_rfc3339(), + share.id.0.to_string(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(share) }) .await @@ -7355,9 +7740,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute("DELETE FROM shares WHERE id = ?1", params![ - id.0.to_string() - ])?; + conn + .execute("DELETE FROM shares WHERE id = ?1", params![ + id.0.to_string() + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7376,11 +7763,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE shares SET access_count = access_count + 1, last_accessed = \ - ?1 WHERE id = ?2", - params![&now, id.0.to_string()], - )?; + conn + .execute( + "UPDATE shares SET access_count = access_count + 1, last_accessed = \ + ?1 WHERE id = ?2", + params![&now, id.0.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7446,14 +7835,17 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT collection_id FROM collection_items WHERE media_id = ?1", - )?; + let mut stmt = conn + .prepare( + "SELECT collection_id FROM collection_members WHERE media_id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; Ok(Uuid::parse_str(&id_str).ok()) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(|r| r.ok().flatten()) .collect::>(); Ok::<_, PinakesError>(ids) @@ -7485,13 +7877,15 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = - conn.prepare("SELECT tag_id FROM media_tags WHERE media_id = ?1")?; + let mut stmt = conn + .prepare("SELECT tag_id FROM media_tags WHERE media_id = ?1") + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; Ok(Uuid::parse_str(&id_str).ok()) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(|r| r.ok().flatten()) .collect::>(); Ok::<_, PinakesError>(ids) @@ -7587,20 +7981,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, \ - action, details, timestamp) + conn + .execute( + "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, \ + action, details, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - activity.id.to_string(), - activity.share_id.0.to_string(), - activity.actor_id.map(|u| u.0.to_string()), - activity.actor_ip, - activity.action.to_string(), - activity.details, - activity.timestamp.to_rfc3339(), - ], - )?; + params![ + activity.id.to_string(), + activity.share_id.0.to_string(), + activity.actor_id.map(|u| u.0.to_string()), + activity.actor_ip, + activity.action.to_string(), + activity.details, + activity.timestamp.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7623,11 +8019,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, share_id, actor_id, actor_ip, action, details, timestamp + let mut stmt = conn + .prepare( + "SELECT id, share_id, actor_id, actor_ip, action, details, timestamp FROM share_activity WHERE share_id = ?1 ORDER BY timestamp \ - DESC LIMIT ?2 OFFSET ?3", - )?; + DESC LIMIT ?2 OFFSET ?3", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let activities = stmt .query_map( params![ @@ -7653,8 +8051,10 @@ impl StorageBackend for SqliteBackend { timestamp: parse_datetime(&row.get::<_, String>(6)?), }) }, - )? - .collect::>>()?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(activities) }) .await @@ -7675,19 +8075,21 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO share_notifications (id, user_id, share_id, \ - notification_type, is_read, created_at) + conn + .execute( + "INSERT INTO share_notifications (id, user_id, share_id, \ + notification_type, is_read, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - notification.id.to_string(), - notification.user_id.0.to_string(), - notification.share_id.0.to_string(), - notification.notification_type.to_string(), - notification.is_read, - notification.created_at.to_rfc3339(), - ], - )?; + params![ + notification.id.to_string(), + notification.user_id.0.to_string(), + notification.share_id.0.to_string(), + notification.notification_type.to_string(), + notification.is_read, + notification.created_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7707,11 +8109,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, user_id, share_id, notification_type, is_read, created_at + let mut stmt = conn + .prepare( + "SELECT id, user_id, share_id, notification_type, is_read, \ + created_at FROM share_notifications WHERE user_id = ?1 AND is_read = 0 \ - ORDER BY created_at DESC", - )?; + ORDER BY created_at DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let notifications = stmt .query_map(params![user_id.0.to_string()], |row| { Ok(crate::sharing::ShareNotification { @@ -7729,8 +8134,10 @@ impl StorageBackend for SqliteBackend { is_read: row.get(4)?, created_at: parse_datetime(&row.get::<_, String>(5)?), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(notifications) }) .await @@ -7753,11 +8160,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE share_notifications SET is_read = 1 WHERE id = ?1 AND user_id \ - = ?2", - params![id.to_string(), user_id.0.to_string()], - )?; + conn + .execute( + "UPDATE share_notifications SET is_read = 1 WHERE id = ?1 AND \ + user_id = ?2", + params![id.to_string(), user_id.0.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7777,10 +8186,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE share_notifications SET is_read = 1 WHERE user_id = ?1", - params![user_id.0.to_string()], - )?; + conn + .execute( + "UPDATE share_notifications SET is_read = 1 WHERE user_id = ?1", + params![user_id.0.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7811,12 +8222,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let row: (String, String) = conn.query_row( - "SELECT path, storage_mode FROM media_items WHERE id = ?1 AND \ - deleted_at IS NULL", - params![id_str], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; + let row: (String, String) = conn + .query_row( + "SELECT path, storage_mode FROM media_items WHERE id = ?1 AND \ + deleted_at IS NULL", + params![id_str], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(row) } }) @@ -7848,11 +8261,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE media_items SET file_name = ?1, path = ?2, updated_at = ?3 \ - WHERE id = ?4", - params![new_name, new_path_str, now, id_str], - )?; + conn + .execute( + "UPDATE media_items SET file_name = ?1, path = ?2, updated_at = ?3 \ + WHERE id = ?4", + params![new_name, new_path_str, now, id_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7879,12 +8294,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let row: (String, String, String) = conn.query_row( - "SELECT path, file_name, storage_mode FROM media_items WHERE id = \ - ?1 AND deleted_at IS NULL", - params![id_str], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - )?; + let row: (String, String, String) = conn + .query_row( + "SELECT path, file_name, storage_mode FROM media_items WHERE id = \ + ?1 AND deleted_at IS NULL", + params![id_str], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(row) } }) @@ -7920,10 +8337,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE media_items SET path = ?1, updated_at = ?2 WHERE id = ?3", - params![new_path_str, now, id_str], - )?; + conn + .execute( + "UPDATE media_items SET path = ?1, updated_at = ?2 WHERE id = ?3", + params![new_path_str, now, id_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -8008,28 +8427,32 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, + let mut stmt = conn + .prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, \ - description, + description, thumbnail_path, created_at, updated_at, file_mtime, date_taken, latitude, longitude, camera_make, \ - camera_model, rating, + camera_model, rating, storage_mode, original_filename, uploaded_at, \ - storage_key, + storage_key, perceptual_hash, deleted_at FROM media_items WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT ?1 OFFSET ?2", - )?; - let rows = stmt.query_map( - params![limit.cast_signed(), offset.cast_signed()], - row_to_media_item, - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt + .query_map( + params![limit.cast_signed(), offset.cast_signed()], + row_to_media_item, + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::new(); for row in rows { - items.push(row?); + items.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } Ok::<_, PinakesError>(items) }) @@ -8049,29 +8472,34 @@ impl StorageBackend for SqliteBackend { // First, get the IDs to clean up related data let mut stmt = conn - .prepare("SELECT id FROM media_items WHERE deleted_at IS NOT NULL")?; + .prepare("SELECT id FROM media_items WHERE deleted_at IS NOT NULL") + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids: Vec = stmt - .query_map([], |row| row.get(0))? + .query_map([], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Delete related data for id in &ids { conn - .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; - conn.execute( - "DELETE FROM collection_members WHERE media_id = ?1", - params![id], - )?; + .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id]) + .map_err(|e| PinakesError::Database(e.to_string()))?; conn - .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![ - id - ])?; + .execute( + "DELETE FROM collection_members WHERE media_id = ?1", + params![id], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + conn + .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![id]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Delete the media items let count = conn - .execute("DELETE FROM media_items WHERE deleted_at IS NOT NULL", [])?; + .execute("DELETE FROM media_items WHERE deleted_at IS NOT NULL", []) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(count as u64) }) .await @@ -8093,35 +8521,42 @@ impl StorageBackend for SqliteBackend { })?; // First, get the IDs to clean up related data - let mut stmt = conn.prepare( - "SELECT id FROM media_items WHERE deleted_at IS NOT NULL AND \ - deleted_at < ?1", - )?; + let mut stmt = conn + .prepare( + "SELECT id FROM media_items WHERE deleted_at IS NOT NULL AND \ + deleted_at < ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids: Vec = stmt - .query_map(params![before_str], |row| row.get(0))? + .query_map(params![before_str], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Delete related data for id in &ids { conn - .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; - conn.execute( - "DELETE FROM collection_members WHERE media_id = ?1", - params![id], - )?; + .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id]) + .map_err(|e| PinakesError::Database(e.to_string()))?; conn - .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![ - id - ])?; + .execute( + "DELETE FROM collection_members WHERE media_id = ?1", + params![id], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + conn + .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![id]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Delete the media items - let count = conn.execute( - "DELETE FROM media_items WHERE deleted_at IS NOT NULL AND deleted_at \ - < ?1", - params![before_str], - )?; + let count = conn + .execute( + "DELETE FROM media_items WHERE deleted_at IS NOT NULL AND \ + deleted_at < ?1", + params![before_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(count as u64) }) .await @@ -8137,11 +8572,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", - [], - |row| row.get(0), - )?; + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", + [], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(count.cast_unsigned()) }) .await @@ -8165,38 +8602,46 @@ impl StorageBackend for SqliteBackend { })?; // Wrap DELETE + INSERT in transaction to ensure atomicity - let tx = conn.transaction()?; + let tx = conn + .transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete existing links for this source tx.execute("DELETE FROM markdown_links WHERE source_media_id = ?1", [ &media_id_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Insert new links - let mut stmt = tx.prepare( - "INSERT INTO markdown_links ( + let mut stmt = tx + .prepare( + "INSERT INTO markdown_links ( id, source_media_id, target_path, target_media_id, link_type, link_text, line_number, context, created_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; for link in &links { - stmt.execute(params![ - link.id.to_string(), - media_id_str, - link.target_path, - link.target_media_id.map(|id| id.0.to_string()), - link.link_type.to_string(), - link.link_text, - link.line_number, - link.context, - link.created_at.to_rfc3339(), - ])?; + stmt + .execute(params![ + link.id.to_string(), + media_id_str, + link.target_path, + link.target_media_id.map(|id| id.0.to_string()), + link.link_type.to_string(), + link.link_text, + link.line_number, + link.context, + link.created_at.to_rfc3339(), + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Commit transaction - if this fails, all changes are rolled back drop(stmt); - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) @@ -8219,19 +8664,23 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, source_media_id, target_path, target_media_id, + let mut stmt = conn + .prepare( + "SELECT id, source_media_id, target_path, target_media_id, link_type, link_text, line_number, context, created_at FROM markdown_links WHERE source_media_id = ?1 ORDER BY line_number", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; - let rows = stmt.query_map([&media_id_str], row_to_markdown_link)?; + let rows = stmt + .query_map([&media_id_str], row_to_markdown_link) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut links = Vec::new(); for row in rows { - links.push(row?); + links.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } Ok::<_, PinakesError>(links) }) @@ -8254,42 +8703,46 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT l.id, l.source_media_id, m.title, m.path, + let mut stmt = conn + .prepare( + "SELECT l.id, l.source_media_id, m.title, m.path, l.link_text, l.line_number, l.context, l.link_type FROM markdown_links l JOIN media_items m ON l.source_media_id = m.id WHERE l.target_media_id = ?1 ORDER BY m.title, l.line_number", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; - let rows = stmt.query_map([&media_id_str], |row| { - let link_id_str: String = row.get(0)?; - let source_id_str: String = row.get(1)?; - let source_title: Option = row.get(2)?; - let source_path: String = row.get(3)?; - let link_text: Option = row.get(4)?; - let line_number: Option = row.get(5)?; - let context: Option = row.get(6)?; - let link_type_str: String = row.get(7)?; + let rows = stmt + .query_map([&media_id_str], |row| { + let link_id_str: String = row.get(0)?; + let source_id_str: String = row.get(1)?; + let source_title: Option = row.get(2)?; + let source_path: String = row.get(3)?; + let link_text: Option = row.get(4)?; + let line_number: Option = row.get(5)?; + let context: Option = row.get(6)?; + let link_type_str: String = row.get(7)?; - Ok(crate::model::BacklinkInfo { - link_id: parse_uuid(&link_id_str)?, - source_id: MediaId(parse_uuid(&source_id_str)?), - source_title, - source_path, - link_text, - line_number, - context, - link_type: link_type_str - .parse() - .unwrap_or(crate::model::LinkType::Wikilink), + Ok(crate::model::BacklinkInfo { + link_id: parse_uuid(&link_id_str)?, + source_id: MediaId(parse_uuid(&source_id_str)?), + source_title, + source_path, + link_text, + line_number, + context, + link_type: link_type_str + .parse() + .unwrap_or(crate::model::LinkType::Wikilink), + }) }) - })?; + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut backlinks = Vec::new(); for row in rows { - backlinks.push(row?); + backlinks.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } Ok::<_, PinakesError>(backlinks) }) @@ -8310,7 +8763,8 @@ impl StorageBackend for SqliteBackend { conn .execute("DELETE FROM markdown_links WHERE source_media_id = ?1", [ &media_id_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -8351,13 +8805,13 @@ impl StorageBackend for SqliteBackend { let mut stmt = conn.prepare( "SELECT target_media_id FROM markdown_links WHERE source_media_id = ?1 AND target_media_id IS NOT NULL", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt.query_map([node_id], |row| { let id: String = row.get(0)?; Ok(id) - })?; + }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - let id = row?; + let id = row.map_err(|e| PinakesError::Database(e.to_string()))?; if !visited.contains(&id) { visited.insert(id.clone()); next_frontier.push(id); @@ -8368,13 +8822,13 @@ impl StorageBackend for SqliteBackend { let mut stmt = conn.prepare( "SELECT source_media_id FROM markdown_links WHERE target_media_id = ?1", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt.query_map([node_id], |row| { let id: String = row.get(0)?; Ok(id) - })?; + }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - let id = row?; + let id = row.map_err(|e| PinakesError::Database(e.to_string()))?; if !visited.contains(&id) { visited.insert(id.clone()); next_frontier.push(id); @@ -8392,13 +8846,14 @@ impl StorageBackend for SqliteBackend { "SELECT DISTINCT id FROM media_items WHERE media_type = 'markdown' AND deleted_at IS NULL LIMIT 500", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt.query_map([], |row| { let id: String = row.get(0)?; Ok(id) - })?; + }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - node_ids.insert(row?); + node_ids.insert(row.map_err(|e| PinakesError::Database(e.to_string()))?); + } } @@ -8407,7 +8862,7 @@ impl StorageBackend for SqliteBackend { let mut stmt = conn.prepare( "SELECT id, COALESCE(title, file_name) as label, title, media_type FROM media_items WHERE id = ?1", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; if let Ok((id, label, title, media_type)) = stmt.query_row([node_id], |row| { Ok(( row.get::<_, String>(0)?, @@ -8421,14 +8876,14 @@ impl StorageBackend for SqliteBackend { "SELECT COUNT(*) FROM markdown_links WHERE source_media_id = ?1", [&id], |row| row.get(0), - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; // Count incoming links let backlink_count: i64 = conn.query_row( "SELECT COUNT(*) FROM markdown_links WHERE target_media_id = ?1", [&id], |row| row.get(0), - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; nodes.push(crate::model::GraphNode { id: id.clone(), @@ -8447,15 +8902,15 @@ impl StorageBackend for SqliteBackend { "SELECT source_media_id, target_media_id, link_type FROM markdown_links WHERE source_media_id = ?1 AND target_media_id IS NOT NULL", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt.query_map([node_id], |row| { let source: String = row.get(0)?; let target: String = row.get(1)?; let link_type_str: String = row.get(2)?; Ok((source, target, link_type_str)) - })?; + }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - let (source, target, link_type_str) = row?; + let (source, target, link_type_str) = row.map_err(|e| PinakesError::Database(e.to_string()))?; if node_ids.contains(&target) { edges.push(crate::model::GraphEdge { source, @@ -8486,8 +8941,9 @@ impl StorageBackend for SqliteBackend { // Find unresolved links and try to resolve them // Strategy 1: Exact path match - let updated1 = conn.execute( - "UPDATE markdown_links + let updated1 = conn + .execute( + "UPDATE markdown_links SET target_media_id = ( SELECT id FROM media_items WHERE path = markdown_links.target_path @@ -8500,19 +8956,21 @@ impl StorageBackend for SqliteBackend { WHERE path = markdown_links.target_path AND deleted_at IS NULL )", - [], - )?; + [], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Strategy 2: Filename match (Obsidian-style) // Match target_path to file_name (with or without .md extension) - let updated2 = conn.execute( - "UPDATE markdown_links + let updated2 = conn + .execute( + "UPDATE markdown_links SET target_media_id = ( SELECT id FROM media_items WHERE (file_name = markdown_links.target_path OR file_name = markdown_links.target_path || '.md' OR REPLACE(file_name, '.md', '') = \ - markdown_links.target_path) + markdown_links.target_path) AND deleted_at IS NULL LIMIT 1 ) @@ -8522,11 +8980,12 @@ impl StorageBackend for SqliteBackend { WHERE (file_name = markdown_links.target_path OR file_name = markdown_links.target_path || '.md' OR REPLACE(file_name, '.md', '') = \ - markdown_links.target_path) + markdown_links.target_path) AND deleted_at IS NULL )", - [], - )?; + [], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>((updated1 + updated2) as u64) }) @@ -8545,10 +9004,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE media_items SET links_extracted_at = ?1 WHERE id = ?2", - params![now, media_id_str], - )?; + conn + .execute( + "UPDATE media_items SET links_extracted_at = ?1 WHERE id = ?2", + params![now, media_id_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -8566,11 +9027,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", - [], - |row| row.get(0), - )?; + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", + [], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(count.cast_unsigned()) }) .await @@ -8589,7 +9052,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("VACUUM INTO ?1", params![dest.to_string_lossy()])?; + db.execute("VACUUM INTO ?1", params![dest.to_string_lossy()]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_mins(5), fut) diff --git a/crates/pinakes-core/src/sync/chunked.rs b/crates/pinakes-core/src/sync/chunked.rs deleted file mode 100644 index e3e29a2..0000000 --- a/crates/pinakes-core/src/sync/chunked.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Chunked upload handling for large file sync. - -use std::path::{Path, PathBuf}; - -use chrono::Utc; -use tokio::{ - fs, - io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, -}; -use tracing::{debug, info}; -use uuid::Uuid; - -use super::{ChunkInfo, UploadSession}; -use crate::error::{PinakesError, Result}; - -/// Manager for chunked uploads. -#[derive(Debug, Clone)] -pub struct ChunkedUploadManager { - temp_dir: PathBuf, -} - -impl ChunkedUploadManager { - /// Create a new chunked upload manager. - #[must_use] - pub const fn new(temp_dir: PathBuf) -> Self { - Self { temp_dir } - } - - /// Initialize the temp directory. - /// - /// # Errors - /// - /// Returns an error if the directory cannot be created. - pub async fn init(&self) -> Result<()> { - fs::create_dir_all(&self.temp_dir).await?; - Ok(()) - } - - /// Get the temp file path for an upload session. - #[must_use] - pub fn temp_path(&self, session_id: Uuid) -> PathBuf { - self.temp_dir.join(format!("{session_id}.upload")) - } - - /// Create the temp file for a new upload session. - /// - /// # Errors - /// - /// Returns an error if the file cannot be created or sized. - pub async fn create_temp_file(&self, session: &UploadSession) -> Result<()> { - let path = self.temp_path(session.id); - - // Create a sparse file of the expected size - let file = fs::File::create(&path).await?; - file.set_len(session.expected_size).await?; - - debug!( - session_id = %session.id, - size = session.expected_size, - "created temp file for upload" - ); - - Ok(()) - } - - /// Write a chunk to the temp file. - /// - /// # Errors - /// - /// Returns an error if the session file is not found, the chunk index is out - /// of range, the chunk size is wrong, or the write fails. - pub async fn write_chunk( - &self, - session: &UploadSession, - chunk_index: u64, - data: &[u8], - ) -> Result { - let path = self.temp_path(session.id); - - if !path.exists() { - return Err(PinakesError::UploadSessionNotFound(session.id.to_string())); - } - - // Calculate offset - let offset = chunk_index * session.chunk_size; - - // Validate chunk - if offset >= session.expected_size { - return Err(PinakesError::ChunkOutOfOrder { - expected: session.chunk_count - 1, - actual: chunk_index, - }); - } - - // Calculate expected chunk size - let expected_size = if chunk_index == session.chunk_count - 1 { - // Last chunk may be smaller - session.expected_size - offset - } else { - session.chunk_size - }; - - if data.len() as u64 != expected_size { - return Err(PinakesError::InvalidData(format!( - "chunk {} has wrong size: expected {}, got {}", - chunk_index, - expected_size, - data.len() - ))); - } - - // Write chunk to file at offset - let mut file = fs::OpenOptions::new().write(true).open(&path).await?; - - file.seek(std::io::SeekFrom::Start(offset)).await?; - file.write_all(data).await?; - file.flush().await?; - - // Compute chunk hash - let hash = blake3::hash(data).to_hex().to_string(); - - debug!( - session_id = %session.id, - chunk_index, - offset, - size = data.len(), - "wrote chunk" - ); - - Ok(ChunkInfo { - upload_id: session.id, - chunk_index, - offset, - size: data.len() as u64, - hash, - received_at: Utc::now(), - }) - } - - /// Verify and finalize the upload. - /// - /// Checks that: - /// 1. All chunks are received - /// 2. File size matches expected - /// 3. Content hash matches expected - /// - /// # Errors - /// - /// Returns an error if chunks are missing, the file size does not match, the - /// hash does not match, or the file metadata cannot be read. - pub async fn finalize( - &self, - session: &UploadSession, - received_chunks: &[ChunkInfo], - ) -> Result { - let path = self.temp_path(session.id); - - // Check all chunks received - if received_chunks.len() as u64 != session.chunk_count { - return Err(PinakesError::InvalidData(format!( - "missing chunks: expected {}, got {}", - session.chunk_count, - received_chunks.len() - ))); - } - - // Verify chunk indices - let mut indices: Vec = - received_chunks.iter().map(|c| c.chunk_index).collect(); - indices.sort_unstable(); - for (i, idx) in indices.iter().enumerate() { - if *idx != i as u64 { - return Err(PinakesError::InvalidData(format!( - "chunk {i} missing or out of order" - ))); - } - } - - // Verify file size - let metadata = fs::metadata(&path).await?; - if metadata.len() != session.expected_size { - return Err(PinakesError::InvalidData(format!( - "file size mismatch: expected {}, got {}", - session.expected_size, - metadata.len() - ))); - } - - // Verify content hash - let computed_hash = compute_file_hash(&path).await?; - if computed_hash != session.expected_hash.0 { - return Err(PinakesError::StorageIntegrity(format!( - "hash mismatch: expected {}, computed {}", - session.expected_hash, computed_hash - ))); - } - - info!( - session_id = %session.id, - hash = %session.expected_hash, - size = session.expected_size, - "finalized chunked upload" - ); - - Ok(path) - } - - /// Cancel an upload and clean up temp file. - /// - /// # Errors - /// - /// Returns an error if the temp file cannot be removed. - pub async fn cancel(&self, session_id: Uuid) -> Result<()> { - let path = self.temp_path(session_id); - if path.exists() { - fs::remove_file(&path).await?; - debug!(session_id = %session_id, "cancelled upload, removed temp file"); - } - Ok(()) - } - - /// Clean up expired temp files. - /// - /// # Errors - /// - /// Returns an error if the temp directory cannot be read. - pub async fn cleanup_expired(&self, max_age_hours: u64) -> Result { - let mut count = 0u64; - let max_age = std::time::Duration::from_secs(max_age_hours * 3600); - - let mut entries = fs::read_dir(&self.temp_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|e| e == "upload") - && let Ok(metadata) = fs::metadata(&path).await - && let Ok(modified) = metadata.modified() - { - let age = std::time::SystemTime::now() - .duration_since(modified) - .unwrap_or_default(); - if age > max_age { - let _ = fs::remove_file(&path).await; - count += 1; - } - } - } - - if count > 0 { - info!(count, "cleaned up expired upload temp files"); - } - Ok(count) - } -} - -/// Compute the BLAKE3 hash of a file. -async fn compute_file_hash(path: &Path) -> Result { - let mut file = fs::File::open(path).await?; - let mut hasher = blake3::Hasher::new(); - let mut buf = vec![0u8; 64 * 1024]; - - loop { - let n = file.read(&mut buf).await?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - - Ok(hasher.finalize().to_hex().to_string()) -} - -#[cfg(test)] -mod tests { - use tempfile::tempdir; - - use super::*; - use crate::{model::ContentHash, sync::UploadStatus}; - - #[tokio::test] - async fn test_chunked_upload() { - let dir = tempdir().unwrap(); - let manager = ChunkedUploadManager::new(dir.path().to_path_buf()); - manager.init().await.unwrap(); - - // Create test data - let data = b"Hello, World! This is test data for chunked upload."; - let hash = blake3::hash(data).to_hex().to_string(); - let chunk_size = 20u64; - - let session = UploadSession { - id: Uuid::now_v7(), - device_id: super::super::DeviceId::new(), - target_path: "/test/file.txt".to_string(), - expected_hash: ContentHash::new(hash.clone()), - expected_size: data.len() as u64, - chunk_size, - chunk_count: (data.len() as u64).div_ceil(chunk_size), - status: UploadStatus::InProgress, - created_at: Utc::now(), - expires_at: Utc::now() + chrono::Duration::hours(24), - last_activity: Utc::now(), - }; - - manager.create_temp_file(&session).await.unwrap(); - - // Write chunks - let mut chunks = Vec::new(); - for i in 0..session.chunk_count { - let start = (i * chunk_size) as usize; - let end = ((i + 1) * chunk_size).min(data.len() as u64) as usize; - let chunk_data = &data[start..end]; - - let chunk = manager.write_chunk(&session, i, chunk_data).await.unwrap(); - chunks.push(chunk); - } - - // Finalize - let final_path = manager.finalize(&session, &chunks).await.unwrap(); - assert!(final_path.exists()); - - // Verify content - let content = fs::read(&final_path).await.unwrap(); - assert_eq!(&content[..], data); - } -} diff --git a/crates/pinakes-core/src/sync/conflict.rs b/crates/pinakes-core/src/sync/conflict.rs deleted file mode 100644 index eab7787..0000000 --- a/crates/pinakes-core/src/sync/conflict.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Conflict detection and resolution for sync. - -use super::DeviceSyncState; -use crate::config::ConflictResolution; - -/// Detect if there's a conflict between local and server state. -#[must_use] -pub fn detect_conflict(state: &DeviceSyncState) -> Option { - // If either side has no hash, no conflict possible - let local_hash = state.local_hash.as_ref()?; - let server_hash = state.server_hash.as_ref()?; - - // Same hash = no conflict - if local_hash == server_hash { - return None; - } - - // Both have different hashes = conflict - Some(ConflictInfo { - path: state.path.clone(), - local_hash: local_hash.clone(), - server_hash: server_hash.clone(), - local_mtime: state.local_mtime, - server_mtime: state.server_mtime, - }) -} - -/// Information about a detected conflict. -#[derive(Debug, Clone)] -pub struct ConflictInfo { - pub path: String, - pub local_hash: String, - pub server_hash: String, - pub local_mtime: Option, - pub server_mtime: Option, -} - -/// Result of resolving a conflict. -#[derive(Debug, Clone)] -pub enum ConflictOutcome { - /// Use the server version - UseServer, - /// Use the local version (upload it) - UseLocal, - /// Keep both versions (rename one) - KeepBoth { new_local_path: String }, - /// Requires manual intervention - Manual, -} - -/// Resolve a conflict based on the configured strategy. -#[must_use] -pub fn resolve_conflict( - conflict: &ConflictInfo, - resolution: ConflictResolution, -) -> ConflictOutcome { - match resolution { - ConflictResolution::ServerWins => ConflictOutcome::UseServer, - ConflictResolution::ClientWins => ConflictOutcome::UseLocal, - ConflictResolution::KeepBoth => { - let new_path = - generate_conflict_path(&conflict.path, &conflict.local_hash); - ConflictOutcome::KeepBoth { - new_local_path: new_path, - } - }, - ConflictResolution::Manual => ConflictOutcome::Manual, - } -} - -/// Generate a new path for the conflicting local file. -/// Format: filename.conflict-<`short_hash>.ext` -fn generate_conflict_path(original_path: &str, local_hash: &str) -> String { - let short_hash = &local_hash[..8.min(local_hash.len())]; - - if let Some((base, ext)) = original_path.rsplit_once('.') { - format!("{base}.conflict-{short_hash}.{ext}") - } else { - format!("{original_path}.conflict-{short_hash}") - } -} - -/// Automatic conflict resolution based on modification times. -/// Useful when `ConflictResolution` is set to a time-based strategy. -#[must_use] -pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome { - match (conflict.local_mtime, conflict.server_mtime) { - (Some(local), Some(server)) => { - if local > server { - ConflictOutcome::UseLocal - } else { - ConflictOutcome::UseServer - } - }, - (Some(_), None) => ConflictOutcome::UseLocal, - (None, Some(_)) => ConflictOutcome::UseServer, - (None, None) => ConflictOutcome::UseServer, // Default to server - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::sync::FileSyncStatus; - - #[test] - fn test_generate_conflict_path() { - assert_eq!( - generate_conflict_path("/path/to/file.txt", "abc12345"), - "/path/to/file.conflict-abc12345.txt" - ); - - assert_eq!( - generate_conflict_path("/path/to/file", "abc12345"), - "/path/to/file.conflict-abc12345" - ); - } - - #[test] - fn test_detect_conflict() { - let state_no_conflict = DeviceSyncState { - device_id: super::super::DeviceId::new(), - path: "/test".to_string(), - local_hash: Some("abc".to_string()), - server_hash: Some("abc".to_string()), - local_mtime: None, - server_mtime: None, - sync_status: FileSyncStatus::Synced, - last_synced_at: None, - conflict_info_json: None, - }; - assert!(detect_conflict(&state_no_conflict).is_none()); - - let state_conflict = DeviceSyncState { - device_id: super::super::DeviceId::new(), - path: "/test".to_string(), - local_hash: Some("abc".to_string()), - server_hash: Some("def".to_string()), - local_mtime: None, - server_mtime: None, - sync_status: FileSyncStatus::Conflict, - last_synced_at: None, - conflict_info_json: None, - }; - assert!(detect_conflict(&state_conflict).is_some()); - } -} diff --git a/crates/pinakes-core/src/sync/mod.rs b/crates/pinakes-core/src/sync/mod.rs index 77181f1..b19f5d7 100644 --- a/crates/pinakes-core/src/sync/mod.rs +++ b/crates/pinakes-core/src/sync/mod.rs @@ -2,13 +2,10 @@ //! //! Provides device registration, change tracking, and conflict resolution //! for syncing media libraries across multiple devices. +//! +//! Pure domain types and non-storage logic live in `pinakes-sync`. +//! Protocol functions that need `DynStorageBackend` stay in this module. -mod chunked; -mod conflict; -mod models; mod protocol; -pub use chunked::*; -pub use conflict::*; -pub use models::*; pub use protocol::*; diff --git a/crates/pinakes-core/src/sync/models.rs b/crates/pinakes-core/src/sync/models.rs deleted file mode 100644 index 5814c20..0000000 --- a/crates/pinakes-core/src/sync/models.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Sync domain models. - -use std::fmt; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{ - config::ConflictResolution, - model::{ContentHash, MediaId}, - users::UserId, -}; - -/// Unique identifier for a sync device. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct DeviceId(pub Uuid); - -impl DeviceId { - #[must_use] - pub fn new() -> Self { - Self(Uuid::now_v7()) - } -} - -impl Default for DeviceId { - fn default() -> Self { - Self::new() - } -} - -impl fmt::Display for DeviceId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Type of sync device. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, -)] -#[serde(rename_all = "lowercase")] -pub enum DeviceType { - Desktop, - Mobile, - Tablet, - Server, - #[default] - Other, -} - -impl fmt::Display for DeviceType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Desktop => write!(f, "desktop"), - Self::Mobile => write!(f, "mobile"), - Self::Tablet => write!(f, "tablet"), - Self::Server => write!(f, "server"), - Self::Other => write!(f, "other"), - } - } -} - -impl std::str::FromStr for DeviceType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "desktop" => Ok(Self::Desktop), - "mobile" => Ok(Self::Mobile), - "tablet" => Ok(Self::Tablet), - "server" => Ok(Self::Server), - "other" => Ok(Self::Other), - _ => Err(format!("unknown device type: {s}")), - } - } -} - -/// A registered sync device. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncDevice { - pub id: DeviceId, - pub user_id: UserId, - pub name: String, - pub device_type: DeviceType, - pub client_version: String, - pub os_info: Option, - pub last_sync_at: Option>, - pub last_seen_at: DateTime, - pub sync_cursor: Option, - pub enabled: bool, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl SyncDevice { - #[must_use] - pub fn new( - user_id: UserId, - name: String, - device_type: DeviceType, - client_version: String, - ) -> Self { - let now = Utc::now(); - Self { - id: DeviceId::new(), - user_id, - name, - device_type, - client_version, - os_info: None, - last_sync_at: None, - last_seen_at: now, - sync_cursor: None, - enabled: true, - created_at: now, - updated_at: now, - } - } -} - -/// Type of change recorded in the sync log. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SyncChangeType { - Created, - Modified, - Deleted, - Moved, - MetadataUpdated, -} - -impl fmt::Display for SyncChangeType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Created => write!(f, "created"), - Self::Modified => write!(f, "modified"), - Self::Deleted => write!(f, "deleted"), - Self::Moved => write!(f, "moved"), - Self::MetadataUpdated => write!(f, "metadata_updated"), - } - } -} - -impl std::str::FromStr for SyncChangeType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "created" => Ok(Self::Created), - "modified" => Ok(Self::Modified), - "deleted" => Ok(Self::Deleted), - "moved" => Ok(Self::Moved), - "metadata_updated" => Ok(Self::MetadataUpdated), - _ => Err(format!("unknown sync change type: {s}")), - } - } -} - -/// An entry in the sync log tracking a change. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncLogEntry { - pub id: Uuid, - pub sequence: i64, - pub change_type: SyncChangeType, - pub media_id: Option, - pub path: String, - pub content_hash: Option, - pub file_size: Option, - pub metadata_json: Option, - pub changed_by_device: Option, - pub timestamp: DateTime, -} - -impl SyncLogEntry { - #[must_use] - pub fn new( - change_type: SyncChangeType, - path: String, - media_id: Option, - content_hash: Option, - ) -> Self { - Self { - id: Uuid::now_v7(), - sequence: 0, // Will be assigned by database - change_type, - media_id, - path, - content_hash, - file_size: None, - metadata_json: None, - changed_by_device: None, - timestamp: Utc::now(), - } - } -} - -/// Sync status for a file on a device. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FileSyncStatus { - Synced, - PendingUpload, - PendingDownload, - Conflict, - Deleted, -} - -impl fmt::Display for FileSyncStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Synced => write!(f, "synced"), - Self::PendingUpload => write!(f, "pending_upload"), - Self::PendingDownload => write!(f, "pending_download"), - Self::Conflict => write!(f, "conflict"), - Self::Deleted => write!(f, "deleted"), - } - } -} - -impl std::str::FromStr for FileSyncStatus { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "synced" => Ok(Self::Synced), - "pending_upload" => Ok(Self::PendingUpload), - "pending_download" => Ok(Self::PendingDownload), - "conflict" => Ok(Self::Conflict), - "deleted" => Ok(Self::Deleted), - _ => Err(format!("unknown file sync status: {s}")), - } - } -} - -/// Sync state for a specific file on a specific device. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeviceSyncState { - pub device_id: DeviceId, - pub path: String, - pub local_hash: Option, - pub server_hash: Option, - pub local_mtime: Option, - pub server_mtime: Option, - pub sync_status: FileSyncStatus, - pub last_synced_at: Option>, - pub conflict_info_json: Option, -} - -/// A sync conflict that needs resolution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncConflict { - pub id: Uuid, - pub device_id: DeviceId, - pub path: String, - pub local_hash: String, - pub local_mtime: i64, - pub server_hash: String, - pub server_mtime: i64, - pub detected_at: DateTime, - pub resolved_at: Option>, - pub resolution: Option, -} - -impl SyncConflict { - #[must_use] - pub fn new( - device_id: DeviceId, - path: String, - local_hash: String, - local_mtime: i64, - server_hash: String, - server_mtime: i64, - ) -> Self { - Self { - id: Uuid::now_v7(), - device_id, - path, - local_hash, - local_mtime, - server_hash, - server_mtime, - detected_at: Utc::now(), - resolved_at: None, - resolution: None, - } - } -} - -/// Status of an upload session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum UploadStatus { - Pending, - InProgress, - Completed, - Failed, - Expired, - Cancelled, -} - -impl fmt::Display for UploadStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Pending => write!(f, "pending"), - Self::InProgress => write!(f, "in_progress"), - Self::Completed => write!(f, "completed"), - Self::Failed => write!(f, "failed"), - Self::Expired => write!(f, "expired"), - Self::Cancelled => write!(f, "cancelled"), - } - } -} - -impl std::str::FromStr for UploadStatus { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "pending" => Ok(Self::Pending), - "in_progress" => Ok(Self::InProgress), - "completed" => Ok(Self::Completed), - "failed" => Ok(Self::Failed), - "expired" => Ok(Self::Expired), - "cancelled" => Ok(Self::Cancelled), - _ => Err(format!("unknown upload status: {s}")), - } - } -} - -/// A chunked upload session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UploadSession { - pub id: Uuid, - pub device_id: DeviceId, - pub target_path: String, - pub expected_hash: ContentHash, - pub expected_size: u64, - pub chunk_size: u64, - pub chunk_count: u64, - pub status: UploadStatus, - pub created_at: DateTime, - pub expires_at: DateTime, - pub last_activity: DateTime, -} - -impl UploadSession { - #[must_use] - pub fn new( - device_id: DeviceId, - target_path: String, - expected_hash: ContentHash, - expected_size: u64, - chunk_size: u64, - timeout_hours: u64, - ) -> Self { - let now = Utc::now(); - let chunk_count = expected_size.div_ceil(chunk_size); - Self { - id: Uuid::now_v7(), - device_id, - target_path, - expected_hash, - expected_size, - chunk_size, - chunk_count, - status: UploadStatus::Pending, - created_at: now, - expires_at: now + chrono::Duration::hours(timeout_hours as i64), - last_activity: now, - } - } -} - -/// Information about an uploaded chunk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkInfo { - pub upload_id: Uuid, - pub chunk_index: u64, - pub offset: u64, - pub size: u64, - pub hash: String, - pub received_at: DateTime, -} diff --git a/crates/pinakes-core/src/sync/protocol.rs b/crates/pinakes-core/src/sync/protocol.rs index 5c0c486..5e796b2 100644 --- a/crates/pinakes-core/src/sync/protocol.rs +++ b/crates/pinakes-core/src/sync/protocol.rs @@ -3,16 +3,16 @@ //! Handles the bidirectional sync protocol between clients and server. use chrono::Utc; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::{ +use pinakes_sync::{ DeviceId, DeviceSyncState, FileSyncStatus, SyncChangeType, SyncLogEntry, }; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + use crate::{ error::Result, model::{ContentHash, MediaId}, diff --git a/crates/pinakes-core/src/upload.rs b/crates/pinakes-core/src/upload.rs index 837b34d..ad2d162 100644 --- a/crates/pinakes-core/src/upload.rs +++ b/crates/pinakes-core/src/upload.rs @@ -13,7 +13,6 @@ use crate::{ error::{PinakesError, Result}, managed_storage::ManagedStorageService, media_type::MediaType, - metadata, model::{MediaId, MediaItem, StorageMode, UploadResult}, storage::DynStorageBackend, }; @@ -58,7 +57,8 @@ pub async fn process_upload( let blob_path = managed.path(&content_hash); // Extract metadata - let extracted = metadata::extract_metadata(&blob_path, &media_type).ok(); + let extracted = + pinakes_metadata::extract_metadata(&blob_path, &media_type).ok(); // Create or get blob record let mime = mime_type.map_or_else(|| media_type.mime_type(), String::from); diff --git a/crates/pinakes-core/src/users.rs b/crates/pinakes-core/src/users.rs index 030bd46..159b972 100644 --- a/crates/pinakes-core/src/users.rs +++ b/crates/pinakes-core/src/users.rs @@ -3,42 +3,12 @@ use chrono::{DateTime, Utc}; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; -use uuid::Uuid; - use crate::{ config::UserRole, error::{PinakesError, Result}, }; -/// User ID -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct UserId(pub Uuid); - -impl UserId { - /// Creates a new user ID. - #[must_use] - pub fn new() -> Self { - Self(Uuid::now_v7()) - } -} - -impl Default for UserId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for UserId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for UserId { - fn from(id: Uuid) -> Self { - Self(id) - } -} +pub use pinakes_types::model::UserId; /// User account with profile information #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/pinakes-core/tests/book_metadata.rs b/crates/pinakes-core/tests/book_metadata.rs index 2223441..81e156c 100644 --- a/crates/pinakes-core/tests/book_metadata.rs +++ b/crates/pinakes-core/tests/book_metadata.rs @@ -1,12 +1,12 @@ use pinakes_core::{ books::{extract_isbn_from_text, normalize_isbn, parse_author_file_as}, - enrichment::{ - books::BookEnricher, - googlebooks::GoogleBooksClient, - openlibrary::OpenLibraryClient, - }, thumbnail::{CoverSize, extract_epub_cover, generate_book_covers}, }; +use pinakes_enrichment::{ + books::BookEnricher, + googlebooks::GoogleBooksClient, + openlibrary::OpenLibraryClient, +}; #[test] fn test_isbn_normalization() { diff --git a/crates/pinakes-core/tests/integration.rs b/crates/pinakes-core/tests/integration.rs index 9033f9c..78b6f1a 100644 --- a/crates/pinakes-core/tests/integration.rs +++ b/crates/pinakes-core/tests/integration.rs @@ -841,10 +841,10 @@ async fn test_external_metadata() { let item = make_test_media("enrich1"); storage.insert_media(&item).await.unwrap(); - let meta = pinakes_core::enrichment::ExternalMetadata { + let meta = pinakes_enrichment::ExternalMetadata { id: uuid::Uuid::now_v7(), media_id: item.id, - source: pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz, + source: pinakes_enrichment::EnrichmentSourceType::MusicBrainz, external_id: Some("mb-123".to_string()), metadata_json: r#"{"title":"Test"}"#.to_string(), confidence: 0.85, @@ -857,7 +857,7 @@ async fn test_external_metadata() { assert_eq!(metas.len(), 1); assert_eq!( metas[0].source, - pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz + pinakes_enrichment::EnrichmentSourceType::MusicBrainz ); assert_eq!(metas[0].external_id.as_deref(), Some("mb-123")); assert!((metas[0].confidence - 0.85).abs() < 0.01); diff --git a/crates/pinakes-core/tests/plugin_integration.rs b/crates/pinakes-core/tests/plugin_integration.rs index c48a250..5130e33 100644 --- a/crates/pinakes-core/tests/plugin_integration.rs +++ b/crates/pinakes-core/tests/plugin_integration.rs @@ -9,10 +9,9 @@ #![allow(clippy::print_stderr, reason = "Fine for tests")] use std::{path::Path, sync::Arc}; -use pinakes_core::{ - config::PluginTimeoutConfig, - plugin::{PluginManager, PluginManagerConfig, PluginPipeline}, -}; +use pinakes_core::plugin::PluginPipeline; +use pinakes_plugin::{PluginManager, PluginManagerConfig}; +use pinakes_types::config::PluginTimeoutConfig; use tempfile::TempDir; /// Path to the compiled test plugin fixture. diff --git a/crates/pinakes-migrations/Cargo.toml b/crates/pinakes-migrations/Cargo.toml index 42284da..5e65ed9 100644 --- a/crates/pinakes-migrations/Cargo.toml +++ b/crates/pinakes-migrations/Cargo.toml @@ -3,6 +3,7 @@ name = "pinakes-migrations" edition.workspace = true version.workspace = true license.workspace = true +publish = false [dependencies] rusqlite = { workspace = true } diff --git a/crates/pinakes-migrations/src/lib.rs b/crates/pinakes-migrations/src/lib.rs index 8092426..a1fc8e5 100644 --- a/crates/pinakes-migrations/src/lib.rs +++ b/crates/pinakes-migrations/src/lib.rs @@ -22,17 +22,29 @@ pub fn sqlite_migrations() -> Migrations<'static> { M::up(include_str!( "../migrations/sqlite/V9__fix_indexes_and_constraints.sql" )), - M::up(include_str!("../migrations/sqlite/V10__incremental_scan.sql")), + M::up(include_str!( + "../migrations/sqlite/V10__incremental_scan.sql" + )), M::up(include_str!( "../migrations/sqlite/V11__session_persistence.sql" )), - M::up(include_str!("../migrations/sqlite/V12__book_management.sql")), + M::up(include_str!( + "../migrations/sqlite/V12__book_management.sql" + )), M::up(include_str!("../migrations/sqlite/V13__photo_metadata.sql")), - M::up(include_str!("../migrations/sqlite/V14__perceptual_hash.sql")), - M::up(include_str!("../migrations/sqlite/V15__managed_storage.sql")), + M::up(include_str!( + "../migrations/sqlite/V14__perceptual_hash.sql" + )), + M::up(include_str!( + "../migrations/sqlite/V15__managed_storage.sql" + )), M::up(include_str!("../migrations/sqlite/V16__sync_system.sql")), - M::up(include_str!("../migrations/sqlite/V17__enhanced_sharing.sql")), - M::up(include_str!("../migrations/sqlite/V18__file_management.sql")), + M::up(include_str!( + "../migrations/sqlite/V17__enhanced_sharing.sql" + )), + M::up(include_str!( + "../migrations/sqlite/V18__file_management.sql" + )), M::up(include_str!("../migrations/sqlite/V19__markdown_links.sql")), ]) } From 934fcba8ca7fd55d0d98660c66b8d975b60209e4 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 May 2026 21:52:21 +0300 Subject: [PATCH 18/22] examples: add WASM plugin examples Signed-off-by: NotAShelf Change-Id: Id4b791396ab37827caced2c8cc03ec356a6a6964 --- examples/plugins/auto-tagger/Cargo.lock | Bin 0 -> 1243 bytes examples/plugins/auto-tagger/Cargo.toml | 15 + examples/plugins/auto-tagger/plugin.toml | 13 + examples/plugins/auto-tagger/src/lib.rs | 303 +++++++ examples/plugins/cbz-comics/Cargo.lock | Bin 0 -> 1673 bytes examples/plugins/cbz-comics/Cargo.toml | 18 + examples/plugins/cbz-comics/plugin.toml | 20 + examples/plugins/cbz-comics/src/lib.rs | 742 ++++++++++++++++++ examples/plugins/subtitle-detector/Cargo.lock | Bin 0 -> 1249 bytes examples/plugins/subtitle-detector/Cargo.toml | 15 + .../plugins/subtitle-detector/plugin.toml | 18 + examples/plugins/subtitle-detector/src/lib.rs | 345 ++++++++ examples/plugins/text-enrichment/Cargo.lock | Bin 0 -> 1247 bytes examples/plugins/text-enrichment/Cargo.toml | 15 + examples/plugins/text-enrichment/plugin.toml | 18 + examples/plugins/text-enrichment/src/lib.rs | 198 +++++ 16 files changed, 1720 insertions(+) create mode 100644 examples/plugins/auto-tagger/Cargo.lock create mode 100644 examples/plugins/auto-tagger/Cargo.toml create mode 100644 examples/plugins/auto-tagger/plugin.toml create mode 100644 examples/plugins/auto-tagger/src/lib.rs create mode 100644 examples/plugins/cbz-comics/Cargo.lock create mode 100644 examples/plugins/cbz-comics/Cargo.toml create mode 100644 examples/plugins/cbz-comics/plugin.toml create mode 100644 examples/plugins/cbz-comics/src/lib.rs create mode 100644 examples/plugins/subtitle-detector/Cargo.lock create mode 100644 examples/plugins/subtitle-detector/Cargo.toml create mode 100644 examples/plugins/subtitle-detector/plugin.toml create mode 100644 examples/plugins/subtitle-detector/src/lib.rs create mode 100644 examples/plugins/text-enrichment/Cargo.lock create mode 100644 examples/plugins/text-enrichment/Cargo.toml create mode 100644 examples/plugins/text-enrichment/plugin.toml create mode 100644 examples/plugins/text-enrichment/src/lib.rs diff --git a/examples/plugins/auto-tagger/Cargo.lock b/examples/plugins/auto-tagger/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..a398a38e786445303fe56a29563e3e3c5e5ffa39 GIT binary patch literal 1243 zcmb`HO>5jR5Qgvm6~Z|->(%#1p-||l_ufJdBWcDf)@ujb+jReZ<Ivs{I>A~J48w(e?Qz5zR?IA3*f*H_scY`o_jPL>r+SPlj=5gb zG_w9DR(%jf;R zFYDnPyEL8l^Lbf!1C0GXZN`_bOuJH5{A!zYz;s;Br%ifLij$9$NGMX;Xdbo3pe>Rz zLI^L7lP)Tsm6EAz}OynX7N8TU?@3U7jX^ut* zusUWRAj%Y|UVs%UW~$O9py3W>sCiz&^C$fKN@^%^qr}?hn%?xh?3T;&F}0WOcb@&N zO-B>0w${i<)>alFpvd3@7g5Dvr1ZfO7wD9YkPSJh2puSpHKq{q2h{#r9Ljinvw1J% zZVZmN=T)ghdJaltvcgp|x^9`vnRL*?f|EXSnW73(5GhJmm+uXQ?SJ?U*_VT{ literal 0 HcmV?d00001 diff --git a/examples/plugins/auto-tagger/Cargo.toml b/examples/plugins/auto-tagger/Cargo.toml new file mode 100644 index 0000000..cdcee85 --- /dev/null +++ b/examples/plugins/auto-tagger/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "auto-tagger" +version = "1.0.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +dlmalloc = { version = "0.2", features = ["global"] } + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/examples/plugins/auto-tagger/plugin.toml b/examples/plugins/auto-tagger/plugin.toml new file mode 100644 index 0000000..24354f4 --- /dev/null +++ b/examples/plugins/auto-tagger/plugin.toml @@ -0,0 +1,13 @@ +[plugin] +name = "auto-tagger" +version = "1.0.0" +api_version = "1.0" +description = "Listens for MediaImported events and emits AutoTagSuggested events based on path pattern rules" +kind = ["event_handler"] +priority = 500 + +[plugin.binary] +wasm = "auto_tagger.wasm" + +[capabilities] +network = false diff --git a/examples/plugins/auto-tagger/src/lib.rs b/examples/plugins/auto-tagger/src/lib.rs new file mode 100644 index 0000000..2f30527 --- /dev/null +++ b/examples/plugins/auto-tagger/src/lib.rs @@ -0,0 +1,303 @@ +//! Auto-tagger plugin for Pinakes. +//! +//! Listens for `MediaImported` events and, based on configurable path pattern +//! rules, emits `AutoTagSuggested` events. Rules map path substrings to tag +//! names. +//! +//! Configuration key `rules` expects a JSON array of objects: +//! `[{"pattern": "/music/", "tag": "music"}, ...]` +//! +//! If no config is present, built-in defaults are used: +//! - `/music/` -> `music` +//! - `/photos/` -> `photo` +//! - `/videos/` -> `video` +//! - `/books/` -> `book` +//! - `/documents/` -> `document` +//! +//! Build with: +//! RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release + +#![no_std] + +extern crate alloc; + +use alloc::{format, string::String, vec, vec::Vec}; +use core::alloc::Layout; + +#[global_allocator] +static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; + +#[panic_handler] +fn panic_handler(_info: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// Host functions provided by the runtime +unsafe extern "C" { + fn host_set_result(ptr: i32, len: i32); + fn host_log(level: i32, ptr: i32, len: i32); + fn host_emit_event(type_ptr: i32, type_len: i32, payload_ptr: i32, payload_len: i32) -> i32; + fn host_get_config(key_ptr: i32, key_len: i32) -> i32; + fn host_get_buffer(dest_ptr: i32, dest_len: i32) -> i32; +} + +fn set_response(json: &[u8]) { + unsafe { + host_set_result(json.as_ptr() as i32, json.len() as i32); + } +} + +fn log_info(msg: &str) { + unsafe { + host_log(2, msg.as_ptr() as i32, msg.len() as i32); + } +} + +unsafe fn read_request(ptr: i32, len: i32) -> Vec { + if ptr < 0 || len <= 0 { + return Vec::new(); + } + let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; + slice.to_vec() +} + +/// Extract a string value from a JSON object for a given key. +fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> { + let json_str = core::str::from_utf8(json).ok()?; + let pattern = format!("\"{}\"", key); + let key_pos = json_str.find(&pattern)?; + let after_key = &json_str[key_pos + pattern.len()..]; + let after_colon = after_key.trim_start().strip_prefix(':')?; + let after_colon = after_colon.trim_start(); + + if after_colon.starts_with('"') { + let value_start = 1; + let value_end = after_colon[value_start..].find('"')?; + Some(&after_colon[value_start..value_start + value_end]) + } else { + None + } +} + +/// A single tagging rule: match `pattern` in path -> apply `tag`. +struct Rule { + pattern: String, + tag: String, +} + +/// Default rules used when no `rules` config key is present. +fn default_rules() -> Vec { + vec![ + Rule { pattern: String::from("/music/"), tag: String::from("music") }, + Rule { pattern: String::from("/photos/"), tag: String::from("photo") }, + Rule { pattern: String::from("/videos/"), tag: String::from("video") }, + Rule { pattern: String::from("/books/"), tag: String::from("book") }, + Rule { pattern: String::from("/documents/"), tag: String::from("document") }, + ] +} + +/// Parse the `rules` JSON array from the config buffer. +/// Expected format: `[{"pattern":"...","tag":"..."},...]` +/// Returns an empty vec on any parse failure (falls back to defaults). +fn parse_rules_json(data: &[u8]) -> Vec { + let text = match core::str::from_utf8(data) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + + let mut rules = Vec::new(); + // Walk through occurrences of "pattern" keys inside object literals. + let mut search = text; + while let Some(p_pos) = search.find("\"pattern\"") { + let after_p = &search[p_pos + 9..]; + let after_colon = match after_p.trim_start().strip_prefix(':') { + Some(s) => s.trim_start(), + None => { + search = &search[p_pos + 1..]; + continue; + } + }; + let pattern = if after_colon.starts_with('"') { + let inner = &after_colon[1..]; + match inner.find('"') { + Some(end) => String::from(&inner[..end]), + None => { + search = &search[p_pos + 1..]; + continue; + } + } + } else { + search = &search[p_pos + 1..]; + continue; + }; + + // Now search for "tag" after the current pattern position. + let remaining = &search[p_pos..]; + let tag = if let Some(t_pos) = remaining.find("\"tag\"") { + let after_t = &remaining[t_pos + 5..]; + let after_colon_t = match after_t.trim_start().strip_prefix(':') { + Some(s) => s.trim_start(), + None => { + search = &search[p_pos + 1..]; + continue; + } + }; + if after_colon_t.starts_with('"') { + let inner = &after_colon_t[1..]; + match inner.find('"') { + Some(end) => String::from(&inner[..end]), + None => { + search = &search[p_pos + 1..]; + continue; + } + } + } else { + search = &search[p_pos + 1..]; + continue; + } + } else { + search = &search[p_pos + 1..]; + continue; + }; + + rules.push(Rule { pattern, tag }); + search = &search[p_pos + 1..]; + } + + rules +} + +/// Load rules from config, falling back to defaults. +fn load_rules() -> Vec { + let key = b"rules"; + let size = unsafe { host_get_config(key.as_ptr() as i32, key.len() as i32) }; + if size <= 0 { + return default_rules(); + } + + let buf_size = size as usize; + let layout = match Layout::from_size_align(buf_size, 1) { + Ok(l) => l, + Err(_) => return default_rules(), + }; + let ptr = unsafe { alloc::alloc::alloc(layout) }; + if ptr.is_null() { + return default_rules(); + } + + let copied = unsafe { host_get_buffer(ptr as i32, size) }; + if copied <= 0 { + unsafe { alloc::alloc::dealloc(ptr, layout) }; + return default_rules(); + } + + let data = unsafe { core::slice::from_raw_parts(ptr, copied as usize) }; + let rules = parse_rules_json(data); + unsafe { alloc::alloc::dealloc(ptr, layout) }; + + if rules.is_empty() { + default_rules() + } else { + rules + } +} + +/// Escape a string for safe inclusion in a JSON string value. +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out +} + +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + if size <= 0 { + return 0; + } + unsafe { + let layout = match Layout::from_size_align(size as usize, 1) { + Ok(l) => l, + Err(_) => return -1, + }; + let ptr = alloc::alloc::alloc(layout); + if ptr.is_null() { + return -1; + } + ptr as i32 + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn initialize() -> i32 { + log_info("auto-tagger initialized"); + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn shutdown() -> i32 { + log_info("auto-tagger shutdown"); + 0 +} + +/// Returns the event types this handler is interested in. +#[unsafe(no_mangle)] +pub extern "C" fn interested_events(_ptr: i32, _len: i32) { + set_response(br#"["MediaImported"]"#); +} + +/// Handle a `MediaImported` event: check path against rules and emit tag events. +#[unsafe(no_mangle)] +pub extern "C" fn handle_event(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + + let media_id = json_get_str(&req, "media_id").unwrap_or(""); + // The payload is nested; attempt to extract `path` from the top-level + // request or from a nested `payload` object. + let path = json_get_str(&req, "path").unwrap_or(""); + + let rules = load_rules(); + let mut matched_count = 0u32; + + for rule in &rules { + if !path.is_empty() && path.contains(rule.pattern.as_str()) { + let event_type = b"AutoTagSuggested"; + let payload = format!( + r#"{{"media_id":"{}","tag":"{}"}}"#, + json_escape(media_id), + json_escape(&rule.tag), + ); + unsafe { + host_emit_event( + event_type.as_ptr() as i32, + event_type.len() as i32, + payload.as_ptr() as i32, + payload.len() as i32, + ); + } + matched_count += 1; + } + } + + if matched_count > 0 { + let msg = format!( + "auto-tagger: matched {} rule(s) for path: {}", + matched_count, + path, + ); + log_info(&msg); + } else { + let msg = format!("auto-tagger: no rules matched for path: {}", path); + log_info(&msg); + } + + set_response(b"{}"); +} diff --git a/examples/plugins/cbz-comics/Cargo.lock b/examples/plugins/cbz-comics/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..06ebc6a5682394a23901e3baa3f4937e0af6b2a5 GIT binary patch literal 1673 zcmb`HO>f&U42JLi6^47<;!2c6NdpE9*lG9Oiek_QnFw|4!gkh=Uq7W?hZak2IT(T@ z!s7crB(K_^yRx)g25RdX4(qfJYe``kp4x9)8fgwIrcJ5oJa$dm zQMy|W`{OeTgp}S`&naY|Ksu+{B<)aUiPGvUNFbQw2Z(G06FTpd79kRc?sWDkK5uFk zB`qHm$f`heO{V*!Gh-^W)Ff`2w#&o5x;mxqbKUIASe}1Pk0n#x-=2Abx0}NM{a#<# zAg`2Q7lBzSR@!J0wI;8TSQ#m$17pEP<+4)X>Zz~RX+UXYHB(exC-MH%`yR09p_l5? z0@y?$leFv%k#{aT1xX7s+6UAzJ0Bt>&-EgpR55c+9Re9)34HbQ1g`XI{t?zt;w!a> zT3*w`vRR&%^VD9V|I_R)EjpTr8qq*x#LB{Zk~ugpWK_``0Oyf~B&#rnY}kTgvOxu? zHhG`VPQrr?Fbk<}lKSqN|d literal 0 HcmV?d00001 diff --git a/examples/plugins/cbz-comics/Cargo.toml b/examples/plugins/cbz-comics/Cargo.toml new file mode 100644 index 0000000..23319ed --- /dev/null +++ b/examples/plugins/cbz-comics/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cbz-comics" +version = "1.0.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +dlmalloc = { version = "0.2", features = ["global"] } +miniz_oxide = { version = "0.8", default-features = false, features = [ + "with-alloc", +] } + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/examples/plugins/cbz-comics/plugin.toml b/examples/plugins/cbz-comics/plugin.toml new file mode 100644 index 0000000..e2f6e74 --- /dev/null +++ b/examples/plugins/cbz-comics/plugin.toml @@ -0,0 +1,20 @@ +[plugin] +name = "cbz-comics" +version = "1.0.0" +api_version = "1.0" +description = "Supports CBZ (Comic Book ZIP) and CBR files with metadata extraction and thumbnail generation" +kind = ["media_type", "metadata_extractor", "thumbnail_generator"] +priority = 500 + +[plugin.binary] +wasm = "cbz_comics.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +# Users must add their media root directories here. Example: +# read = ["/home/user/comics"] +# write = ["/home/user/.cache/pinakes/thumbnails"] +read = [] +write = [] diff --git a/examples/plugins/cbz-comics/src/lib.rs b/examples/plugins/cbz-comics/src/lib.rs new file mode 100644 index 0000000..98d8f7b --- /dev/null +++ b/examples/plugins/cbz-comics/src/lib.rs @@ -0,0 +1,742 @@ +//! CBZ/CBR comics plugin for Pinakes. +//! +//! Registers comic book ZIP (`cbz`) and RAR (`cbr`) media types, extracts +//! metadata from CBZ archives (including `ComicInfo.xml` when present), and +//! generates thumbnails from the cover image. +//! +//! CBR is registered as a media type but metadata extraction is limited to +//! format detection only (RAR parsing is not implemented). +//! +//! ZIP parsing is implemented from scratch without external ZIP crates to keep +//! the WASM binary small. +//! +//! The `filesystem.read` and `filesystem.write` capabilities in `plugin.toml` +//! must be configured for the directories containing comic files and the +//! thumbnail output directory respectively. +//! +//! Build with: +//! RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release + +#![no_std] + +extern crate alloc; + +use alloc::{format, string::{String, ToString}, vec, vec::Vec}; +use core::alloc::Layout; + +#[global_allocator] +static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; + +#[panic_handler] +fn panic_handler(_info: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// Host functions provided by the runtime +unsafe extern "C" { + fn host_set_result(ptr: i32, len: i32); + fn host_log(level: i32, ptr: i32, len: i32); + fn host_read_file(path_ptr: i32, path_len: i32) -> i32; + fn host_get_buffer(dest_ptr: i32, dest_len: i32) -> i32; + fn host_write_file(path_ptr: i32, path_len: i32, data_ptr: i32, data_len: i32) -> i32; +} + +fn set_response(json: &[u8]) { + unsafe { + host_set_result(json.as_ptr() as i32, json.len() as i32); + } +} + +fn log_info(msg: &str) { + unsafe { + host_log(2, msg.as_ptr() as i32, msg.len() as i32); + } +} + +unsafe fn read_request(ptr: i32, len: i32) -> Vec { + if ptr < 0 || len <= 0 { + return Vec::new(); + } + let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; + slice.to_vec() +} + +/// Extract a string value from a JSON object for a given key. +fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> { + let json_str = core::str::from_utf8(json).ok()?; + let pattern = format!("\"{}\"", key); + let key_pos = json_str.find(&pattern)?; + let after_key = &json_str[key_pos + pattern.len()..]; + let after_colon = after_key.trim_start().strip_prefix(':')?; + let after_colon = after_colon.trim_start(); + + if after_colon.starts_with('"') { + let value_start = 1; + let value_end = after_colon[value_start..].find('"')?; + Some(&after_colon[value_start..value_start + value_end]) + } else { + None + } +} + +/// Escape a string for safe inclusion in a JSON string value. +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out +} + +// 20 MB content read limit for comic archives +const MAX_FILE_BYTES: usize = 20 * 1024 * 1024; + +// ZIP signatures (little-endian u32) +const SIG_LOCAL_FILE: u32 = 0x04034b50; +const SIG_CENTRAL_DIR: u32 = 0x02014b50; +const SIG_EOCD: u32 = 0x06054b50; + +// Compression methods +const COMPRESS_STORE: u16 = 0; +const COMPRESS_DEFLATE: u16 = 8; + +/// Read a little-endian u16 from a byte slice at the given offset. +/// Returns `None` if out of bounds. +fn read_u16_le(data: &[u8], offset: usize) -> Option { + let b0 = *data.get(offset)? as u16; + let b1 = *data.get(offset + 1)? as u16; + Some(b0 | (b1 << 8)) +} + +/// Read a little-endian u32 from a byte slice at the given offset. +/// Returns `None` if out of bounds. +fn read_u32_le(data: &[u8], offset: usize) -> Option { + let b0 = *data.get(offset)? as u32; + let b1 = *data.get(offset + 1)? as u32; + let b2 = *data.get(offset + 2)? as u32; + let b3 = *data.get(offset + 3)? as u32; + Some(b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) +} + +/// Read a big-endian u16 from a byte slice at the given offset. +fn read_u16_be(data: &[u8], offset: usize) -> Option { + let b0 = *data.get(offset)? as u16; + let b1 = *data.get(offset + 1)? as u16; + Some((b0 << 8) | b1) +} + +/// Read a big-endian u32 from a byte slice at the given offset. +fn read_u32_be(data: &[u8], offset: usize) -> Option { + let b0 = *data.get(offset)? as u32; + let b1 = *data.get(offset + 1)? as u32; + let b2 = *data.get(offset + 2)? as u32; + let b3 = *data.get(offset + 3)? as u32; + Some((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) +} + +/// A parsed central directory entry from a ZIP archive. +struct ZipEntry { + name: String, + compression: u16, + compressed_size: u32, + local_offset: u32, +} + +/// Find the End of Central Directory record offset by scanning backwards. +fn find_eocd(data: &[u8]) -> Option { + if data.len() < 22 { + return None; + } + // Scan backwards for the EOCD signature. The maximum comment size is + // 65535 bytes, so we only need to scan that far from the end. + let scan_start = if data.len() > 22 + 65535 { + data.len() - 22 - 65535 + } else { + 0 + }; + let mut i = data.len() - 22; + loop { + if read_u32_le(data, i) == Some(SIG_EOCD) { + return Some(i); + } + if i == scan_start { + break; + } + i -= 1; + } + None +} + +/// Parse all central directory entries from a ZIP archive. +fn parse_central_directory(data: &[u8]) -> Vec { + let mut entries = Vec::new(); + + let eocd_offset = match find_eocd(data) { + Some(o) => o, + None => return entries, + }; + + // EOCD layout (offsets relative to EOCD start): + // 0: signature (4) + // 4: disk number (2) + // 6: start disk (2) + // 8: entries on disk (2) + // 10: total entries (2) + // 12: central dir size (4) + // 16: central dir offset (4) + // 20: comment length (2) + let cd_offset = match read_u32_le(data, eocd_offset + 16) { + Some(o) => o as usize, + None => return entries, + }; + let total_entries = match read_u16_le(data, eocd_offset + 10) { + Some(n) => n as usize, + None => return entries, + }; + + let mut pos = cd_offset; + for _ in 0..total_entries { + if pos + 46 > data.len() { + break; + } + if read_u32_le(data, pos) != Some(SIG_CENTRAL_DIR) { + break; + } + + // Central directory entry layout: + // 0: signature (4) + // 4: version made by (2) + // 6: version needed (2) + // 8: flags (2) + // 10: compression (2) + // 12: mod time (2) + // 14: mod date (2) + // 16: crc32 (4) + // 20: compressed size (4) + // 24: uncompressed size (4) + // 28: filename length (2) + // 30: extra field length (2) + // 32: file comment length (2) + // 34: disk start (2) + // 36: internal attrs (2) + // 38: external attrs (4) + // 42: local header offset (4) + // 46: filename... + let compression = match read_u16_le(data, pos + 10) { Some(v) => v, None => break }; + let compressed_size = match read_u32_le(data, pos + 20) { Some(v) => v, None => break }; + // uncompressed_size at pos+24 is intentionally not stored; size comes from decompressor output. + let fname_len = match read_u16_le(data, pos + 28) { Some(v) => v as usize, None => break }; + let extra_len = match read_u16_le(data, pos + 30) { Some(v) => v as usize, None => break }; + let comment_len = match read_u16_le(data, pos + 32) { Some(v) => v as usize, None => break }; + let local_offset = match read_u32_le(data, pos + 42) { Some(v) => v, None => break }; + + let fname_start = pos + 46; + let fname_end = fname_start + fname_len; + if fname_end > data.len() { + break; + } + + let name = core::str::from_utf8(&data[fname_start..fname_end]) + .unwrap_or("") + .to_ascii_lowercase(); + + entries.push(ZipEntry { + name, + compression, + compressed_size, + local_offset, + }); + + pos = fname_end + extra_len + comment_len; + } + + entries +} + +/// Read raw bytes for a local file entry (the actual compressed/stored data). +/// Returns a slice into `data` containing the compressed bytes. +fn local_file_data<'a>(data: &'a [u8], entry: &ZipEntry) -> Option<&'a [u8]> { + let off = entry.local_offset as usize; + if off + 30 > data.len() { + return None; + } + if read_u32_le(data, off) != Some(SIG_LOCAL_FILE) { + return None; + } + + // Local file header layout: + // 0: signature (4) + // 4: version needed (2) + // 6: flags (2) + // 8: compression (2) + // 10: mod time (2) + // 12: mod date (2) + // 14: crc32 (4) + // 18: compressed size (4) + // 22: uncompressed size (4) + // 26: filename length (2) + // 28: extra length (2) + // 30: filename... + let fname_len = read_u16_le(data, off + 26)? as usize; + let extra_len = read_u16_le(data, off + 28)? as usize; + let data_start = off + 30 + fname_len + extra_len; + let data_end = data_start + entry.compressed_size as usize; + if data_end > data.len() { + return None; + } + Some(&data[data_start..data_end]) +} + +/// Decompress a stored (STORE) or deflated (DEFLATE) entry. +/// Returns the uncompressed bytes. +fn decompress_entry(data: &[u8], entry: &ZipEntry) -> Option> { + let raw = local_file_data(data, entry)?; + match entry.compression { + COMPRESS_STORE => Some(raw.to_vec()), + COMPRESS_DEFLATE => { + miniz_oxide::inflate::decompress_to_vec(raw).ok() + } + _ => None, + } +} + +/// Returns true if a filename has an image extension. +fn is_image_filename(name: &str) -> bool { + name.ends_with(".jpg") + || name.ends_with(".jpeg") + || name.ends_with(".png") + || name.ends_with(".webp") +} + +/// Extract a simple XML element value using substring search. +/// Looks for `value` and returns the inner text. +fn xml_get_text<'a>(xml: &'a str, tag: &str) -> Option<&'a str> { + let open = format!("<{}>", tag); + let close = format!("", tag); + let start = xml.find(&open)?; + let after_open = &xml[start + open.len()..]; + let end = after_open.find(&close)?; + Some(&after_open[..end]) +} + +/// Metadata extracted from a ComicInfo.xml file. +struct ComicInfo { + title: Option, + series: Option, + issue_number: Option, + writer: Option, + page_count: Option, + language: Option, + genre: Option, + summary: Option, +} + +/// Parse key fields from a ComicInfo.xml byte slice. +fn parse_comic_info(data: &[u8]) -> ComicInfo { + let text = core::str::from_utf8(data).unwrap_or(""); + ComicInfo { + title: xml_get_text(text, "Title") .map(|s| s.trim().to_ascii_lowercase()).filter(|s| !s.is_empty()).map(|s| { + // Re-capitalize first letter for title + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }), + series: xml_get_text(text, "Series") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + issue_number: xml_get_text(text, "Number") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + writer: xml_get_text(text, "Writer") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + page_count: xml_get_text(text, "PageCount") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + language: xml_get_text(text, "LanguageISO").map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + genre: xml_get_text(text, "Genre") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + summary: xml_get_text(text, "Summary") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + } +} + +/// Image dimension and format information. +struct ImageInfo { + width: u32, + height: u32, + format: &'static str, +} + +/// Parse image dimensions and detect format from raw image bytes. +fn parse_image_info(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + // JPEG: starts with 0xFF 0xD8 + if data[0] == 0xFF && data[1] == 0xD8 { + // Scan for SOF0 (0xFF 0xC0) or SOF2 (0xFF 0xC2) marker + let mut i = 2usize; + while i + 8 < data.len() { + if data[i] == 0xFF { + let marker = data[i + 1]; + if marker == 0xC0 || marker == 0xC2 { + // SOF marker layout: + // 0: 0xFF + // 1: marker + // 2-3: segment length (big-endian) + // 4: precision + // 5-6: height (big-endian u16) + // 7-8: width (big-endian u16) + let height = read_u16_be(data, i + 5)? as u32; + let width = read_u16_be(data, i + 7)? as u32; + return Some(ImageInfo { width, height, format: "jpeg" }); + } else if marker == 0xFF { + // Padding byte + i += 1; + continue; + } else if marker == 0xD8 || marker == 0xD9 { + // SOI / EOI - no length field + i += 2; + continue; + } else { + // Skip segment: length at i+2 (includes the 2 length bytes) + if let Some(seg_len) = read_u16_be(data, i + 2) { + i += 2 + seg_len as usize; + } else { + break; + } + } + } else { + i += 1; + } + } + // Return a JPEG without dimensions if SOF not found + return Some(ImageInfo { width: 0, height: 0, format: "jpeg" }); + } + + // PNG: starts with 0x89 0x50 0x4E 0x47 ('PNG') + if data.len() >= 24 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + // IHDR chunk: width at bytes 16-19, height at bytes 20-23 (big-endian u32) + let width = read_u32_be(data, 16)?; + let height = read_u32_be(data, 20)?; + return Some(ImageInfo { width, height, format: "png" }); + } + + // WebP: RIFF....WEBP + if data.len() >= 12 + && &data[0..4] == b"RIFF" + && &data[8..12] == b"WEBP" + { + return Some(ImageInfo { width: 0, height: 0, format: "webp" }); + } + + None +} + +/// Load a CBZ archive into memory. Returns the raw bytes or an error string. +fn load_cbz_file(path: &str) -> Result, &'static str> { + let file_size = unsafe { host_read_file(path.as_ptr() as i32, path.len() as i32) }; + if file_size < 0 { + return Err("read failed"); + } + if file_size as usize >= MAX_FILE_BYTES { + return Err("too large"); + } + let buf_size = file_size as usize; + if buf_size == 0 { + return Ok(Vec::new()); + } + + let layout = Layout::from_size_align(buf_size, 1).map_err(|_| "alloc failed")?; + let buf_ptr = unsafe { alloc::alloc::alloc(layout) }; + if buf_ptr.is_null() { + return Err("alloc failed"); + } + + let copied = unsafe { host_get_buffer(buf_ptr as i32, file_size) }; + if copied <= 0 { + unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; + return Err("buffer copy failed"); + } + + let data = unsafe { core::slice::from_raw_parts(buf_ptr, copied as usize) }.to_vec(); + unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; + Ok(data) +} + +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + if size <= 0 { + return 0; + } + unsafe { + let layout = match Layout::from_size_align(size as usize, 1) { + Ok(l) => l, + Err(_) => return -1, + }; + let ptr = alloc::alloc::alloc(layout); + if ptr.is_null() { + return -1; + } + ptr as i32 + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn initialize() -> i32 { + log_info("cbz-comics initialized"); + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn shutdown() -> i32 { + log_info("cbz-comics shutdown"); + 0 +} + +/// Returns the comic media type definitions. +#[unsafe(no_mangle)] +pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) { + let response = br#"[ +{"id":"comic-cbz","name":"Comic Book ZIP","category":"document","extensions":["cbz"],"mime_types":["application/vnd.comicbook+zip"]}, +{"id":"comic-cbr","name":"Comic Book RAR","category":"document","extensions":["cbr"],"mime_types":["application/vnd.comicbook-rar"]} +]"#; + set_response(response); +} + +/// Check whether this plugin can handle a given path. +#[unsafe(no_mangle)] +pub extern "C" fn can_handle(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + let path = json_get_str(&req, "path").unwrap_or("").to_ascii_lowercase(); + let can = path.ends_with(".cbz") || path.ends_with(".cbr"); + if can { + set_response(br#"{"can_handle":true}"#); + } else { + set_response(br#"{"can_handle":false}"#); + } +} + +/// Returns the media type IDs this extractor supports. +#[unsafe(no_mangle)] +pub extern "C" fn supported_types(_ptr: i32, _len: i32) { + set_response(br#"["comic-cbz","comic-cbr"]"#); +} + +/// Extract metadata from a CBZ or CBR file. +#[unsafe(no_mangle)] +pub extern "C" fn extract_metadata(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + let path = match json_get_str(&req, "path") { + Some(p) => p, + None => { + set_response(br#"{"extra":{"error":"missing path"}}"#); + return; + } + }; + + let lower = path.to_ascii_lowercase(); + + // CBR: register the type but do not attempt to parse RAR. + if lower.ends_with(".cbr") { + set_response(br#"{"extra":{"format":"cbr","note":"cbr-unsupported"}}"#); + return; + } + + // Load CBZ archive + let data = match load_cbz_file(path) { + Ok(d) => d, + Err("too large") => { + set_response(br#"{"extra":{"format":"cbz","too_large":"true"}}"#); + return; + } + Err(e) => { + let resp = format!(r#"{{"extra":{{"format":"cbz","error":"{}"}}}}"#, e); + set_response(resp.as_bytes()); + return; + } + }; + + let entries = parse_central_directory(&data); + + // Count image files as page count. + let image_count = entries.iter().filter(|e| is_image_filename(&e.name)).count(); + + // Look for ComicInfo.xml (case-insensitive). + let comic_info_entry = entries.iter().find(|e| { + let n = e.name.as_str(); + n == "comicinfo.xml" || n.ends_with("/comicinfo.xml") + }); + + let info = if let Some(entry) = comic_info_entry { + // Only decompress STORE entries here for simplicity; skip DEFLATE ones. + if entry.compression == COMPRESS_STORE || entry.compression == COMPRESS_DEFLATE { + if let Some(xml_bytes) = decompress_entry(&data, entry) { + Some(parse_comic_info(&xml_bytes)) + } else { + None + } + } else { + None + } + } else { + None + }; + + let msg = format!( + "cbz-comics: {} entries, {} images, ComicInfo.xml={}", + entries.len(), + image_count, + info.is_some(), + ); + log_info(&msg); + + // Build response JSON + let mut extra_pairs: Vec<(&str, String)> = vec![ + ("format", String::from("cbz")), + ]; + + let page_count_str; + if let Some(ref ci) = info { + if let Some(ref pc) = ci.page_count { + page_count_str = pc.clone(); + extra_pairs.push(("page_count", page_count_str.clone())); + } else { + page_count_str = format!("{}", image_count); + extra_pairs.push(("page_count", page_count_str.clone())); + } + if let Some(ref s) = ci.series { extra_pairs.push(("series", s.clone())) } + if let Some(ref n) = ci.issue_number { extra_pairs.push(("issue_number", n.clone())) } + if let Some(ref l) = ci.language { extra_pairs.push(("language", l.clone())) } + } else { + page_count_str = format!("{}", image_count); + extra_pairs.push(("page_count", page_count_str.clone())); + } + + // Build extra JSON object + let mut extra_json = String::from("{"); + for (i, (k, v)) in extra_pairs.iter().enumerate() { + if i > 0 { extra_json.push(','); } + extra_json.push('"'); + extra_json.push_str(k); + extra_json.push_str("\":\""); + extra_json.push_str(&json_escape(v)); + extra_json.push('"'); + } + extra_json.push('}'); + + let title_field = info.as_ref() + .and_then(|ci| ci.title.as_ref()) + .map(|t| format!(r#","title":"{}""#, json_escape(t))) + .unwrap_or_default(); + + let artist_field = info.as_ref() + .and_then(|ci| ci.writer.as_ref()) + .map(|w| format!(r#","artist":"{}""#, json_escape(w))) + .unwrap_or_default(); + + let genre_field = info.as_ref() + .and_then(|ci| ci.genre.as_ref()) + .map(|g| format!(r#","genre":"{}""#, json_escape(g))) + .unwrap_or_default(); + + let desc_field = info.as_ref() + .and_then(|ci| ci.summary.as_ref()) + .map(|s| format!(r#","description":"{}""#, json_escape(s))) + .unwrap_or_default(); + + let resp = format!( + r#"{{"extra":{}{}{}{}{}}}"#, + extra_json, title_field, artist_field, genre_field, desc_field, + ); + set_response(resp.as_bytes()); +} + +/// Generate a thumbnail from the cover image of a CBZ archive. +#[unsafe(no_mangle)] +pub extern "C" fn generate_thumbnail(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + let source_path = match json_get_str(&req, "source_path") { + Some(p) => p, + None => { + set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); + return; + } + }; + let output_path = match json_get_str(&req, "output_path") { + Some(p) => p, + None => { + set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); + return; + } + }; + + let lower = source_path.to_ascii_lowercase(); + if !lower.ends_with(".cbz") { + set_response(br#"{"path":"","width":0,"height":0,"format":"unknown"}"#); + return; + } + + let data = match load_cbz_file(source_path) { + Ok(d) => d, + Err(_) => { + set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); + return; + } + }; + + let entries = parse_central_directory(&data); + + // Find alphabetically first image file for the cover. + let mut image_entries: Vec<&ZipEntry> = entries.iter().filter(|e| is_image_filename(&e.name)).collect(); + image_entries.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); + + let cover = match image_entries.first() { + Some(e) => e, + None => { + set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); + return; + } + }; + + let image_bytes = match decompress_entry(&data, cover) { + Some(b) => b, + None => { + set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); + return; + } + }; + + let info = parse_image_info(&image_bytes).unwrap_or(ImageInfo { + width: 0, height: 0, format: "jpeg", + }); + + // Write thumbnail bytes to output path + let write_result = unsafe { + host_write_file( + output_path.as_ptr() as i32, + output_path.len() as i32, + image_bytes.as_ptr() as i32, + image_bytes.len() as i32, + ) + }; + if write_result < 0 { + set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); + return; + } + + let msg = format!( + "cbz-comics: thumbnail {}x{} {} written to {}", + info.width, info.height, info.format, output_path, + ); + log_info(&msg); + + let resp = format!( + r#"{{"path":"{}","width":{},"height":{},"format":"{}"}}"#, + json_escape(output_path), + info.width, + info.height, + info.format, + ); + set_response(resp.as_bytes()); +} diff --git a/examples/plugins/subtitle-detector/Cargo.lock b/examples/plugins/subtitle-detector/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..dda81f78a90e755efd0a764341c3f865622eac62 GIT binary patch literal 1249 zcmb`GOK;mS5QOjg6@qhYEtAW4fC2@2>b_{_=A_bfnCdl6 zb9?gR>W3CnE_v*`<}c1mp2qe^E1Tx&>D9*<-{bRhGx`(O(_N%~pK14LaK}5=$z8Kd z>l`mLGxm8Y^ZDDcl-K3)aOiV6uAz(5=`gQL*$;l~4{;N@ba~q6D(By>iARht%X->A z9RR(~{kWs*s_8L%V@{Vf}qIKNIBmu}7o{-R)q$L`bn3sg=3p(!}wgOy7 zta%4__TZ5V1K>goNykKtOt8!go=9_3Gnci$CMA+ia_tS+z#s!u3 literal 0 HcmV?d00001 diff --git a/examples/plugins/subtitle-detector/Cargo.toml b/examples/plugins/subtitle-detector/Cargo.toml new file mode 100644 index 0000000..2b7d8ec --- /dev/null +++ b/examples/plugins/subtitle-detector/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "subtitle-detector" +version = "1.0.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +dlmalloc = { version = "0.2", features = ["global"] } + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/examples/plugins/subtitle-detector/plugin.toml b/examples/plugins/subtitle-detector/plugin.toml new file mode 100644 index 0000000..d836b75 --- /dev/null +++ b/examples/plugins/subtitle-detector/plugin.toml @@ -0,0 +1,18 @@ +[plugin] +name = "subtitle-detector" +version = "1.0.0" +api_version = "1.0" +description = "Registers SRT, VTT, and ASS subtitle formats and extracts language and entry count metadata" +kind = ["media_type", "metadata_extractor"] +priority = 500 + +[plugin.binary] +wasm = "subtitle_detector.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +# Users must add their media root directories here. Example: +# read = ["/home/user/media", "/mnt/nas/subtitles"] +read = [] diff --git a/examples/plugins/subtitle-detector/src/lib.rs b/examples/plugins/subtitle-detector/src/lib.rs new file mode 100644 index 0000000..bfab5e7 --- /dev/null +++ b/examples/plugins/subtitle-detector/src/lib.rs @@ -0,0 +1,345 @@ +//! Subtitle-detector plugin for Pinakes. +//! +//! Registers SRT, VTT, and ASS/SSA subtitle file formats and extracts +//! language code and entry count metadata from them. +//! +//! Registered media types: +//! - `subtitle-srt`: extensions `["srt"]`, mime `["text/x-subrip"]` +//! - `subtitle-vtt`: extensions `["vtt"]`, mime `["text/vtt"]` +//! - `subtitle-ass`: extensions `["ass","ssa"]`, mime `["text/x-ass"]` +//! +//! Language detection uses filename conventions: `movie.en.srt` -> `en`. +//! +//! The `filesystem.read` capability in `plugin.toml` must be configured +//! to include the directories containing subtitle files. +//! +//! Build with: +//! RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release + +#![no_std] + +extern crate alloc; + +use alloc::{format, vec::Vec}; +use core::alloc::Layout; + +#[global_allocator] +static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; + +#[panic_handler] +fn panic_handler(_info: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// Host functions provided by the runtime +unsafe extern "C" { + fn host_set_result(ptr: i32, len: i32); + fn host_log(level: i32, ptr: i32, len: i32); + fn host_read_file(path_ptr: i32, path_len: i32) -> i32; + fn host_get_buffer(dest_ptr: i32, dest_len: i32) -> i32; +} + +fn set_response(json: &[u8]) { + unsafe { + host_set_result(json.as_ptr() as i32, json.len() as i32); + } +} + +fn log_info(msg: &str) { + unsafe { + host_log(2, msg.as_ptr() as i32, msg.len() as i32); + } +} + +unsafe fn read_request(ptr: i32, len: i32) -> Vec { + if ptr < 0 || len <= 0 { + return Vec::new(); + } + let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; + slice.to_vec() +} + +/// Extract a string value from a JSON object for a given key. +fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> { + let json_str = core::str::from_utf8(json).ok()?; + let pattern = format!("\"{}\"", key); + let key_pos = json_str.find(&pattern)?; + let after_key = &json_str[key_pos + pattern.len()..]; + let after_colon = after_key.trim_start().strip_prefix(':')?; + let after_colon = after_colon.trim_start(); + + if after_colon.starts_with('"') { + let value_start = 1; + let value_end = after_colon[value_start..].find('"')?; + Some(&after_colon[value_start..value_start + value_end]) + } else { + None + } +} + +/// Escape a string for safe inclusion in a JSON string value. +fn json_escape(s: &str) -> alloc::string::String { + let mut out = alloc::string::String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out +} + +// 512 KB content read limit for subtitle files +const MAX_FILE_BYTES: usize = 512 * 1024; + +/// Subtitle format variants. +enum SubtitleFormat { + Srt, + Vtt, + Ass, +} + +/// Determine subtitle format from file path extension. +fn detect_format(path: &str) -> Option { + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".srt") { + Some(SubtitleFormat::Srt) + } else if lower.ends_with(".vtt") { + Some(SubtitleFormat::Vtt) + } else if lower.ends_with(".ass") || lower.ends_with(".ssa") { + Some(SubtitleFormat::Ass) + } else { + None + } +} + +/// Try to detect a 2-3 letter language code from a filename stem. +/// Matches patterns like `movie.en.srt` or `film.fra.vtt`. +/// Returns the code if found. +fn detect_language(path: &str) -> Option<&str> { + // Get the filename component + let filename = path.rsplit('/').next().unwrap_or(path); + // Strip the final extension + let stem = if let Some(dot) = filename.rfind('.') { + &filename[..dot] + } else { + filename + }; + // Check for another dot-separated segment at the end of the stem + if let Some(dot) = stem.rfind('.') { + let candidate = &stem[dot + 1..]; + let len = candidate.len(); + if (len == 2 || len == 3) && candidate.bytes().all(|b| b.is_ascii_alphabetic()) { + return Some(candidate); + } + } + None +} + +/// Count `-->` occurrences in a byte slice. +fn count_arrow_markers(data: &[u8]) -> usize { + let mut count = 0usize; + let mut i = 0usize; + while i + 2 < data.len() { + if data[i] == b'-' && data[i + 1] == b'-' && data[i + 2] == b'>' { + count += 1; + i += 3; + } else { + i += 1; + } + } + count +} + +/// Count `Dialogue:` lines in an ASS/SSA file. +fn count_ass_dialogues(data: &[u8]) -> usize { + let mut count = 0usize; + let needle = b"Dialogue:"; + let mut i = 0usize; + // Count only at line starts (after newline or at file start) + let mut at_line_start = true; + while i < data.len() { + if at_line_start && data[i..].starts_with(needle) { + count += 1; + i += needle.len(); + at_line_start = false; + } else { + if data[i] == b'\n' { + at_line_start = true; + } else { + at_line_start = false; + } + i += 1; + } + } + count +} + +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + if size <= 0 { + return 0; + } + unsafe { + let layout = match Layout::from_size_align(size as usize, 1) { + Ok(l) => l, + Err(_) => return -1, + }; + let ptr = alloc::alloc::alloc(layout); + if ptr.is_null() { + return -1; + } + ptr as i32 + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn initialize() -> i32 { + log_info("subtitle-detector initialized"); + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn shutdown() -> i32 { + log_info("subtitle-detector shutdown"); + 0 +} + +/// Returns the media type definitions provided by this plugin. +#[unsafe(no_mangle)] +pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) { + let response = br#"[ +{"id":"subtitle-srt","name":"SubRip Subtitle","category":"document","extensions":["srt"],"mime_types":["text/x-subrip"]}, +{"id":"subtitle-vtt","name":"WebVTT Subtitle","category":"document","extensions":["vtt"],"mime_types":["text/vtt"]}, +{"id":"subtitle-ass","name":"Advanced SubStation Alpha Subtitle","category":"document","extensions":["ass","ssa"],"mime_types":["text/x-ass"]} +]"#; + set_response(response); +} + +/// Check whether this plugin can handle a given path. +#[unsafe(no_mangle)] +pub extern "C" fn can_handle(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + let path = json_get_str(&req, "path").unwrap_or(""); + let can = detect_format(path).is_some(); + if can { + set_response(br#"{"can_handle":true}"#); + } else { + set_response(br#"{"can_handle":false}"#); + } +} + +/// Returns the media type IDs this extractor supports. +#[unsafe(no_mangle)] +pub extern "C" fn supported_types(_ptr: i32, _len: i32) { + set_response(br#"["subtitle-srt","subtitle-vtt","subtitle-ass"]"#); +} + +/// Extract metadata from a subtitle file. +#[unsafe(no_mangle)] +pub extern "C" fn extract_metadata(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + let path = match json_get_str(&req, "path") { + Some(p) => p, + None => { + set_response(br#"{"extra":{"error":"missing path"}}"#); + return; + } + }; + + let format = match detect_format(path) { + Some(f) => f, + None => { + set_response(br#"{"extra":{"error":"unsupported format"}}"#); + return; + } + }; + + let format_str = match format { + SubtitleFormat::Srt => "srt", + SubtitleFormat::Vtt => "vtt", + SubtitleFormat::Ass => "ass", + }; + + let language = detect_language(path); + + // Load file contents + let file_size = unsafe { host_read_file(path.as_ptr() as i32, path.len() as i32) }; + if file_size < 0 { + // Return what we have without entry count + let lang_field = language + .map(|l| format!(r#","language":"{}""#, json_escape(l))) + .unwrap_or_default(); + let resp = format!( + r#"{{"extra":{{"format":"{}"{}}}}}"#, + format_str, lang_field, + ); + set_response(resp.as_bytes()); + return; + } + + if file_size as usize >= MAX_FILE_BYTES { + let lang_field = language + .map(|l| format!(r#","language":"{}""#, json_escape(l))) + .unwrap_or_default(); + let resp = format!( + r#"{{"extra":{{"format":"{}","too_large":"true"{}}}}}"#, + format_str, lang_field, + ); + set_response(resp.as_bytes()); + return; + } + + let buf_size = file_size as usize; + let entry_count = if buf_size == 0 { + 0usize + } else { + let layout = match Layout::from_size_align(buf_size, 1) { + Ok(l) => l, + Err(_) => { + set_response(br#"{"extra":{"error":"alloc failed"}}"#); + return; + } + }; + let buf_ptr = unsafe { alloc::alloc::alloc(layout) }; + if buf_ptr.is_null() { + set_response(br#"{"extra":{"error":"alloc failed"}}"#); + return; + } + + let copied = unsafe { host_get_buffer(buf_ptr as i32, file_size) }; + let count = if copied > 0 { + let data = unsafe { core::slice::from_raw_parts(buf_ptr, copied as usize) }; + match format_str { + "srt" => count_arrow_markers(data), + "vtt" => count_arrow_markers(data), + _ => count_ass_dialogues(data), + } + } else { + 0 + }; + + unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; + count + }; + + let msg = format!( + "subtitle-detector: format={}, entries={}, path={}", + format_str, entry_count, path, + ); + log_info(&msg); + + let lang_field = language + .map(|l| format!(r#","language":"{}""#, json_escape(l))) + .unwrap_or_default(); + + let resp = format!( + r#"{{"extra":{{"format":"{}","entry_count":"{}"{}}}}}"#, + format_str, entry_count, lang_field, + ); + set_response(resp.as_bytes()); +} diff --git a/examples/plugins/text-enrichment/Cargo.lock b/examples/plugins/text-enrichment/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..0697c2420c2dddeded216402223496ec7c273411 GIT binary patch literal 1247 zcmb`G%Wm5+5JmU;3c*>nmN|U0K!E~Xb>Cf(1s^jq5j_HlZsNZ$WgtNeI6JRMkppS& zIirX6?=i0}oOIbQoW{m zX-7Yu{Lo^`B~N|V{KI9<^VI%qWz#%9zWDg;dwhCoCO=|5-9_s6nRcHFchIp;?wWNz zE%8#bV4v5roWC7Qd0BrP4t*}iQ|RJ69+uNu_Jg1LL)@BNyFBl6ZRc&*#3RP%^)zmu zaY8V%ffIosA(do7N_0{q2?YR_E5l6?HVMIPJ#|)tO z%?JGNbvxt`ciZN*(wkq`{d!*O^V99zm+H^XE}8AULkAVK)LQXCT2~j&AtY}dgCK%a zoLi@f0UDu$Pl^l|gvJZcD<Nj>i$FawTsYK6VqL%?oRc$UBB0=@HyD`-72`&~k GSN{PV^M!B# literal 0 HcmV?d00001 diff --git a/examples/plugins/text-enrichment/Cargo.toml b/examples/plugins/text-enrichment/Cargo.toml new file mode 100644 index 0000000..d073c1f --- /dev/null +++ b/examples/plugins/text-enrichment/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "text-enrichment" +version = "1.0.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +dlmalloc = { version = "0.2", features = ["global"] } + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/examples/plugins/text-enrichment/plugin.toml b/examples/plugins/text-enrichment/plugin.toml new file mode 100644 index 0000000..f451a3e --- /dev/null +++ b/examples/plugins/text-enrichment/plugin.toml @@ -0,0 +1,18 @@ +[plugin] +name = "text-enrichment" +version = "1.0.0" +api_version = "1.0" +description = "Enriches plain text files with word count, line count, character count, and estimated reading time" +kind = ["metadata_extractor"] +priority = 500 + +[plugin.binary] +wasm = "text_enrichment.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +# Users must add their media root directories here. Example: +# read = ["/home/user/media", "/mnt/nas/texts"] +read = [] diff --git a/examples/plugins/text-enrichment/src/lib.rs b/examples/plugins/text-enrichment/src/lib.rs new file mode 100644 index 0000000..f6b248d --- /dev/null +++ b/examples/plugins/text-enrichment/src/lib.rs @@ -0,0 +1,198 @@ +//! Text-enrichment plugin for Pinakes. +//! +//! Extracts word count, line count, character count, and estimated reading +//! time from plain text (`.txt`) files. +//! +//! The `filesystem.read` capability list in `plugin.toml` must be configured +//! to include the directories where text files live. +//! +//! Build with: +//! RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release + +#![no_std] + +extern crate alloc; + +use alloc::{format, vec::Vec}; +use core::alloc::Layout; + +#[global_allocator] +static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; + +#[panic_handler] +fn panic_handler(_info: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// Host functions provided by the runtime +unsafe extern "C" { + fn host_set_result(ptr: i32, len: i32); + fn host_log(level: i32, ptr: i32, len: i32); + fn host_read_file(path_ptr: i32, path_len: i32) -> i32; + fn host_get_buffer(dest_ptr: i32, dest_len: i32) -> i32; +} + +fn set_response(json: &[u8]) { + unsafe { + host_set_result(json.as_ptr() as i32, json.len() as i32); + } +} + +fn log_info(msg: &str) { + unsafe { + host_log(2, msg.as_ptr() as i32, msg.len() as i32); + } +} + +unsafe fn read_request(ptr: i32, len: i32) -> Vec { + if ptr < 0 || len <= 0 { + return Vec::new(); + } + let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; + slice.to_vec() +} + +/// Extract a string value from a JSON object for a given key. +fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> { + let json_str = core::str::from_utf8(json).ok()?; + let pattern = format!("\"{}\"", key); + let key_pos = json_str.find(&pattern)?; + let after_key = &json_str[key_pos + pattern.len()..]; + let after_colon = after_key.trim_start().strip_prefix(':')?; + let after_colon = after_colon.trim_start(); + + if after_colon.starts_with('"') { + let value_start = 1; + let value_end = after_colon[value_start..].find('"')?; + Some(&after_colon[value_start..value_start + value_end]) + } else { + None + } +} + +// 5 MB content read limit +const MAX_FILE_BYTES: usize = 5 * 1024 * 1024; + +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + if size <= 0 { + return 0; + } + unsafe { + let layout = match Layout::from_size_align(size as usize, 1) { + Ok(l) => l, + Err(_) => return -1, + }; + let ptr = alloc::alloc::alloc(layout); + if ptr.is_null() { + return -1; + } + ptr as i32 + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn initialize() -> i32 { + log_info("text-enrichment initialized"); + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn shutdown() -> i32 { + log_info("text-enrichment shutdown"); + 0 +} + +/// Returns the media types this extractor supports. +#[unsafe(no_mangle)] +pub extern "C" fn supported_types(_ptr: i32, _len: i32) { + set_response(br#"["text"]"#); +} + +/// Extract text statistics from a plain text file. +#[unsafe(no_mangle)] +pub extern "C" fn extract_metadata(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + let path = match json_get_str(&req, "path") { + Some(p) => p, + None => { + set_response(br#"{"extra":{"error":"missing path"}}"#); + return; + } + }; + + // Ask the host to load the file into the exchange buffer. + let file_size = unsafe { host_read_file(path.as_ptr() as i32, path.len() as i32) }; + if file_size < 0 { + set_response(br#"{"extra":{"error":"read failed"}}"#); + return; + } + + if file_size as usize >= MAX_FILE_BYTES { + set_response(br#"{"extra":{"too_large":"true"}}"#); + return; + } + + let buf_size = file_size as usize; + if buf_size == 0 { + let resp = r#"{"extra":{"word_count":"0","line_count":"0","byte_count":"0","reading_minutes":"0"}}"#; + set_response(resp.as_bytes()); + return; + } + + let layout = match Layout::from_size_align(buf_size, 1) { + Ok(l) => l, + Err(_) => { + set_response(br#"{"extra":{"error":"alloc failed"}}"#); + return; + } + }; + let buf_ptr = unsafe { alloc::alloc::alloc(layout) }; + if buf_ptr.is_null() { + set_response(br#"{"extra":{"error":"alloc failed"}}"#); + return; + } + + let copied = unsafe { host_get_buffer(buf_ptr as i32, file_size) }; + if copied <= 0 { + unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; + set_response(br#"{"extra":{"error":"buffer copy failed"}}"#); + return; + } + + let content = unsafe { core::slice::from_raw_parts(buf_ptr, copied as usize) }; + + let byte_count = content.len(); + let line_count = content.iter().filter(|&&b| b == b'\n').count() + + if content.last().map_or(true, |&b| b != b'\n') { 1 } else { 0 }; + + // Count words: transitions from whitespace to non-whitespace. + let mut word_count = 0usize; + let mut in_word = false; + for &b in content { + let is_ws = b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'; + if !is_ws && !in_word { + word_count += 1; + in_word = true; + } else if is_ws { + in_word = false; + } + } + + // Estimate reading time at 200 words per minute, rounding up. + let reading_minutes = (word_count + 199) / 200; + + unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; + + let msg = format!( + "text-enrichment: {} words, {} lines, {} chars", + word_count, line_count, byte_count + ); + log_info(&msg); + + let resp = format!( + r#"{{"extra":{{"word_count":"{}","line_count":"{}","byte_count":"{}","reading_minutes":"{}"}}}}"#, + word_count, line_count, byte_count, reading_minutes, + ); + set_response(resp.as_bytes()); +} From 047801a9da4ec4296599703dda3fc5e830060ad2 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 May 2026 21:52:27 +0300 Subject: [PATCH 19/22] pinakes-server: import directly from extracted crates Signed-off-by: NotAShelf Change-Id: Id43ab8edfd56196d376d72ecc136f6086a6a6964 --- packages/pinakes-server/Cargo.toml | 5 +++ packages/pinakes-server/src/dto/enrichment.rs | 6 +-- packages/pinakes-server/src/dto/sync.rs | 16 ++++---- packages/pinakes-server/src/main.rs | 41 +++++++++++++------ packages/pinakes-server/src/routes/media.rs | 24 +++++------ packages/pinakes-server/src/routes/plugins.rs | 2 +- packages/pinakes-server/src/routes/sync.rs | 32 +++++++-------- packages/pinakes-server/src/state.rs | 5 ++- packages/pinakes-server/tests/plugin.rs | 5 ++- 9 files changed, 77 insertions(+), 59 deletions(-) diff --git a/packages/pinakes-server/Cargo.toml b/packages/pinakes-server/Cargo.toml index aacccdf..6ead01c 100644 --- a/packages/pinakes-server/Cargo.toml +++ b/packages/pinakes-server/Cargo.toml @@ -7,6 +7,11 @@ license.workspace = true [dependencies] pinakes-core = { workspace = true } pinakes-plugin-api = { workspace = true } +pinakes-types = { workspace = true } +pinakes-enrichment = { workspace = true } +pinakes-metadata = { workspace = true } +pinakes-plugin = { workspace = true } +pinakes-sync = { workspace = true } tokio = { workspace = true } serde = { workspace = true } diff --git a/packages/pinakes-server/src/dto/enrichment.rs b/packages/pinakes-server/src/dto/enrichment.rs index 4e144de..a1f19bd 100644 --- a/packages/pinakes-server/src/dto/enrichment.rs +++ b/packages/pinakes-server/src/dto/enrichment.rs @@ -13,10 +13,8 @@ pub struct ExternalMetadataResponse { pub last_updated: DateTime, } -impl From - for ExternalMetadataResponse -{ - fn from(m: pinakes_core::enrichment::ExternalMetadata) -> Self { +impl From for ExternalMetadataResponse { + fn from(m: pinakes_enrichment::ExternalMetadata) -> Self { let metadata = serde_json::from_str(&m.metadata_json).unwrap_or_else(|e| { tracing::warn!( "failed to deserialize external metadata JSON for media {}: {}", diff --git a/packages/pinakes-server/src/dto/sync.rs b/packages/pinakes-server/src/dto/sync.rs index 34b2056..0f7b172 100644 --- a/packages/pinakes-server/src/dto/sync.rs +++ b/packages/pinakes-server/src/dto/sync.rs @@ -25,8 +25,8 @@ pub struct DeviceResponse { pub created_at: DateTime, } -impl From for DeviceResponse { - fn from(d: pinakes_core::sync::SyncDevice) -> Self { +impl From for DeviceResponse { + fn from(d: pinakes_sync::SyncDevice) -> Self { Self { id: d.id.0.to_string(), name: d.name, @@ -72,8 +72,8 @@ pub struct SyncChangeResponse { pub timestamp: DateTime, } -impl From for SyncChangeResponse { - fn from(e: pinakes_core::sync::SyncLogEntry) -> Self { +impl From for SyncChangeResponse { + fn from(e: pinakes_sync::SyncLogEntry) -> Self { Self { id: e.id.to_string(), sequence: e.sequence, @@ -124,8 +124,8 @@ pub struct ConflictResponse { pub detected_at: DateTime, } -impl From for ConflictResponse { - fn from(c: pinakes_core::sync::SyncConflict) -> Self { +impl From for ConflictResponse { + fn from(c: pinakes_sync::SyncConflict) -> Self { Self { id: c.id.to_string(), path: c.path, @@ -162,8 +162,8 @@ pub struct UploadSessionResponse { pub expires_at: DateTime, } -impl From for UploadSessionResponse { - fn from(s: pinakes_core::sync::UploadSession) -> Self { +impl From for UploadSessionResponse { + fn from(s: pinakes_sync::UploadSession) -> Self { Self { id: s.id.to_string(), target_path: s.target_path, diff --git a/packages/pinakes-server/src/main.rs b/packages/pinakes-server/src/main.rs index 28dbeb9..d48f079 100644 --- a/packages/pinakes-server/src/main.rs +++ b/packages/pinakes-server/src/main.rs @@ -236,10 +236,27 @@ async fn main() -> Result<()> { // Initialize plugin manager if plugins are enabled (needed before job queue) let plugin_manager = if config.plugins.enabled { - match pinakes_core::plugin::PluginManager::new( + let pm_cfg = { + let p = &config.plugins; + pinakes_plugin::PluginManagerConfig { + plugin_dirs: p.plugin_dirs.clone(), + enable_hot_reload: p.enable_hot_reload, + allow_unsigned: p.allow_unsigned, + max_concurrent_ops: p.max_concurrent_ops, + plugin_timeout_secs: p.plugin_timeout_secs, + timeouts: pinakes_types::config::PluginTimeoutConfig { + capability_query_secs: p.timeouts.capability_query_secs, + processing_secs: p.timeouts.processing_secs, + event_handler_secs: p.timeouts.event_handler_secs, + }, + max_consecutive_failures: p.max_consecutive_failures, + trusted_keys: p.trusted_keys.clone(), + } + }; + match pinakes_plugin::PluginManager::new( config.plugins.data_dir.clone(), config.plugins.cache_dir.clone(), - config.plugins.clone().into(), + pm_cfg, ) { Ok(pm) => { tracing::info!("Plugin manager initialized"); @@ -538,15 +555,13 @@ async fn main() -> Result<()> { } }, JobKind::Enrich { media_ids } => { - use pinakes_core::{ - enrichment::{ - MetadataEnricher, - books::BookEnricher, - lastfm::LastFmEnricher, - musicbrainz::MusicBrainzEnricher, - tmdb::TmdbEnricher, - }, - media_type::MediaCategory, + use pinakes_core::media_type::MediaCategory; + use pinakes_enrichment::{ + MetadataEnricher, + books::BookEnricher, + lastfm::LastFmEnricher, + musicbrainz::MusicBrainzEnricher, + tmdb::TmdbEnricher, }; let enrich_cfg = &config.enrichment; @@ -598,7 +613,7 @@ async fn main() -> Result<()> { let category = item.media_type.category(); for enricher in &enrichers { let source = enricher.source(); - use pinakes_core::enrichment::EnrichmentSourceType; + use pinakes_enrichment::EnrichmentSourceType; let applicable = match source { EnrichmentSourceType::MusicBrainz | EnrichmentSourceType::LastFm => { @@ -758,7 +773,7 @@ async fn main() -> Result<()> { let chunked_upload_manager = { let config_read = config_arc.read().await; if config_read.sync.enabled { - let manager = pinakes_core::sync::ChunkedUploadManager::new( + let manager = pinakes_sync::ChunkedUploadManager::new( config_read.sync.temp_upload_dir.clone(), ); match manager.init().await { diff --git a/packages/pinakes-server/src/routes/media.rs b/packages/pinakes-server/src/routes/media.rs index 6aa3ec5..ed4a7be 100644 --- a/packages/pinakes-server/src/routes/media.rs +++ b/packages/pinakes-server/src/routes/media.rs @@ -1258,10 +1258,10 @@ pub async fn rename_media( // Record in sync log let item = state.storage.get_media(media_id).await?; - let change = pinakes_core::sync::SyncLogEntry { + let change = pinakes_sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Moved, + change_type: pinakes_sync::SyncChangeType::Moved, media_id: Some(media_id), path: item.path.to_string_lossy().to_string(), content_hash: Some(item.content_hash.clone()), @@ -1319,10 +1319,10 @@ pub async fn move_media_endpoint( // Record in sync log let item = state.storage.get_media(media_id).await?; - let change = pinakes_core::sync::SyncLogEntry { + let change = pinakes_sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Moved, + change_type: pinakes_sync::SyncChangeType::Moved, media_id: Some(media_id), path: item.path.to_string_lossy().to_string(), content_hash: Some(item.content_hash.clone()), @@ -1404,10 +1404,10 @@ pub async fn batch_move_media( continue; }; let new_path = req.destination.join(file_name); - let change = pinakes_core::sync::SyncLogEntry { + let change = pinakes_sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Moved, + change_type: pinakes_sync::SyncChangeType::Moved, media_id: Some(*media_id), path: new_path.to_string_lossy().to_string(), content_hash: None, @@ -1464,10 +1464,10 @@ pub async fn soft_delete_media( state.storage.soft_delete_media(media_id).await?; // Record in sync log - let change = pinakes_core::sync::SyncLogEntry { + let change = pinakes_sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Deleted, + change_type: pinakes_sync::SyncChangeType::Deleted, media_id: Some(media_id), path: item.path.to_string_lossy().to_string(), content_hash: Some(item.content_hash.clone()), @@ -1524,10 +1524,10 @@ pub async fn restore_media( let item = state.storage.get_media(media_id).await?; // Record in sync log - let change = pinakes_core::sync::SyncLogEntry { + let change = pinakes_sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Created, + change_type: pinakes_sync::SyncChangeType::Created, media_id: Some(media_id), path: item.path.to_string_lossy().to_string(), content_hash: Some(item.content_hash.clone()), @@ -1681,10 +1681,10 @@ pub async fn permanent_delete_media( state.storage.delete_media(media_id).await?; // Record in sync log - let change = pinakes_core::sync::SyncLogEntry { + let change = pinakes_sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Deleted, + change_type: pinakes_sync::SyncChangeType::Deleted, media_id: Some(media_id), path: item.path.to_string_lossy().to_string(), content_hash: Some(item.content_hash.clone()), diff --git a/packages/pinakes-server/src/routes/plugins.rs b/packages/pinakes-server/src/routes/plugins.rs index e2b45b0..e5399d5 100644 --- a/packages/pinakes-server/src/routes/plugins.rs +++ b/packages/pinakes-server/src/routes/plugins.rs @@ -4,7 +4,7 @@ use axum::{ Json, extract::{Path, State}, }; -use pinakes_core::plugin::PluginManager; +use pinakes_plugin::PluginManager; use rustc_hash::FxHashMap; use crate::{ diff --git a/packages/pinakes-server/src/routes/sync.rs b/packages/pinakes-server/src/routes/sync.rs index debc4cd..33a09d6 100644 --- a/packages/pinakes-server/src/routes/sync.rs +++ b/packages/pinakes-server/src/routes/sync.rs @@ -11,19 +11,17 @@ use chrono::Utc; use pinakes_core::{ config::ConflictResolution, model::ContentHash, - sync::{ - DeviceId, - DeviceType, - SyncChangeType, - SyncConflict, - SyncDevice, - SyncLogEntry, - UploadSession, - UploadStatus, - generate_device_token, - hash_device_token, - update_device_cursor, - }, + sync::{generate_device_token, hash_device_token, update_device_cursor}, +}; +use pinakes_sync::{ + DeviceId, + DeviceType, + SyncChangeType, + SyncConflict, + SyncDevice, + SyncLogEntry, + UploadSession, + UploadStatus, }; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio_util::io::ReaderStream; @@ -176,7 +174,7 @@ pub async fn get_device( .map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?; // Verify ownership - if device.user_id != user_id { + if device.user_id.0 != user_id.0 { return Err(ApiError::forbidden("Not authorized to access this device")); } @@ -213,7 +211,7 @@ pub async fn update_device( .map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?; // Verify ownership - if device.user_id != user_id { + if device.user_id.0 != user_id.0 { return Err(ApiError::forbidden("Not authorized to update this device")); } @@ -261,7 +259,7 @@ pub async fn delete_device( .map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?; // Verify ownership - if device.user_id != user_id { + if device.user_id.0 != user_id.0 { return Err(ApiError::forbidden("Not authorized to delete this device")); } @@ -302,7 +300,7 @@ pub async fn regenerate_token( .map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?; // Verify ownership - if device.user_id != user_id { + if device.user_id.0 != user_id.0 { return Err(ApiError::forbidden( "Not authorized to regenerate token for this device", )); diff --git a/packages/pinakes-server/src/state.rs b/packages/pinakes-server/src/state.rs index 2917ed5..eba6994 100644 --- a/packages/pinakes-server/src/state.rs +++ b/packages/pinakes-server/src/state.rs @@ -5,14 +5,15 @@ use pinakes_core::{ config::Config, jobs::JobQueue, managed_storage::ManagedStorageService, - plugin::{PluginManager, PluginPipeline}, + plugin::PluginPipeline, scan::ScanProgress, scheduler::TaskScheduler, storage::DynStorageBackend, - sync::ChunkedUploadManager, transcode::TranscodeService, webhooks::WebhookDispatcher, }; +use pinakes_plugin::PluginManager; +use pinakes_sync::ChunkedUploadManager; use tokio::sync::{RwLock, Semaphore}; // Note: Sessions are now stored in the database via StorageBackend diff --git a/packages/pinakes-server/tests/plugin.rs b/packages/pinakes-server/tests/plugin.rs index 287fef7..36b4be6 100644 --- a/packages/pinakes-server/tests/plugin.rs +++ b/packages/pinakes-server/tests/plugin.rs @@ -17,7 +17,8 @@ use common::{ test_addr, }; use http_body_util::BodyExt; -use pinakes_core::{config::PluginsConfig, plugin::PluginManager}; +use pinakes_core::config::PluginsConfig; +use pinakes_plugin::PluginManager; use tower::ServiceExt; async fn setup_app_with_plugins() @@ -50,7 +51,7 @@ async fn setup_app_with_plugins() max_concurrent_ops: 2, plugin_timeout_secs: 10, timeouts: - pinakes_core::config::PluginTimeoutConfig::default(), + pinakes_types::config::PluginTimeoutConfig::default(), max_consecutive_failures: 5, trusted_keys: vec![], }; From 602cfb68b7823ace1748c92ba893a5acc9924587 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 May 2026 21:52:31 +0300 Subject: [PATCH 20/22] treewide: fix as many Clippy warnings as I humanly can Signed-off-by: NotAShelf Change-Id: I3c99acd032679bb7a04505db1a712b906a6a6964 --- crates/pinakes-core/src/events.rs | 23 +- crates/pinakes-core/src/jobs.rs | 4 +- crates/pinakes-core/src/opener.rs | 5 + crates/pinakes-core/src/plugin/pipeline.rs | 69 ++---- crates/pinakes-core/src/sharing.rs | 2 +- crates/pinakes-core/src/storage/migrations.rs | 6 + crates/pinakes-core/src/storage/mod.rs | 20 +- crates/pinakes-core/src/storage/sqlite.rs | 170 ++++++-------- crates/pinakes-core/src/subtitles.rs | 7 +- crates/pinakes-core/src/thumbnail.rs | 8 +- crates/pinakes-core/src/transcode.rs | 2 +- crates/pinakes-core/src/users.rs | 26 ++- crates/pinakes-core/src/webhooks.rs | 2 +- crates/pinakes-enrichment/src/books.rs | 8 +- crates/pinakes-enrichment/src/tmdb.rs | 24 +- crates/pinakes-migrations/src/lib.rs | 2 + crates/pinakes-plugin/src/loader.rs | 23 +- crates/pinakes-plugin/src/manager.rs | 5 +- crates/pinakes-plugin/src/runtime.rs | 19 +- crates/pinakes-plugin/src/security.rs | 85 +++++-- crates/pinakes-sync/src/conflict.rs | 3 +- crates/pinakes-sync/src/models.rs | 2 +- crates/pinakes-types/src/config.rs | 28 ++- .../pinakes-types/src/media_type/builtin.rs | 12 +- .../pinakes-types/src/media_type/registry.rs | 12 + crates/pinakes-types/src/model.rs | 4 +- packages/pinakes-server/src/app.rs | 48 ++-- packages/pinakes-server/src/auth.rs | 17 +- packages/pinakes-server/src/dto/sharing.rs | 4 +- packages/pinakes-server/src/error.rs | 10 +- packages/pinakes-server/src/main.rs | 47 ++-- .../pinakes-server/src/routes/analytics.rs | 15 ++ packages/pinakes-server/src/routes/audit.rs | 3 + packages/pinakes-server/src/routes/auth.rs | 60 +++-- packages/pinakes-server/src/routes/backup.rs | 3 + packages/pinakes-server/src/routes/books.rs | 50 +++- .../pinakes-server/src/routes/collections.rs | 21 ++ packages/pinakes-server/src/routes/config.rs | 60 +++-- .../pinakes-server/src/routes/database.rs | 9 + .../pinakes-server/src/routes/duplicates.rs | 3 + .../pinakes-server/src/routes/enrichment.rs | 9 + packages/pinakes-server/src/routes/export.rs | 6 + packages/pinakes-server/src/routes/health.rs | 12 +- .../pinakes-server/src/routes/integrity.rs | 15 ++ packages/pinakes-server/src/routes/jobs.rs | 6 + packages/pinakes-server/src/routes/media.rs | 214 +++++++++++++----- packages/pinakes-server/src/routes/notes.rs | 36 ++- packages/pinakes-server/src/routes/photos.rs | 8 + .../pinakes-server/src/routes/playlists.rs | 34 ++- packages/pinakes-server/src/routes/plugins.rs | 30 +++ .../src/routes/saved_searches.rs | 9 + packages/pinakes-server/src/routes/scan.rs | 3 + .../src/routes/scheduled_tasks.rs | 41 ++-- packages/pinakes-server/src/routes/search.rs | 6 + packages/pinakes-server/src/routes/shares.rs | 79 +++++-- packages/pinakes-server/src/routes/social.rs | 53 +++-- .../pinakes-server/src/routes/statistics.rs | 3 + .../pinakes-server/src/routes/streaming.rs | 67 +++++- .../pinakes-server/src/routes/subtitles.rs | 18 +- packages/pinakes-server/src/routes/sync.rs | 57 ++++- packages/pinakes-server/src/routes/tags.rs | 21 ++ .../pinakes-server/src/routes/transcode.rs | 12 + packages/pinakes-server/src/routes/upload.rs | 12 + packages/pinakes-server/src/routes/users.rs | 24 ++ .../pinakes-server/src/routes/webhooks.rs | 35 +-- 65 files changed, 1191 insertions(+), 540 deletions(-) diff --git a/crates/pinakes-core/src/events.rs b/crates/pinakes-core/src/events.rs index cbe90ae..e2222e9 100644 --- a/crates/pinakes-core/src/events.rs +++ b/crates/pinakes-core/src/events.rs @@ -3,10 +3,7 @@ use chrono::{DateTime, Utc}; -use crate::{ - error::Result, - model::{MediaId, MediaItem}, -}; +use crate::model::{MediaId, MediaItem}; /// Configuration for event detection #[derive(Debug, Clone)] @@ -68,15 +65,16 @@ fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { } /// Detect photo events from a list of media items +#[must_use] pub fn detect_events( mut items: Vec, config: &EventDetectionConfig, -) -> Result> { +) -> Vec { // Filter to only photos with date_taken items.retain(|item| item.date_taken.is_some()); if items.is_empty() { - return Ok(Vec::new()); + return Vec::new(); } // Sort by date_taken (None < Some, but all are Some after retain) @@ -84,7 +82,7 @@ pub fn detect_events( let mut events: Vec = Vec::new(); let Some(first_date) = items[0].date_taken else { - return Ok(Vec::new()); + return Vec::new(); }; let mut current_event_items: Vec = vec![items[0].id]; let mut current_start_time = first_date; @@ -171,21 +169,22 @@ pub fn detect_events( }); } - Ok(events) + events } /// Detect photo bursts (rapid sequences of photos) /// Returns groups of media IDs that are likely burst sequences +#[must_use] pub fn detect_bursts( mut items: Vec, max_gap_secs: i64, min_burst_size: usize, -) -> Result>> { +) -> Vec> { // Filter to only photos with date_taken items.retain(|item| item.date_taken.is_some()); if items.is_empty() { - return Ok(Vec::new()); + return Vec::new(); } // Sort by date_taken (None < Some, but all are Some after retain) @@ -193,7 +192,7 @@ pub fn detect_bursts( let mut bursts: Vec> = Vec::new(); let Some(first_date) = items[0].date_taken else { - return Ok(Vec::new()); + return Vec::new(); }; let mut current_burst: Vec = vec![items[0].id]; let mut last_time = first_date; @@ -221,5 +220,5 @@ pub fn detect_bursts( bursts.push(current_burst); } - Ok(bursts) + bursts } diff --git a/crates/pinakes-core/src/jobs.rs b/crates/pinakes-core/src/jobs.rs index d4bc106..ba68220 100644 --- a/crates/pinakes-core/src/jobs.rs +++ b/crates/pinakes-core/src/jobs.rs @@ -185,12 +185,12 @@ impl JobQueue { }; { - let mut map = self.jobs.write().await; - map.insert(id, job); // Prune old terminal jobs to prevent unbounded memory growth. // Keep at most 500 completed/failed/cancelled entries, removing the // oldest. const MAX_TERMINAL_JOBS: usize = 500; + let mut map = self.jobs.write().await; + map.insert(id, job); let mut terminal: Vec<(Uuid, chrono::DateTime)> = map .iter() .filter(|(_, j)| { diff --git a/crates/pinakes-core/src/opener.rs b/crates/pinakes-core/src/opener.rs index 5326154..db3b90e 100644 --- a/crates/pinakes-core/src/opener.rs +++ b/crates/pinakes-core/src/opener.rs @@ -3,6 +3,11 @@ use std::{path::Path, process::Command}; use crate::error::{PinakesError, Result}; pub trait Opener: Send + Sync { + /// Open the file at `path` with the system default application. + /// + /// # Errors + /// + /// Returns an error if the opener command fails to launch or exits non-zero. fn open(&self, path: &Path) -> Result<()>; } diff --git a/crates/pinakes-core/src/plugin/pipeline.rs b/crates/pinakes-core/src/plugin/pipeline.rs index 3a8439e..fcd7a3d 100644 --- a/crates/pinakes-core/src/plugin/pipeline.rs +++ b/crates/pinakes-core/src/plugin/pipeline.rs @@ -20,6 +20,7 @@ use std::{ use pinakes_metadata::ExtractedMetadata; use pinakes_plugin::{ + CapabilityEnforcer, PluginManager, rpc::{ CanHandleRequest, @@ -131,7 +132,7 @@ impl PluginPipeline { pub async fn discover_capabilities(&self) -> crate::error::Result<()> { info!("discovering plugin capabilities"); - let timeout = Duration::from_secs(self.timeouts.capability_query_secs); + let timeout = Duration::from_secs(self.timeouts.capability_query); let mut caps = CachedCapabilities::new(); // Discover metadata extractors @@ -322,7 +323,7 @@ impl PluginPipeline { /// Iterates `MediaTypeProvider` plugins in priority order, falling back to /// the built-in resolver at implicit priority 100. pub async fn resolve_media_type(&self, path: &Path) -> Option { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self.manager.get_enabled_by_kind_sorted("media_type").await; let mut builtin_ran = false; @@ -341,11 +342,7 @@ impl PluginPipeline { } // Validate the call is allowed for this plugin kind - if !self - .manager - .enforcer() - .validate_function_call(kinds, "can_handle") - { + if !CapabilityEnforcer::validate_function_call(kinds, "can_handle") { continue; } @@ -441,7 +438,7 @@ impl PluginPipeline { path: &Path, media_type: &MediaType, ) -> crate::error::Result { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("metadata_extractor") @@ -475,10 +472,7 @@ impl PluginPipeline { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "extract_metadata") + if !CapabilityEnforcer::validate_function_call(kinds, "extract_metadata") { continue; } @@ -558,7 +552,7 @@ impl PluginPipeline { media_type: &MediaType, thumb_dir: &Path, ) -> crate::error::Result> { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("thumbnail_generator") @@ -596,11 +590,10 @@ impl PluginPipeline { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "generate_thumbnail") - { + if !CapabilityEnforcer::validate_function_call( + kinds, + "generate_thumbnail", + ) { continue; } @@ -693,7 +686,7 @@ impl PluginPipeline { event_type: &str, payload: &serde_json::Value, ) { - let timeout = Duration::from_secs(self.timeouts.event_handler_secs); + let timeout = Duration::from_secs(self.timeouts.event_handler); // Collect plugin IDs interested in this event let interested_ids: Vec = { @@ -725,11 +718,7 @@ impl PluginPipeline { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "handle_event") - { + if !CapabilityEnforcer::validate_function_call(kinds, "handle_event") { continue; } @@ -789,7 +778,7 @@ impl PluginPipeline { limit: usize, offset: usize, ) -> Vec { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("search_backend") @@ -801,11 +790,7 @@ impl PluginPipeline { if !self.is_healthy(id).await { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "search") - { + if !CapabilityEnforcer::validate_function_call(kinds, "search") { continue; } @@ -862,7 +847,7 @@ impl PluginPipeline { /// Index a media item in all search backend plugins (fan-out). pub async fn index_item(&self, req: &IndexItemRequest) { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("search_backend") @@ -872,11 +857,7 @@ impl PluginPipeline { if !self.is_healthy(id).await { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "index_item") - { + if !CapabilityEnforcer::validate_function_call(kinds, "index_item") { continue; } @@ -905,7 +886,7 @@ impl PluginPipeline { /// Remove a media item from all search backend plugins (fan-out). pub async fn remove_item(&self, media_id: &str) { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); let plugins = self .manager .get_enabled_by_kind_sorted("search_backend") @@ -919,11 +900,7 @@ impl PluginPipeline { if !self.is_healthy(id).await { continue; } - if !self - .manager - .enforcer() - .validate_function_call(kinds, "remove_item") - { + if !CapabilityEnforcer::validate_function_call(kinds, "remove_item") { continue; } @@ -963,7 +940,7 @@ impl PluginPipeline { /// Load a specific theme by ID from the provider that registered it. pub async fn load_theme(&self, theme_id: &str) -> Option { - let timeout = Duration::from_secs(self.timeouts.processing_secs); + let timeout = Duration::from_secs(self.timeouts.processing); // Find which plugin owns this theme let owner_id = { @@ -990,11 +967,7 @@ impl PluginPipeline { let plugin = plugins.iter().find(|(id, ..)| id == &owner_id)?; let (id, _priority, kinds, wasm) = plugin; - if !self - .manager - .enforcer() - .validate_function_call(kinds, "load_theme") - { + if !CapabilityEnforcer::validate_function_call(kinds, "load_theme") { return None; } diff --git a/crates/pinakes-core/src/sharing.rs b/crates/pinakes-core/src/sharing.rs index 62856bc..66fc2dc 100644 --- a/crates/pinakes-core/src/sharing.rs +++ b/crates/pinakes-core/src/sharing.rs @@ -204,7 +204,7 @@ impl SharePermissions { /// Merges two permission sets, taking the most permissive values. #[must_use] - pub const fn merge(&self, other: &Self) -> Self { + pub const fn merge(self, other: Self) -> Self { Self { view: ShareViewPermissions { can_view: self.view.can_view || other.view.can_view, diff --git a/crates/pinakes-core/src/storage/migrations.rs b/crates/pinakes-core/src/storage/migrations.rs index ac78968..2de24b7 100644 --- a/crates/pinakes-core/src/storage/migrations.rs +++ b/crates/pinakes-core/src/storage/migrations.rs @@ -1,3 +1,6 @@ +/// # Errors +/// +/// Returns an error if migrations fail to apply. #[cfg(feature = "sqlite")] pub fn run_sqlite_migrations( conn: &mut rusqlite::Connection, @@ -7,6 +10,9 @@ pub fn run_sqlite_migrations( .map_err(|e| crate::error::PinakesError::Migration(e.to_string())) } +/// # Errors +/// +/// Returns an error if migrations fail to apply. #[cfg(feature = "postgres")] pub async fn run_postgres_migrations( client: &mut tokio_postgres::Client, diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs index 968e3f2..a505e42 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -423,10 +423,12 @@ pub trait StorageBackend: Send + Sync + 'static { user_id: crate::users::UserId, media_id: crate::model::MediaId, ) -> Result { - match self.check_library_access(user_id, media_id).await { - Ok(perm) => Ok(perm.can_read()), - Err(_) => Ok(false), - } + Ok( + self + .check_library_access(user_id, media_id) + .await + .is_ok_and(|_perm| crate::users::LibraryPermission::can_read()), + ) } /// Check if a user has write access to a media item @@ -435,10 +437,12 @@ pub trait StorageBackend: Send + Sync + 'static { user_id: crate::users::UserId, media_id: crate::model::MediaId, ) -> Result { - match self.check_library_access(user_id, media_id).await { - Ok(perm) => Ok(perm.can_write()), - Err(_) => Ok(false), - } + Ok( + self + .check_library_access(user_id, media_id) + .await + .is_ok_and(crate::users::LibraryPermission::can_write), + ) } /// Rate a media item (1-5 stars) with an optional text review. diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index 82987f0..f11f2ee 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -255,7 +255,6 @@ fn row_to_audit_entry(row: &Row) -> rusqlite::Result { let action = match action_str.as_str() { "imported" => AuditAction::Imported, - "updated" => AuditAction::Updated, "deleted" => AuditAction::Deleted, "tagged" => AuditAction::Tagged, "untagged" => AuditAction::Untagged, @@ -725,7 +724,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -863,7 +861,6 @@ impl StorageBackend for SqliteBackend { drop(stmt); item.custom_fields = load_custom_fields_sync(&db, item.id) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); item }; Ok(item) @@ -902,10 +899,8 @@ impl StorageBackend for SqliteBackend { if let Some(mut item) = result { item.custom_fields = load_custom_fields_sync(&db, item.id) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); Some(item) } else { - drop(db); None } }; @@ -945,10 +940,8 @@ impl StorageBackend for SqliteBackend { if let Some(mut item) = result { item.custom_fields = load_custom_fields_sync(&db, item.id) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); Some(item) } else { - drop(db); None } }; @@ -1006,7 +999,6 @@ impl StorageBackend for SqliteBackend { drop(stmt); load_custom_fields_batch(&db, &mut rows) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); rows }; Ok(rows) @@ -1062,7 +1054,6 @@ impl StorageBackend for SqliteBackend { ], ) .map_err(db_ctx("update_media", &item.id))?; - drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!( "media item {}", @@ -1088,7 +1079,6 @@ impl StorageBackend for SqliteBackend { id.0.to_string() ]) .map_err(db_ctx("delete_media", id))?; - drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); } @@ -1147,7 +1137,6 @@ impl StorageBackend for SqliteBackend { ], ) .map_err(db_ctx("create_tag", &name))?; - drop(db); Tag { id, name, @@ -1184,7 +1173,6 @@ impl StorageBackend for SqliteBackend { } })?; drop(stmt); - drop(db); tag }; Ok(tag) @@ -1211,7 +1199,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -1230,7 +1217,6 @@ impl StorageBackend for SqliteBackend { let changed = db .execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()]) .map_err(db_ctx("delete_tag", id))?; - drop(db); if changed == 0 { return Err(PinakesError::TagNotFound(id.to_string())); } @@ -1299,7 +1285,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -1330,7 +1315,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -1372,7 +1356,6 @@ impl StorageBackend for SqliteBackend { ], ) .map_err(db_ctx("create_collection", &name))?; - drop(db); Collection { id, name, @@ -1413,7 +1396,6 @@ impl StorageBackend for SqliteBackend { } })?; drop(stmt); - drop(db); collection }; Ok(collection) @@ -1441,7 +1423,6 @@ impl StorageBackend for SqliteBackend { .collect::>>() .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - drop(db); rows }; Ok(rows) @@ -1462,7 +1443,6 @@ impl StorageBackend for SqliteBackend { id.to_string() ]) .map_err(db_ctx("delete_collection", id))?; - drop(db); if changed == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); } @@ -1565,7 +1545,6 @@ impl StorageBackend for SqliteBackend { drop(stmt); load_custom_fields_batch(&db, &mut rows) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); rows }; Ok(rows) @@ -1675,7 +1654,6 @@ impl StorageBackend for SqliteBackend { let total_count: i64 = db .query_row(&count_sql, count_param_refs.as_slice(), |row| row.get(0)) .map_err(|e| PinakesError::Database(e.to_string()))?; - drop(db); SearchResults { items, @@ -1777,7 +1755,6 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))? }; drop(stmt); - drop(db); rows }; @@ -1854,7 +1831,6 @@ impl StorageBackend for SqliteBackend { map.insert(name, field); } drop(stmt); - drop(db); map }; Ok(map) @@ -2907,7 +2883,7 @@ impl StorageBackend for SqliteBackend { crate::users::UserProfile { avatar_path: None, bio: None, - preferences: Default::default(), + preferences: crate::users::UserPreferences::default(), } }; @@ -3017,36 +2993,34 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; // Fetch updated user - Ok( - db.query_row( - "SELECT id, username, password_hash, role, created_at, updated_at \ - FROM users WHERE id = ?", - [&id_str], - |row| { - let id_str: String = row.get(0)?; - let profile = load_user_profile_sync(&db, &id_str)?; - Ok(crate::users::User { - id: crate::users::UserId(parse_uuid(&id_str)?), - username: row.get(1)?, - password_hash: row.get(2)?, - role: serde_json::from_str(&row.get::<_, String>(3)?) - .unwrap_or(crate::config::UserRole::Viewer), - profile, - created_at: chrono::DateTime::parse_from_rfc3339( - &row.get::<_, String>(4)?, - ) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339( - &row.get::<_, String>(5)?, - ) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - }) - }, - ) - .map_err(|e| PinakesError::Database(e.to_string()))?, + db.query_row( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users WHERE id = ?", + [&id_str], + |row| { + let id_str: String = row.get(0)?; + let profile = load_user_profile_sync(&db, &id_str)?; + Ok(crate::users::User { + id: crate::users::UserId(parse_uuid(&id_str)?), + username: row.get(1)?, + password_hash: row.get(2)?, + role: serde_json::from_str(&row.get::<_, String>(3)?) + .unwrap_or(crate::config::UserRole::Viewer), + profile, + created_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(4)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(5)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + }) + }, ) + .map_err(|e| PinakesError::Database(e.to_string())) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) .await @@ -5949,39 +5923,33 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); - for row in rows { - match row { - Ok((item, current_page, total_pages)) => { - // Calculate status based on progress - let calculated_status = total_pages.map_or( - // No total pages known, assume reading - crate::model::ReadingStatus::Reading, - |total| { - if total > 0 { - let percent = (f64::from(current_page) / f64::from(total) - * 100.0) - .min(100.0); - if percent >= 100.0 { - crate::model::ReadingStatus::Completed - } else if percent > 0.0 { - crate::model::ReadingStatus::Reading - } else { - crate::model::ReadingStatus::ToRead - } - } else { - crate::model::ReadingStatus::Reading - } - }, - ); - - // Filter by status if specified - match status { - None => results.push(item), - Some(s) if s == calculated_status => results.push(item), - _ => {}, + for (item, current_page, total_pages) in rows.flatten() { + // Calculate status based on progress + let calculated_status = total_pages.map_or( + // No total pages known, assume reading + crate::model::ReadingStatus::Reading, + |total| { + if total > 0 { + let percent = + (f64::from(current_page) / f64::from(total) * 100.0).min(100.0); + if percent >= 100.0 { + crate::model::ReadingStatus::Completed + } else if percent > 0.0 { + crate::model::ReadingStatus::Reading + } else { + crate::model::ReadingStatus::ToRead + } + } else { + crate::model::ReadingStatus::Reading } }, - Err(_) => continue, + ); + + // Filter by status if specified + match status { + None => results.push(item), + Some(s) if s == calculated_status => results.push(item), + _ => {}, } } Ok::<_, PinakesError>(results) @@ -7809,7 +7777,7 @@ impl StorageBackend for SqliteBackend { ) if *share_user == uid => { return Ok(Some(share.permissions)); }, - _ => continue, + _ => {}, } } @@ -8216,7 +8184,7 @@ impl StorageBackend for SqliteBackend { let new_name = new_name.to_string(); let (old_path, storage_mode) = tokio::task::spawn_blocking({ - let conn = conn.clone(); + let conn = Arc::clone(&conn); let id_str = id_str.clone(); move || { let conn = conn.lock().map_err(|e| { @@ -8239,7 +8207,7 @@ impl StorageBackend for SqliteBackend { })??; let old_path_buf = std::path::PathBuf::from(&old_path); - let parent = old_path_buf.parent().unwrap_or(std::path::Path::new("")); + let parent = old_path_buf.parent().unwrap_or_else(|| Path::new("")); let new_path = parent.join(&new_name); let new_path_str = new_path.to_string_lossy().to_string(); @@ -8288,7 +8256,7 @@ impl StorageBackend for SqliteBackend { let new_dir = new_directory.to_path_buf(); let (old_path, file_name, storage_mode) = tokio::task::spawn_blocking({ - let conn = conn.clone(); + let conn = Arc::clone(&conn); let id_str = id_str.clone(); move || { let conn = conn.lock().map_err(|e| { @@ -8788,10 +8756,8 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| PinakesError::Database(format!("connection mutex poisoned: {e}")))?; let mut nodes = Vec::new(); let mut edges = Vec::new(); - let mut node_ids = rustc_hash::FxHashSet::default(); - // Get nodes - either all markdown files or those connected to center - if let Some(center_id) = center_id_str { + let node_ids = if let Some(center_id) = center_id_str { // BFS to find connected nodes within depth let mut frontier = vec![center_id.clone()]; let mut visited = rustc_hash::FxHashSet::default(); @@ -8839,9 +8805,10 @@ impl StorageBackend for SqliteBackend { frontier = next_frontier; } - node_ids = visited; + visited } else { // Get all markdown files with links (limit to 500 for performance) + let mut ids = rustc_hash::FxHashSet::default(); let mut stmt = conn.prepare( "SELECT DISTINCT id FROM media_items WHERE media_type = 'markdown' AND deleted_at IS NULL @@ -8852,10 +8819,10 @@ impl StorageBackend for SqliteBackend { Ok(id) }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - node_ids.insert(row.map_err(|e| PinakesError::Database(e.to_string()))?); - + ids.insert(row.map_err(|e| PinakesError::Database(e.to_string()))?); } - } + ids + }; // Build nodes with metadata for node_id in &node_ids { @@ -9108,11 +9075,6 @@ fn row_to_share(row: &Row) -> rusqlite::Result { let password_hash: Option = row.get(7)?; let target = match target_type.as_str() { - "media" => { - crate::sharing::ShareTarget::Media { - media_id: MediaId(parse_uuid(&target_id_str)?), - } - }, "collection" => { crate::sharing::ShareTarget::Collection { collection_id: parse_uuid(&target_id_str)?, @@ -9136,12 +9098,6 @@ fn row_to_share(row: &Row) -> rusqlite::Result { }; let recipient = match recipient_type.as_str() { - "public_link" => { - crate::sharing::ShareRecipient::PublicLink { - token: public_token.unwrap_or_default(), - password_hash, - } - }, "user" => { crate::sharing::ShareRecipient::User { user_id: crate::users::UserId(parse_uuid( diff --git a/crates/pinakes-core/src/subtitles.rs b/crates/pinakes-core/src/subtitles.rs index 76cc8b5..d6ddb9c 100644 --- a/crates/pinakes-core/src/subtitles.rs +++ b/crates/pinakes-core/src/subtitles.rs @@ -172,9 +172,8 @@ pub async fn list_embedded_tracks( } })?; - let streams = match json.get("streams").and_then(|s| s.as_array()) { - Some(s) => s, - None => return Ok(vec![]), + let Some(streams) = json.get("streams").and_then(|s| s.as_array()) else { + return Ok(vec![]); }; let mut tracks = Vec::new(); @@ -203,7 +202,7 @@ pub async fn list_embedded_tracks( .map(str::to_owned); tracks.push(SubtitleTrackInfo { - index: idx as u32, + index: u32::try_from(idx).unwrap_or(u32::MAX), language, format, title, diff --git a/crates/pinakes-core/src/thumbnail.rs b/crates/pinakes-core/src/thumbnail.rs index 3c79b25..118b568 100644 --- a/crates/pinakes-core/src/thumbnail.rs +++ b/crates/pinakes-core/src/thumbnail.rs @@ -367,7 +367,7 @@ pub enum CoverSize { impl CoverSize { #[must_use] - pub const fn dimensions(&self) -> Option<(u32, u32)> { + pub const fn dimensions(self) -> Option<(u32, u32)> { match self { Self::Tiny => Some((64, 64)), Self::Grid => Some((320, 320)), @@ -377,7 +377,7 @@ impl CoverSize { } #[must_use] - pub const fn filename(&self) -> &'static str { + pub const fn filename(self) -> &'static str { match self { Self::Tiny => "tiny.jpg", Self::Grid => "grid.jpg", @@ -541,7 +541,7 @@ pub enum ThumbnailSize { impl ThumbnailSize { /// Get the pixel size for this thumbnail variant #[must_use] - pub const fn pixels(&self) -> u32 { + pub const fn pixels(self) -> u32 { match self { Self::Tiny => 64, Self::Grid => 320, @@ -551,7 +551,7 @@ impl ThumbnailSize { /// Get the subdirectory name for this size #[must_use] - pub const fn subdir_name(&self) -> &'static str { + pub const fn subdir_name(self) -> &'static str { match self { Self::Tiny => "tiny", Self::Grid => "grid", diff --git a/crates/pinakes-core/src/transcode.rs b/crates/pinakes-core/src/transcode.rs index 416c1a6..1edfd8d 100644 --- a/crates/pinakes-core/src/transcode.rs +++ b/crates/pinakes-core/src/transcode.rs @@ -103,9 +103,9 @@ impl TranscodeService { pub fn new(config: TranscodingConfig) -> Self { let max_concurrent = config.max_concurrent.max(1); Self { + config, sessions: Arc::new(RwLock::new(FxHashMap::default())), semaphore: Arc::new(Semaphore::new(max_concurrent)), - config, } } diff --git a/crates/pinakes-core/src/users.rs b/crates/pinakes-core/src/users.rs index 159b972..2c3a774 100644 --- a/crates/pinakes-core/src/users.rs +++ b/crates/pinakes-core/src/users.rs @@ -1,15 +1,15 @@ //! User management and authentication use chrono::{DateTime, Utc}; +pub use pinakes_types::model::UserId; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; + use crate::{ config::UserRole, error::{PinakesError, Result}, }; -pub use pinakes_types::model::UserId; - /// User account with profile information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { @@ -67,24 +67,24 @@ pub enum LibraryPermission { impl LibraryPermission { /// Checks if read permission is granted. #[must_use] - pub const fn can_read(&self) -> bool { + pub const fn can_read() -> bool { true } /// Checks if write permission is granted. #[must_use] - pub const fn can_write(&self) -> bool { + pub const fn can_write(self) -> bool { matches!(self, Self::Write | Self::Admin) } /// Checks if admin permission is granted. #[must_use] - pub const fn can_admin(&self) -> bool { + pub const fn can_admin(self) -> bool { matches!(self, Self::Admin) } #[must_use] - pub const fn as_str(&self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Read => "read", Self::Write => "write", @@ -132,6 +132,10 @@ pub mod auth { use super::{PinakesError, Result}; /// Hash a password using Argon2 + /// + /// # Errors + /// + /// Returns an error if password hashing fails. pub fn hash_password(password: &str) -> Result { use argon2::{ Argon2, @@ -150,6 +154,10 @@ pub mod auth { } /// Verify a password against a hash + /// + /// # Errors + /// + /// Returns an error if the hash is invalid or cannot be parsed. pub fn verify_password(password: &str, hash: &str) -> Result { use argon2::{ Argon2, @@ -193,17 +201,17 @@ mod tests { #[test] fn test_library_permission_levels() { let read = LibraryPermission::Read; - assert!(read.can_read()); + assert!(LibraryPermission::can_read()); assert!(!read.can_write()); assert!(!read.can_admin()); let write = LibraryPermission::Write; - assert!(write.can_read()); + assert!(LibraryPermission::can_read()); assert!(write.can_write()); assert!(!write.can_admin()); let admin = LibraryPermission::Admin; - assert!(admin.can_read()); + assert!(LibraryPermission::can_read()); assert!(admin.can_write()); assert!(admin.can_admin()); } diff --git a/crates/pinakes-core/src/webhooks.rs b/crates/pinakes-core/src/webhooks.rs index 424d86a..fae9669 100644 --- a/crates/pinakes-core/src/webhooks.rs +++ b/crates/pinakes-core/src/webhooks.rs @@ -69,7 +69,7 @@ impl WebhookDispatcher { /// Dispatch an event to all matching webhooks. /// This is fire-and-forget, errors are logged but not propagated. pub fn dispatch(self: &Arc, event: WebhookEvent) { - let this = self.clone(); + let this = Arc::clone(self); tokio::spawn(async move { this.dispatch_inner(&event).await; }); diff --git a/crates/pinakes-enrichment/src/books.rs b/crates/pinakes-enrichment/src/books.rs index 63b09c6..6786ca1 100644 --- a/crates/pinakes-enrichment/src/books.rs +++ b/crates/pinakes-enrichment/src/books.rs @@ -74,7 +74,7 @@ impl BookEnricher { })?; Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), + id: Uuid::now_v7(), media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */ source: EnrichmentSourceType::OpenLibrary, external_id: None, @@ -104,7 +104,7 @@ impl BookEnricher { })?; Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), + id: Uuid::now_v7(), media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */ source: EnrichmentSourceType::GoogleBooks, external_id: Some(book.id.clone()), @@ -136,7 +136,7 @@ impl BookEnricher { })?; return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), + id: Uuid::now_v7(), media_id: pinakes_types::model::MediaId(Uuid::nil()), source: EnrichmentSourceType::OpenLibrary, external_id: result.key.clone(), @@ -155,7 +155,7 @@ impl BookEnricher { })?; return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), + id: Uuid::now_v7(), media_id: pinakes_types::model::MediaId(Uuid::nil()), source: EnrichmentSourceType::GoogleBooks, external_id: Some(book.id.clone()), diff --git a/crates/pinakes-enrichment/src/tmdb.rs b/crates/pinakes-enrichment/src/tmdb.rs index 810db2e..027402e 100644 --- a/crates/pinakes-enrichment/src/tmdb.rs +++ b/crates/pinakes-enrichment/src/tmdb.rs @@ -20,21 +20,21 @@ pub struct TmdbEnricher { impl TmdbEnricher { /// Create a new `TMDb` enricher. /// - /// # Panics + /// # Errors /// - /// Panics if the HTTP client cannot be built (programming error in client - /// configuration). - #[must_use] - pub fn new(api_key: String) -> Self { - Self { - client: reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .expect("failed to build HTTP client with configured timeouts"), + /// Returns an error if the HTTP client cannot be built (e.g. TLS + /// initialisation failure). + pub fn new(api_key: String) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .map_err(|e| PinakesError::External(e.to_string()))?; + Ok(Self { + client, api_key, base_url: "https://api.themoviedb.org/3".to_string(), - } + }) } } diff --git a/crates/pinakes-migrations/src/lib.rs b/crates/pinakes-migrations/src/lib.rs index a1fc8e5..2890155 100644 --- a/crates/pinakes-migrations/src/lib.rs +++ b/crates/pinakes-migrations/src/lib.rs @@ -5,6 +5,7 @@ mod postgres_migrations { embed_migrations!("migrations/postgres"); } +#[must_use] pub fn sqlite_migrations() -> Migrations<'static> { Migrations::new(vec![ M::up(include_str!("../migrations/sqlite/V1__initial_schema.sql")), @@ -49,6 +50,7 @@ pub fn sqlite_migrations() -> Migrations<'static> { ]) } +#[must_use] pub fn postgres_runner() -> refinery::Runner { postgres_migrations::migrations::runner() } diff --git a/crates/pinakes-plugin/src/loader.rs b/crates/pinakes-plugin/src/loader.rs index f8242e8..5aeb484 100644 --- a/crates/pinakes-plugin/src/loader.rs +++ b/crates/pinakes-plugin/src/loader.rs @@ -21,11 +21,7 @@ impl PluginLoader { } /// Discover all plugins in configured directories - /// - /// # Errors - /// - /// Returns an error if a plugin directory cannot be searched. - pub fn discover_plugins(&self) -> Result> { + pub fn discover_plugins(&self) -> Vec { let mut manifests = Vec::new(); for dir in &self.plugin_dirs { @@ -41,7 +37,7 @@ impl PluginLoader { manifests.extend(found); } - Ok(manifests) + manifests } /// Discover plugins in a specific directory @@ -271,7 +267,7 @@ impl PluginLoader { /// /// Returns an error if the path does not exist, is missing `plugin.toml`, /// the WASM binary is not found, or the WASM file is invalid. - pub fn validate_plugin_package(&self, path: &Path) -> Result<()> { + pub fn validate_plugin_package(path: &Path) -> Result<()> { // Check that the path exists if !path.exists() { return Err(anyhow!("Plugin path does not exist: {}", path.display())); @@ -339,7 +335,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - let manifests = loader.discover_plugins().unwrap(); + let manifests = loader.discover_plugins(); assert_eq!(manifests.len(), 0); } @@ -367,7 +363,7 @@ wasm = "plugin.wasm" .unwrap(); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - let manifests = loader.discover_plugins().unwrap(); + let manifests = loader.discover_plugins(); assert_eq!(manifests.len(), 1); assert_eq!(manifests[0].plugin.name, "test-plugin"); @@ -392,17 +388,15 @@ wasm = "plugin.wasm" "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - let loader = PluginLoader::new(vec![]); - // Should fail without WASM file - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); + assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_err()); // Create valid WASM file (magic number only) std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") .unwrap(); // Should succeed now - assert!(loader.validate_plugin_package(&plugin_dir).is_ok()); + assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_ok()); } #[test] @@ -426,7 +420,6 @@ wasm = "plugin.wasm" // Create invalid WASM file std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap(); - let loader = PluginLoader::new(vec![]); - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); + assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_err()); } } diff --git a/crates/pinakes-plugin/src/manager.rs b/crates/pinakes-plugin/src/manager.rs index 22609e2..cfe33a9 100644 --- a/crates/pinakes-plugin/src/manager.rs +++ b/crates/pinakes-plugin/src/manager.rs @@ -144,7 +144,7 @@ impl PluginManager { pub async fn discover_and_load_all(&self) -> Result> { info!("Discovering plugins from {:?}", self.config.plugin_dirs); - let manifests = self.loader.discover_plugins()?; + let manifests = self.loader.discover_plugins(); let ordered = Self::resolve_load_order(&manifests); let mut loaded_plugins = Vec::new(); @@ -645,6 +645,7 @@ impl PluginManager { }, } } + drop(registry); pages } @@ -666,6 +667,7 @@ impl PluginManager { merged.insert(k.clone(), v.clone()); } } + drop(registry); merged } @@ -686,6 +688,7 @@ impl PluginManager { widgets.push((plugin.id.clone(), widget.clone())); } } + drop(registry); widgets } diff --git a/crates/pinakes-plugin/src/runtime.rs b/crates/pinakes-plugin/src/runtime.rs index e07a1c4..72afb43 100644 --- a/crates/pinakes-plugin/src/runtime.rs +++ b/crates/pinakes-plugin/src/runtime.rs @@ -561,9 +561,7 @@ impl HostFunctions { if let Some(ref allowed) = caller.data().context.capabilities.network.allowed_domains { - let parsed = if let Ok(u) = url::Url::parse(&url_str) { - u - } else { + let Ok(parsed) = url::Url::parse(&url_str) else { tracing::warn!(url = %url_str, "plugin provided invalid URL"); return -1; }; @@ -717,15 +715,12 @@ impl HostFunctions { return -2; } - match std::env::var(&key_str) { - Ok(value) => { - let bytes = value.into_bytes(); - let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = bytes; - len - }, - Err(_) => -1, - } + std::env::var(&key_str).map_or(-1, |value| { + let bytes = value.into_bytes(); + let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); + caller.data_mut().exchange_buffer = bytes; + len + }) }, )?; diff --git a/crates/pinakes-plugin/src/security.rs b/crates/pinakes-plugin/src/security.rs index 6bebb94..b9810a3 100644 --- a/crates/pinakes-plugin/src/security.rs +++ b/crates/pinakes-plugin/src/security.rs @@ -242,7 +242,6 @@ impl CapabilityEnforcer { /// bugs from calling wrong functions on plugins. Returns `true` if allowed. #[must_use] pub fn validate_function_call( - &self, plugin_kinds: &[String], function_name: &str, ) -> bool { @@ -423,51 +422,91 @@ mod tests { #[test] fn test_validate_function_call_lifecycle_always_allowed() { - let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "initialize")); - assert!(enforcer.validate_function_call(&kinds, "shutdown")); - assert!(enforcer.validate_function_call(&kinds, "health_check")); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "initialize" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, "shutdown" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "health_check" + )); } #[test] fn test_validate_function_call_metadata_extractor() { - let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); - assert!(enforcer.validate_function_call(&kinds, "supported_types")); - assert!(!enforcer.validate_function_call(&kinds, "search")); - assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail")); - assert!(!enforcer.validate_function_call(&kinds, "can_handle")); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "extract_metadata" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "supported_types" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, "search" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, + "generate_thumbnail" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, + "can_handle" + )); } #[test] fn test_validate_function_call_multi_kind() { - let enforcer = CapabilityEnforcer::new(); let kinds = vec!["media_type".to_string(), "metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "can_handle")); - assert!(enforcer.validate_function_call(&kinds, "supported_media_types")); - assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); - assert!(!enforcer.validate_function_call(&kinds, "search")); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "can_handle" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "supported_media_types" + )); + assert!(CapabilityEnforcer::validate_function_call( + &kinds, + "extract_metadata" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, "search" + )); } #[test] fn test_validate_function_call_unknown_function() { - let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; - assert!(!enforcer.validate_function_call(&kinds, "unknown_func")); - assert!(!enforcer.validate_function_call(&kinds, "")); + assert!(!CapabilityEnforcer::validate_function_call( + &kinds, + "unknown_func" + )); + assert!(!CapabilityEnforcer::validate_function_call(&kinds, "")); } #[test] fn test_validate_function_call_shared_supported_types() { - let enforcer = CapabilityEnforcer::new(); let extractor = vec!["metadata_extractor".to_string()]; let generator = vec!["thumbnail_generator".to_string()]; let search = vec!["search_backend".to_string()]; - assert!(enforcer.validate_function_call(&extractor, "supported_types")); - assert!(enforcer.validate_function_call(&generator, "supported_types")); - assert!(!enforcer.validate_function_call(&search, "supported_types")); + assert!(CapabilityEnforcer::validate_function_call( + &extractor, + "supported_types" + )); + assert!(CapabilityEnforcer::validate_function_call( + &generator, + "supported_types" + )); + assert!(!CapabilityEnforcer::validate_function_call( + &search, + "supported_types" + )); } } diff --git a/crates/pinakes-sync/src/conflict.rs b/crates/pinakes-sync/src/conflict.rs index 986ccdd..0a9993b 100644 --- a/crates/pinakes-sync/src/conflict.rs +++ b/crates/pinakes-sync/src/conflict.rs @@ -94,8 +94,7 @@ pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome { } }, (Some(_), None) => ConflictOutcome::UseLocal, - (None, Some(_)) => ConflictOutcome::UseServer, - (None, None) => ConflictOutcome::UseServer, // Default to server + (None, Some(_) | None) => ConflictOutcome::UseServer, // Default to server } } diff --git a/crates/pinakes-sync/src/models.rs b/crates/pinakes-sync/src/models.rs index 229c5e9..6588d47 100644 --- a/crates/pinakes-sync/src/models.rs +++ b/crates/pinakes-sync/src/models.rs @@ -364,7 +364,7 @@ impl UploadSession { chunk_count, status: UploadStatus::Pending, created_at: now, - expires_at: now + chrono::Duration::hours(timeout_hours as i64), + expires_at: now + chrono::Duration::hours(timeout_hours.cast_signed()), last_activity: now, } } diff --git a/crates/pinakes-types/src/config.rs b/crates/pinakes-types/src/config.rs index 9c131a8..29e31f1 100644 --- a/crates/pinakes-types/src/config.rs +++ b/crates/pinakes-types/src/config.rs @@ -504,7 +504,7 @@ pub enum UserRole { impl UserRole { #[must_use] - pub const fn can_read(self) -> bool { + pub const fn can_read() -> bool { true } @@ -533,14 +533,20 @@ impl std::fmt::Display for UserRole { pub struct PluginTimeoutConfig { /// Timeout for capability discovery queries (`supported_types`, /// `interested_events`) - #[serde(default = "default_capability_query_timeout")] - pub capability_query_secs: u64, + #[serde( + default = "default_capability_query_timeout", + rename = "capability_query_secs" + )] + pub capability_query: u64, /// Timeout for processing calls (`extract_metadata`, `generate_thumbnail`) - #[serde(default = "default_processing_timeout")] - pub processing_secs: u64, + #[serde(default = "default_processing_timeout", rename = "processing_secs")] + pub processing: u64, /// Timeout for event handler calls - #[serde(default = "default_event_handler_timeout")] - pub event_handler_secs: u64, + #[serde( + default = "default_event_handler_timeout", + rename = "event_handler_secs" + )] + pub event_handler: u64, } const fn default_capability_query_timeout() -> u64 { @@ -558,9 +564,9 @@ const fn default_event_handler_timeout() -> u64 { impl Default for PluginTimeoutConfig { fn default() -> Self { Self { - capability_query_secs: default_capability_query_timeout(), - processing_secs: default_processing_timeout(), - event_handler_secs: default_event_handler_timeout(), + capability_query: default_capability_query_timeout(), + processing: default_processing_timeout(), + event_handler: default_event_handler_timeout(), } } } @@ -1138,7 +1144,7 @@ pub enum StorageBackendType { impl StorageBackendType { #[must_use] - pub const fn as_str(&self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Sqlite => "sqlite", Self::Postgres => "postgres", diff --git a/crates/pinakes-types/src/media_type/builtin.rs b/crates/pinakes-types/src/media_type/builtin.rs index 93701b7..9201b6f 100644 --- a/crates/pinakes-types/src/media_type/builtin.rs +++ b/crates/pinakes-types/src/media_type/builtin.rs @@ -63,7 +63,7 @@ pub enum MediaCategory { impl BuiltinMediaType { /// Get the unique, stable ID for this media type. #[must_use] - pub const fn id(&self) -> &'static str { + pub const fn id(self) -> &'static str { match self { Self::Mp3 => "mp3", Self::Flac => "flac", @@ -100,7 +100,7 @@ impl BuiltinMediaType { /// Get the display name for this media type #[must_use] - pub fn name(&self) -> String { + pub fn name(self) -> String { match self { Self::Mp3 => "MP3 Audio".to_string(), Self::Flac => "FLAC Audio".to_string(), @@ -180,7 +180,7 @@ impl BuiltinMediaType { } #[must_use] - pub const fn mime_type(&self) -> &'static str { + pub const fn mime_type(self) -> &'static str { match self { Self::Mp3 => "audio/mpeg", Self::Flac => "audio/flac", @@ -216,7 +216,7 @@ impl BuiltinMediaType { } #[must_use] - pub const fn category(&self) -> MediaCategory { + pub const fn category(self) -> MediaCategory { match self { Self::Mp3 | Self::Flac @@ -246,7 +246,7 @@ impl BuiltinMediaType { } #[must_use] - pub const fn extensions(&self) -> &'static [&'static str] { + pub const fn extensions(self) -> &'static [&'static str] { match self { Self::Mp3 => &["mp3"], Self::Flac => &["flac"], @@ -283,7 +283,7 @@ impl BuiltinMediaType { /// Returns true if this is a RAW image format. #[must_use] - pub const fn is_raw(&self) -> bool { + pub const fn is_raw(self) -> bool { matches!( self, Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2 diff --git a/crates/pinakes-types/src/media_type/registry.rs b/crates/pinakes-types/src/media_type/registry.rs index 871f12c..0c116dd 100644 --- a/crates/pinakes-types/src/media_type/registry.rs +++ b/crates/pinakes-types/src/media_type/registry.rs @@ -49,6 +49,10 @@ impl MediaTypeRegistry { } /// Register a new media type + /// + /// # Errors + /// + /// Returns an error if a media type with the same ID is already registered. pub fn register(&mut self, descriptor: MediaTypeDescriptor) -> Result<()> { // Check if ID is already registered if self.types.contains_key(&descriptor.id) { @@ -74,6 +78,10 @@ impl MediaTypeRegistry { } /// Unregister a media type + /// + /// # Errors + /// + /// Returns an error if no media type with the given ID is registered. pub fn unregister(&mut self, id: &str) -> Result<()> { let descriptor = self .types @@ -146,6 +154,10 @@ impl MediaTypeRegistry { } /// Unregister all types from a specific plugin + /// + /// # Errors + /// + /// Returns an error if unregistering any individual type fails. pub fn unregister_plugin(&mut self, plugin_id: &str) -> Result { let type_ids: Vec = self .types diff --git a/crates/pinakes-types/src/model.rs b/crates/pinakes-types/src/model.rs index f2f2863..c978f51 100644 --- a/crates/pinakes-types/src/model.rs +++ b/crates/pinakes-types/src/model.rs @@ -215,7 +215,7 @@ pub enum CustomFieldType { impl CustomFieldType { #[must_use] - pub const fn as_str(&self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Text => "text", Self::Number => "number", @@ -262,7 +262,7 @@ pub enum CollectionKind { impl CollectionKind { #[must_use] - pub const fn as_str(&self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Manual => "manual", Self::Virtual => "virtual", diff --git a/packages/pinakes-server/src/app.rs b/packages/pinakes-server/src/app.rs index 3d2f531..88f0080 100644 --- a/packages/pinakes-server/src/app.rs +++ b/packages/pinakes-server/src/app.rs @@ -27,8 +27,6 @@ pub fn create_router( create_router_with_tls(state, rate_limits, None) } -/// Build a governor rate limiter from per-second and burst-size values. -/// Panics if the config is invalid (callers must validate before use). fn build_governor( per_second: u64, burst_size: u32, @@ -38,13 +36,18 @@ fn build_governor( governor::middleware::NoOpMiddleware, >, > { - Arc::new( - GovernorConfigBuilder::default() - .per_second(per_second) - .burst_size(burst_size) - .finish() - .expect("rate limit config was validated at startup"), - ) + // finish() returns None only when per_second=0; clamp to ensure it always + // returns Some + let per_second = per_second.max(1); + let burst_size = burst_size.max(1); + let Some(config) = GovernorConfigBuilder::default() + .per_second(per_second) + .burst_size(burst_size) + .finish() + else { + return build_governor(1, 1); + }; + Arc::new(config) } /// Create the router with TLS configuration for security headers @@ -521,8 +524,16 @@ pub fn create_router_with_tls( // CORS configuration: use config-driven origins if specified, // otherwise fall back to default localhost origins let cors = { - let origins: Vec = - if let Ok(config_read) = state.config.try_read() { + let default_origins = || { + vec![ + HeaderValue::from_static("http://localhost:3000"), + HeaderValue::from_static("http://127.0.0.1:3000"), + HeaderValue::from_static("tauri://localhost"), + ] + }; + let origins: Vec = state.config.try_read().map_or_else( + |_| default_origins(), + |config_read| { if config_read.server.cors_enabled && !config_read.server.cors_origins.is_empty() { @@ -533,19 +544,10 @@ pub fn create_router_with_tls( .filter_map(|o| HeaderValue::from_str(o).ok()) .collect() } else { - vec![ - HeaderValue::from_static("http://localhost:3000"), - HeaderValue::from_static("http://127.0.0.1:3000"), - HeaderValue::from_static("tauri://localhost"), - ] + default_origins() } - } else { - vec![ - HeaderValue::from_static("http://localhost:3000"), - HeaderValue::from_static("http://127.0.0.1:3000"), - HeaderValue::from_static("tauri://localhost"), - ] - }; + }, + ); CorsLayer::new() .allow_origin(origins) diff --git a/packages/pinakes-server/src/auth.rs b/packages/pinakes-server/src/auth.rs index 6405612..ca81026 100644 --- a/packages/pinakes-server/src/auth.rs +++ b/packages/pinakes-server/src/auth.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use axum::{ extract::{Request, State}, http::StatusCode, @@ -90,8 +92,10 @@ pub async fn require_auth( if session.expires_at < now { let username = session.username; // Delete expired session in a bounded background task - if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() { - let storage = state.storage.clone(); + if let Ok(permit) = + Arc::clone(&state.session_semaphore).try_acquire_owned() + { + let storage = Arc::clone(&state.storage); let token_owned = token.clone(); tokio::spawn(async move { if let Err(e) = storage.delete_session(&token_owned).await { @@ -105,8 +109,9 @@ pub async fn require_auth( } // Update last_accessed timestamp in a bounded background task - if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() { - let storage = state.storage.clone(); + if let Ok(permit) = Arc::clone(&state.session_semaphore).try_acquire_owned() + { + let storage = Arc::clone(&state.storage); let token_owned = token.clone(); tokio::spawn(async move { if let Err(e) = storage.touch_session(&token_owned).await { @@ -209,7 +214,9 @@ pub async fn require_admin(request: Request, next: Next) -> Response { /// Resolve the authenticated username (from request extensions) to a `UserId`. /// -/// Returns an error if the user cannot be found. +/// # Errors +/// +/// Returns an error if the user cannot be found in the database. pub async fn resolve_user_id( storage: &pinakes_core::storage::DynStorageBackend, username: &str, diff --git a/packages/pinakes-server/src/dto/sharing.rs b/packages/pinakes-server/src/dto/sharing.rs index 4757e26..65dc4c1 100644 --- a/packages/pinakes-server/src/dto/sharing.rs +++ b/packages/pinakes-server/src/dto/sharing.rs @@ -17,6 +17,7 @@ pub struct CreateShareRequest { } #[derive(Debug, Deserialize, utoipa::ToSchema)] +#[allow(clippy::struct_field_names)] pub struct SharePermissionsRequest { pub can_view: Option, pub can_download: Option, @@ -47,6 +48,7 @@ pub struct ShareResponse { } #[derive(Debug, Serialize, utoipa::ToSchema)] +#[allow(clippy::struct_excessive_bools, clippy::struct_field_names)] pub struct SharePermissionsResponse { pub can_view: bool, pub can_download: bool, @@ -197,6 +199,6 @@ pub struct AccessSharedRequest { #[derive(Debug, Serialize, utoipa::ToSchema)] #[serde(untagged)] pub enum SharedContentResponse { - Single(super::MediaResponse), + Single(Box), Multiple { items: Vec }, } diff --git a/packages/pinakes-server/src/error.rs b/packages/pinakes-server/src/error.rs index c18592d..b5016ed 100644 --- a/packages/pinakes-server/src/error.rs +++ b/packages/pinakes-server/src/error.rs @@ -15,7 +15,11 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { use pinakes_core::error::PinakesError; let (status, message) = match &self.0 { - PinakesError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + PinakesError::NotFound(msg) + | PinakesError::TagNotFound(msg) + | PinakesError::CollectionNotFound(msg) => { + (StatusCode::NOT_FOUND, msg.clone()) + }, PinakesError::FileNotFound(path) => { // Only expose the file name, not the full path let name = path.file_name().map_or_else( @@ -25,10 +29,6 @@ impl IntoResponse for ApiError { tracing::debug!(path = %path.display(), "file not found"); (StatusCode::NOT_FOUND, format!("file not found: {name}")) }, - PinakesError::TagNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - PinakesError::CollectionNotFound(msg) => { - (StatusCode::NOT_FOUND, msg.clone()) - }, PinakesError::DuplicateHash(msg) => (StatusCode::CONFLICT, msg.clone()), PinakesError::UnsupportedMediaType(path) => { let name = path.file_name().map_or_else( diff --git a/packages/pinakes-server/src/main.rs b/packages/pinakes-server/src/main.rs index d48f079..3c8c030 100644 --- a/packages/pinakes-server/src/main.rs +++ b/packages/pinakes-server/src/main.rs @@ -4,6 +4,7 @@ use anyhow::Result; use axum::{Router, response::Redirect, routing::any}; use clap::Parser; use pinakes_core::{config::Config, storage::StorageBackend}; +use pinakes_enrichment::EnrichmentSourceType; use pinakes_server::{app, state::AppState}; use tokio::sync::RwLock; use tracing::info; @@ -189,7 +190,7 @@ async fn main() -> Result<()> { // Start filesystem watcher if configured if config.scanning.watch { - let watch_storage = storage.clone(); + let watch_storage = Arc::clone(&storage); let watch_dirs = config.directories.roots.clone(); let watch_ignore = config.scanning.ignore_patterns.clone(); tokio::spawn(async move { @@ -245,9 +246,9 @@ async fn main() -> Result<()> { max_concurrent_ops: p.max_concurrent_ops, plugin_timeout_secs: p.plugin_timeout_secs, timeouts: pinakes_types::config::PluginTimeoutConfig { - capability_query_secs: p.timeouts.capability_query_secs, - processing_secs: p.timeouts.processing_secs, - event_handler_secs: p.timeouts.event_handler_secs, + capability_query: p.timeouts.capability_query, + processing: p.timeouts.processing, + event_handler: p.timeouts.event_handler, }, max_consecutive_failures: p.max_consecutive_failures, trusted_keys: p.trusted_keys.clone(), @@ -297,7 +298,7 @@ async fn main() -> Result<()> { }; // Initialize job queue with executor - let job_storage = storage.clone(); + let job_storage = Arc::clone(&storage); let job_config = config.clone(); let job_transcode = transcode_service.clone(); let job_webhooks = webhook_dispatcher.clone(); @@ -306,7 +307,7 @@ async fn main() -> Result<()> { config.jobs.worker_count, config.jobs.job_timeout_secs, move |job_id, kind, cancel, jobs| { - let storage = job_storage.clone(); + let storage = Arc::clone(&job_storage); let config = job_config.clone(); let transcode_svc = job_transcode.clone(); let webhooks = job_webhooks.clone(); @@ -400,10 +401,16 @@ async fn main() -> Result<()> { if cancel.is_cancelled() { break; } + #[expect( + clippy::cast_precision_loss, + reason = "progress ratio; precision loss negligible for \ + display" + )] + let progress = i as f32 / total as f32; JobQueue::update_progress( &jobs, job_id, - i as f32 / total as f32, + progress, format!("{i}/{total}"), ) .await; @@ -575,7 +582,12 @@ async fn main() -> Result<()> { enrich_cfg.sources.tmdb.enabled, enrich_cfg.sources.tmdb.api_key.clone(), ) { - enrichers.push(Box::new(TmdbEnricher::new(key))); + match TmdbEnricher::new(key) { + Ok(e) => enrichers.push(Box::new(e)), + Err(err) => { + tracing::warn!("Failed to build TMDB enricher: {err}"); + }, + } } if let (true, Some(key)) = ( enrich_cfg.sources.lastfm.enabled, @@ -613,7 +625,6 @@ async fn main() -> Result<()> { let category = item.media_type.category(); for enricher in &enrichers { let source = enricher.source(); - use pinakes_enrichment::EnrichmentSourceType; let applicable = match source { EnrichmentSourceType::MusicBrainz | EnrichmentSourceType::LastFm => { @@ -674,7 +685,7 @@ async fn main() -> Result<()> { JobKind::CleanupAnalytics => { let retention_days = config.analytics.retention_days; let before = chrono::Utc::now() - - chrono::Duration::days(retention_days as i64); + - chrono::Duration::days(retention_days.cast_signed()); match storage.cleanup_old_events(before).await { Ok(count) => { JobQueue::complete( @@ -690,7 +701,7 @@ async fn main() -> Result<()> { JobKind::TrashPurge => { let retention_days = config.trash.retention_days; let before = chrono::Utc::now() - - chrono::Duration::days(retention_days as i64); + - chrono::Duration::days(retention_days.cast_signed()); match storage.purge_old_trash(before).await { Ok(count) => { @@ -723,9 +734,9 @@ async fn main() -> Result<()> { let shutdown_token = tokio_util::sync::CancellationToken::new(); let config_arc = Arc::new(RwLock::new(config)); let scheduler = pinakes_core::scheduler::TaskScheduler::new( - job_queue.clone(), + Arc::clone(&job_queue), shutdown_token.clone(), - config_arc.clone(), + Arc::clone(&config_arc), Some(config_path.clone()), ); let scheduler = Arc::new(scheduler); @@ -735,7 +746,7 @@ async fn main() -> Result<()> { // Spawn scheduler background loop { - let scheduler = scheduler.clone(); + let scheduler = Arc::clone(&scheduler); tokio::spawn(async move { scheduler.run().await; }); @@ -796,8 +807,8 @@ async fn main() -> Result<()> { }; let state = AppState { - storage: storage.clone(), - config: config_arc.clone(), + storage: Arc::clone(&storage), + config: Arc::clone(&config_arc), config_path: Some(config_path), scan_progress: pinakes_core::scan::ScanProgress::new(), job_queue, @@ -816,7 +827,7 @@ async fn main() -> Result<()> { // Periodic session cleanup (every 15 minutes) { - let storage_clone = storage.clone(); + let storage_clone = Arc::clone(&storage); let cancel = shutdown_token.clone(); tokio::spawn(async move { let mut interval = @@ -844,7 +855,7 @@ async fn main() -> Result<()> { // Periodic chunked upload cleanup (every hour) if let Some(ref manager) = state.chunked_upload_manager { - let manager_clone = manager.clone(); + let manager_clone = Arc::clone(manager); let cancel = shutdown_token.clone(); tokio::spawn(async move { let mut interval = diff --git a/packages/pinakes-server/src/routes/analytics.rs b/packages/pinakes-server/src/routes/analytics.rs index fda8fd9..9b61bf8 100644 --- a/packages/pinakes-server/src/routes/analytics.rs +++ b/packages/pinakes-server/src/routes/analytics.rs @@ -39,6 +39,9 @@ const MAX_LIMIT: u64 = 100; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_most_viewed( State(state): State, Query(params): Query, @@ -74,6 +77,9 @@ pub async fn get_most_viewed( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_recently_viewed( State(state): State, Extension(username): Extension, @@ -103,6 +109,9 @@ pub async fn get_recently_viewed( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn record_event( State(state): State, Extension(username): Extension, @@ -141,6 +150,9 @@ pub async fn record_event( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_watch_progress( State(state): State, Extension(username): Extension, @@ -174,6 +186,9 @@ pub async fn get_watch_progress( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_watch_progress( State(state): State, Extension(username): Extension, diff --git a/packages/pinakes-server/src/routes/audit.rs b/packages/pinakes-server/src/routes/audit.rs index 80ccd10..272a37e 100644 --- a/packages/pinakes-server/src/routes/audit.rs +++ b/packages/pinakes-server/src/routes/audit.rs @@ -24,6 +24,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_audit( State(state): State, Query(params): Query, diff --git a/packages/pinakes-server/src/routes/auth.rs b/packages/pinakes-server/src/routes/auth.rs index a4561f5..d75e9d6 100644 --- a/packages/pinakes-server/src/routes/auth.rs +++ b/packages/pinakes-server/src/routes/auth.rs @@ -1,8 +1,10 @@ +use argon2::password_hash::PasswordVerifier; use axum::{ Json, extract::State, http::{HeaderMap, StatusCode}, }; +use rand::seq::IndexedRandom as _; use crate::{ dto::{LoginRequest, LoginResponse, UserInfoResponse}, @@ -17,6 +19,16 @@ const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,\ p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk"; +/// Authenticate a user with username and password, creating a session. +/// +/// # Errors +/// +/// Returns an error if the credentials are invalid or the session cannot be +/// created. +/// +/// # Panics +/// +/// Panics if the CHARSET is empty (it is not). #[utoipa::path( post, path = "/api/v1/auth/login", @@ -53,12 +65,8 @@ pub async fn login( // Always perform password verification to prevent timing attacks. // If the user doesn't exist, we verify against a dummy hash to ensure // consistent response times regardless of whether the username exists. - use argon2::password_hash::PasswordVerifier; - - let (hash_to_verify, user_found) = match user { - Some(u) => (&u.password_hash as &str, true), - None => (DUMMY_HASH, false), - }; + let (hash_to_verify, user_found) = + user.map_or((DUMMY_HASH, false), |u| (&u.password_hash as &str, true)); let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -97,13 +105,14 @@ pub async fn login( // Generate session token using unbiased uniform distribution #[expect(clippy::expect_used)] let token: String = { - use rand::seq::IndexedRandom; const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let mut rng = rand::rng(); - (0..48) - .map(|_| *CHARSET.choose(&mut rng).expect("non-empty charset") as char) - .collect() + std::iter::repeat_with(|| { + *CHARSET.choose(&mut rng).expect("non-empty charset") as char + }) + .take(48) + .collect() }; let role = user.role; @@ -118,7 +127,9 @@ pub async fn login( role: role.to_string(), created_at: now, expires_at: now - + chrono::Duration::hours(config.accounts.session_expiry_hours as i64), + + chrono::Duration::hours( + config.accounts.session_expiry_hours.cast_signed(), + ), last_accessed: now, }; @@ -195,6 +206,12 @@ pub async fn logout( StatusCode::OK } +/// Return current user info from the bearer token session. +/// +/// # Errors +/// +/// Returns an error if the token is missing, invalid, or the session lookup +/// fails. #[utoipa::path( get, path = "/api/v1/auth/me", @@ -243,6 +260,11 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { /// Refresh the current session, extending its expiry by the configured /// duration. +/// +/// # Errors +/// +/// Returns an error if the token is missing, the session does not exist, or the +/// database update fails. #[utoipa::path( post, path = "/api/v1/auth/refresh", @@ -261,7 +283,7 @@ pub async fn refresh( let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; let config = state.config.read().await; - let expiry_hours = config.accounts.session_expiry_hours as i64; + let expiry_hours = config.accounts.session_expiry_hours.cast_signed(); drop(config); let new_expires_at = @@ -297,9 +319,8 @@ pub async fn revoke_all_sessions( State(state): State, headers: HeaderMap, ) -> StatusCode { - let token = match extract_bearer_token(&headers) { - Some(t) => t, - None => return StatusCode::UNAUTHORIZED, + let Some(token) = extract_bearer_token(&headers) else { + return StatusCode::UNAUTHORIZED; }; // Get current session to find username @@ -340,7 +361,11 @@ pub async fn revoke_all_sessions( } } -/// List all active sessions (admin only) +/// List all active sessions (admin only). +/// +/// # Errors +/// +/// Returns an error if the database query fails. #[derive(serde::Serialize, utoipa::ToSchema)] pub struct SessionListResponse { pub sessions: Vec, @@ -367,6 +392,9 @@ pub struct SessionInfo { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_active_sessions( State(state): State, ) -> Result, StatusCode> { diff --git a/packages/pinakes-server/src/routes/backup.rs b/packages/pinakes-server/src/routes/backup.rs index d80b31f..69e84e3 100644 --- a/packages/pinakes-server/src/routes/backup.rs +++ b/packages/pinakes-server/src/routes/backup.rs @@ -23,6 +23,9 @@ use crate::{error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_backup( State(state): State, ) -> Result { diff --git a/packages/pinakes-server/src/routes/books.rs b/packages/pinakes-server/src/routes/books.rs index 9993492..5640bba 100644 --- a/packages/pinakes-server/src/routes/books.rs +++ b/packages/pinakes-server/src/routes/books.rs @@ -168,19 +168,23 @@ pub struct AuthorSummary { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_book_metadata( State(state): State, Path(media_id): Path, ) -> Result { let media_id = MediaId(media_id); - let metadata = - state - .storage - .get_book_metadata(media_id) - .await? - .ok_or(ApiError(PinakesError::NotFound( + let metadata = state + .storage + .get_book_metadata(media_id) + .await? + .ok_or_else(|| { + ApiError(PinakesError::NotFound( "Book metadata not found".to_string(), - )))?; + )) + })?; Ok(Json(BookMetadataResponse::from(metadata))) } @@ -206,6 +210,9 @@ pub async fn get_book_metadata( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_books( State(state): State, Query(query): Query, @@ -247,6 +254,9 @@ pub async fn list_books( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_series( State(state): State, ) -> Result { @@ -276,6 +286,9 @@ pub async fn list_series( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_series_books( State(state): State, Path(series_name): Path, @@ -304,6 +317,9 @@ pub async fn get_series_books( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_authors( State(state): State, Query(pagination): Query, @@ -338,6 +354,9 @@ pub async fn list_authors( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_author_books( State(state): State, Path(author_name): Path, @@ -369,6 +388,9 @@ pub async fn get_author_books( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_reading_progress( State(state): State, Extension(username): Extension, @@ -381,9 +403,11 @@ pub async fn get_reading_progress( .storage .get_reading_progress(user_id.0, media_id) .await? - .ok_or(ApiError(PinakesError::NotFound( - "Reading progress not found".to_string(), - )))?; + .ok_or_else(|| { + ApiError(PinakesError::NotFound( + "Reading progress not found".to_string(), + )) + })?; Ok(Json(ReadingProgressResponse::from(progress))) } @@ -402,6 +426,9 @@ pub async fn get_reading_progress( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_reading_progress( State(state): State, Extension(username): Extension, @@ -438,6 +465,9 @@ pub async fn update_reading_progress( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_reading_list( State(state): State, Extension(username): Extension, diff --git a/packages/pinakes-server/src/routes/collections.rs b/packages/pinakes-server/src/routes/collections.rs index a1df04d..49b7b0a 100644 --- a/packages/pinakes-server/src/routes/collections.rs +++ b/packages/pinakes-server/src/routes/collections.rs @@ -30,6 +30,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_collection( State(state): State, Json(req): Json, @@ -85,6 +88,9 @@ pub async fn create_collection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_collections( State(state): State, ) -> Result>, ApiError> { @@ -107,6 +113,9 @@ pub async fn list_collections( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_collection( State(state): State, Path(id): Path, @@ -129,6 +138,9 @@ pub async fn get_collection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_collection( State(state): State, Path(id): Path, @@ -158,6 +170,9 @@ pub async fn delete_collection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_member( State(state): State, Path(collection_id): Path, @@ -190,6 +205,9 @@ pub async fn add_member( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_member( State(state): State, Path((collection_id, media_id)): Path<(Uuid, Uuid)>, @@ -216,6 +234,9 @@ pub async fn remove_member( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_members( State(state): State, Path(collection_id): Path, diff --git a/packages/pinakes-server/src/routes/config.rs b/packages/pinakes-server/src/routes/config.rs index 7a76f83..55fdbaa 100644 --- a/packages/pinakes-server/src/routes/config.rs +++ b/packages/pinakes-server/src/routes/config.rs @@ -26,6 +26,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_config( State(state): State, ) -> Result, ApiError> { @@ -36,18 +39,15 @@ pub async fn get_config( .config_path .as_ref() .map(|p| p.to_string_lossy().to_string()); - let config_writable = match &state.config_path { - Some(path) => { - if path.exists() { - std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) - } else { - path.parent().is_some_and(|parent| { - std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) - }) - } - }, - None => false, - }; + let config_writable = state.config_path.as_ref().is_some_and(|path| { + if path.exists() { + std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) + } else { + path.parent().is_some_and(|parent| { + std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) + }) + } + }); Ok(Json(ConfigResponse { backend: config.storage.backend.to_string(), @@ -86,6 +86,9 @@ pub async fn get_config( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_ui_config( State(state): State, ) -> Result, ApiError> { @@ -106,6 +109,9 @@ pub async fn get_ui_config( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_ui_config( State(state): State, Json(req): Json, @@ -153,6 +159,9 @@ pub async fn update_ui_config( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_scanning_config( State(state): State, Json(req): Json, @@ -179,18 +188,15 @@ pub async fn update_scanning_config( .config_path .as_ref() .map(|p| p.to_string_lossy().to_string()); - let config_writable = match &state.config_path { - Some(path) => { - if path.exists() { - std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) - } else { - path.parent().is_some_and(|parent| { - std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) - }) - } - }, - None => false, - }; + let config_writable = state.config_path.as_ref().is_some_and(|path| { + if path.exists() { + std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) + } else { + path.parent().is_some_and(|parent| { + std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) + }) + } + }); Ok(Json(ConfigResponse { backend: config.storage.backend.to_string(), @@ -232,6 +238,9 @@ pub async fn update_scanning_config( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_root( State(state): State, Json(req): Json, @@ -272,6 +281,9 @@ pub async fn add_root( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_root( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/database.rs b/packages/pinakes-server/src/routes/database.rs index e88fcb8..a927dd4 100644 --- a/packages/pinakes-server/src/routes/database.rs +++ b/packages/pinakes-server/src/routes/database.rs @@ -14,6 +14,9 @@ use crate::{dto::DatabaseStatsResponse, error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn database_stats( State(state): State, ) -> Result, ApiError> { @@ -40,6 +43,9 @@ pub async fn database_stats( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn vacuum_database( State(state): State, ) -> Result, ApiError> { @@ -59,6 +65,9 @@ pub async fn vacuum_database( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn clear_database( State(state): State, ) -> Result, ApiError> { diff --git a/packages/pinakes-server/src/routes/duplicates.rs b/packages/pinakes-server/src/routes/duplicates.rs index 6150979..ddcb5f4 100644 --- a/packages/pinakes-server/src/routes/duplicates.rs +++ b/packages/pinakes-server/src/routes/duplicates.rs @@ -17,6 +17,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_duplicates( State(state): State, ) -> Result>, ApiError> { diff --git a/packages/pinakes-server/src/routes/enrichment.rs b/packages/pinakes-server/src/routes/enrichment.rs index 1060cc3..c7b9897 100644 --- a/packages/pinakes-server/src/routes/enrichment.rs +++ b/packages/pinakes-server/src/routes/enrichment.rs @@ -25,6 +25,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_enrichment( State(state): State, Path(id): Path, @@ -52,6 +55,9 @@ pub async fn trigger_enrichment( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_external_metadata( State(state): State, Path(id): Path, @@ -79,6 +85,9 @@ pub async fn get_external_metadata( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_enrich( State(state): State, Json(req): Json, // Reuse: has media_ids field diff --git a/packages/pinakes-server/src/routes/export.rs b/packages/pinakes-server/src/routes/export.rs index 8251272..4172b2c 100644 --- a/packages/pinakes-server/src/routes/export.rs +++ b/packages/pinakes-server/src/routes/export.rs @@ -24,6 +24,9 @@ pub struct ExportRequest { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_export( State(state): State, ) -> Result, ApiError> { @@ -51,6 +54,9 @@ pub async fn trigger_export( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_export_with_options( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/health.rs b/packages/pinakes-server/src/routes/health.rs index 7d30c27..14d9a0a 100644 --- a/packages/pinakes-server/src/routes/health.rs +++ b/packages/pinakes-server/src/routes/health.rs @@ -66,7 +66,8 @@ pub async fn health(State(state): State) -> Json { Ok(count) => { DatabaseHealth { status: "ok".to_string(), - latency_ms: db_start.elapsed().as_millis() as u64, + latency_ms: u64::try_from(db_start.elapsed().as_millis()) + .unwrap_or(u64::MAX), media_count: Some(count), } }, @@ -74,7 +75,8 @@ pub async fn health(State(state): State) -> Json { response.status = "degraded".to_string(); DatabaseHealth { status: format!("error: {e}"), - latency_ms: db_start.elapsed().as_millis() as u64, + latency_ms: u64::try_from(db_start.elapsed().as_millis()) + .unwrap_or(u64::MAX), media_count: None, } }, @@ -147,7 +149,8 @@ pub async fn readiness(State(state): State) -> impl IntoResponse { let db_start = Instant::now(); match state.storage.count_media().await { Ok(_) => { - let latency = db_start.elapsed().as_millis() as u64; + let latency = + u64::try_from(db_start.elapsed().as_millis()).unwrap_or(u64::MAX); ( StatusCode::OK, Json(serde_json::json!({ @@ -203,7 +206,8 @@ pub async fn health_detailed( Ok(count) => ("ok".to_string(), Some(count)), Err(e) => (format!("error: {e}"), None), }; - let db_latency = db_start.elapsed().as_millis() as u64; + let db_latency = + u64::try_from(db_start.elapsed().as_millis()).unwrap_or(u64::MAX); // Check filesystem let roots = state.storage.list_root_dirs().await.unwrap_or_default(); diff --git a/packages/pinakes-server/src/routes/integrity.rs b/packages/pinakes-server/src/routes/integrity.rs index f688e79..f3bda1a 100644 --- a/packages/pinakes-server/src/routes/integrity.rs +++ b/packages/pinakes-server/src/routes/integrity.rs @@ -21,6 +21,9 @@ pub struct OrphanResolveRequest { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_orphan_detection( State(state): State, ) -> Result, ApiError> { @@ -42,6 +45,9 @@ pub async fn trigger_orphan_detection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_verify_integrity( State(state): State, Json(req): Json, @@ -73,6 +79,9 @@ pub struct VerifyIntegrityRequest { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_cleanup_thumbnails( State(state): State, ) -> Result, ApiError> { @@ -102,6 +111,9 @@ pub struct GenerateThumbnailsRequest { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn generate_all_thumbnails( State(state): State, body: Option>, @@ -140,6 +152,9 @@ pub async fn generate_all_thumbnails( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn resolve_orphans( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/jobs.rs b/packages/pinakes-server/src/routes/jobs.rs index c7319cb..ba2863c 100644 --- a/packages/pinakes-server/src/routes/jobs.rs +++ b/packages/pinakes-server/src/routes/jobs.rs @@ -34,6 +34,9 @@ pub async fn list_jobs(State(state): State) -> Json> { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_job( State(state): State, Path(id): Path, @@ -57,6 +60,9 @@ pub async fn get_job( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn cancel_job( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/media.rs b/packages/pinakes-server/src/routes/media.rs index ed4a7be..ff4c0ce 100644 --- a/packages/pinakes-server/src/routes/media.rs +++ b/packages/pinakes-server/src/routes/media.rs @@ -2,7 +2,10 @@ use axum::{ Json, extract::{Path, Query, State}, }; -use pinakes_core::{model::MediaId, storage::DynStorageBackend}; +use pinakes_core::{ + model::{CustomField, CustomFieldType, MediaId}, + storage::DynStorageBackend, +}; use rustc_hash::FxHashMap; use uuid::Uuid; @@ -113,6 +116,9 @@ async fn apply_import_post_processing( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn import_media( State(state): State, Json(req): Json, @@ -156,6 +162,9 @@ pub async fn import_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_media( State(state): State, Query(params): Query, @@ -184,6 +193,9 @@ pub async fn list_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media( State(state): State, Path(id): Path, @@ -199,7 +211,7 @@ const MAX_SHORT_TEXT: usize = 500; const MAX_LONG_TEXT: usize = 10_000; fn validate_optional_text( - field: &Option, + field: Option<&str>, name: &str, max: usize, ) -> Result<(), ApiError> { @@ -231,16 +243,23 @@ fn validate_optional_text( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_media( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { - validate_optional_text(&req.title, "title", MAX_SHORT_TEXT)?; - validate_optional_text(&req.artist, "artist", MAX_SHORT_TEXT)?; - validate_optional_text(&req.album, "album", MAX_SHORT_TEXT)?; - validate_optional_text(&req.genre, "genre", MAX_SHORT_TEXT)?; - validate_optional_text(&req.description, "description", MAX_LONG_TEXT)?; + validate_optional_text(req.title.as_deref(), "title", MAX_SHORT_TEXT)?; + validate_optional_text(req.artist.as_deref(), "artist", MAX_SHORT_TEXT)?; + validate_optional_text(req.album.as_deref(), "album", MAX_SHORT_TEXT)?; + validate_optional_text(req.genre.as_deref(), "genre", MAX_SHORT_TEXT)?; + validate_optional_text( + req.description.as_deref(), + "description", + MAX_LONG_TEXT, + )?; let mut item = state.storage.get_media(MediaId(id)).await?; @@ -302,6 +321,9 @@ pub async fn update_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_media( State(state): State, Path(id): Path, @@ -353,6 +375,9 @@ pub async fn delete_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn open_media( State(state): State, Path(id): Path, @@ -384,6 +409,9 @@ pub async fn open_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn stream_media( State(state): State, Path(id): Path, @@ -509,6 +537,9 @@ fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn import_with_options( State(state): State, Json(req): Json, @@ -557,6 +588,9 @@ pub async fn import_with_options( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_import( State(state): State, Json(req): Json, @@ -645,6 +679,9 @@ pub async fn batch_import( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn import_directory_endpoint( State(state): State, Json(req): Json, @@ -713,6 +750,50 @@ pub async fn import_directory_endpoint( })) } +fn walk_dir_preview( + dir: &std::path::Path, + recursive: bool, + roots: &[std::path::PathBuf], + result: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + // Skip hidden files/dirs + if path + .file_name() + .is_some_and(|n| n.to_string_lossy().starts_with('.')) + { + continue; + } + if path.is_dir() { + if recursive { + walk_dir_preview(&path, recursive, roots, result); + } + } else if path.is_file() + && let Some(mt) = pinakes_core::media_type::MediaType::from_path(&path) + { + let size = entry.metadata().ok().map_or(0, |m| m.len()); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let media_type = serde_json::to_value(mt) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + result.push(DirectoryPreviewFile { + path: crate::dto::relativize_path(&path, roots), + file_name, + media_type, + file_size: size, + }); + } + } +} + #[utoipa::path( post, path = "/api/v1/media/import/preview", @@ -726,6 +807,9 @@ pub async fn import_directory_endpoint( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn preview_directory( State(state): State, Json(req): Json, @@ -765,51 +849,7 @@ pub async fn preview_directory( let files: Vec = tokio::task::spawn_blocking(move || { let mut result = Vec::new(); - fn walk_dir( - dir: &std::path::Path, - recursive: bool, - roots: &[std::path::PathBuf], - result: &mut Vec, - ) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - // Skip hidden files/dirs - if path - .file_name() - .is_some_and(|n| n.to_string_lossy().starts_with('.')) - { - continue; - } - if path.is_dir() { - if recursive { - walk_dir(&path, recursive, roots, result); - } - } else if path.is_file() - && let Some(mt) = - pinakes_core::media_type::MediaType::from_path(&path) - { - let size = entry.metadata().ok().map_or(0, |m| m.len()); - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let media_type = serde_json::to_value(mt) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(); - result.push(DirectoryPreviewFile { - path: crate::dto::relativize_path(&path, roots), - file_name, - media_type, - file_size: size, - }); - } - } - } - walk_dir(&dir, recursive, &roots_for_walk, &mut result); + walk_dir_preview(&dir, recursive, &roots_for_walk, &mut result); result }) .await @@ -843,6 +883,9 @@ pub async fn preview_directory( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn set_custom_field( State(state): State, Path(id): Path, @@ -862,7 +905,6 @@ pub async fn set_custom_field( )), )); } - use pinakes_core::model::{CustomField, CustomFieldType}; let field_type = match req.field_type.as_str() { "number" => CustomFieldType::Number, "date" => CustomFieldType::Date, @@ -897,6 +939,9 @@ pub async fn set_custom_field( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_custom_field( State(state): State, Path((id, name)): Path<(Uuid, String)>, @@ -922,6 +967,9 @@ pub async fn delete_custom_field( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_tag( State(state): State, Json(req): Json, @@ -943,7 +991,7 @@ pub async fn batch_tag( { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: count as usize, + processed: usize::try_from(count).unwrap_or(usize::MAX), errors: Vec::new(), })) }, @@ -968,6 +1016,9 @@ pub async fn batch_tag( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_all_media( State(state): State, ) -> Result, ApiError> { @@ -986,7 +1037,7 @@ pub async fn delete_all_media( match state.storage.delete_all_media().await { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: count as usize, + processed: usize::try_from(count).unwrap_or(usize::MAX), errors: Vec::new(), })) }, @@ -1013,6 +1064,9 @@ pub async fn delete_all_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_delete( State(state): State, Json(req): Json, @@ -1044,7 +1098,7 @@ pub async fn batch_delete( match state.storage.batch_delete_media(&media_ids).await { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: count as usize, + processed: usize::try_from(count).unwrap_or(usize::MAX), errors: Vec::new(), })) }, @@ -1071,6 +1125,9 @@ pub async fn batch_delete( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_add_to_collection( State(state): State, Json(req): Json, @@ -1090,7 +1147,7 @@ pub async fn batch_add_to_collection( &state.storage, req.collection_id, MediaId(*media_id), - i as i32, + i32::try_from(i).unwrap_or(i32::MAX), ) .await { @@ -1115,6 +1172,9 @@ pub async fn batch_add_to_collection( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_update( State(state): State, Json(req): Json, @@ -1144,7 +1204,7 @@ pub async fn batch_update( { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: count as usize, + processed: usize::try_from(count).unwrap_or(usize::MAX), errors: Vec::new(), })) }, @@ -1170,6 +1230,9 @@ pub async fn batch_update( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_thumbnail( State(state): State, Path(id): Path, @@ -1214,6 +1277,9 @@ pub async fn get_thumbnail( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_count( State(state): State, ) -> Result, ApiError> { @@ -1237,6 +1303,9 @@ pub async fn get_media_count( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn rename_media( State(state): State, Path(id): Path, @@ -1305,6 +1374,9 @@ pub async fn rename_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn move_media_endpoint( State(state): State, Path(id): Path, @@ -1368,6 +1440,9 @@ pub async fn move_media_endpoint( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_move_media( State(state): State, Json(req): Json, @@ -1451,6 +1526,9 @@ pub async fn batch_move_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn soft_delete_media( State(state): State, Path(id): Path, @@ -1511,6 +1589,9 @@ pub async fn soft_delete_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn restore_media( State(state): State, Path(id): Path, @@ -1573,6 +1654,9 @@ pub async fn restore_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_trash( State(state): State, Query(params): Query, @@ -1603,6 +1687,9 @@ pub async fn list_trash( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trash_info( State(state): State, ) -> Result, ApiError> { @@ -1622,6 +1709,9 @@ pub async fn trash_info( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn empty_trash( State(state): State, ) -> Result, ApiError> { @@ -1656,6 +1746,14 @@ pub async fn empty_trash( ), security(("bearer_auth" = [])) )] +// axum handlers cannot be generic over hasher types without breaking routing +#[expect( + clippy::implicit_hasher, + reason = "axum handler; generic over hasher breaks routing" +)] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn permanent_delete_media( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/notes.rs b/packages/pinakes-server/src/routes/notes.rs index 6fe3a37..30a7e5f 100644 --- a/packages/pinakes-server/src/routes/notes.rs +++ b/packages/pinakes-server/src/routes/notes.rs @@ -12,13 +12,16 @@ use axum::{ extract::{Path, Query, State}, routing::{get, post}, }; -use pinakes_core::model::{ - BacklinkInfo, - GraphData, - GraphEdge, - GraphNode, - MarkdownLink, - MediaId, +use pinakes_core::{ + media_type::{BuiltinMediaType, MediaType}, + model::{ + BacklinkInfo, + GraphData, + GraphEdge, + GraphNode, + MarkdownLink, + MediaId, + }, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -214,6 +217,9 @@ pub struct UnresolvedLinksResponse { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_backlinks( State(state): State, Path(id): Path, @@ -247,6 +253,9 @@ pub async fn get_backlinks( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_outgoing_links( State(state): State, Path(id): Path, @@ -282,6 +291,9 @@ pub async fn get_outgoing_links( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_graph( State(state): State, Query(params): Query, @@ -310,6 +322,9 @@ pub async fn get_graph( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn reindex_links( State(state): State, Path(id): Path, @@ -320,7 +335,6 @@ pub async fn reindex_links( let media = state.storage.get_media(media_id).await?; // Only process markdown files - use pinakes_core::media_type::{BuiltinMediaType, MediaType}; match &media.media_type { MediaType::Builtin(BuiltinMediaType::Markdown) => {}, _ => { @@ -369,6 +383,9 @@ pub async fn reindex_links( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn resolve_links( State(state): State, ) -> Result, ApiError> { @@ -391,6 +408,9 @@ pub async fn resolve_links( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_unresolved_count( State(state): State, ) -> Result, ApiError> { diff --git a/packages/pinakes-server/src/routes/photos.rs b/packages/pinakes-server/src/routes/photos.rs index 7320427..9da3687 100644 --- a/packages/pinakes-server/src/routes/photos.rs +++ b/packages/pinakes-server/src/routes/photos.rs @@ -81,6 +81,10 @@ pub struct MapMarker { security(("bearer_auth" = [])) )] /// Get timeline of photos grouped by date +/// +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_timeline( State(state): State, Query(query): Query, @@ -183,6 +187,10 @@ pub async fn get_timeline( security(("bearer_auth" = [])) )] /// Get photos in a bounding box for map view +/// +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_map_photos( State(state): State, Query(query): Query, diff --git a/packages/pinakes-server/src/routes/playlists.rs b/packages/pinakes-server/src/routes/playlists.rs index 420897e..7a2cefc 100644 --- a/packages/pinakes-server/src/routes/playlists.rs +++ b/packages/pinakes-server/src/routes/playlists.rs @@ -3,6 +3,7 @@ use axum::{ extract::{Extension, Path, State}, }; use pinakes_core::{model::MediaId, playlists::Playlist, users::UserId}; +use rand::seq::SliceRandom as _; use uuid::Uuid; use crate::{ @@ -64,6 +65,9 @@ async fn check_playlist_access( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_playlist( State(state): State, Extension(username): Extension, @@ -102,6 +106,9 @@ pub async fn create_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_playlists( State(state): State, Extension(username): Extension, @@ -130,6 +137,9 @@ pub async fn list_playlists( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_playlist( State(state): State, Extension(username): Extension, @@ -156,6 +166,9 @@ pub async fn get_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_playlist( State(state): State, Extension(username): Extension, @@ -198,6 +211,9 @@ pub async fn update_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_playlist( State(state): State, Extension(username): Extension, @@ -223,6 +239,9 @@ pub async fn delete_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_item( State(state): State, Extension(username): Extension, @@ -235,7 +254,7 @@ pub async fn add_item( p } else { let items = state.storage.get_playlist_items(id).await?; - items.len() as i32 + i32::try_from(items.len()).unwrap_or(i32::MAX) }; state .storage @@ -260,6 +279,9 @@ pub async fn add_item( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_item( State(state): State, Extension(username): Extension, @@ -287,6 +309,9 @@ pub async fn remove_item( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_items( State(state): State, Extension(username): Extension, @@ -318,6 +343,9 @@ pub async fn list_items( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn reorder_item( State(state): State, Extension(username): Extension, @@ -346,6 +374,9 @@ pub async fn reorder_item( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn shuffle_playlist( State(state): State, Extension(username): Extension, @@ -353,7 +384,6 @@ pub async fn shuffle_playlist( ) -> Result>, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, false).await?; - use rand::seq::SliceRandom; let mut items = state.storage.get_playlist_items(id).await?; items.shuffle(&mut rand::rng()); let roots = state.config.read().await.directories.roots.clone(); diff --git a/packages/pinakes-server/src/routes/plugins.rs b/packages/pinakes-server/src/routes/plugins.rs index e5399d5..ae6a63d 100644 --- a/packages/pinakes-server/src/routes/plugins.rs +++ b/packages/pinakes-server/src/routes/plugins.rs @@ -42,6 +42,9 @@ fn require_plugin_manager( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugins( State(state): State, ) -> Result>, ApiError> { @@ -69,6 +72,9 @@ pub async fn list_plugins( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_plugin( State(state): State, Path(id): Path, @@ -99,6 +105,9 @@ pub async fn get_plugin( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn install_plugin( State(state): State, Json(req): Json, @@ -140,6 +149,9 @@ pub async fn install_plugin( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn uninstall_plugin( State(state): State, Path(id): Path, @@ -170,6 +182,9 @@ pub async fn uninstall_plugin( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn toggle_plugin( State(state): State, Path(id): Path, @@ -219,6 +234,9 @@ pub async fn toggle_plugin( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugin_ui_pages( State(state): State, ) -> Result>, ApiError> { @@ -249,6 +267,9 @@ pub async fn list_plugin_ui_pages( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugin_ui_widgets( State(state): State, ) -> Result>, ApiError> { @@ -275,6 +296,9 @@ pub async fn list_plugin_ui_widgets( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn emit_plugin_event( State(state): State, Json(req): Json, @@ -297,6 +321,9 @@ pub async fn emit_plugin_event( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugin_ui_theme_extensions( State(state): State, ) -> Result>, ApiError> { @@ -318,6 +345,9 @@ pub async fn list_plugin_ui_theme_extensions( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn reload_plugin( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/saved_searches.rs b/packages/pinakes-server/src/routes/saved_searches.rs index 11bb4f0..9ccc981 100644 --- a/packages/pinakes-server/src/routes/saved_searches.rs +++ b/packages/pinakes-server/src/routes/saved_searches.rs @@ -44,6 +44,9 @@ const VALID_SORT_ORDERS: &[&str] = &[ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_saved_search( State(state): State, Json(req): Json, @@ -100,6 +103,9 @@ pub async fn create_saved_search( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_saved_searches( State(state): State, ) -> Result>, ApiError> { @@ -137,6 +143,9 @@ pub async fn list_saved_searches( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_saved_search( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/scan.rs b/packages/pinakes-server/src/routes/scan.rs index f78b089..e0e89e7 100644 --- a/packages/pinakes-server/src/routes/scan.rs +++ b/packages/pinakes-server/src/routes/scan.rs @@ -20,6 +20,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_scan( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/scheduled_tasks.rs b/packages/pinakes-server/src/routes/scheduled_tasks.rs index 270c4ab..4b7c3fb 100644 --- a/packages/pinakes-server/src/routes/scheduled_tasks.rs +++ b/packages/pinakes-server/src/routes/scheduled_tasks.rs @@ -16,6 +16,9 @@ use crate::{dto::ScheduledTaskResponse, error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_scheduled_tasks( State(state): State, ) -> Result>, ApiError> { @@ -50,23 +53,26 @@ pub async fn list_scheduled_tasks( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn toggle_scheduled_task( State(state): State, Path(id): Path, ) -> Result, ApiError> { - match state.scheduler.toggle_task(&id).await { - Some(enabled) => { + state.scheduler.toggle_task(&id).await.map_or_else( + || { + Err(ApiError(pinakes_core::error::PinakesError::NotFound( + format!("scheduled task not found: {id}"), + ))) + }, + |enabled| { Ok(Json(serde_json::json!({ "id": id, "enabled": enabled, }))) }, - None => { - Err(ApiError(pinakes_core::error::PinakesError::NotFound( - format!("scheduled task not found: {id}"), - ))) - }, - } + ) } #[utoipa::path( @@ -82,21 +88,24 @@ pub async fn toggle_scheduled_task( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn run_scheduled_task_now( State(state): State, Path(id): Path, ) -> Result, ApiError> { - match state.scheduler.run_now(&id).await { - Some(job_id) => { + state.scheduler.run_now(&id).await.map_or_else( + || { + Err(ApiError(pinakes_core::error::PinakesError::NotFound( + format!("scheduled task not found: {id}"), + ))) + }, + |job_id| { Ok(Json(serde_json::json!({ "id": id, "job_id": job_id, }))) }, - None => { - Err(ApiError(pinakes_core::error::PinakesError::NotFound( - format!("scheduled task not found: {id}"), - ))) - }, - } + ) } diff --git a/packages/pinakes-server/src/routes/search.rs b/packages/pinakes-server/src/routes/search.rs index bebb04b..a062f1f 100644 --- a/packages/pinakes-server/src/routes/search.rs +++ b/packages/pinakes-server/src/routes/search.rs @@ -40,6 +40,9 @@ fn resolve_sort(sort: Option<&str>) -> SortOrder { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn search( State(state): State, Query(params): Query, @@ -87,6 +90,9 @@ pub async fn search( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn search_post( State(state): State, Json(body): Json, diff --git a/packages/pinakes-server/src/routes/shares.rs b/packages/pinakes-server/src/routes/shares.rs index 965b79e..e4f48bb 100644 --- a/packages/pinakes-server/src/routes/shares.rs +++ b/packages/pinakes-server/src/routes/shares.rs @@ -61,6 +61,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_share( State(state): State, Extension(username): Extension, @@ -149,27 +152,28 @@ pub async fn create_share( }; // Parse permissions - let permissions = if let Some(perms) = req.permissions { - SharePermissions { - view: ShareViewPermissions { - can_view: perms.can_view.unwrap_or(true), - can_download: perms.can_download.unwrap_or(false), - can_reshare: perms.can_reshare.unwrap_or(false), - }, - mutate: ShareMutatePermissions { - can_edit: perms.can_edit.unwrap_or(false), - can_delete: perms.can_delete.unwrap_or(false), - can_add: perms.can_add.unwrap_or(false), - }, - } - } else { - SharePermissions::view_only() - }; + let permissions = + req + .permissions + .map_or_else(SharePermissions::view_only, |perms| { + SharePermissions { + view: ShareViewPermissions { + can_view: perms.can_view.unwrap_or(true), + can_download: perms.can_download.unwrap_or(false), + can_reshare: perms.can_reshare.unwrap_or(false), + }, + mutate: ShareMutatePermissions { + can_edit: perms.can_edit.unwrap_or(false), + can_delete: perms.can_delete.unwrap_or(false), + can_add: perms.can_add.unwrap_or(false), + }, + } + }); // Calculate expiration - let expires_at = req - .expires_in_hours - .map(|hours| Utc::now() + chrono::Duration::hours(hours as i64)); + let expires_at = req.expires_in_hours.map(|hours: u64| { + Utc::now() + chrono::Duration::hours(hours.cast_signed()) + }); let share = Share { id: ShareId(Uuid::now_v7()), @@ -228,6 +232,9 @@ pub async fn create_share( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_outgoing( State(state): State, Extension(username): Extension, @@ -261,6 +268,9 @@ pub async fn list_outgoing( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_incoming( State(state): State, Extension(username): Extension, @@ -293,6 +303,9 @@ pub async fn list_incoming( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_share( State(state): State, Extension(username): Extension, @@ -337,6 +350,9 @@ pub async fn get_share( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_share( State(state): State, Extension(username): Extension, @@ -430,6 +446,9 @@ pub async fn update_share( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_share( State(state): State, Extension(username): Extension, @@ -487,6 +506,9 @@ pub async fn delete_share( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_delete( State(state): State, Extension(username): Extension, @@ -540,6 +562,9 @@ pub async fn batch_delete( (status = 404, description = "Not found"), ) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn access_shared( State(state): State, Path(token): Path, @@ -618,8 +643,8 @@ pub async fn access_shared( .await .map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?; - Ok(Json(SharedContentResponse::Single(MediaResponse::new( - item, &roots, + Ok(Json(SharedContentResponse::Single(Box::new( + MediaResponse::new(item, &roots), )))) }, ShareTarget::Collection { collection_id } => { @@ -724,6 +749,9 @@ pub async fn access_shared( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_activity( State(state): State, Extension(username): Extension, @@ -767,6 +795,9 @@ pub async fn get_activity( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_notifications( State(state): State, Extension(username): Extension, @@ -796,6 +827,9 @@ pub async fn get_notifications( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn mark_notification_read( State(state): State, Extension(username): Extension, @@ -823,6 +857,9 @@ pub async fn mark_notification_read( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn mark_all_read( State(state): State, Extension(username): Extension, diff --git a/packages/pinakes-server/src/routes/social.rs b/packages/pinakes-server/src/routes/social.rs index b378026..5fd01b9 100644 --- a/packages/pinakes-server/src/routes/social.rs +++ b/packages/pinakes-server/src/routes/social.rs @@ -22,6 +22,8 @@ use crate::{ state::AppState, }; +const MAX_SHARE_EXPIRY_HOURS: u64 = 8760; // 1 year + #[derive(Deserialize)] pub struct ShareLinkQuery { pub password: Option, @@ -41,6 +43,9 @@ pub struct ShareLinkQuery { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn rate_media( State(state): State, Extension(username): Extension, @@ -85,6 +90,9 @@ pub async fn rate_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_ratings( State(state): State, Path(id): Path, @@ -109,6 +117,9 @@ pub async fn get_media_ratings( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_comment( State(state): State, Extension(username): Extension, @@ -143,6 +154,9 @@ pub async fn add_comment( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_comments( State(state): State, Path(id): Path, @@ -165,6 +179,9 @@ pub async fn get_media_comments( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_favorite( State(state): State, Extension(username): Extension, @@ -190,6 +207,9 @@ pub async fn add_favorite( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_favorite( State(state): State, Extension(username): Extension, @@ -214,6 +234,9 @@ pub async fn remove_favorite( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_favorites( State(state): State, Extension(username): Extension, @@ -245,6 +268,9 @@ pub async fn list_favorites( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_share_link( State(state): State, Extension(username): Extension, @@ -265,19 +291,18 @@ pub async fn create_share_link( }, None => None, }; - const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year if let Some(h) = req.expires_in_hours - && h > MAX_EXPIRY_HOURS + && h > MAX_SHARE_EXPIRY_HOURS { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation(format!( - "expires_in_hours cannot exceed {MAX_EXPIRY_HOURS}" + "expires_in_hours cannot exceed {MAX_SHARE_EXPIRY_HOURS}" )), )); } - let expires_at = req - .expires_in_hours - .map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64)); + let expires_at = req.expires_in_hours.map(|h: u64| { + chrono::Utc::now() + chrono::Duration::hours(h.cast_signed()) + }); let link = state .storage .create_share_link( @@ -305,6 +330,9 @@ pub async fn create_share_link( (status = 404, description = "Not found"), ) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn access_shared_media( State(state): State, Path(token): Path, @@ -330,15 +358,10 @@ pub async fn access_shared_media( } // Verify password if set if let Some(ref hash) = link.password_hash { - let password = match query.password.as_deref() { - Some(p) => p, - None => { - return Err(ApiError( - pinakes_core::error::PinakesError::Authentication( - "password required for this share link".into(), - ), - )); - }, + let Some(password) = query.password.as_deref() else { + return Err(ApiError(pinakes_core::error::PinakesError::Authentication( + "password required for this share link".into(), + ))); }; let valid = pinakes_core::users::auth::verify_password(password, hash) .unwrap_or(false); diff --git a/packages/pinakes-server/src/routes/statistics.rs b/packages/pinakes-server/src/routes/statistics.rs index 47d1a3b..d03d089 100644 --- a/packages/pinakes-server/src/routes/statistics.rs +++ b/packages/pinakes-server/src/routes/statistics.rs @@ -13,6 +13,9 @@ use crate::{dto::LibraryStatisticsResponse, error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn library_statistics( State(state): State, ) -> Result, ApiError> { diff --git a/packages/pinakes-server/src/routes/streaming.rs b/packages/pinakes-server/src/routes/streaming.rs index 622b5aa..c1748fe 100644 --- a/packages/pinakes-server/src/routes/streaming.rs +++ b/packages/pinakes-server/src/routes/streaming.rs @@ -1,3 +1,5 @@ +use std::fmt::Write as _; + use axum::{ extract::{Path, State}, http::StatusCode, @@ -61,6 +63,9 @@ fn escape_xml(s: &str) -> String { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn hls_master_playlist( State(state): State, Path(id): Path, @@ -78,10 +83,11 @@ pub async fn hls_master_playlist( let bandwidth = estimate_bandwidth(profile); let encoded_name = utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); - playlist.push_str(&format!( + let _ = write!( + playlist, "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={w}x{h}\n/api/v1/\ media/{id}/stream/hls/{encoded_name}/playlist.m3u8\n\n", - )); + ); } build_response("application/vnd.apple.mpegurl", playlist) @@ -103,6 +109,9 @@ pub async fn hls_master_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn hls_variant_playlist( State(state): State, Path((id, profile)): Path<(Uuid, String)>, @@ -118,6 +127,12 @@ pub async fn hls_variant_playlist( )); } let segment_duration = 10.0; + #[expect( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "duration/segment_duration is always non-negative and bounded by \ + media length" + )] let num_segments = (duration / segment_duration).ceil() as usize; let mut playlist = String::from( @@ -126,14 +141,20 @@ pub async fn hls_variant_playlist( ); for i in 0..num_segments.max(1) { let seg_dur = if i == num_segments.saturating_sub(1) && duration > 0.0 { - (i as f64).mul_add(-segment_duration, duration) + #[expect( + clippy::cast_precision_loss, + reason = "segment index is small, precision loss is negligible" + )] + let i_f64 = i as f64; + i_f64.mul_add(-segment_duration, duration) } else { segment_duration }; - playlist.push_str(&format!("#EXTINF:{seg_dur:.3},\n")); - playlist.push_str(&format!( - "/api/v1/media/{id}/stream/hls/{profile}/segment{i}.ts\n" - )); + let _ = writeln!(playlist, "#EXTINF:{seg_dur:.3},"); + let _ = writeln!( + playlist, + "/api/v1/media/{id}/stream/hls/{profile}/segment{i}.ts" + ); } playlist.push_str("#EXT-X-ENDLIST\n"); @@ -157,6 +178,9 @@ pub async fn hls_variant_playlist( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn hls_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, @@ -206,7 +230,7 @@ pub async fn hls_segment( Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "no transcode session found; start a transcode first via POST \ - /media/{id}/transcode" + /media/:id/transcode" .into(), ), )) @@ -225,6 +249,9 @@ pub async fn hls_segment( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn dash_manifest( State(state): State, Path(id): Path, @@ -239,7 +266,19 @@ pub async fn dash_manifest( ), )); } + #[expect( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "duration is always non-negative and bounded; hours/minutes fit \ + in u32" + )] let hours = (duration / 3600.0) as u32; + #[expect( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "duration is always non-negative and bounded; hours/minutes fit \ + in u32" + )] let minutes = ((duration % 3600.0) / 60.0) as u32; let seconds = duration % 60.0; @@ -253,12 +292,13 @@ pub async fn dash_manifest( let xml_name = escape_xml(&profile.name); let url_name = utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); - representations.push_str(&format!( - r#" + let _ = write!( + representations, + r#" "#, - )); + ); } let mpd = format!( @@ -291,6 +331,9 @@ pub async fn dash_manifest( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn dash_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, @@ -338,7 +381,7 @@ pub async fn dash_segment( Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "no transcode session found; start a transcode first via POST \ - /media/{id}/transcode" + /media/:id/transcode" .into(), ), )) diff --git a/packages/pinakes-server/src/routes/subtitles.rs b/packages/pinakes-server/src/routes/subtitles.rs index 3e55af3..3f80bce 100644 --- a/packages/pinakes-server/src/routes/subtitles.rs +++ b/packages/pinakes-server/src/routes/subtitles.rs @@ -1,3 +1,5 @@ +use std::path::Component; + use axum::{ Json, extract::{Path, State}, @@ -38,6 +40,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_subtitles( State(state): State, Path(id): Path, @@ -74,6 +79,9 @@ pub async fn list_subtitles( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn add_subtitle( State(state): State, Path(id): Path, @@ -139,7 +147,6 @@ pub async fn add_subtitle( let path = std::path::PathBuf::from(&path_str); - use std::path::Component; if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) { @@ -204,6 +211,9 @@ pub async fn add_subtitle( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_subtitle( State(state): State, Path(id): Path, @@ -227,6 +237,9 @@ pub async fn delete_subtitle( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_subtitle_content( State(state): State, Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>, @@ -300,6 +313,9 @@ pub async fn get_subtitle_content( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_offset( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/sync.rs b/packages/pinakes-server/src/routes/sync.rs index 33a09d6..7b2fd34 100644 --- a/packages/pinakes-server/src/routes/sync.rs +++ b/packages/pinakes-server/src/routes/sync.rs @@ -68,6 +68,9 @@ const DEFAULT_CHANGES_LIMIT: u64 = 100; ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn register_device( State(state): State, Extension(username): Extension, @@ -132,6 +135,9 @@ pub async fn register_device( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_devices( State(state): State, Extension(username): Extension, @@ -161,6 +167,9 @@ pub async fn list_devices( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_device( State(state): State, Extension(username): Extension, @@ -197,6 +206,9 @@ pub async fn get_device( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_device( State(state): State, Extension(username): Extension, @@ -246,6 +258,9 @@ pub async fn update_device( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_device( State(state): State, Extension(username): Extension, @@ -287,6 +302,9 @@ pub async fn delete_device( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn regenerate_token( State(state): State, Extension(username): Extension, @@ -342,6 +360,9 @@ pub async fn regenerate_token( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_changes( State(state): State, Query(params): Query, @@ -391,6 +412,9 @@ pub async fn get_changes( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn report_changes( State(state): State, Extension(_username): Extension, @@ -505,6 +529,9 @@ pub async fn report_changes( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn acknowledge_changes( State(state): State, Extension(_username): Extension, @@ -545,6 +572,9 @@ pub async fn acknowledge_changes( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_conflicts( State(state): State, Extension(_username): Extension, @@ -587,6 +617,9 @@ pub async fn list_conflicts( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn resolve_conflict( State(state): State, Extension(_username): Extension, @@ -625,6 +658,9 @@ pub async fn resolve_conflict( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_upload( State(state): State, Extension(_username): Extension, @@ -665,7 +701,8 @@ pub async fn create_upload( chunk_count, status: UploadStatus::Pending, created_at: now, - expires_at: now + chrono::Duration::hours(upload_timeout_hours as i64), + expires_at: now + + chrono::Duration::hours(upload_timeout_hours.cast_signed()), last_activity: now, }; @@ -706,6 +743,9 @@ pub async fn create_upload( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn upload_chunk( State(state): State, Path((session_id, chunk_index)): Path<(Uuid, u64)>, @@ -767,6 +807,9 @@ pub async fn upload_chunk( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_upload_status( State(state): State, Path(id): Path, @@ -793,6 +836,9 @@ pub async fn get_upload_status( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn complete_upload( State(state): State, Path(id): Path, @@ -809,7 +855,8 @@ pub async fn complete_upload( .await .map_err(|e| ApiError::internal(format!("Failed to get chunks: {e}")))?; - if chunks.len() != session.chunk_count as usize { + if chunks.len() != usize::try_from(session.chunk_count).unwrap_or(usize::MAX) + { return Err(ApiError::bad_request(format!( "Missing chunks: expected {}, got {}", session.chunk_count, @@ -961,6 +1008,9 @@ pub async fn complete_upload( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn cancel_upload( State(state): State, Path(id): Path, @@ -1004,6 +1054,9 @@ pub async fn cancel_upload( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn download_file( State(state): State, Path(path): Path, diff --git a/packages/pinakes-server/src/routes/tags.rs b/packages/pinakes-server/src/routes/tags.rs index 506f855..90fffa6 100644 --- a/packages/pinakes-server/src/routes/tags.rs +++ b/packages/pinakes-server/src/routes/tags.rs @@ -25,6 +25,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_tag( State(state): State, Json(req): Json, @@ -53,6 +56,9 @@ pub async fn create_tag( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_tags( State(state): State, ) -> Result>, ApiError> { @@ -73,6 +79,9 @@ pub async fn list_tags( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_tag( State(state): State, Path(id): Path, @@ -95,6 +104,9 @@ pub async fn get_tag( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_tag( State(state): State, Path(id): Path, @@ -118,6 +130,9 @@ pub async fn delete_tag( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn tag_media( State(state): State, Path(media_id): Path, @@ -154,6 +169,9 @@ pub async fn tag_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn untag_media( State(state): State, Path((media_id, tag_id)): Path<(Uuid, Uuid)>, @@ -185,6 +203,9 @@ pub async fn untag_media( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_tags( State(state): State, Path(media_id): Path, diff --git a/packages/pinakes-server/src/routes/transcode.rs b/packages/pinakes-server/src/routes/transcode.rs index 81a8b5c..2cc9169 100644 --- a/packages/pinakes-server/src/routes/transcode.rs +++ b/packages/pinakes-server/src/routes/transcode.rs @@ -25,6 +25,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn start_transcode( State(state): State, Path(id): Path, @@ -55,6 +58,9 @@ pub async fn start_transcode( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_session( State(state): State, Path(id): Path, @@ -73,6 +79,9 @@ pub async fn get_session( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_sessions( State(state): State, Query(params): Query, @@ -99,6 +108,9 @@ pub async fn list_sessions( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn cancel_session( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/upload.rs b/packages/pinakes-server/src/routes/upload.rs index cba6451..693cb62 100644 --- a/packages/pinakes-server/src/routes/upload.rs +++ b/packages/pinakes-server/src/routes/upload.rs @@ -44,6 +44,9 @@ fn sanitize_content_disposition(filename: &str) -> String { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn upload_file( State(state): State, mut multipart: Multipart, @@ -110,6 +113,9 @@ pub async fn upload_file( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn download_file( State(state): State, Path(id): Path, @@ -192,6 +198,9 @@ pub async fn download_file( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn move_to_managed( State(state): State, Path(id): Path, @@ -226,6 +235,9 @@ pub async fn move_to_managed( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn managed_stats( State(state): State, ) -> ApiResult> { diff --git a/packages/pinakes-server/src/routes/users.rs b/packages/pinakes-server/src/routes/users.rs index f88e466..29c6ad3 100644 --- a/packages/pinakes-server/src/routes/users.rs +++ b/packages/pinakes-server/src/routes/users.rs @@ -27,6 +27,9 @@ use crate::{ ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_users( State(state): State, ) -> Result>, ApiError> { @@ -53,6 +56,9 @@ pub async fn list_users( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn create_user( State(state): State, Json(req): Json, @@ -116,6 +122,9 @@ pub async fn create_user( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_user( State(state): State, Path(id): Path, @@ -151,6 +160,9 @@ pub async fn get_user( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn update_user( State(state): State, Path(id): Path, @@ -199,6 +211,9 @@ pub async fn update_user( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_user( State(state): State, Path(id): Path, @@ -227,6 +242,9 @@ pub async fn delete_user( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn get_user_libraries( State(state): State, Path(id): Path, @@ -277,6 +295,9 @@ fn validate_root_path(path: &str) -> Result<(), ApiError> { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn grant_library_access( State(state): State, Path(id): Path, @@ -316,6 +337,9 @@ pub async fn grant_library_access( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn revoke_library_access( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/webhooks.rs b/packages/pinakes-server/src/routes/webhooks.rs index ca53d70..875ec37 100644 --- a/packages/pinakes-server/src/routes/webhooks.rs +++ b/packages/pinakes-server/src/routes/webhooks.rs @@ -20,6 +20,9 @@ pub struct WebhookInfo { ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn list_webhooks( State(state): State, ) -> Result>, ApiError> { @@ -48,6 +51,9 @@ pub async fn list_webhooks( ), security(("bearer_auth" = [])) )] +/// # Errors +/// +/// Returns an error if the database operation fails or the request is invalid. pub async fn test_webhook( State(state): State, ) -> Result, ApiError> { @@ -55,17 +61,20 @@ pub async fn test_webhook( let count = config.webhooks.len(); drop(config); - if let Some(ref dispatcher) = state.webhook_dispatcher { - dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::Test); - Ok(Json(serde_json::json!({ - "webhooks_configured": count, - "test_sent": true - }))) - } else { - Ok(Json(serde_json::json!({ - "webhooks_configured": 0, - "test_sent": false, - "message": "no webhooks configured" - }))) - } + state.webhook_dispatcher.as_ref().map_or_else( + || { + Ok(Json(serde_json::json!({ + "webhooks_configured": 0, + "test_sent": false, + "message": "no webhooks configured" + }))) + }, + |dispatcher| { + dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::Test); + Ok(Json(serde_json::json!({ + "webhooks_configured": count, + "test_sent": true + }))) + }, + ) } From 845ddfc8c87010f3221cca91161a7ac7cdb2d16b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 23 May 2026 17:48:11 +0300 Subject: [PATCH 21/22] chore: configure Taplo Signed-off-by: NotAShelf Change-Id: Ie17436ede89ec19688abd265908479ec6a6a6964 --- .taplo.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .taplo.toml diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..48e58d7 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,12 @@ +[formatting] +align_entries = true +column_width = 110 +compact_arrays = false +reorder_keys = true + +[[rule]] +include = [ "**/Cargo.toml" ] +keys = [ "package", "workspace.package" ] + +[rule.formatting] +reorder_keys = false From 1153228f13d197fdbdeb6890d370d6e01ade1f86 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 24 May 2026 14:30:14 +0300 Subject: [PATCH 22/22] chore: tag v0.4.0-dev; bump dependencies Signed-off-by: NotAShelf Change-Id: Ic8de4ee50c15874ed695ed0812a55b866a6a6964 --- Cargo.lock | Bin 256995 -> 256995 bytes Cargo.toml | 251 ++++++++++++++++++++++++++--------------------------- 2 files changed, 124 insertions(+), 127 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc842dc01ec17784deeae8e53558f2ae6f62f7ca..735750e0e456d7a24132e47c8bf573e721aaf199 100644 GIT binary patch delta 3681 zcmZu!eW;#g9p-uNn%kLfEw*#^$#x_P`k43kN4ayh)Fpw=b39pNZFzrgK`EQ_pe^?i zF-aO)Pk4h$Ze~I&VNU4npJ@e3saX**2+_%mMEY0|goW7kp7%YgLF_P|^Pczp-S_YM zUBBzP-t%XdoYxwqLdekNofT$oYBH{ltPAVGz&3sl}!+uF`c91 z#X2QvFd}m+I=_8o_t$+3yLEf7=&stgpnG@k*15tH)Hq?Hx0>Ozk}}Op7G4^rL$+20 zWu?h3D4ldP-Z~#bmPvBFx1?CV^`&+d3V(V~%vz2z)G|x)D1}Z+M#_ZXMxt0$j%sIp zwn}E9c*rTUm^_t?3ukY_(R%K??fV}51zFr(cIWhp3yF#tr7YZ9n^-I{isHSlC$NYc%>Nyp9a-7GaqH9AyTITfC(HO zBpmIM@kR#CeTht3l>+4gXVHj-01u3^U|wR~vRB7|-$?h;JwsN4VUkh>L}GAU0mYP9 zQbstDnDj{i*l05~RvXG9#-b!sIRxiL@UlDc=va5|jz!(h*~_|z5AGTa#fz9d-hr|v z3C*K*R6~>j#+2w)(m`+$`qzB=u|uysQx8oxYwG=*nich5 z&ol+1M07>m^w#euSjxFz_IOL4}A#06A< zqchEN%*vXSb@lg>mAyY5Yqs3m$U45R<-Hv%+IQN)FKg=!|7e%iudi&M8N9Hj?poEp zaq)R|J3Dy(VDI0n+ttk_pW3pveGOMd%?F0%*3@6EZy&*%j*PdgKD?p*bEN>Pl!Nudh&8lr{~iV(cw!aA@8f>f~bn1eGAqo_c&poL{xQ&yL` zW*|j}4=k=PT+`-Z-OXS)C{Beipm~4gywOVHe+w*Bo#=R~OY*#7pWs~XikHFDErwv<5^CDu{soIq3SQ8gw!L*lsShC@3* zB4sReEof|ogXBdcV+nQddz#Vuw4~ba^$`JlWwk8Z6nVOPx4X<7gf&LrD9mlH0oRerlLh)1^QKT z6t-AZAeI=K%%+cO5|BLBjh`LuPR%T@A4}~O{aGxkPi`YC>hT-N)fER$*R3~^(}SY~ z4O~MjC4+2Hn`DB~c*jFg7?jTtv>32uuo(_T$`O<6gUnnXVhfoZVt8`uVrLKDN*2~X zPmv|_^oMHt+!t2C6Q$n3Ay@&oA>kI{t$ea6IB5e2XBbQ>M~!p=^1H%H-LjpG^}aVv zjyD&F1Pp((ovf=Po-FHqV}_h=hG$JCle|bVX3N1NSSaJTD2ycn(4zah0e81(y7Ya8 zV|lWK*wBy^~DM;{!z!2%7=+sCC~P zC{&3hia=$QKupzKCu|Gc8J?QqDMFVhxpy%9GVs1zk1eiW-bLR2UuxA0Gh|IY^#yY0 z{7I5>a7#kEAubREhC5TbZ)=;Ds>L3DR_1CwOGxPOq zkX*wqDN=G|%rh?)qBBCILrciQ1t-EtbrLZ3zOJ~UrM3Vb@bmiA)3AoyKd_))9mug^ zQTV6;fCy*77^hSM$0P!yMjWY7!kZIw zjDGDiWWoPq^hDx5W(1=EnbG)CpU!Qecmj&u%<_bg?!Aw}SbGz`Op&0#u#)qgGbIP+UlH;-X%nVQ3 zJE^nbyjyw69BItBK@6fV+MT*~N%xNfpBNULDM7htnU9(AoJ|yh8kvG|R3w>>#=sSd-%qs-FFWy>mED&(Qf0x$%Xd0ic~u%ytXW47mHw$8fQHU z<7T*)Qz{#Ye@uH`QnaQdZ3 zk`S6J={57x+90%7mf4i0@mAYxqtwc#h#SOMSI)N+_Z&J^Up&8A+`WG2SHr4vG+5=m z<-A0Uo?4oyWXVKq1rsSM>Ks${7_86Q3gd#8xmd-ui?Vpt`NJE!_505p?&{|2?;G9K z3Xy{=(K^9(Ow?dY%w?yoo7XSzetyHm zsG>`hD#2+P?muIcImKuSh9`>@B}NDIBu1GCg;Slqmd;q`Bh$>3VfWqFzr1$J~ zL<}rNE&|QM<>V4Zz_Klr>k6liE3Klv)JpmBiFe*lQRLmKb6K5t!pboq#iLs2! zS>c^#REAW5Czeu*K~bPIfng%1}p@EyYTi1Ktg|gTx=ZvoBdQ>f1NJ{A?|=jjVTVYF5|p&oochn`WAI zgZpNi$1d#}y=^Y2przJ|hoZG+%DIefSVvh9!70wI(*<)iLIx zJDN3v*N-%Fw>P3ruW$L_s@3g(+tDxQ)=U51uBcyI(>^=;k9Bqaoc8x`Kd)^Mjh;U+ z_|MvQZS$^AU36~yY9j`ydG|uOb@gW(+6VB`t5_fNHd7(M@F;d6s{Ui+OXw7YU^ z`{~)x^CAn6;fcfyh7Z1J38-Wl3s)jo59C55qV(L!QZ$D=Nku~df2zL++S7~KEr*xY zlb>w!xG<$rOCcNs3W0u2Re*}c#GwqXXkki$)G{uxX#@HO?;JE0DiXZnu|D#0Gu3@* zUJWKMZlBy(Pe0mjo5iS83MJ}-3KCe?QRI?rbXGBs+4I7KlTdpnji4!Tr4rx`64Fvq zH~rE?z2;zh0Z|*(;9CdV%P(wX=TB~%wV*ACgAYZeUSMH(6Kk`5&W z$(?|I6y}A=PA1#^c5b46@6mS2;E`kP=7$>HU37ZK46KF&A3j+F&52n8MM82+k~%1? zge}1hpg)2|S29FCXTg-PjH0XVk;kWN-ApbcdLyrF4_Vbe{NQ4;sX_hibK7U3!2t`y zU71fQgHqhv47gc?QG)MYXUC0Dm=E}1i-67>{Ag-uF8zd|?pw+FM%U&FGF7Lxl5^^& zE#&mTZ6&w9ci|YrAiL5iBb-hd14$5isAcI@-V2BtMNU*4wxe#@MxGoUIHR*<#Yl(IK}mr>W1JM! z%LutBSSR@IB~5TY4QGSqqEV=^w$L>SH*6;Bn`_%;_3`cF-p;Ro*-p+HLsP)wgAM&= z;D4Iae&je(`M}{cB_@Uunq-pj*}}?|^}$(yBSAbu^$#|agJV0$L(RK(Q{UK4E*RXg z6OD{dVwu6yXktpL3>|YoWb~R!$S7PCbkGPBRwuL%Zp7@_Nf>$ZhYw!PIBTUE{MsCI&)Vj3;t*W zfU?9=rHZa5zs_{zl*Ky^@gpL6VfX!G%(F01t@0DSFAI{=$ zpgJ?Uw_HI3&PZGb;0}Q0?{wAO_RNRs#;eE`K;RRRY^-xvkrihxJ(Lez`-OeiOl_Jv zPcF!mbg2laISAQD4k!Rjk@c{IIdKNSAw#i5Ef}-95-SMhjDudyU@~b4Sf%iI+fsT7>vP3bIEWi%S1L12`~+^2FH=i#@vhF#0YB7 zoFr!s^Gw~di)mhE<{upV`A#xDyI`it;oFep0mV54lfqJqz&$0CN(#uUK-Pvo8)gK)&>`1S y%~PMu0;h5)+4UFuo27Nk)FpN63t-XE`qtO-Q?hnwNrPP{$&zO9n-|F94gUkm#5{HY diff --git a/Cargo.toml b/Cargo.toml index a52b16f..eef9a81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,4 @@ [workspace] -members = ["crates/*", "packages/*", "xtask"] exclude = [ "crates/pinakes-core/tests/fixtures/test-plugin", "examples/plugins/auto-tagger", @@ -7,88 +6,61 @@ exclude = [ "examples/plugins/subtitle-detector", "examples/plugins/cbz-comics", ] +members = [ "crates/*", "packages/*", "xtask" ] resolver = "3" [workspace.package] -edition = "2024" # keep in sync with .rustfmt.toml -version = "0.3.0-dev" -license = "EUPL-1.2" -readme = true -rust-version = "1.95.0" # follows nightly Rust +edition = "2024" # keep in sync with .rustfmt.toml +license = "EUPL-1.2" +rust-version = "1.95.0" +version = "0.4.0-dev" +readme = true [workspace.dependencies] # Crate components for Pinakes. Those are the internal dependencies that are built # while building any package. -pinakes-core = { path = "./crates/pinakes-core" } -pinakes-plugin-api = { path = "./crates/pinakes-plugin-api" } -pinakes-migrations = { path = "./crates/pinakes-migrations" } -pinakes-types = { path = "./crates/pinakes-types" } -pinakes-metadata = { path = "./crates/pinakes-metadata" } -pinakes-plugin = { path = "./crates/pinakes-plugin" } +pinakes-core = { path = "./crates/pinakes-core" } pinakes-enrichment = { path = "./crates/pinakes-enrichment" } -pinakes-sync = { path = "./crates/pinakes-sync" } +pinakes-metadata = { path = "./crates/pinakes-metadata" } +pinakes-migrations = { path = "./crates/pinakes-migrations" } +pinakes-plugin = { path = "./crates/pinakes-plugin" } +pinakes-plugin-api = { path = "./crates/pinakes-plugin-api" } +pinakes-sync = { path = "./crates/pinakes-sync" } +pinakes-types = { path = "./crates/pinakes-types" } # Pinakes itself is a REST API server. UI and TUI are official visual components # that connect to the server. Using the API documentation, the user can write # their own clients, but we separate "crates" and "packages" to establish the # distinction properly. pinakes-server = { path = "./packages/pinakes-server" } -pinakes-ui = { path = "./packages/pinakes-ui" } -pinakes-tui = { path = "./packages/pinakes-tui" } +pinakes-tui = { path = "./packages/pinakes-tui" } +pinakes-ui = { path = "./packages/pinakes-ui" } # Other dependencies. Declaring them in the virtual manifests lets use reuse the crates # without having to track individual crate version across different types of crates. This # also includes *dev* dependencies. -tokio = { version = "1.52.3", features = ["full"] } -tokio-util = { version = "0.7.18", features = ["rt"] } -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -toml = "1.1.2" -clap = { version = "4.6.1", features = ["derive", "env"] } -chrono = { version = "0.4.44", features = ["serde"] } -uuid = { version = "1.23.1", features = ["v7", "serde"] } -thiserror = "2.0.18" +ammonia = "4.1.2" anyhow = "1.0.102" -tracing = "0.1.44" -tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } -blake3 = "1.8.5" -rustc-hash = "2.1.2" -ed25519-dalek = { version = "2.2.0", features = ["std"] } -lofty = "0.24.0" -lopdf = "0.40.0" -epub = "2.1.5" -matroska = "0.30.1" -gray_matter = "0.3.2" -kamadak-exif = "0.6.1" -rusqlite = { version = "0.39.0", features = ["bundled", "column_decltype"] } -tokio-postgres = { version = "0.7.17", features = [ - "with-uuid-1", - "with-chrono-0_4", - "with-serde_json-1", -] } -deadpool-postgres = "0.14.1" -postgres-types = { version = "0.2.13", features = ["derive"] } -postgres-native-tls = "0.5.3" -native-tls = "0.2.18" -refinery = { version = "0.9.1", features = ["tokio-postgres"] } -rusqlite_migration = "2.5.0" -walkdir = "2.5.0" -notify = { version = "8.2.0", features = ["macos_fsevent"] } -winnow = "1.0.3" -axum = { version = "0.8.9", features = ["macros", "multipart"] } -axum-server = { version = "0.8.0" } -tower = "0.5.3" -tower-http = { version = "0.6.11", features = ["cors", "trace", "set-header"] } -governor = "0.10.4" -tower_governor = "0.8.0" -reqwest = { version = "0.13.3", features = ["json", "query", "blocking"] } -url = "2.5" -ratatui = "0.30.0" -crossterm = "0.29.0" -dioxus = { version = "0.7.9", features = ["desktop", "router"] } -dioxus-core = { version = "0.7.9" } +argon2 = { version = "0.5.3", features = [ "std" ] } async-trait = "0.1.89" +axum = { version = "0.8.9", features = [ "macros", "multipart" ] } +axum-server = { version = "0.8.0" } +blake3 = "1.8.5" +chrono = { version = "0.4.44", features = [ "serde" ] } +clap = { version = "4.6.1", features = [ "derive", "env" ] } +crossterm = "0.29.0" +deadpool-postgres = "0.14.1" +dioxus = { version = "0.7.9", features = [ "desktop", "router" ] } +dioxus-core = { version = "0.7.9" } +dioxus-free-icons = { version = "0.10.0", features = [ "font-awesome-solid" ] } +ed25519-dalek = { version = "2.2.0", features = [ "std" ] } +epub = "2.1.5" futures = "0.3.32" +gloo-timers = { version = "0.4.0", features = [ "futures" ] } +governor = "0.10.4" +gray_matter = "0.3.2" +http = "1.4.0" +http-body-util = "0.1.3" image = { version = "0.25.10", default-features = false, features = [ "jpeg", "png", @@ -97,72 +69,97 @@ image = { version = "0.25.10", default-features = false, features = [ "tiff", "bmp", ] } -pulldown-cmark = "0.13.4" -ammonia = "4.1.2" -argon2 = { version = "0.5.3", features = ["std"] } -mime_guess = "2.0.5" -regex = "1.12.3" -dioxus-free-icons = { version = "0.10.0", features = ["font-awesome-solid"] } -rfd = "0.17.2" -gloo-timers = { version = "0.4.0", features = ["futures"] } -rand = "0.10.1" -moka = { version = "0.12.15", features = ["future"] } -urlencoding = "2.1.3" image_hasher = "3.1.1" +kamadak-exif = "0.6.1" +lofty = "0.24.0" +lopdf = "0.40.0" +matroska = "0.30.1" +mime_guess = "2.0.5" +moka = { version = "0.12.15", features = [ "future" ] } +native-tls = "0.2.18" +notify = { version = "8.2.0", features = [ "macos_fsevent" ] } percent-encoding = "2.3.2" -http = "1.4.0" -wasmtime = { version = "44.0.1", features = ["component-model"] } -wit-bindgen = "0.57.1" +postgres-native-tls = "0.5.3" +postgres-types = { version = "0.2.13", features = [ "derive" ] } +pulldown-cmark = "0.13.4" +rand = "0.10.1" +ratatui = "0.30.0" +refinery = { version = "0.9.1", features = [ "tokio-postgres" ] } +regex = "1.12.3" +reqwest = { version = "0.13.3", features = [ "json", "query", "blocking" ] } +rfd = "0.17.2" +rusqlite = { version = "0.39.0", features = [ "bundled", "column_decltype" ] } +rusqlite_migration = "2.5.0" +rustc-hash = "2.1.2" +serde = { version = "1.0.228", features = [ "derive" ] } +serde_json = "1.0.150" tempfile = "3.27.0" -utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] } +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = [ "full" ] } +tokio-postgres = { version = "0.7.17", features = [ "with-uuid-1", "with-chrono-0_4", "with-serde_json-1" ] } +tokio-util = { version = "0.7.18", features = [ "rt" ] } +toml = "1.1.2" +tower = "0.5.3" +tower-http = { version = "0.6.11", features = [ "cors", "trace", "set-header" ] } +tower_governor = "0.8.0" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = [ "env-filter", "json" ] } +url = "2.5" +urlencoding = "2.1.3" +utoipa = { version = "5.5.0", features = [ "axum_extras", "uuid", "chrono" ] } utoipa-axum = { version = "0.2.0" } -utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } -http-body-util = "0.1.3" +utoipa-swagger-ui = { version = "9.0.2", features = [ "axum" ] } +uuid = { version = "1.23.1", features = [ "v7", "serde" ] } +walkdir = "2.5.0" +wasmtime = { version = "45.0.0", features = [ "component-model" ] } +winnow = "1.0.3" +wit-bindgen = "0.57.1" # See: # [workspace.lints.clippy] -cargo = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } complexity = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -perf = { level = "warn", priority = -1 } -style = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } # The lint groups above enable some less-than-desirable rules, we should manually # enable those to keep our sanity. -absolute_paths = "allow" -arbitrary_source_item_ordering = "allow" -clone_on_ref_ptr = "warn" -dbg_macro = "warn" -empty_drop = "warn" -empty_structs_with_brackets = "warn" -exit = "warn" -filetype_is_file = "warn" -get_unwrap = "warn" -implicit_return = "allow" -infinite_loop = "warn" +absolute_paths = "allow" +arbitrary_source_item_ordering = "allow" +clone_on_ref_ptr = "warn" +dbg_macro = "warn" +empty_drop = "warn" +empty_structs_with_brackets = "warn" +exit = "warn" +filetype_is_file = "warn" +get_unwrap = "warn" +implicit_return = "allow" +infinite_loop = "warn" map_with_unused_argument_over_ranges = "warn" -missing_docs_in_private_items = "allow" -multiple_crate_versions = "allow" # :( -non_ascii_literal = "allow" -non_std_lazy_statics = "warn" -pathbuf_init_then_push = "warn" -pattern_type_mismatch = "allow" -question_mark_used = "allow" -rc_buffer = "warn" -rc_mutex = "warn" -rest_pat_in_fully_bound_structs = "warn" -similar_names = "allow" -single_call_fn = "allow" -std_instead_of_core = "allow" -too_long_first_doc_paragraph = "allow" -too_many_lines = "allow" -undocumented_unsafe_blocks = "warn" -unnecessary_safety_comment = "warn" -unused_result_ok = "warn" -unused_trait_names = "allow" -too_many_arguments = "allow" +missing_docs_in_private_items = "allow" +multiple_crate_versions = "allow" # :( +non_ascii_literal = "allow" +non_std_lazy_statics = "warn" +pathbuf_init_then_push = "warn" +pattern_type_mismatch = "allow" +question_mark_used = "allow" +rc_buffer = "warn" +rc_mutex = "warn" +rest_pat_in_fully_bound_structs = "warn" +significant_drop_tightening = "allow" # rusqlite Statement<'conn> borrows the guard; cannot drop early +similar_names = "allow" +single_call_fn = "allow" +std_instead_of_core = "allow" +too_long_first_doc_paragraph = "allow" +too_many_arguments = "allow" +too_many_lines = "allow" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +unused_result_ok = "warn" +unused_trait_names = "allow" # False positive: # clippy's build script check doesn't recognize workspace-inherited metadata @@ -170,23 +167,23 @@ too_many_arguments = "allow" cargo_common_metadata = "allow" # In the honor of a recent Cloudflare regression -panic = "deny" +panic = "deny" unwrap_used = "deny" # Less dangerous, but we'd like to know # Those must be opt-in, and are fine ONLY in tests and examples. -expect_used = "warn" -print_stderr = "warn" -print_stdout = "warn" -todo = "warn" +expect_used = "warn" +print_stderr = "warn" +print_stdout = "warn" +todo = "warn" unimplemented = "warn" -unreachable = "warn" +unreachable = "warn" [profile.dev.package] -blake3 = { opt-level = 3 } -image = { opt-level = 3 } -regex = { opt-level = 3 } -argon2 = { opt-level = 3 } +argon2 = { opt-level = 3 } +blake3 = { opt-level = 3 } +image = { opt-level = 3 } +lofty = { opt-level = 3 } +lopdf = { opt-level = 3 } matroska = { opt-level = 3 } -lopdf = { opt-level = 3 } -lofty = { opt-level = 3 } +regex = { opt-level = 3 }