From 010d417636feba52cc28fe0eb64c04b4a12e2250 Mon Sep 17 00:00:00 2001 From: fraa2a Date: Fri, 9 Oct 2026 13:09:22 +0200 Subject: [PATCH 01/14] fix: checkpoint bounded media workers and audit correction plan --- .github/workflows/core-tests.yml | 2 +- CLAUDE.md | 7 ++- docs/ARCHITECTURE.md | 19 +++--- .../plans/2026-10-09-audit-errors.md | 23 ++++++++ libs/recording/bounded_worker.h | 58 +++++++++++++++++++ libs/recording/media_control.h | 56 ++++++++++++++++++ tests/phase1/CMakeLists.txt | 9 +++ tests/phase1/bounded_worker_test.cpp | 35 +++++++++++ tests/phase1/media_control_test.cpp | 32 ++++++++++ 9 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-09-audit-errors.md create mode 100644 libs/recording/bounded_worker.h create mode 100644 libs/recording/media_control.h create mode 100644 tests/phase1/bounded_worker_test.cpp create mode 100644 tests/phase1/media_control_test.cpp diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml index c8bdfea..4a06e08 100644 --- a/.github/workflows/core-tests.yml +++ b/.github/workflows/core-tests.yml @@ -23,7 +23,7 @@ jobs: run: python3 scripts/check-text.py - name: Run portable native regressions run: | - for suite in phase1 phase2 u6; do + for suite in phase1 phase2 u6 logging; do cmake -S "tests/$suite" -B "build-tests/$suite" -DCMAKE_CXX_FLAGS='-Werror' cmake --build "build-tests/$suite" --parallel 2 ctest --test-dir "build-tests/$suite" --output-on-failure diff --git a/CLAUDE.md b/CLAUDE.md index 6a837f0..227e248 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,8 +43,9 @@ Desktop UI live in `app/desktop-ui`: ## Tests And Verification -No full auto test suite yet. No claim test verification -unless specific command run. +Portable native, Rust, browser UI, Stream Deck and Windows updater recovery +regressions run in GitHub Actions. Report exact commands and results; portable +tests do not establish real-device or clean-install behavior. Useful checks: @@ -77,7 +78,7 @@ Main modules: - `app/recorder`: `Monolith.exe`, Win32 tray, message-only window, hotkeys, lifecycle, recording/replay orchestration, runtime status, updater. - `app/desktop-ui`: `Monolith.UI.exe`, Tauri v2/WebView2 host + Preact UI. - Rust modules serve bundled frontend, expose local HTTP APIs, read clip + Rust modules serve the bundled frontend, expose Tauri commands, read clip catalogs read-only, write settings, call engine JSON-RPC. - `libs/capture`: Windows.Graphics.Capture D3D11 capture w/ CPU-readable BGRA staging path. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5257600..3c3d3cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,8 +36,8 @@ Long-term target = separate headless record engine process + UI/tray process in - `app/recorder`: orchestrator, tray, hotkeys, app state, settings reload, updater, runtime status, command dispatch. - `app/desktop-ui`: Tauri v2/WebView2 host + Preact frontend. Rust modules: - `server.rs`, `media.rs`, `settings_store.rs`, `clip_catalog.rs`, - `engine_rpc.rs`, `game_catalog.rs`, `paths.rs`. + `main.rs`, `commands.rs`, `settings_store.rs`, `clip_catalog.rs`, + `collections.rs`, `engine_rpc.rs`, `game_catalog.rs`, `paths.rs`. - `libs/capture`: Windows.Graphics.Capture display capture over D3D11. - `libs/audio`: WASAPI loopback, mic/input device capture, process-loopback, active-game detect. - `libs/encoding`: FFmpeg H.264/H.265/AAC wrappers, thumbnail gen, mux helpers, `TrackMixer`. @@ -54,7 +54,8 @@ Video path now: ```text Windows.Graphics.Capture frame -> D3D11 texture - -> CPU-readable staging texture + -> GPU downscale when supported + -> output-sized CPU-readable staging texture -> BGRA pacer buffer -> FFmpeg video encoder -> encoded video packets @@ -119,7 +120,9 @@ Active Game detect = best-effort. Use fullscreen/foreground/audio signals + conf UI use system WebView2 window thru Tauri v2. This exception apply only to `app/desktop-ui`; record engine stay native, no embed browser runtime. -Tauri host run loopback HTTP server, navigate WebView to `http://127.0.0.1:/`. Keep normal frontend `fetch()`, media, thumbnail, SSE routes, no rewrite components to Tauri `invoke()`. +The host serves the bundled Vite frontend. Operations use Tauri invoke commands +and events. The recorder JSON-RPC endpoint is separate from frontend delivery. +Media access is checked against the current configured roots. ## Runtime Paths @@ -140,8 +143,6 @@ GPU texture -> CPU staging -> BGRA vector -> sws_scale/color conversion Priority perf work: -1. GPU downscale before CPU readback. -2. Output-sized staging texture. -3. Explicit color range/matrix in CPU path. -4. D3D11/NV12 hardware encoder path. -5. AVPacket lifetime/ref-counting improve in mux path. \ No newline at end of file +GPU downscale and output-sized staging already exist with a native-size fallback. +Remaining directions are explicit color range/matrix, GPU-resident D3D11/NV12 +encoding and fewer packet copies. Measure queue pressure and disk latency first. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-10-09-audit-errors.md b/docs/superpowers/plans/2026-10-09-audit-errors.md new file mode 100644 index 0000000..66dba71 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-audit-errors.md @@ -0,0 +1,23 @@ +# Audit corrections implementation plan + +**Goal:** Implement the user-approved modifications from the complete Monolith audit in one English pull request. + +**Baseline:** main 2e8c4ec1616215344b968a5f09a004cf9df6d494. + +**Design:** Preserve the native engine, Tauri sidecars, screenshot-based frontend and module boundaries. Keep media mutations in the engine. Use persistent catalog/clip identities, bounded owned workers, session-owned bookmarks, recoverable file mutations and authenticated updater metadata. Do not add unrelated features or heavy dependencies. + +## Tasks + +- [ ] Engine M01-M08, S04, F04: start order, common clock, WGC target, deferred reload, owned asynchronous writing/lifecycle, device recovery, disk session cleanup, visible failures and session bookmarks. +- [ ] Storage S01-S03, S06-S07, S10: validated paths, serialized recoverable trim, conservative reconciliation, active-writer exclusion and first-request deadline. +- [ ] Host S05, S08-S09, Q02-Q03: immutable membership identities, nondestructive reads, engine-only media writes, current-root checked media access, lightweight membership query. +- [ ] Frontend F01-F03, F05-F12: serialized settings and close flush, identity-based selection, editable-field shortcut guards, thumbnail/collection/favorite refresh, bounded probes, accessibility and fullscreen state. Expose audio-track API limits truthfully. +- [ ] Updater U01-U10: producer/consumer schema compatibility, signed component/version/digest/size, streaming verification, real process exit/readiness, stable recovery entry, cancellation and state ordering, build quoting and strict Stream Deck responses. +- [ ] Q01, Q04-Q05: bounded asynchronous runtime-rotated logs, focused responsibility extraction, current architecture/test documentation. +- [ ] Integration: native suites, Rust suite and Windows cross-check, frontend builds/browser tests, Stream Deck tests, PowerShell producer/consumer, text/whitespace checks. +- [ ] Independent review: fix concrete issues and rerun affected tests. +- [ ] Publish: durable branch checkpoints, one PR, verify CI. Keep Windows hardware/WebView2/clean-install gaps explicit. + +## Review focus + +Slow disk and shutdown; interrupted trim and competing mutations; settings save failures and out-of-order responses; catalog/root/clip identity changes; optional audio recovery; first upgrade from the old updater. Do not equate a portable passing suite with Windows runtime certification. diff --git a/libs/recording/bounded_worker.h b/libs/recording/bounded_worker.h new file mode 100644 index 0000000..bc5fce9 --- /dev/null +++ b/libs/recording/bounded_worker.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +namespace recording { +class BoundedWorker { + struct Job { std::function run; size_t bytes; }; + std::mutex mutex_; + std::condition_variable changed_; + std::deque jobs_; + size_t max_jobs_, max_bytes_, bytes_ = 0; + bool active_ = false, closing_ = false; + std::function error_; + std::thread thread_; + void run() { + std::unique_lock lock(mutex_); + for (;;) { + changed_.wait(lock, [&] { return closing_ || !jobs_.empty(); }); + if (jobs_.empty()) break; + auto job = std::move(jobs_.front()); + jobs_.pop_front(); bytes_ -= job.bytes; active_ = true; + lock.unlock(); + try { job.run(); } catch (...) { try { if (error_) error_(); } catch (...) {} } + lock.lock(); active_ = false; changed_.notify_all(); + } + } +public: + BoundedWorker(size_t max_jobs, size_t max_bytes, std::function error = {}) + : max_jobs_(max_jobs), max_bytes_(max_bytes), error_(std::move(error)), + thread_([this] { run(); }) {} + ~BoundedWorker() { close(); } + BoundedWorker(const BoundedWorker&) = delete; + BoundedWorker& operator=(const BoundedWorker&) = delete; + bool submit(std::function run, size_t bytes = 0) { + std::lock_guard lock(mutex_); + if (closing_ || jobs_.size() >= max_jobs_ || bytes > max_bytes_ - bytes_) return false; + jobs_.push_back({std::move(run), bytes}); bytes_ += bytes; + changed_.notify_one(); return true; + } + bool submit_control(std::function run) { + std::lock_guard lock(mutex_); + if (closing_ || jobs_.size() >= max_jobs_ + 1) return false; + jobs_.push_back({std::move(run), 0}); changed_.notify_one(); return true; + } + void drain() { + std::unique_lock lock(mutex_); + changed_.wait(lock, [&] { return jobs_.empty() && !active_; }); + } + void close() { + { std::lock_guard lock(mutex_); closing_ = true; changed_.notify_all(); } + if (thread_.joinable()) thread_.join(); + } +}; +} diff --git a/libs/recording/media_control.h b/libs/recording/media_control.h new file mode 100644 index 0000000..912889d --- /dev/null +++ b/libs/recording/media_control.h @@ -0,0 +1,56 @@ +#pragma once +#include +#include +#include +#include +#include +#include +namespace recording { +class SessionEpoch { + std::atomic value_{0}; +public: + uint64_t begin() { return value_.fetch_add(1) + 1; } + uint64_t current() const { return value_.load(); } + bool accepts(uint64_t value) const { return value == current(); } +}; +template +bool target_changed(Target opened, Target desired) { return opened != desired; } +template +bool start_with_media(Start start, Media media, Rollback rollback) { + if (!start()) return false; + if (media()) return true; + rollback(); return false; +} +class PendingRestart { + bool pending_ = false; +public: + void request(bool changed) { pending_ = pending_ || changed; } + bool take(bool recording) { + if (recording) return false; + return std::exchange(pending_, false); + } +}; +template +class SessionBatch { +public: + struct Batch { uint64_t id = 0; std::vector items; }; +private: + std::mutex mutex_; + uint64_t next_ = 0; + Batch current_; +public: + void begin() { std::lock_guard lock(mutex_); current_ = {++next_, {}}; } + uint64_t id() { std::lock_guard lock(mutex_); return current_.id; } + bool add(Item item, uint64_t expected = 0) { + std::lock_guard lock(mutex_); + if (!current_.id || current_.items.size() >= 4096 || + (expected && expected != current_.id)) return false; + current_.items.push_back(std::move(item)); return true; + } + Batch finish() { std::lock_guard lock(mutex_); return std::exchange(current_, {}); } +}; +// WASAPI QPC positions are expressed in 100 ns units. +inline int64_t capture_time_us(int64_t position_100ns, int64_t origin_100ns) { + return std::max(0, (position_100ns - origin_100ns) / 10); +} +} diff --git a/tests/phase1/CMakeLists.txt b/tests/phase1/CMakeLists.txt index ee940c3..ae07d9d 100644 --- a/tests/phase1/CMakeLists.txt +++ b/tests/phase1/CMakeLists.txt @@ -23,3 +23,12 @@ endif() add_executable(pcm_input_test pcm_input_test.cpp) target_include_directories(pcm_input_test PRIVATE ../../libs) add_test(NAME pcm_input COMMAND pcm_input_test) + +find_package(Threads REQUIRED) +add_executable(media_control_test media_control_test.cpp) +target_include_directories(media_control_test PRIVATE ../../libs) +add_test(NAME media_control COMMAND media_control_test) +add_executable(bounded_worker_test bounded_worker_test.cpp) +target_include_directories(bounded_worker_test PRIVATE ../../libs) +target_link_libraries(bounded_worker_test PRIVATE Threads::Threads) +add_test(NAME bounded_worker COMMAND bounded_worker_test) diff --git a/tests/phase1/bounded_worker_test.cpp b/tests/phase1/bounded_worker_test.cpp new file mode 100644 index 0000000..98340e4 --- /dev/null +++ b/tests/phase1/bounded_worker_test.cpp @@ -0,0 +1,35 @@ +#include +#include +#include +#include +#include +int main() { + std::atomic errors{0}; + recording::BoundedWorker worker(2, 10, [&] { ++errors; }); + std::promise entered, release; + auto wait = release.get_future().share(); + assert(worker.submit([&] { entered.set_value(); wait.wait(); }, 1)); + entered.get_future().wait(); + std::vector order; + assert(worker.submit([&] { order.push_back(1); }, 6)); + assert(!worker.submit([] {}, 5)); + assert(worker.submit([&] { order.push_back(2); }, 4)); + assert(!worker.submit([] {}, 0)); + assert(worker.submit_control([&] { order.push_back(9); })); + release.set_value(); worker.drain(); + assert((order == std::vector{1, 2, 9})); + assert(worker.submit([] { throw 1; })); worker.drain(); assert(errors == 1); + assert(worker.submit([&] { order.push_back(3); })); worker.close(); + assert(order.back() == 3 && !worker.submit([] {})); + recording::BoundedWorker producer(4, 10), control(1, 0); + std::promise slow_entered, slow_release, complete; + auto slow_wait = slow_release.get_future().share(), completed = complete.get_future().share(); + std::atomic packets{0}; + assert(producer.submit([&] { slow_entered.set_value(); slow_wait.wait(); ++packets; }, 5)); + slow_entered.get_future().wait(); assert(producer.submit([&] { ++packets; }, 5)); + assert(control.submit([&] { producer.drain(); complete.set_value(); })); + assert(completed.wait_for(std::chrono::milliseconds(0)) == std::future_status::timeout); + slow_release.set_value(); + assert(completed.wait_for(std::chrono::seconds(2)) == std::future_status::ready); + assert(packets == 2); control.close(); producer.close(); +} diff --git a/tests/phase1/media_control_test.cpp b/tests/phase1/media_control_test.cpp new file mode 100644 index 0000000..ae462e1 --- /dev/null +++ b/tests/phase1/media_control_test.cpp @@ -0,0 +1,32 @@ +#include +#include +#include +#include +int main() { + recording::SessionEpoch epoch; + const auto old = epoch.begin(), next = epoch.begin(); + assert(!epoch.accepts(old) && epoch.accepts(next)); + assert(recording::target_changed(0, 10)); + assert(recording::target_changed(10, 20)); + assert(!recording::target_changed(20, 20)); + bool recording = false, media = false, session = false; + assert(recording::start_with_media([&] { recording = true; session = true; return true; }, + [&] { assert(session); media = recording; return media; }, [&] { recording = false; })); + assert(recording && media); + recording = false; + assert(!recording::start_with_media([&] { recording = true; return true; }, + [] { return false; }, [&] { recording = false; })); + assert(!recording); + recording::PendingRestart pending; + pending.request(true); assert(!pending.take(true)); + pending.request(false); assert(pending.take(false)); assert(!pending.take(false)); + recording::SessionBatch batch; + batch.begin(); const auto aid = batch.id(); assert(batch.add("A")); + auto a = batch.finish(); assert(!batch.add("late A")); + batch.begin(); assert(!batch.add("stale A", aid)); assert(batch.add("B")); + auto b = batch.finish(); + assert(a.id != b.id && a.items == std::vector{"A"}); + assert(b.items == std::vector{"B"}); + assert(recording::capture_time_us(6000001000, 1000000000) == 500000100); + assert(recording::capture_time_us(999999999, 1000000000) == 0); +} From 3b77da296507f6f68b0888efeb9da9f3158b8d0a Mon Sep 17 00:00:00 2001 From: fraa2a Date: Fri, 9 Oct 2026 13:15:22 +0200 Subject: [PATCH 02/14] fix: checkpoint reconstructed media, UI, storage host and updater corrections --- .github/workflows/core-tests.yml | 2 + .github/workflows/windows-ci.yml | 4 + app/desktop-ui/package.json | 2 +- .../src-tauri/capabilities/default.json | 3 +- app/desktop-ui/src-tauri/src/asset_scope.rs | 11 - app/desktop-ui/src-tauri/src/clip_catalog.rs | 383 +++-------------- .../src-tauri/src/clip_mutations.rs | 123 ++++++ app/desktop-ui/src-tauri/src/collections.rs | 171 ++++---- app/desktop-ui/src-tauri/src/commands.rs | 400 ++++++++++++------ app/desktop-ui/src-tauri/src/main.rs | 58 ++- app/desktop-ui/src-tauri/src/media_assets.rs | 235 ++++++++++ app/desktop-ui/src-tauri/src/request_pool.rs | 118 ++++++ app/desktop-ui/src-tauri/tauri.conf.json | 8 +- app/desktop-ui/tests/README.md | 8 + app/desktop-ui/tests/audit.mjs | 250 +++++++++++ app/desktop-ui/tests/ui.mjs | 6 +- app/desktop-ui/ui/src/app.tsx | 30 +- .../ui/src/home/audio-track-notice.tsx | 10 + app/desktop-ui/ui/src/home/clip-card.tsx | 193 +++++---- .../ui/src/home/collection-detail.tsx | 29 +- .../ui/src/home/collection-picker.tsx | 11 +- app/desktop-ui/ui/src/home/detail-view.tsx | 14 +- app/desktop-ui/ui/src/home/fullscreen.tsx | 11 +- app/desktop-ui/ui/src/lib/api.ts | 58 ++- app/desktop-ui/ui/src/lib/media-probes.ts | 23 + app/desktop-ui/ui/src/lib/multitrack.ts | 7 +- app/desktop-ui/ui/src/lib/player.ts | 7 + app/desktop-ui/ui/src/lib/settings-api.ts | 66 ++- app/desktop-ui/ui/src/lib/window.ts | 56 ++- app/desktop-ui/ui/src/settings/controls.tsx | 35 +- .../ui/src/settings/settings-view.tsx | 66 +-- app/desktop-ui/ui/src/shell/titlebar.tsx | 54 +-- app/desktop-ui/ui/styles.css | 4 + app/recorder/src/updater.cpp | 5 + app/updater/src-tauri/Cargo.toml | 2 +- app/updater/src-tauri/src/download.rs | 146 ++++--- app/updater/src-tauri/src/engine_rpc.rs | 24 +- app/updater/src-tauri/src/install.rs | 91 ++++ app/updater/src-tauri/src/main.rs | 93 ++-- app/updater/src-tauri/src/manifest.rs | 134 ++++++ app/updater/src-tauri/src/paths.rs | 40 +- app/updater/src-tauri/src/process.rs | 228 ++++++++++ app/updater/src-tauri/src/state.rs | 47 +- app/updater/src-tauri/src/versions.rs | 8 +- app/updater/ui/src/state-stream.ts | 20 + app/updater/ui/src/updater.tsx | 15 +- build.bat | 6 +- libs/audio/audio.cpp | 48 ++- libs/audio/audio.h | 1 + libs/capture/capture.cpp | 12 + libs/disk-segments/disk_segments.cpp | 40 +- libs/disk-segments/session_directory.h | 120 ++++++ libs/encoding/encoding.cpp | 15 +- libs/encoding/encoding.h | 2 +- libs/encoding/media_ownership.h | 45 ++ libs/logging/log_writer.h | 123 ++++++ libs/logging/logging.cpp | 67 +-- libs/logging/logging.h | 2 +- libs/recording/recording.cpp | 40 +- libs/recording/recording.h | 3 + libs/replay-buffer/replay_buffer.cpp | 8 +- plugins/stream-deck/manifest.json | 2 +- plugins/stream-deck/package-lock.json | 4 +- plugins/stream-deck/package.json | 2 +- plugins/stream-deck/src/ipc-client.ts | 12 +- scripts/generate-update-manifest.ps1 | 42 +- tests/logging/CMakeLists.txt | 10 + tests/logging/log_writer_test.cpp | 66 +++ tests/logging/win32-shim/windows.h | 12 + tests/phase1/CMakeLists.txt | 4 + tests/phase1/segment_session_test.cpp | 33 ++ tests/phase2/encoding_test.cpp | 14 + tests/phase2/media_test.cpp | 15 +- tests/rust/Cargo.toml | 2 +- tests/rust/src/host_tests.rs | 103 +++++ tests/rust/src/lib.rs | 24 +- tests/rust/src/tests.rs | 39 +- tests/stream-deck/ipc-client.test.mjs | 34 +- tests/updater/build-spaces.ps1 | 35 ++ tests/updater/manifest-contract.ps1 | 39 ++ tests/updater/state-stream.test.mjs | 41 ++ 81 files changed, 3307 insertions(+), 1067 deletions(-) delete mode 100644 app/desktop-ui/src-tauri/src/asset_scope.rs create mode 100644 app/desktop-ui/src-tauri/src/clip_mutations.rs create mode 100644 app/desktop-ui/src-tauri/src/media_assets.rs create mode 100644 app/desktop-ui/src-tauri/src/request_pool.rs create mode 100644 app/desktop-ui/tests/audit.mjs create mode 100644 app/desktop-ui/ui/src/home/audio-track-notice.tsx create mode 100644 app/desktop-ui/ui/src/lib/media-probes.ts create mode 100644 app/updater/src-tauri/src/process.rs create mode 100644 app/updater/ui/src/state-stream.ts create mode 100644 libs/disk-segments/session_directory.h create mode 100644 libs/encoding/media_ownership.h create mode 100644 libs/logging/log_writer.h create mode 100644 tests/logging/CMakeLists.txt create mode 100644 tests/logging/log_writer_test.cpp create mode 100644 tests/logging/win32-shim/windows.h create mode 100644 tests/phase1/segment_session_test.cpp create mode 100644 tests/rust/src/host_tests.rs create mode 100644 tests/updater/build-spaces.ps1 create mode 100644 tests/updater/manifest-contract.ps1 create mode 100644 tests/updater/state-stream.test.mjs diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml index 4a06e08..aa49386 100644 --- a/.github/workflows/core-tests.yml +++ b/.github/workflows/core-tests.yml @@ -40,6 +40,8 @@ jobs: npm ci --ignore-scripts --no-audit npm run build node --test ../../tests/stream-deck/ipc-client.test.mjs + - name: Test updater state ordering + run: node --experimental-strip-types --test tests/updater/state-stream.test.mjs - name: Check and build frontends run: | for app in desktop-ui updater; do diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index 91a776e..defb0ad 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -17,6 +17,10 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - run: ./tests/updater/build-spaces.ps1 + shell: powershell + - run: ./tests/updater/manifest-contract.ps1 + shell: pwsh - run: cargo test --locked --no-default-features --manifest-path tests/rust/Cargo.toml build: diff --git a/app/desktop-ui/package.json b/app/desktop-ui/package.json index 66293c3..e070b80 100644 --- a/app/desktop-ui/package.json +++ b/app/desktop-ui/package.json @@ -9,7 +9,7 @@ "dev": "vite", "tauri": "tauri", "tauri:dev": "tauri dev", - "test:ui": "node tests/ui.mjs" + "test:ui": "node tests/ui.mjs && node tests/audit.mjs" }, "dependencies": { "@preact/signals": "^1.3.0", diff --git a/app/desktop-ui/src-tauri/capabilities/default.json b/app/desktop-ui/src-tauri/capabilities/default.json index f5a83e2..f96fb28 100644 --- a/app/desktop-ui/src-tauri/capabilities/default.json +++ b/app/desktop-ui/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ "core:window:allow-start-dragging", "core:event:default", "core:window:allow-maximize", - "core:window:allow-unmaximize" + "core:window:allow-unmaximize", + "core:window:allow-destroy" ] } diff --git a/app/desktop-ui/src-tauri/src/asset_scope.rs b/app/desktop-ui/src-tauri/src/asset_scope.rs deleted file mode 100644 index ce6c78b..0000000 --- a/app/desktop-ui/src-tauri/src/asset_scope.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Refresh asset permissions after output-folder changes; include .thumbs. - -use tauri::{AppHandle, Manager}; - -pub fn refresh(app: &AppHandle) { - let dirs = crate::settings_store::output_dirs(); - // recursive: true also covers each folder's .thumbs subdirectory. - let scope = app.asset_protocol_scope(); - let _ = scope.allow_directory(&dirs.clips, true); - let _ = scope.allow_directory(&dirs.recs, true); -} diff --git a/app/desktop-ui/src-tauri/src/clip_catalog.rs b/app/desktop-ui/src-tauri/src/clip_catalog.rs index eb09c46..59a0509 100644 --- a/app/desktop-ui/src-tauri/src/clip_catalog.rs +++ b/app/desktop-ui/src-tauri/src/clip_catalog.rs @@ -1,8 +1,7 @@ use crate::{game_catalog, settings_store}; -use rusqlite::{params, Connection, OpenFlags, OptionalExtension, TransactionBehavior}; +use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap}; -use std::fs; use std::path::{Path, PathBuf}; #[derive(Clone, Copy)] @@ -36,10 +35,13 @@ pub struct ClipFilter { pub search: Option, } -#[derive(Serialize)] +#[derive(Clone, Serialize)] pub struct Clip { pub id: i64, pub source: String, + pub catalog_uid: String, + pub clip_uid: String, + pub media_revision: i64, pub video_file: String, pub title: String, pub thumbnail_file: Option, @@ -74,34 +76,30 @@ fn media_folder(source: ClipSource) -> PathBuf { } } -// Bookmark sequences are 1-based within each clip. +fn open(source: ClipSource) -> Option { + let conn = + Connection::open_with_flags(db_path(source), OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?; + conn.busy_timeout(std::time::Duration::from_millis(4000)) + .ok()?; + Some(conn) +} -const BOOKMARK_DDL: &str = "CREATE TABLE IF NOT EXISTS clip_bookmarks ( - clip_id INTEGER NOT NULL, - seq INTEGER NOT NULL, - time_seconds REAL NOT NULL, - label TEXT NOT NULL, - color TEXT NOT NULL DEFAULT '', - PRIMARY KEY (clip_id, seq))"; +#[derive(Clone)] +pub struct ClipIdentity { + pub catalog_uid: String, + pub clip_uid: String, +} -fn open(source: ClipSource, readonly: bool) -> Option { - let path = db_path(source); - if !path.is_file() { - return None; - } - let flags = if readonly { - OpenFlags::SQLITE_OPEN_READ_ONLY - } else { - OpenFlags::SQLITE_OPEN_READ_WRITE - }; - let conn = Connection::open_with_flags(path, flags).ok()?; - let _ = conn.busy_timeout(std::time::Duration::from_millis(4000)); - if !readonly { - // Ensure the bookmarks table exists once per write connection instead - // of re-running the DDL on every add_bookmark insert. - let _ = conn.execute(BOOKMARK_DDL, []); +pub fn identity(source: ClipSource, id: i64) -> Result { + let conn = open(source).ok_or_else(|| "catalog unavailable".to_string())?; + let identity = conn.query_row( + "SELECT (SELECT value FROM catalog_metadata WHERE key='catalog_uid'), clip_uid FROM clips WHERE id=?1", + params![id], |row| Ok(ClipIdentity { catalog_uid: row.get(0)?, clip_uid: row.get(1)? }), + ).map_err(|err| format!("catalog identity unavailable: {err}"))?; + if identity.catalog_uid.is_empty() || identity.clip_uid.is_empty() { + return Err("catalog identity migration required; start the recorder".into()); } - Some(conn) + Ok(identity) } fn file_size(path: &Path) -> u64 { @@ -148,10 +146,28 @@ fn clip_select_sql(conn: &Connection) -> String { } else { "NULL" }; + let uid_expr = if has_column(conn, "clips", "clip_uid") { + "clip_uid" + } else { + "''" + }; + let revision_expr = if has_column(conn, "clips", "media_revision") { + "media_revision" + } else { + "0" + }; + let catalog_uid = conn + .query_row( + "SELECT value FROM catalog_metadata WHERE key='catalog_uid'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap_or_default(); + let catalog_uid = catalog_uid.replace('\'', "''"); format!( "SELECT id, video_file, thumbnail_file, created_at_utc, duration_seconds, game_process_name, game_display_name, favorite, - COALESCE(NULLIF(title, ''), 'Untitled'), {discord_expr}, {exe_path_expr} + COALESCE(NULLIF(title, ''), 'Untitled'), {discord_expr}, {exe_path_expr}, '{catalog_uid}', {uid_expr}, {revision_expr} FROM clips", ) } @@ -184,6 +200,9 @@ fn map_clip_row( Ok(Clip { id, source: source.as_str().to_string(), + catalog_uid: row.get(11)?, + clip_uid: row.get(12)?, + media_revision: row.get(13)?, video_file, title: row .get::<_, String>(8) @@ -212,7 +231,7 @@ fn map_clip_row( } fn read_source(source: ClipSource, filter: &ClipFilter) -> Vec { - let Some(conn) = open(source, true) else { + let Some(conn) = open(source) else { return Vec::new(); }; let folder = media_folder(source); @@ -336,106 +355,6 @@ pub fn distinct_hashtags() -> Vec { tags.into_iter().collect() } -fn video_file_for(conn: &Connection, id: i64) -> Result { - conn.query_row( - "SELECT video_file FROM clips WHERE id = ?1", - params![id], - |row| row.get(0), - ) - .map_err(|_| "clip not found".to_string()) -} - -pub fn set_duration(source: ClipSource, id: i64, duration: f64) -> Result<(), String> { - if !duration.is_finite() || duration <= 0.0 { - return Err("invalid duration".to_string()); - } - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - conn.execute( - "UPDATE clips SET duration_seconds = ?1 WHERE id = ?2", - params![duration, id], - ) - .map_err(|err| err.to_string())?; - if conn.changes() == 0 { - return Err("clip not found".to_string()); - } - Ok(()) -} - -fn clip_exists(conn: &Connection, id: i64) -> bool { - conn.query_row("SELECT 1 FROM clips WHERE id = ?1", params![id], |_| Ok(())) - .is_ok() -} - -fn thumb_basename_for(video_file: &str) -> String { - Path::new(video_file) - .file_stem() - .map(|stem| format!("{}.png", stem.to_string_lossy())) - .unwrap_or_else(|| "thumb.png".to_string()) -} - -pub fn set_favorite(source: ClipSource, id: i64, favorite: bool) -> Result<(), String> { - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - conn.execute( - "UPDATE clips SET favorite = ?1 WHERE id = ?2", - params![favorite as i64, id], - ) - .map_err(|err| err.to_string())?; - if conn.changes() == 0 { - return Err("clip not found".to_string()); - } - Ok(()) -} - -pub fn set_title(source: ClipSource, id: i64, title: &str) -> Result<(), String> { - let value = if title.is_empty() { "Untitled" } else { title }; - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - conn.execute( - "UPDATE clips SET title = ?1 WHERE id = ?2", - params![value, id], - ) - .map_err(|err| err.to_string())?; - if conn.changes() == 0 { - return Err("clip not found".to_string()); - } - Ok(()) -} - -pub fn add_hashtag(source: ClipSource, id: i64, tag: &str) -> Result<(), String> { - if tag.is_empty() { - return Err("empty tag".to_string()); - } - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - if !clip_exists(&conn, id) { - return Err("clip not found".to_string()); - } - conn.execute( - "INSERT OR IGNORE INTO clip_hashtags (clip_id, tag) VALUES (?1, ?2)", - params![id, tag], - ) - .map_err(|err| err.to_string())?; - Ok(()) -} - -pub fn remove_hashtag(source: ClipSource, id: i64, tag: &str) -> Result<(), String> { - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - conn.execute( - "DELETE FROM clip_hashtags WHERE clip_id = ?1 AND tag = ?2", - params![id, tag], - ) - .map_err(|err| err.to_string())?; - Ok(()) -} - #[derive(Serialize)] pub struct BookmarkRow { pub seq: i64, @@ -445,7 +364,7 @@ pub struct BookmarkRow { } pub fn list_bookmarks(source: ClipSource, id: i64) -> Result, String> { - let Some(conn) = open(source, true) else { + let Some(conn) = open(source) else { return Err("catalog unavailable".to_string()); }; let Ok(mut stmt) = conn.prepare( @@ -469,205 +388,3 @@ pub fn list_bookmarks(source: ClipSource, id: i64) -> Result, S rows.collect::, _>>() .map_err(|err| err.to_string()) } - -pub fn add_bookmark( - source: ClipSource, - id: i64, - time_seconds: f64, - label: &str, - color: &str, -) -> Result<(), String> { - if !time_seconds.is_finite() || time_seconds < 0.0 { - return Err("invalid bookmark time".to_string()); - } - let Some(mut conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - let tx = conn - .transaction_with_behavior(TransactionBehavior::Immediate) - .map_err(|err| err.to_string())?; - if !clip_exists(&tx, id) { - return Err("clip not found".to_string()); - } - let next_seq: i64 = tx - .query_row( - "SELECT COALESCE(MAX(seq), 0) + 1 FROM clip_bookmarks WHERE clip_id = ?1", - params![id], - |row| row.get(0), - ) - .map_err(|err| err.to_string())?; - tx.execute( - "INSERT INTO clip_bookmarks (clip_id, seq, time_seconds, label, color) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![id, next_seq, time_seconds, label, color], - ) - .map_err(|err| err.to_string())?; - tx.commit().map_err(|err| err.to_string()) -} - -pub fn update_bookmark( - source: ClipSource, - id: i64, - seq: i64, - label: &str, - color: &str, -) -> Result<(), String> { - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - conn.execute( - "UPDATE clip_bookmarks SET label = ?1, color = ?2 WHERE clip_id = ?3 AND seq = ?4", - params![label, color, id, seq], - ) - .map_err(|err| err.to_string())?; - if conn.changes() == 0 { - return Err("bookmark not found".to_string()); - } - Ok(()) -} - -pub fn remove_bookmark(source: ClipSource, id: i64, seq: i64) -> Result<(), String> { - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - conn.execute( - "DELETE FROM clip_bookmarks WHERE clip_id = ?1 AND seq = ?2", - params![id, seq], - ) - .map_err(|err| err.to_string())?; - if conn.changes() == 0 { - return Err("bookmark not found".to_string()); - } - Ok(()) -} - -pub fn remove_clip(source: ClipSource, id: i64) -> Result<(), String> { - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - let row = conn - .query_row( - "SELECT video_file, thumbnail_file FROM clips WHERE id = ?1", - params![id], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), - ) - .map_err(|_| "clip not found".to_string())?; - let (video_file, thumbnail_file) = row; - - // One transaction: a failure between the DELETEs must not strand hashtag - // or bookmark rows pointing at a deleted clip. - let tx = conn - .unchecked_transaction() - .map_err(|err| err.to_string())?; - tx.execute("DELETE FROM clip_bookmarks WHERE clip_id = ?1", params![id]) - .map_err(|err| err.to_string())?; - tx.execute("DELETE FROM clip_hashtags WHERE clip_id = ?1", params![id]) - .map_err(|err| err.to_string())?; - tx.execute("DELETE FROM clips WHERE id = ?1", params![id]) - .map_err(|err| err.to_string())?; - tx.commit().map_err(|err| err.to_string())?; - - let folder = media_folder(source); - let _ = fs::remove_file(folder.join(&video_file)); - let thumb = thumbnail_file.unwrap_or_else(|| thumb_basename_for(&video_file)); - let _ = fs::remove_file(folder.join(".thumbs").join(&thumb)); - Ok(()) -} - -pub fn rename_clip(source: ClipSource, id: i64, new_stem: &str) -> Result<(), String> { - if new_stem.is_empty() || new_stem.chars().any(|c| "\\/:*?\"<>|.".contains(c)) { - return Err("invalid clip name".to_string()); - } - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - let row = conn - .query_row( - "SELECT video_file, thumbnail_file FROM clips WHERE id = ?1", - params![id], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), - ) - .map_err(|_| "clip not found".to_string())?; - let (old_video, old_thumb) = row; - - let folder = media_folder(source); - let ext = Path::new(&old_video) - .extension() - .map(|e| e.to_string_lossy().to_string()) - .unwrap_or_default(); - let new_video = if ext.is_empty() { - new_stem.to_string() - } else { - format!("{new_stem}.{ext}") - }; - let new_thumb = format!("{new_stem}.png"); - - if folder.join(&new_video).is_file() { - return Err("a clip with that name already exists".to_string()); - } - - fs::rename(folder.join(&old_video), folder.join(&new_video)) - .map_err(|_| "could not rename video file".to_string())?; - - let old_thumb_name = old_thumb.unwrap_or_else(|| thumb_basename_for(&old_video)); - let thumb_renamed = fs::rename( - folder.join(".thumbs").join(&old_thumb_name), - folder.join(".thumbs").join(&new_thumb), - ) - .is_ok(); - - if let Err(err) = conn.execute( - "UPDATE clips SET video_file = ?1, thumbnail_file = ?2 WHERE id = ?3", - params![ - new_video, - if thumb_renamed { - Some(&new_thumb) - } else { - None - }, - id - ], - ) { - // Restore the prior filename if the catalog update fails. - let _ = fs::rename(folder.join(&new_video), folder.join(&old_video)); - if thumb_renamed { - let _ = fs::rename( - folder.join(".thumbs").join(&new_thumb), - folder.join(".thumbs").join(&old_thumb_name), - ); - } - return Err(err.to_string()); - } - Ok(()) -} - -pub fn save_thumbnail_capture(source: ClipSource, id: i64, png: &[u8]) -> Result { - if png.is_empty() || png.len() > 8 * 1024 * 1024 { - return Err("invalid thumbnail".to_string()); - } - if !png.starts_with(b"\x89PNG\r\n\x1a\n") { - return Err("thumbnail must be png".to_string()); - } - let Some(conn) = open(source, false) else { - return Err("catalog unavailable".to_string()); - }; - let video_file = video_file_for(&conn, id)?; - let stem = Path::new(&video_file) - .file_stem() - .map(|s| s.to_string_lossy().to_string()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| "invalid video name".to_string())?; - let thumb_file = format!("{stem}.png"); - let folder = media_folder(source).join(".thumbs"); - fs::create_dir_all(&folder).map_err(|err| err.to_string())?; - fs::write(folder.join(&thumb_file), png).map_err(|err| err.to_string())?; - conn.execute( - "UPDATE clips SET thumbnail_file = ?1 WHERE id = ?2", - params![thumb_file, id], - ) - .map_err(|err| err.to_string())?; - if conn.changes() == 0 { - return Err("clip not found".to_string()); - } - Ok(thumb_file) -} diff --git a/app/desktop-ui/src-tauri/src/clip_mutations.rs b/app/desktop-ui/src-tauri/src/clip_mutations.rs new file mode 100644 index 0000000..e1b97f7 --- /dev/null +++ b/app/desktop-ui/src-tauri/src/clip_mutations.rs @@ -0,0 +1,123 @@ +use base64::{engine::general_purpose, Engine as _}; +use serde_json::Value; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +pub fn send(method: &str, params: Value) -> Result<(), String> { + let bytes = serde_json::to_vec(¶ms).map_err(|err| err.to_string())?; + if bytes.len() > 60 * 1024 { + return Err("clip request too large".into()); + } + let response = crate::engine_rpc::mutate_clip(method, params); + if response.get("ok").and_then(Value::as_bool) == Some(true) { + Ok(()) + } else { + Err(response + .get("error") + .and_then(Value::as_str) + .unwrap_or("engine error") + .to_string()) + } +} + +fn stage(root: &Path, data_url: &str) -> Result<(String, PathBuf), String> { + if data_url.len() > 12 * 1024 * 1024 { + return Err("thumbnail too large".into()); + } + let encoded = data_url + .strip_prefix("data:image/png;base64,") + .ok_or_else(|| "thumbnail must be png".to_string())?; + let png = general_purpose::STANDARD + .decode(encoded) + .map_err(|_| "bad thumbnail data".to_string())?; + if png.len() > 8 * 1024 * 1024 || !png.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err("invalid png thumbnail".into()); + } + fs::create_dir_all(root).map_err(|err| err.to_string())?; + if let Ok(entries) = fs::read_dir(root) { + for entry in entries.take(128).flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.len() == 36 + && name.ends_with(".png") + && name[..32].bytes().all(|c| c.is_ascii_hexdigit()) + { + if entry + .metadata() + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|time| time.elapsed().ok()) + .is_some_and(|age| age.as_secs() > 86400) + { + let _ = fs::remove_file(entry.path()); + } + } + } + } + let random = rusqlite::Connection::open_in_memory().map_err(|err| err.to_string())?; + let token: String = random + .query_row("SELECT lower(hex(randomblob(16)))", [], |row| row.get(0)) + .map_err(|err| err.to_string())?; + let path = root.join(format!("{token}.png")); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|err| err.to_string())?; + if let Err(err) = file.write_all(&png).and_then(|_| file.sync_all()) { + drop(file); + let _ = fs::remove_file(&path); + return Err(err.to_string()); + } + Ok((token, path)) +} + +pub fn capture( + source: &str, + id: i64, + catalog_uid: &str, + clip_uid: &str, + media_revision: i64, + data_url: &str, +) -> Result { + let root = crate::paths::monolith_data_dir().join("thumb-upload"); + let (token, path) = stage(&root, data_url)?; + let result = send( + "clip_capture_thumb", + serde_json::json!({"source":source,"id":id,"catalog_uid":catalog_uid,"clip_uid":clip_uid,"media_revision":media_revision,"upload_token":token}), + ); + let _ = fs::remove_file(path); + result?; + crate::clip_catalog::clip_by_id( + crate::clip_catalog::ClipSource::parse(source).ok_or_else(|| "bad source".to_string())?, + id, + ) + .and_then(|clip| clip.thumbnail_file) + .ok_or_else(|| "thumbnail unavailable after capture".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn staging_bounds_validates_and_creates_unique_uploads() { + let root = std::env::temp_dir().join(format!("monolith-upload-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let png = b"\x89PNG\r\n\x1a\nfixture"; + let encoded = format!( + "data:image/png;base64,{}", + general_purpose::STANDARD.encode(png) + ); + let (token, first) = stage(&root, &encoded).unwrap(); + assert_eq!(token.len(), 32); + assert!(token.bytes().all(|c| c.is_ascii_hexdigit())); + assert_eq!(fs::read(&first).unwrap(), png); + let (_, second) = stage(&root, &encoded).unwrap(); + assert_ne!(first, second); + assert!(stage(&root, "data:image/png;base64,bm90LXBuZw==").is_err()); + assert!(stage(&root, &"a".repeat(12 * 1024 * 1024)).is_err()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/app/desktop-ui/src-tauri/src/collections.rs b/app/desktop-ui/src-tauri/src/collections.rs index e7811a6..1eda7aa 100644 --- a/app/desktop-ui/src-tauri/src/collections.rs +++ b/app/desktop-ui/src-tauri/src/collections.rs @@ -1,4 +1,4 @@ -// Collection members use (source, clip_id); IDs can overlap across catalogs. +// Catalog and clip identities survive row ID reuse and folder changes. use crate::clip_catalog::{self, ClipSource}; use crate::paths; @@ -17,8 +17,11 @@ CREATE TABLE IF NOT EXISTS collection_clips ( source TEXT NOT NULL, clip_id INTEGER NOT NULL, added_at_utc TEXT NOT NULL, - PRIMARY KEY (collection_id, source, clip_id) -);"; + catalog_uid TEXT NOT NULL, + clip_uid TEXT NOT NULL, + PRIMARY KEY (collection_id, source, catalog_uid, clip_uid) +); +CREATE INDEX IF NOT EXISTS collection_member_identity ON collection_clips(source,catalog_uid,clip_uid);"; fn open() -> Result { let dir = paths::monolith_data_dir(); @@ -27,6 +30,27 @@ fn open() -> Result { let _ = conn.busy_timeout(std::time::Duration::from_millis(4000)); conn.execute_batch("PRAGMA foreign_keys = ON;") .map_err(|err| err.to_string())?; + let legacy: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('collection_clips') WHERE name='catalog_uid'", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|err| err.to_string())? + == 0; + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name='collection_clips'", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|err| err.to_string())? + != 0; + if exists && legacy { + conn.execute_batch("ALTER TABLE collection_clips RENAME TO collection_clips_legacy;") + .map_err(|err| err.to_string())?; + } + conn.execute_batch("CREATE TABLE IF NOT EXISTS collection_clips_legacy(collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, source TEXT NOT NULL, clip_id INTEGER NOT NULL, added_at_utc TEXT NOT NULL, PRIMARY KEY(collection_id,source,clip_id));").map_err(|err| err.to_string())?; conn.execute_batch(DDL).map_err(|err| err.to_string())?; Ok(conn) } @@ -61,6 +85,7 @@ pub struct CollectionSummary { pub color: String, pub created_at_utc: String, pub clip_count: i64, + pub unresolved_count: i64, } pub fn list_collections() -> Result, String> { @@ -69,7 +94,9 @@ pub fn list_collections() -> Result, String> { .prepare( "SELECT c.id, c.name, c.color, c.created_at_utc, (SELECT COUNT(*) FROM collection_clips cc - WHERE cc.collection_id = c.id) AS clip_count + WHERE cc.collection_id = c.id) + + (SELECT COUNT(*) FROM collection_clips_legacy l WHERE l.collection_id=c.id) AS clip_count, + (SELECT COUNT(*) FROM collection_clips_legacy l WHERE l.collection_id=c.id) FROM collections c ORDER BY c.created_at_utc, c.id", ) .map_err(|err| err.to_string())?; @@ -81,6 +108,7 @@ pub fn list_collections() -> Result, String> { color: row.get(2)?, created_at_utc: row.get(3)?, clip_count: row.get(4)?, + unresolved_count: row.get(5)?, }) }) .map_err(|err| err.to_string())?; @@ -132,25 +160,35 @@ pub fn add_clip_to_collection( source: ClipSource, clip_id: i64, ) -> Result<(), String> { - let conn = open()?; - // Only accept clips that exist in the catalog right now. - if !clip_catalog::clips_by_ids(source, &[clip_id])?.contains_key(&clip_id) { - return Err("clip not found".to_string()); + let identity = clip_catalog::identity(source, clip_id)?; + add_clip_with_identity(collection_id, source, clip_id, identity) +} + +pub fn add_clip_with_identity( + collection_id: i64, + source: ClipSource, + clip_id: i64, + identity: clip_catalog::ClipIdentity, +) -> Result<(), String> { + let current = clip_catalog::identity(source, clip_id)?; + if current.catalog_uid != identity.catalog_uid || current.clip_uid != identity.clip_uid { + return Err("clip identity changed; refresh the library".into()); } + let conn = open()?; let exists: bool = conn .query_row( - "SELECT 1 FROM collections WHERE id = ?1", + "SELECT 1 FROM collections WHERE id=?1", params![collection_id], |_| Ok(()), ) .is_ok(); if !exists { - return Err("collection not found".to_string()); + return Err("collection not found".into()); } + conn.execute("INSERT OR IGNORE INTO collection_clips(collection_id,source,clip_id,added_at_utc,catalog_uid,clip_uid) VALUES(?1,?2,?3,?4,?5,?6)",params![collection_id,source.as_str(),clip_id,now_iso8601_utc(),identity.catalog_uid,identity.clip_uid]).map_err(|err|err.to_string())?; conn.execute( - "INSERT OR IGNORE INTO collection_clips (collection_id, source, clip_id, added_at_utc) - VALUES (?1, ?2, ?3, ?4)", - params![collection_id, source.as_str(), clip_id, now_iso8601_utc()], + "DELETE FROM collection_clips_legacy WHERE collection_id=?1 AND source=?2 AND clip_id=?3", + params![collection_id, source.as_str(), clip_id], ) .map_err(|err| err.to_string())?; Ok(()) @@ -160,79 +198,68 @@ pub fn remove_clip_from_collection( collection_id: i64, source: ClipSource, clip_id: i64, +) -> Result<(), String> { + let identity = clip_catalog::identity(source, clip_id)?; + remove_clip_with_identity(collection_id, source, identity) +} + +pub fn remove_clip_with_identity( + collection_id: i64, + source: ClipSource, + identity: clip_catalog::ClipIdentity, ) -> Result<(), String> { let conn = open()?; - conn.execute( - "DELETE FROM collection_clips WHERE collection_id = ?1 AND source = ?2 AND clip_id = ?3", - params![collection_id, source.as_str(), clip_id], - ) - .map_err(|err| err.to_string())?; + conn.execute("DELETE FROM collection_clips WHERE collection_id=?1 AND source=?2 AND catalog_uid=?3 AND clip_uid=?4",params![collection_id,source.as_str(),identity.catalog_uid,identity.clip_uid]).map_err(|err|err.to_string())?; Ok(()) } -// Prune absent clips only after all required catalogs have been read. -pub fn collection_clips(collection_id: i64) -> Result, String> { +pub fn collection_memberships(source: ClipSource, id: i64) -> Result, String> { + let identity = clip_catalog::identity(source, id)?; let conn = open()?; - let mut stmt = conn - .prepare( - "SELECT source, clip_id FROM collection_clips - WHERE collection_id = ?1 ORDER BY datetime(added_at_utc) DESC, clip_id DESC", + let mut stmt=conn.prepare("SELECT collection_id FROM collection_clips WHERE source=?1 AND catalog_uid=?2 AND clip_uid=?3 ORDER BY collection_id").map_err(|err|err.to_string())?; + let rows = stmt + .query_map( + params![source.as_str(), identity.catalog_uid, identity.clip_uid], + |row| row.get(0), ) .map_err(|err| err.to_string())?; - let rows = stmt + rows.collect::, _>>() + .map_err(|err| err.to_string()) +} + +pub fn collection_clips(collection_id: i64) -> Result, String> { + let conn = open()?; + let mut stmt=conn.prepare("SELECT source,clip_id,catalog_uid,clip_uid FROM collection_clips WHERE collection_id=?1 ORDER BY added_at_utc DESC,clip_id DESC").map_err(|err|err.to_string())?; + let entries = stmt .query_map(params![collection_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) }) - .map_err(|err| err.to_string())?; - - let entries = rows + .map_err(|err| err.to_string())? .collect::, _>>() .map_err(|err| err.to_string())?; - let replay_ids: Vec<_> = entries - .iter() - .filter(|(source, _)| source == "replay") - .map(|(_, id)| *id) - .collect(); - let manual_ids: Vec<_> = entries - .iter() - .filter(|(source, _)| source == "manual") - .map(|(_, id)| *id) - .collect(); - let mut replay = if replay_ids.is_empty() { - std::collections::HashMap::new() - } else { - clip_catalog::clips_by_ids(ClipSource::Replay, &replay_ids)? - }; - let mut manual = if manual_ids.is_empty() { - std::collections::HashMap::new() - } else { - clip_catalog::clips_by_ids(ClipSource::Manual, &manual_ids)? - }; let mut clips = Vec::new(); - let mut stale = Vec::new(); - for (source, id) in entries { - let clip = match source.as_str() { - "replay" => replay.remove(&id), - "manual" => manual.remove(&id), - _ => None, - }; - if let Some(clip) = clip { - clips.push(clip); - } else { - stale.push((source, id)); + for source in [ClipSource::Replay, ClipSource::Manual] { + let members: Vec<_> = entries + .iter() + .filter(|entry| entry.0 == source.as_str()) + .collect(); + if members.is_empty() { + continue; + } + let ids: Vec<_> = members.iter().map(|entry| entry.1).collect(); + let available = clip_catalog::clips_by_ids(source, &ids)?; + for member in members { + if let Some(clip) = available.get(&member.1) { + if clip.catalog_uid == member.2 && clip.clip_uid == member.3 { + clips.push(clip.clone()); + } + } } } - drop(stmt); - // Prune only after both catalogs were read without errors. - let tx = conn - .unchecked_transaction() - .map_err(|err| err.to_string())?; - for (source, id) in stale { - tx.execute( - "DELETE FROM collection_clips WHERE collection_id = ?1 AND source = ?2 AND clip_id = ?3", - params![collection_id, source, id], - ).map_err(|err| err.to_string())?; - } - tx.commit().map_err(|err| err.to_string())?; Ok(clips) } diff --git a/app/desktop-ui/src-tauri/src/commands.rs b/app/desktop-ui/src-tauri/src/commands.rs index 551da54..64c3245 100644 --- a/app/desktop-ui/src-tauri/src/commands.rs +++ b/app/desktop-ui/src-tauri/src/commands.rs @@ -1,8 +1,8 @@ // Native Tauri commands. Run blocking disk, decode and network work through spawn_blocking. -use crate::{clip_catalog, engine_rpc, game_catalog, settings_store}; #[cfg(target_os = "windows")] use crate::exe_icon; +use crate::{clip_catalog, engine_rpc, game_catalog, settings_store}; use base64::{engine::general_purpose, Engine as _}; use clip_catalog::{Clip, ClipFilter, ClipSource}; use game_catalog::CatalogEntry; @@ -32,7 +32,6 @@ async fn blocking_result( .map_err(|err| err.to_string())? } - #[tauri::command] pub async fn list_clips(filter: ClipFilter) -> Vec { blocking(move || clip_catalog::list_clips(&filter)).await @@ -48,7 +47,6 @@ pub async fn distinct_hashtags() -> Vec { blocking(clip_catalog::distinct_hashtags).await } - #[tauri::command] pub async fn engine_status() -> Value { blocking(engine_rpc::get_status).await @@ -91,78 +89,168 @@ pub async fn set_selected_game(exe: String, pid: Option) -> Result<(), Stri } } - -#[tauri::command] -pub async fn clip_set_duration(source: String, id: i64, duration: f64) -> Result<(), String> { +async fn clip_mutation( + method: &'static str, + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + mut params: Value, +) -> Result<(), String> { let src = parse_source(&source)?; - blocking_result(move || clip_catalog::set_duration(src, id, duration)).await + params["source"] = serde_json::json!(src.as_str()); + params["id"] = serde_json::json!(id); + params["catalog_uid"] = serde_json::json!(catalog_uid); + params["clip_uid"] = serde_json::json!(clip_uid); + blocking_result(move || crate::clip_mutations::send(method, params)).await +} +#[tauri::command] +pub async fn clip_set_duration( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + media_revision: i64, + duration: f64, +) -> Result<(), String> { + clip_mutation( + "clip_set_duration", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"duration":duration,"media_revision":media_revision}), + ) + .await } - #[tauri::command] -pub async fn thumb_capture(source: String, id: i64, data_url: String) -> Result { +pub async fn thumb_capture( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + media_revision: i64, + data_url: String, +) -> Result { let src = parse_source(&source)?; - let result = blocking_result(move || { - let encoded = data_url.split_once(',').map(|(_, data)| data).unwrap_or(&data_url); - let decoded = general_purpose::STANDARD - .decode(encoded.as_bytes()) - .map_err(|_| "bad thumbnail data".to_string())?; - clip_catalog::save_thumbnail_capture(src, id, &decoded) + blocking_result(move || { + crate::clip_mutations::capture( + src.as_str(), + id, + &catalog_uid, + &clip_uid, + media_revision, + &data_url, + ) }) - .await; - if let Err(err) = &result { - eprintln!("[thumb_capture] {source} clip {id} failed: {err}"); - } - result + .await } - #[tauri::command] -pub async fn clip_set_favorite(source: String, id: i64, favorite: bool) -> Result<(), String> { - let src = parse_source(&source)?; - blocking_result(move || clip_catalog::set_favorite(src, id, favorite)).await +pub async fn clip_set_favorite( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + favorite: bool, +) -> Result<(), String> { + clip_mutation( + "clip_set_favorite", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"favorite":favorite}), + ) + .await } - #[tauri::command] -pub async fn clip_set_title(source: String, id: i64, title: String) -> Result<(), String> { - let src = parse_source(&source)?; - blocking_result(move || clip_catalog::set_title(src, id, &title)).await +pub async fn clip_set_title( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + title: String, +) -> Result<(), String> { + clip_mutation( + "clip_set_title", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"title":title}), + ) + .await } - #[tauri::command] -pub async fn clip_add_hashtag(source: String, id: i64, tag: String) -> Result<(), String> { - let src = parse_source(&source)?; - blocking_result(move || clip_catalog::add_hashtag(src, id, &tag)).await +pub async fn clip_add_hashtag( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + tag: String, +) -> Result<(), String> { + clip_mutation( + "clip_add_hashtag", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"tag":tag}), + ) + .await } - #[tauri::command] -pub async fn clip_remove_hashtag(source: String, id: i64, tag: String) -> Result<(), String> { - let src = parse_source(&source)?; - blocking_result(move || clip_catalog::remove_hashtag(src, id, &tag)).await +pub async fn clip_remove_hashtag( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + tag: String, +) -> Result<(), String> { + clip_mutation( + "clip_remove_hashtag", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"tag":tag}), + ) + .await } - #[tauri::command] -pub async fn clip_rename(source: String, id: i64, new_name: String) -> Result<(), String> { - let src = parse_source(&source)?; - let params = serde_json::json!({ "source": src.as_str(), "id": id, "new_name": new_name }); - let result = blocking(move || engine_rpc::mutate_clip("clip_rename", params)).await; - if result.get("ok").and_then(Value::as_bool).unwrap_or(false) { - Ok(()) - } else { - Err(result.get("error").and_then(Value::as_str) - .unwrap_or("recorder must be running to rename clips").to_string()) - } +pub async fn clip_rename( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + new_name: String, +) -> Result<(), String> { + clip_mutation( + "clip_rename", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"new_name":new_name}), + ) + .await } - #[tauri::command] -pub async fn clip_delete(source: String, id: i64) -> Result<(), String> { - let src = parse_source(&source)?; - let params = serde_json::json!({ "source": src.as_str(), "id": id }); - let result = blocking(move || engine_rpc::mutate_clip("clip_delete", params)).await; - if result.get("ok").and_then(Value::as_bool).unwrap_or(false) { - Ok(()) - } else { - Err(result.get("error").and_then(Value::as_str) - .unwrap_or("recorder must be running to delete clips").to_string()) - } +pub async fn clip_delete( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, +) -> Result<(), String> { + clip_mutation( + "clip_delete", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({}), + ) + .await } // Explorer requires /select, and the path in separate arguments. @@ -213,53 +301,41 @@ pub async fn open_updater() -> Result<(), String> { .await } -// Only mutation that still needs the engine: thumbnail regeneration decodes a -// video frame via FFmpeg, which only the recorder process links against. #[tauri::command] -pub async fn clip_regen_thumb(source: String, id: i64) -> Result<(), String> { - let src = parse_source(&source)?; - let result = blocking(move || { - let params = serde_json::json!({ "source": src.as_str(), "id": id }); - engine_rpc::mutate_clip("clip_regen_thumb", params) - }) - .await; - if result.get("ok").and_then(Value::as_bool).unwrap_or(false) { - Ok(()) - } else { - let err = result - .get("error") - .and_then(Value::as_str) - .unwrap_or("engine error") - .to_string(); - eprintln!("[clip_regen_thumb] {source} clip {id} failed: {err}"); - Err(err) - } +pub async fn clip_regen_thumb( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, +) -> Result<(), String> { + clip_mutation( + "clip_regen_thumb", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({}), + ) + .await } - -// Lossless trim of a clip's video file, performed by the engine (single -// writer): the engine rewrites the file in place, retimes bookmarks and bumps -// the clip generation. `start`/`end` are seconds into the original file. #[tauri::command] -pub async fn clip_trim(source: String, id: i64, start: f64, end: f64) -> Result<(), String> { - let src = parse_source(&source)?; - let result = blocking(move || { - let params = serde_json::json!({ - "source": src.as_str(), "id": id, "start": start, "end": end, - }); - engine_rpc::mutate_clip("clip_trim", params) - }) - .await; - if result.get("ok").and_then(Value::as_bool).unwrap_or(false) { - Ok(()) - } else { - let err = result - .get("error") - .and_then(Value::as_str) - .unwrap_or("engine error") - .to_string(); - eprintln!("[clip_trim] {source} clip {id} failed: {err}"); - Err(err) - } +pub async fn clip_trim( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + start: f64, + end: f64, +) -> Result<(), String> { + clip_mutation( + "clip_trim", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"start":start,"end":end}), + ) + .await } // Adds a bookmark at the current position of the running manual recording. @@ -267,7 +343,8 @@ pub async fn clip_trim(source: String, id: i64, start: f64, end: f64) -> Result< // an engine error string when nothing is recording (or it is paused). #[tauri::command] pub async fn recording_add_bookmark() -> Result<(), String> { - let result = blocking(|| engine_rpc::mutate_clip("recording_add_bookmark", serde_json::json!({}))).await; + let result = + blocking(|| engine_rpc::mutate_clip("recording_add_bookmark", serde_json::json!({}))).await; if result.get("ok").and_then(Value::as_bool).unwrap_or(false) { Ok(()) } else { @@ -279,9 +356,11 @@ pub async fn recording_add_bookmark() -> Result<(), String> { } } - #[tauri::command] -pub async fn clip_list_bookmarks(source: String, id: i64) -> Result, String> { +pub async fn clip_list_bookmarks( + source: String, + id: i64, +) -> Result, String> { let src = parse_source(&source)?; blocking_result(move || clip_catalog::list_bookmarks(src, id)).await } @@ -290,33 +369,76 @@ pub async fn clip_list_bookmarks(source: String, id: i64) -> Result Result<(), String> { - let src = parse_source(&source)?; - blocking_result(move || clip_catalog::add_bookmark(src, id, time_seconds, &label, &color)).await + clip_mutation( + "clip_add_bookmark", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"time_seconds":time_seconds,"label":label,"color":color}), + ) + .await } - #[tauri::command] pub async fn clip_update_bookmark( source: String, id: i64, + catalog_uid: String, + clip_uid: String, seq: i64, label: String, color: String, ) -> Result<(), String> { + clip_mutation( + "clip_update_bookmark", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"seq":seq,"label":label,"color":color}), + ) + .await +} +#[tauri::command] +pub async fn clip_delete_bookmark( + source: String, + id: i64, + catalog_uid: String, + clip_uid: String, + seq: i64, +) -> Result<(), String> { + clip_mutation( + "clip_delete_bookmark", + source, + id, + catalog_uid, + clip_uid, + serde_json::json!({"seq":seq}), + ) + .await +} +#[tauri::command] +pub async fn clip_snapshot(source: String, id: i64) -> Result { let src = parse_source(&source)?; - blocking_result(move || clip_catalog::update_bookmark(src, id, seq, &label, &color)).await + blocking_result(move || { + clip_catalog::clips_by_ids(src, &[id])? + .remove(&id) + .ok_or_else(|| "clip unavailable".into()) + }) + .await } - #[tauri::command] -pub async fn clip_delete_bookmark(source: String, id: i64, seq: i64) -> Result<(), String> { +pub async fn collection_memberships(source: String, id: i64) -> Result, String> { let src = parse_source(&source)?; - blocking_result(move || clip_catalog::remove_bookmark(src, id, seq)).await + blocking_result(move || crate::collections::collection_memberships(src, id)).await } - #[tauri::command] pub async fn list_collections() -> Result, String> { blocking_result(crate::collections::list_collections).await @@ -342,9 +464,22 @@ pub async fn add_clip_to_collection( collection_id: i64, source: String, clip_id: i64, + catalog_uid: String, + clip_uid: String, ) -> Result<(), String> { let src = parse_source(&source)?; - blocking_result(move || crate::collections::add_clip_to_collection(collection_id, src, clip_id)).await + blocking_result(move || { + crate::collections::add_clip_with_identity( + collection_id, + src, + clip_id, + clip_catalog::ClipIdentity { + catalog_uid, + clip_uid, + }, + ) + }) + .await } #[tauri::command] @@ -352,9 +487,22 @@ pub async fn remove_clip_from_collection( collection_id: i64, source: String, clip_id: i64, + catalog_uid: String, + clip_uid: String, ) -> Result<(), String> { let src = parse_source(&source)?; - blocking_result(move || crate::collections::remove_clip_from_collection(collection_id, src, clip_id)).await + let _ = clip_id; + blocking_result(move || { + crate::collections::remove_clip_with_identity( + collection_id, + src, + clip_catalog::ClipIdentity { + catalog_uid, + clip_uid, + }, + ) + }) + .await } #[tauri::command] @@ -362,7 +510,6 @@ pub async fn collection_clips(collection_id: i64) -> Result, String> { blocking_result(move || crate::collections::collection_clips(collection_id)).await } - #[tauri::command] pub async fn get_settings() -> Value { blocking(|| settings_store::read_config().unwrap_or(Value::Null)).await @@ -404,21 +551,20 @@ fn find_hotkey_collision(config: &Value) -> Option { } #[tauri::command] -pub async fn save_settings(app: tauri::AppHandle, config: Value) -> Result<(), String> { +pub async fn save_settings(config: Value) -> Result<(), String> { if !config.is_object() { return Err("bad config".to_string()); } if let Some(err) = find_hotkey_collision(&config) { return Err(err); } - blocking_result(move || settings_store::write_config(&config).map_err(|err| err.to_string())).await?; + blocking_result(move || settings_store::write_config(&config).map_err(|err| err.to_string())) + .await?; if let Err(err) = blocking(engine_rpc::reload_settings).await { // Settings are saved; the engine just couldn't reload them right now // (it may not be running - it reads the new values at next start). eprintln!("engine settings reload failed: {err}"); } - // Add asset permissions for the updated output folders. - crate::asset_scope::refresh(&app); Ok(()) } @@ -436,23 +582,27 @@ pub async fn pick_folder(current: Option) -> Option { dialog = dialog.set_directory(start); } } - dialog.pick_folder().map(|p| p.to_string_lossy().to_string()) + dialog + .pick_folder() + .map(|p| p.to_string_lossy().to_string()) }) .await } - #[cfg(target_os = "windows")] #[tauri::command] pub async fn exe_icon(path: String, process: String) -> Option { blocking(move || { - exe_icon::icon_png(&path, &process) - .map(|bytes| format!("data:image/png;base64,{}", general_purpose::STANDARD.encode(bytes))) + exe_icon::icon_png(&path, &process).map(|bytes| { + format!( + "data:image/png;base64,{}", + general_purpose::STANDARD.encode(bytes) + ) + }) }) .await } - #[tauri::command] pub async fn game_catalog_map() -> BTreeMap { blocking(game_catalog::catalog_map).await diff --git a/app/desktop-ui/src-tauri/src/main.rs b/app/desktop-ui/src-tauri/src/main.rs index 56bb70c..3e0e117 100644 --- a/app/desktop-ui/src-tauri/src/main.rs +++ b/app/desktop-ui/src-tauri/src/main.rs @@ -1,15 +1,17 @@ // Tauri hosts bundled frontend assets and the native command/event bridge. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod asset_scope; mod clip_catalog; +mod clip_mutations; mod collections; mod commands; mod engine_rpc; #[cfg(target_os = "windows")] mod exe_icon; mod game_catalog; +mod media_assets; mod paths; +mod request_pool; mod settings_store; use std::thread; @@ -45,7 +47,58 @@ fn spawn_artwork_refresh() { } fn main() { + let media_pool = request_pool::Pool::new( + 4, + 128, + |(request, responder): (tauri::http::Request>, tauri::UriSchemeResponder)| { + let dirs = settings_store::output_dirs(); + let range = request + .headers() + .get("Range") + .and_then(|value| value.to_str().ok()); + let mut result = if request.method() == tauri::http::Method::HEAD { + media_assets::serve_head(request.uri().path(), range, &[dirs.clips, dirs.recs]) + } else { + media_assets::serve(request.uri().path(), range, &[dirs.clips, dirs.recs]) + }; + if request.method() != tauri::http::Method::GET + && request.method() != tauri::http::Method::HEAD + { + result.status = 405; + result.body.clear(); + } + let mut response = tauri::http::Response::builder().status(result.status); + for (name, value) in result.headers { + response = response.header(name, value); + } + match response.body(result.body) { + Ok(response) => responder.respond(response), + Err(err) => { + eprintln!("media response failed: {err}"); + responder.respond( + tauri::http::Response::builder() + .status(500) + .body(Vec::new()) + .unwrap(), + ); + } + } + }, + ) + .expect("failed to create media request workers"); tauri::Builder::default() + .register_asynchronous_uri_scheme_protocol("media", move |_context, request, responder| { + if let Err((_request, responder)) = media_pool.submit((request, responder)) { + responder.respond( + tauri::http::Response::builder() + .status(503) + .header("Retry-After", "1") + .header("Access-Control-Allow-Origin", "*") + .body(Vec::new()) + .unwrap(), + ); + } + }) .invoke_handler(tauri::generate_handler![ commands::list_clips, commands::distinct_games, @@ -75,6 +128,8 @@ fn main() { commands::add_clip_to_collection, commands::remove_clip_from_collection, commands::collection_clips, + commands::collection_memberships, + commands::clip_snapshot, commands::reveal_in_explorer, commands::open_updater, commands::get_settings, @@ -88,7 +143,6 @@ fn main() { commands::game_artwork, ]) .setup(|app| { - asset_scope::refresh(&app.handle()); spawn_clip_watch(app.handle().clone()); spawn_artwork_refresh(); diff --git a/app/desktop-ui/src-tauri/src/media_assets.rs b/app/desktop-ui/src-tauri/src/media_assets.rs new file mode 100644 index 0000000..6b65809 --- /dev/null +++ b/app/desktop-ui/src-tauri/src/media_assets.rs @@ -0,0 +1,235 @@ +use std::{ + fs, + io::{Read, Seek, SeekFrom}, + path::PathBuf, +}; + +pub struct Response { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +fn error(status: u16) -> Response { + Response { + status, + headers: vec![("Access-Control-Allow-Origin".into(), "*".into())], + body: Vec::new(), + } +} + +fn decode(path: &str) -> Option { + let bytes = path.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let pair = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?; + decoded.push(u8::from_str_radix(pair, 16).ok()?); + i += 3; + } else { + decoded.push(bytes[i]); + i += 1; + } + } + if decoded.contains(&0) { + return None; + } + let decoded = String::from_utf8(decoded).ok()?; + #[cfg(windows)] + let decoded = decoded.strip_prefix('/').unwrap_or(&decoded).to_string(); + Some(PathBuf::from(decoded)) +} + +fn serve_request(path: &str, range: Option<&str>, roots: &[PathBuf], head: bool) -> Response { + let Some(path) = decode(path) else { + return error(400); + }; + let Ok(path) = path.canonicalize() else { + return error(404); + }; + if !roots + .iter() + .filter_map(|root| root.canonicalize().ok()) + .any(|root| path.starts_with(root)) + { + return error(403); + } + let mime = match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "mp4" => "video/mp4", + "mkv" => "video/x-matroska", + "webm" => "video/webm", + "mov" => "video/quicktime", + _ => return error(403), + }; + let Ok(mut file) = fs::File::open(&path) else { + return error(404); + }; + let Ok(meta) = file.metadata() else { + return error(500); + }; + if !meta.is_file() { + return error(404); + } + let size = meta.len(); + let (status, start, end) = if let Some(range) = range { + let invalid = || { + let mut response = error(416); + response + .headers + .push(("Content-Range".into(), format!("bytes */{size}"))); + response + }; + let Some(range) = range.strip_prefix("bytes=") else { + return invalid(); + }; + let Some((first, last)) = range.split_once('-') else { + return invalid(); + }; + if range.contains(',') || size == 0 { + return invalid(); + } + let (start, end) = if first.is_empty() { + let Ok(suffix) = last.parse::() else { + return invalid(); + }; + if suffix == 0 { + return invalid(); + } + (size.saturating_sub(suffix), size - 1) + } else { + let Ok(start) = first.parse::() else { + return invalid(); + }; + let end = if last.is_empty() { + size - 1 + } else { + let Ok(end) = last.parse::() else { + return invalid(); + }; + end.min(size - 1) + }; + (start, end) + }; + if start >= size || start > end { + return invalid(); + } + (206, start, end.min(start.saturating_add(1024 * 1024 - 1))) + } else { + if !head && size > 8 * 1024 * 1024 { + return error(413); + } + (200, 0, size.saturating_sub(1)) + }; + let len = if size == 0 { 0 } else { end - start + 1 }; + let mut body = Vec::new(); + if !head { + body.reserve(len as usize); + if file.seek(SeekFrom::Start(start)).is_err() + || file.take(len).read_to_end(&mut body).is_err() + || body.len() != len as usize + { + return error(500); + } + } + let mut headers = vec![ + ("Content-Type".into(), mime.into()), + ("Content-Length".into(), len.to_string()), + ("Accept-Ranges".into(), "bytes".into()), + ("Access-Control-Allow-Origin".into(), "*".into()), + ("Cache-Control".into(), "no-store".into()), + ]; + if status == 206 { + headers.push(( + "Content-Range".into(), + format!("bytes {start}-{end}/{size}"), + )); + } + Response { + status, + headers, + body, + } +} + +pub fn serve(path: &str, range: Option<&str>, roots: &[PathBuf]) -> Response { + serve_request(path, range, roots, false) +} +pub fn serve_head(path: &str, range: Option<&str>, roots: &[PathBuf]) -> Response { + serve_request(path, range, roots, true) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_current_canonical_roots_are_readable_and_ranges_are_bounded() { + let base = std::env::temp_dir().join(format!("monolith-media-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let a = base.join("a"); + let b = base.join("b"); + fs::create_dir_all(&a).unwrap(); + fs::create_dir_all(&b).unwrap(); + fs::write(a.join("one.png"), b"thumbnail").unwrap(); + fs::write(b.join("two.mp4"), b"0123456789").unwrap(); + let url = a.join("one.png").to_string_lossy().into_owned(); + assert_eq!(serve(&url, None, &[a.clone()]).body, b"thumbnail"); + assert_eq!(serve(&url, None, &[b.clone()]).status, 403); + assert_eq!(serve(&url, None, &[a.clone()]).status, 200); + assert_eq!( + serve( + &a.join("../b/two.mp4").to_string_lossy(), + None, + &[a.clone()] + ) + .status, + 403 + ); + let video = b.join("two.mp4").to_string_lossy().into_owned(); + let response = serve(&video, Some("bytes=2-5"), &[b.clone()]); + assert_eq!(response.status, 206); + assert_eq!(response.body, b"2345"); + assert_eq!(serve(&video, Some("bytes=-3"), &[b.clone()]).body, b"789"); + for invalid in ["bytes=20-30", "bytes=4-2", "bytes=0-1,3-4", "items=1-2"] { + assert_eq!(serve(&video, Some(invalid), &[b.clone()]).status, 416); + } + let large = b.join("large.mp4"); + fs::File::create(&large) + .unwrap() + .set_len(32 * 1024 * 1024) + .unwrap(); + assert_eq!( + serve(&large.to_string_lossy(), None, &[b.clone()]).status, + 413 + ); + let head = serve_head(&large.to_string_lossy(), None, &[b.clone()]); + assert_eq!(head.status, 200); + assert!(head.body.is_empty()); + assert!(head + .headers + .contains(&("Content-Length".into(), (32 * 1024 * 1024).to_string()))); + assert_eq!( + serve(&large.to_string_lossy(), Some("bytes=0-"), &[b.clone()]) + .body + .len(), + 1024 * 1024 + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(b.join("two.mp4"), a.join("escape.mp4")).unwrap(); + assert_eq!( + serve(&a.join("escape.mp4").to_string_lossy(), None, &[a]).status, + 403 + ); + } + fs::remove_dir_all(base).unwrap(); + } +} diff --git a/app/desktop-ui/src-tauri/src/request_pool.rs b/app/desktop-ui/src-tauri/src/request_pool.rs new file mode 100644 index 0000000..b3a9be8 --- /dev/null +++ b/app/desktop-ui/src-tauri/src/request_pool.rs @@ -0,0 +1,118 @@ +use std::{ + io, + sync::{mpsc, Arc, Mutex}, + thread, +}; + +pub struct Pool { + sender: Option>, + workers: Vec>, +} +impl Pool { + pub fn new( + workers: usize, + capacity: usize, + process: impl Fn(T) + Send + Sync + 'static, + ) -> io::Result { + if workers == 0 || capacity == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "request pool requires workers and queue capacity", + )); + } + let (sender, receiver) = mpsc::sync_channel(capacity); + let receiver = Arc::new(Mutex::new(receiver)); + let process = Arc::new(process); + let mut pool = Self { + sender: Some(sender), + workers: Vec::with_capacity(workers), + }; + for index in 0..workers { + let receiver = receiver.clone(); + let process = process.clone(); + pool.workers.push( + thread::Builder::new() + .name(format!("media-{index}")) + .spawn(move || loop { + let job = receiver + .lock() + .unwrap_or_else(|err| err.into_inner()) + .recv(); + let Ok(job) = job else { + break; + }; + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| process(job))) + .is_err() + { + eprintln!("media request worker panicked"); + } + })?, + ); + } + Ok(pool) + } + pub fn submit(&self, value: T) -> Result<(), T> { + match self + .sender + .as_ref() + .expect("request pool sender missing") + .try_send(value) + { + Ok(()) => Ok(()), + Err(mpsc::TrySendError::Full(value) | mpsc::TrySendError::Disconnected(value)) => { + Err(value) + } + } + } +} +impl Drop for Pool { + fn drop(&mut self) { + self.sender.take(); + for worker in self.workers.drain(..) { + let _ = worker.join(); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + #[test] + fn workers_and_queue_have_fixed_capacity_and_resume_after_saturation() { + let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new())); + let worker_gate = gate.clone(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let pool = Pool::new(2, 2, move |job: u8| { + started_tx.send(job).unwrap(); + let (lock, changed) = &*worker_gate; + let mut released = lock.lock().unwrap(); + while !*released { + released = changed.wait(released).unwrap(); + } + done_tx.send(job).unwrap(); + }) + .unwrap(); + pool.submit(1).unwrap(); + pool.submit(2).unwrap(); + started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + pool.submit(3).unwrap(); + pool.submit(4).unwrap(); + assert_eq!(pool.submit(5), Err(5)); + assert!( + started_rx.try_recv().is_err(), + "only two workers may run concurrently" + ); + let (lock, changed) = &*gate; + *lock.lock().unwrap() = true; + changed.notify_all(); + for _ in 0..4 { + done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + } + pool.submit(6).unwrap(); + assert_eq!(done_rx.recv_timeout(Duration::from_secs(2)).unwrap(), 6); + drop(pool); + assert!(started_rx.recv_timeout(Duration::from_secs(2)).is_ok()); + } +} diff --git a/app/desktop-ui/src-tauri/tauri.conf.json b/app/desktop-ui/src-tauri/tauri.conf.json index 8fb133a..49d1b0b 100644 --- a/app/desktop-ui/src-tauri/tauri.conf.json +++ b/app/desktop-ui/src-tauri/tauri.conf.json @@ -12,9 +12,9 @@ "app": { "windows": [], "security": { - "csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; media-src 'self' asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'", + "csp": "default-src 'self'; img-src 'self' media: http://media.localhost data:; media-src 'self' media: http://media.localhost; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'", "assetProtocol": { - "enable": true, + "enable": false, "scope": [] } } @@ -22,6 +22,8 @@ "bundle": { "active": false, "targets": "all", - "icon": ["../../../app/assets/exeicon.ico"] + "icon": [ + "../../../app/assets/exeicon.ico" + ] } } diff --git a/app/desktop-ui/tests/README.md b/app/desktop-ui/tests/README.md index d7a29e4..bdc87f7 100644 --- a/app/desktop-ui/tests/README.md +++ b/app/desktop-ui/tests/README.md @@ -26,3 +26,11 @@ Fixture thumbnails are generated test data, not shipped app content. Media requests return an empty response. This check does not validate decoding, recording devices, Windows dialogs, native window behavior or WebView2. Those still need a Windows runtime smoke test. + +The audit regressions in `audit.mjs` use port 4174. They cover settings close +paths, shared draft serialization and visible failures, stable clip identities, +focused-control keys, collection events, bounded visible metadata repair, +accessible field names, Favorites removal and fullscreen window restoration. +They also check pipeline status, compact collection membership queries and the +unsupported audio-track notice. Mock media events prove thumbnail UI updates; +they do not validate codecs, multitrack playback or native Windows behavior. diff --git a/app/desktop-ui/tests/audit.mjs b/app/desktop-ui/tests/audit.mjs new file mode 100644 index 0000000..69032c3 --- /dev/null +++ b/app/desktop-ui/tests/audit.mjs @@ -0,0 +1,250 @@ +import assert from "node:assert/strict"; +import { readFile, mkdir } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; +import { chromium } from "playwright-core"; + +const base = "http://127.0.0.1:4174"; +const seed = JSON.parse(await readFile("../../config/default-config.json", "utf8")); +const server = spawn(process.execPath, ["node_modules/vite/bin/vite.js", "preview", "--host", "127.0.0.1", "--port", "4174", "--strictPort"], { stdio: "pipe" }); +let serverError = ""; +server.stderr.on("data", (chunk) => { serverError += chunk; }); +let browser; +try { + let ready = false; + for (let attempt = 0; attempt < 100; attempt++) { + if (server.exitCode !== null) throw new Error(`Preview exited: ${serverError}`); + try { ready = (await fetch(base)).ok; } catch {} + if (ready) break; + await delay(100); + } + assert(ready, "Preview did not start"); + browser = await chromium.launch({ executablePath: process.env.UI_BROWSER_PATH || undefined, headless: true, args: ["--no-sandbox", "--disable-gpu"] }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); + if (process.env.UI_SCREENSHOT_DIR) await mkdir(process.env.UI_SCREENSHOT_DIR, { recursive: true }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/__fixtures__/**", async (route) => { + if (route.request().url().includes("video")) { + return route.fulfill({ status: 204 }); + } + const id = Number(route.request().url().match(/(\d+)/g)?.at(-1) ?? 0); + const hue = (id * 37) % 360; + return route.fulfill({ contentType: "image/svg+xml", body: `UI test fixture ${id}` }); + }); + await page.addInitScript((config) => { + const clips = Array.from({ length: 15 }, (_, i) => ({ id: i + 1, source: "replay", catalog_uid: "catalog-replay", clip_uid: `clip${i + 1}`, media_revision: 0, video_file: `video${i + 1}.mkv`, title: `Match ${String(i + 1).padStart(2, "0")}`, thumbnail_file: `thumb${i + 1}.svg`, video_path: `video${i + 1}.mkv`, thumbnail_path: `thumb${i + 1}.svg`, created_at_utc: "2026-10-08T10:00:00Z", duration_seconds: 30, game_process_name: "arena.exe", game_display_name: "Arena", favorite: false, hashtags: ["gameplay"], size_bytes: 12000000 })); + const collections = [{ id: 1, name: "Best moments", color: "#c4ff42", clip_count: 3, created_at_utc: "2026-10-08T10:00:00Z" }]; + let engine = { connected: true, recording: false, replay_enabled: true, capture_running: true, replay_running: true, recording_error: "", version: "1.6.2" }; + const calls = []; + window.__uiTest = { calls, config, engine, clips, collections, failCommand: false, unknown: [] }; + window.__uiTest.maximized=false; window.__uiTest.mediaLoads=0; window.__uiTest.saveDelay=0; window.__uiTest.activeSaves=0; window.__uiTest.maxSaves=0; + HTMLMediaElement.prototype.load=function(){if(this.getAttribute('src'))window.__uiTest.mediaLoads++;}; + let callback = 0; const handlers = new Map(); const listeners = new Map(); + window.__uiTest.emit = (event = "clips") => { for (const [id, listener] of listeners) if (listener.event === event) handlers.get(listener.handler)?.({event, id, payload:null}); }; + window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener(event, id) { listeners.delete(id); } }; + window.__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } }, + transformCallback: (fn) => { handlers.set(++callback, fn); return callback; }, + unregisterCallback() {}, + convertFileSrc: (path) => `/__fixtures__/${path}`, + invoke: async (command, args = {}) => { + calls.push({ command, args }); + switch (command) { + case "list_clips": return structuredClone(clips.filter((clip) => (!args.filter.favorite || clip.favorite) && (!args.filter.search || clip.title.toLowerCase().includes(args.filter.search.toLowerCase())) && (!args.filter.game || args.filter.game === clip.game_display_name) && (!args.filter.hashtag || clip.hashtags.includes(args.filter.hashtag)))); + case "distinct_games": return ["Arena"]; + case "distinct_hashtags": return ["gameplay"]; + case "engine_status": return structuredClone(engine); + case "get_settings": return structuredClone(config); + case "save_settings": {window.__uiTest.activeSaves++; window.__uiTest.maxSaves=Math.max(window.__uiTest.maxSaves,window.__uiTest.activeSaves); await new Promise(r=>setTimeout(r,window.__uiTest.saveDelay)); window.__uiTest.activeSaves--; if(window.__uiTest.failSave)throw new Error('Settings unavailable'); Object.assign(config,structuredClone(args.config)); return;} + case 'plugin:window|close': window.__uiTest.emit('tauri://close-requested'); return; + case 'plugin:window|destroy': window.__uiTest.windowClosedConfig = structuredClone(config); return; + case 'plugin:window|is_maximized': return window.__uiTest.maximized; + case 'plugin:window|maximize': window.__uiTest.maximized=true; return; + case 'plugin:window|unmaximize': window.__uiTest.maximized=false; return; + case 'clip_snapshot': return structuredClone(clips.find(c=>c.source===args.source&&c.id===args.id)); + case 'thumb_capture': {const c=clips.find(c=>c.id===args.id); c.thumbnail_file='new.png'; c.thumbnail_path='new.png'; return 'new.png';} + case 'clip_regen_thumb': {const c=clips.find(c=>c.id===args.id); c.thumbnail_file='engine.png'; c.thumbnail_path='engine.png'; return;} + case 'clip_set_duration': return; + case 'collection_memberships': return [1]; + case 'reveal_in_explorer': return; + case "runtime_status": return { monitors: [{ device: "DISPLAY1", width: 1920, height: 1080, primary: true }], available_encoders: ["h264_nvenc"], input_devices: [], audio_sessions: [] }; + case "plugin:app|version": return "1.5.0"; + case "plugin:event|listen": listeners.set(callback, {event:args.event,handler:args.handler}); return callback; + case "game_catalog_map": return {}; + case "game_icon": + case "exe_icon": return null; + case "game_artwork": return { icon: null, cover: null }; + case "clip_list_bookmarks": return []; + case "clip_set_favorite": clips.find((clip) => clip.id === args.id).favorite = args.favorite; return; + case "list_collections": return structuredClone(collections); + case "collection_clips": return structuredClone(clips.slice(0, 3)); + case "create_collection": { const id = Math.max(...collections.map((c) => c.id), 0) + 1; collections.push({ id, name: args.name, color: args.color, clip_count: 0 }); return id; } + case "rename_collection": collections.find((c) => c.id === args.id).name = args.name; return; + case "delete_collection": collections.splice(collections.findIndex((c) => c.id === args.id), 1); return; + case "recorder_command": + if (window.__uiTest.failCommand) throw new Error("Recorder unavailable"); + if (args.method === "recording_start") engine.recording = true; + if (args.method === "recording_stop") engine.recording = false; + return; + default: + if (command.startsWith("plugin:window|") || command === "plugin:event|unlisten") return; + window.__uiTest.unknown.push(command); + throw new Error(`Unexpected mock command: ${command}`); + } + }, + }; + }, seed); + await page.goto(base); + async function until(fn, label) { + for (let attempt = 0; attempt < 100; attempt++) { + if (await fn()) return; + await delay(50); + } + throw new Error(`Timed out: ${label}`); + } + + + await until(async () => await page.locator(".card").count() === 15, "library"); + const failures=[]; + async function check(name,run) { + if (process.env.UI_AUDIT_FILTER && !name.includes(process.env.UI_AUDIT_FILTER)) return; + await page.reload(); + await until(async()=>await page.locator('.card').count()===15,'library'); + try {await run(); console.log(`PASS ${name}`);} catch(error) {failures.push(`${name}: ${error.message}`); console.log(`FAIL ${name}: ${error.message}`);} + } + async function settings(){await page.getByRole('button',{name:'Settings',exact:true}).click(); await until(async()=>await page.locator('[role=switch]').count()===3,'settings');} + const replay=()=>page.locator('.set-field').filter({hasText:'Replay buffer'}).getByRole('switch'); + for(const exit of ['Close settings','Escape','Library','Collections']) await check(`F01 flush ${exit}`,async()=>{ + await settings(); await replay().click(); + if(exit==='Escape')await page.keyboard.press('Escape'); else await page.getByRole('button',{name:exit,exact:true}).click(); + await delay(750); assert.equal(await page.evaluate(()=>window.__uiTest.config.replay_buffer.enabled),false); + }); + await check('F01 close window flushes pending settings',async()=>{ + await settings();await page.evaluate(()=>window.__uiTest.saveDelay=300);await replay().click(); + await page.getByRole('button',{name:'Close',exact:true}).click(); + await until(async()=>await page.evaluate(()=>!!window.__uiTest.windowClosedConfig),'window close'); + assert.equal(await page.evaluate(()=>window.__uiTest.windowClosedConfig.replay_buffer.enabled),false); + }); + await check('F01 native close waits and retains window on save error',async()=>{ + await settings();await page.evaluate(()=>{window.__uiTest.saveDelay=300;window.__uiTest.failSave=true;});await replay().click(); + await page.evaluate(()=>window.__uiTest.emit('tauri://close-requested')); + await until(async()=>(await page.locator('[role=alert]').allTextContents()).some(s=>s.includes('Settings unavailable')),'close save failure'); + assert.equal(await page.evaluate(()=>!!window.__uiTest.windowClosedConfig),false); + await page.evaluate(()=>{window.__uiTest.failSave=false;window.__uiTest.emit('tauri://close-requested');}); + await until(async()=>await page.evaluate(()=>!!window.__uiTest.windowClosedConfig),'native close retry'); + assert.equal(await page.evaluate(()=>window.__uiTest.windowClosedConfig.replay_buffer.enabled),false); + }); + await check('F02 shared serialized writes',async()=>{ + await settings(); await page.evaluate(()=>window.__uiTest.saveDelay=900); + await page.getByTitle('Capture source and mode').click(); await page.getByRole('button',{name:'Game Recording',exact:true}).click(); + await replay().click(); await delay(600); await page.getByRole('button',{name:'Close settings',exact:true}).click(); + await delay(2300); + assert.equal(await page.evaluate(()=>window.__uiTest.config.capture_mode.mode),'game_only'); + assert.equal(await page.evaluate(()=>window.__uiTest.config.replay_buffer.enabled),false); + assert.equal(await page.evaluate(()=>window.__uiTest.maxSaves),1); + }); + await check('F02 failure visible after exit',async()=>{ + await settings(); await page.evaluate(()=>window.__uiTest.failSave=true); await replay().click(); await page.getByRole('button',{name:'Close settings',exact:true}).click(); + await until(async()=>(await page.locator('[role=alert]').allTextContents()).some(s=>s.includes('Settings unavailable')),'save failure'); + }); + await check('F03 detail identity insert and removal',async()=>{ + await page.getByRole('button',{name:'Open Match 01',exact:true}).click(); + await page.evaluate(()=>{let t=window.__uiTest;t.clips.unshift({...t.clips[0],id:99,clip_uid:"clip99",title:'New clip'});t.emit();}); await delay(250); + assert.equal(await page.locator('.detail-name').textContent(),'Match 01'); + await page.evaluate(()=>{let t=window.__uiTest;t.clips.splice(t.clips.findIndex(c=>c.id===1),1);t.emit();}); + await until(async()=>await page.locator('.detail').count()===0,'removed detail'); + }); + await check('F03 reused local ID never retargets detail',async()=>{ + await page.getByRole('button',{name:'Open Match 01',exact:true}).click(); + await page.evaluate(()=>{window.__uiTest.clips[0]={...window.__uiTest.clips[0],clip_uid:'replacement',title:'Replacement'};window.__uiTest.emit();}); + await until(async()=>await page.locator('.detail').count()===0,'replaced clip identity'); + }); + await check('F03 legacy empty UIDs preserve individual clips',async()=>{ + await page.evaluate(()=>{window.__uiTest.clips.forEach(c=>{c.clip_uid='';c.catalog_uid='';});window.__uiTest.emit();}); + await page.getByRole('button',{name:'Open Match 02',exact:true}).click();assert.equal(await page.locator('.detail-name').textContent(),'Match 02'); + await page.evaluate(()=>{let t=window.__uiTest;t.clips.unshift({...t.clips[0],id:99,video_path:'other.mkv',title:'New clip'});t.emit();});await delay(250); + assert.equal(await page.locator('.detail-name').textContent(),'Match 02'); + }); + await check('F04 clipping uses running pipeline and recording errors surface',async()=>{ + assert((await page.getByTitle('Capture source and mode').textContent()).includes('Clipping')); + await page.evaluate(()=>{window.__uiTest.engine.replay_running=false;}); + await until(async()=>(await page.getByTitle('Capture source and mode').textContent()).includes('Idle'),'replay idle'); + await page.evaluate(()=>{window.__uiTest.engine.recording_error='Writer failed';}); + await until(async()=>(await page.locator('[role=alert]').allTextContents()).some(s=>s.includes('Writer failed')),'recording error'); + }); + await check('F05 arrows belong to focused text and sliders',async()=>{ + await page.getByRole('button',{name:'Open Match 01',exact:true}).click(); await page.locator('.tag-input').fill('hello'); + await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowRight'); + assert.equal(await page.locator('.detail-name').textContent(),'Match 01'); + await page.locator('.detail input[type=range]').first().focus(); await page.keyboard.press('ArrowRight'); + assert.equal(await page.locator('.detail-name').textContent(),'Match 01'); + }); + await check('F07 unavailable track API warns and offers file',async()=>{ + await page.getByRole('button',{name:'Open Match 01',exact:true}).click(); + await until(async()=>(await page.locator('[role=status]').allTextContents()).some(s=>s.includes('additional audio tracks')),'track capability notice'); + await page.getByRole('button',{name:'Show video file',exact:true}).click(); + assert.equal(await page.evaluate(()=>window.__uiTest.calls.filter(c=>c.command==='reveal_in_explorer').length),1); + }); + await check('F08 collection event refresh',async()=>{ + await page.getByRole('button',{name:'Collections',exact:true}).click(); await page.getByRole('button',{name:'Best moments 3 clips'}).click(); + await until(async()=>await page.locator('.card').count()===3,'collection'); + await page.evaluate(()=>{window.__uiTest.clips[0].title='Changed';window.__uiTest.emit();}); + await until(async()=>await page.getByRole('button',{name:'Open Changed',exact:true}).count()===1,'collection refresh'); + }); + await check('F09 no reads for valid catalog metadata',async()=>{await delay(100);assert.equal(await page.evaluate(()=>window.__uiTest.mediaLoads),0);}); + await check('F09 bounded visible missing-media probes',async()=>{ + await page.route('**/__fixtures__/video*', () => {}); + await page.setViewportSize({width:800,height:600}); + await page.evaluate(()=>{window.__uiTest.clips.forEach(c=>{c.thumbnail_file=null;c.thumbnail_path=null;c.duration_seconds=null;});window.__uiTest.mediaLoads=0;window.__uiTest.emit();}); + await delay(250);assert(await page.evaluate(()=>window.__uiTest.mediaLoads)<=2,'at most two concurrent repair probes'); + await page.unroute("**/__fixtures__/video*"); + await page.setViewportSize({width:1920,height:1080}); + }); + await check('F10 named switch and select',async()=>{ + await settings(); assert.equal(await page.getByRole('switch',{name:'Replay buffer',exact:true}).count(),1); + await page.getByRole('navigation',{name:'Settings pages'}).getByRole('button',{name:'Capture',exact:true}).click(); + assert.equal(await page.getByRole('combobox',{name:'Resolution',exact:true}).count(),1); + }); + await check('F11 unfavorite exits filtered list',async()=>{ + await page.locator('.card').first().getByTitle('Add to favorites').click();await page.getByRole('button',{name:'Favorites',exact:true}).click(); + await until(async()=>await page.locator('.card').count()===1,'favorites');await page.locator('.card').getByTitle('Remove from favorites').click(); + await until(async()=>await page.locator('.card').count()===0,'unfavorite'); + }); + for(const maximized of [false,true])await check(`F12 restore window ${maximized}`,async()=>{ + await page.evaluate(v=>window.__uiTest.maximized=v,maximized);await page.getByRole('button',{name:'Open Match 01',exact:true}).click(); + await page.locator('.detail').getByTitle('Fullscreen',{exact:true}).click();await delay(100);await page.keyboard.press('Escape');await delay(100); + assert.equal(await page.evaluate(()=>window.__uiTest.maximized),maximized); + }); + await check('Q03 membership reads remain compact',async()=>{ + await page.getByRole('button',{name:'Open Match 01',exact:true}).click(); + await page.locator('.detail').getByTitle('Add to collection',{exact:true}).click(); + await until(async()=>await page.getByRole('button',{name:'In collection',exact:true}).count()===1,'membership picker'); + assert.equal(await page.evaluate(()=>window.__uiTest.calls.filter(c=>c.command==='collection_clips').length),0); + assert.equal(await page.evaluate(()=>window.__uiTest.calls.filter(c=>c.command==='collection_memberships').length),1); + }); + await check('Q02 transient media saturation retries thumbnail without regeneration',async()=>{ + let requests=0; + await page.route('**/flaky.svg*',route=>{requests++;return requests===1?route.fulfill({status:503}):route.fulfill({contentType:'image/svg+xml',body:''});}); + await page.evaluate(()=>{window.__uiTest.clips[0].thumbnail_path='flaky.svg';window.__uiTest.emit();}); + await until(async()=>requests>=2&&await page.locator('.card').first().locator('.card-thumb').evaluate(img=>img.complete&&img.naturalWidth>0),'thumbnail retry'); + assert.equal(await page.evaluate(()=>window.__uiTest.calls.filter(c=>c.command==='clip_regen_thumb').length),0); + await page.unroute('**/flaky.svg*'); + }); + await check('F06 engine fallback restores missing thumbnail path',async()=>{ + await page.evaluate(()=>{const c=window.__uiTest.clips[0];c.thumbnail_file=null;c.thumbnail_path=null;c.video_file='unsupported-video.mkv';window.__uiTest.emit();}); + await until(async()=>await page.locator('.card').first().locator('.card-thumb').count()===1,'engine thumbnail'); + assert((await page.locator('.card').first().locator('.card-thumb').getAttribute('src')).includes('engine.png')); + }); + await check('F06 generated thumbnail path displayed',async()=>{ + await page.evaluate(()=>{const c=window.__uiTest.clips[0];c.thumbnail_file=null;c.thumbnail_path=null;c.duration_seconds=null;window.__uiTest.emit();}); + await until(async()=>await page.locator('.card').first().locator('.thumb-placeholder').count()===1,'missing thumb'); + await page.evaluate(()=>{ + Object.defineProperty(HTMLMediaElement.prototype,'duration',{get:()=>30});Object.defineProperty(HTMLVideoElement.prototype,'videoWidth',{get:()=>640});Object.defineProperty(HTMLVideoElement.prototype,'videoHeight',{get:()=>360});CanvasRenderingContext2D.prototype.drawImage=()=>{}; + HTMLMediaElement.prototype.load=function(){if(!this.getAttribute('src'))return;setTimeout(()=>{this.dispatchEvent(new Event('loadedmetadata'));this.dispatchEvent(new Event('loadeddata'));},10);}; + window.__uiTest.clips[0].video_file='video-new.mkv';window.__uiTest.emit(); + }); + await until(async()=>await page.locator('.card').first().locator('.card-thumb').count()===1,'generated thumbnail'); + }); + assert.equal(errors.length,0,errors.join('\n'));assert.equal(failures.length,0,failures.join('\n')); +} finally {await browser?.close();server.kill();} diff --git a/app/desktop-ui/tests/ui.mjs b/app/desktop-ui/tests/ui.mjs index d16bc8c..d872268 100644 --- a/app/desktop-ui/tests/ui.mjs +++ b/app/desktop-ui/tests/ui.mjs @@ -33,9 +33,9 @@ try { return route.fulfill({ contentType: "image/svg+xml", body: `UI test fixture ${id}` }); }); await page.addInitScript((config) => { - const clips = Array.from({ length: 15 }, (_, i) => ({ id: i + 1, source: "replay", video_file: `video${i + 1}.mkv`, title: `Match ${String(i + 1).padStart(2, "0")}`, thumbnail_file: `thumb${i + 1}.svg`, video_path: `video${i + 1}.mkv`, thumbnail_path: `thumb${i + 1}.svg`, created_at_utc: "2026-10-08T10:00:00Z", duration_seconds: 30, game_process_name: "arena.exe", game_display_name: "Arena", favorite: false, hashtags: ["gameplay"], size_bytes: 12000000 })); + const clips = Array.from({ length: 15 }, (_, i) => ({ id: i + 1, source: "replay", catalog_uid: "catalog-replay", clip_uid: `clip${i + 1}`, media_revision: 0, video_file: `video${i + 1}.mkv`, title: `Match ${String(i + 1).padStart(2, "0")}`, thumbnail_file: `thumb${i + 1}.svg`, video_path: `video${i + 1}.mkv`, thumbnail_path: `thumb${i + 1}.svg`, created_at_utc: "2026-10-08T10:00:00Z", duration_seconds: 30, game_process_name: "arena.exe", game_display_name: "Arena", favorite: false, hashtags: ["gameplay"], size_bytes: 12000000 })); const collections = [{ id: 1, name: "Best moments", color: "#c4ff42", clip_count: 3, created_at_utc: "2026-10-08T10:00:00Z" }]; - let engine = { connected: true, recording: false, replay_enabled: true, version: "1.6.2" }; + let engine = { connected: true, recording: false, replay_enabled: true, capture_running: true, replay_running: true, recording_error: "", version: "1.6.2" }; const calls = []; window.__uiTest = { calls, config, engine, clips, collections, failCommand: false, unknown: [] }; let callback = 0; @@ -113,7 +113,7 @@ try { await page.keyboard.press("Enter"); await until(async () => await page.locator(".detail").count() === 1, "keyboard clip opening"); await page.keyboard.press("Escape"); - await until(async () => await count() === 15, "detail close"); + await until(async () => await page.locator(".detail").count() === 0, "detail close"); await page.getByRole("button", { name: "Collections", exact: true }).click(); await page.getByRole("button", { name: "New collection", exact: true }).first().click(); await page.getByPlaceholder("Collection name").fill("Highlights"); diff --git a/app/desktop-ui/ui/src/app.tsx b/app/desktop-ui/ui/src/app.tsx index 161b688..3b2cd9c 100644 --- a/app/desktop-ui/ui/src/app.tsx +++ b/app/desktop-ui/ui/src/app.tsx @@ -1,7 +1,8 @@ +import { useSettingsClose } from "./lib/window.ts"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { collectionsApi, - type Clip, + type Clip, clipKey, clipApi, type CollectionSummary, fetchClips, @@ -37,6 +38,7 @@ const sameFilter = (a: Filter, b: Filter) => a.game === b.game && a.hashtag === b.hashtag && a.favorite === b.favorite && a.search === b.search; export function App() { + useSettingsClose(); const [clips, setClips] = useState([]); const [games, setGames] = useState([]); const [hashtags, setHashtags] = useState([]); @@ -49,7 +51,9 @@ export function App() { const [tagDialog, setTagDialog] = useState(null); const [confirmDel, setConfirmDel] = useState(null); const [fullscreen, setFullscreen] = useState<{ clip: Clip; initialTime: number } | null>(null); - const [detailIndex, setDetailIndex] = useState(null); + const [detailKey, setDetailKey] = useState(null); + const detailIndex = detailKey === null ? -1 : clips.findIndex((clip) => clipKey(clip) === detailKey); + useEffect(() => { if (detailKey !== null && detailIndex < 0) setDetailKey(null); }, [detailKey, detailIndex]); const [showSettings, setShowSettings] = useState(false); const [collectionView, setCollectionView] = useState<{ kind: "list" } | { kind: "detail"; id: number } | null>(null); const [collections, setCollections] = useState([]); @@ -82,15 +86,15 @@ export function App() { const updateClip = useCallback((next: Clip) => { setClips((items) => items.map((item) => ( - item.source === next.source && item.id === next.id ? next : item - ))); - setTagDialog((item) => item && item.source === next.source && item.id === next.id ? next : item); + clipKey(item) === clipKey(next) ? next : item + )).filter((clip) => (!filter.favorite || clip.favorite) && (!filter.game || filter.game === clip.game_display_name) && (!filter.hashtag || clip.hashtags.includes(filter.hashtag)) && (!filter.search || `${clip.title} ${clip.video_file}`.toLowerCase().includes(filter.search.toLowerCase())))); + setTagDialog((item) => item && clipKey(item) === clipKey(next) ? next : item); setFullscreen((item) => ( - item && item.clip.source === next.source && item.clip.id === next.id + item && clipKey(item.clip) === clipKey(next) ? { ...item, clip: next } : item )); - }, []); + }, [filter]); useEffect(() => { void reload(); @@ -119,7 +123,7 @@ export function App() { const openDetail = useCallback((clip: Clip) => { const i = clips.findIndex((item) => item.source === clip.source && item.id === clip.id); - if (i >= 0) setDetailIndex(i); + if (i >= 0) setDetailKey(clipKey(clips[i])); }, [clips]); // Card-level mutations (favorite, thumbnail capture, duration fix) are all @@ -171,7 +175,7 @@ export function App() { return; } setConfirmDel(null); - setDetailIndex(null); + setDetailKey(null); await reload(true); }; @@ -324,13 +328,13 @@ export function App() { /> )} - {detailIndex !== null && clips.length > 0 && ( + {detailIndex >= 0 && ( setDetailIndex(null)} + onIndex={(i) => setDetailKey(clipKey(clips[i]))} + onClose={() => setDetailKey(null)} onChanged={() => reload(true)} onClipUpdate={updateClip} onDelete={setConfirmDel} diff --git a/app/desktop-ui/ui/src/home/audio-track-notice.tsx b/app/desktop-ui/ui/src/home/audio-track-notice.tsx new file mode 100644 index 0000000..27b5337 --- /dev/null +++ b/app/desktop-ui/ui/src/home/audio-track-notice.tsx @@ -0,0 +1,10 @@ +import { type Clip, clipApi } from "../lib/api.ts"; + +export function AudioTrackNotice({ clip }: { clip: Clip }) { + return ( +
+ This player cannot select additional audio tracks. Open the video in an external player to hear all tracks. + +
+ ); +} diff --git a/app/desktop-ui/ui/src/home/clip-card.tsx b/app/desktop-ui/ui/src/home/clip-card.tsx index bbc8556..0f21617 100644 --- a/app/desktop-ui/ui/src/home/clip-card.tsx +++ b/app/desktop-ui/ui/src/home/clip-card.tsx @@ -1,6 +1,7 @@ import { memo } from "preact/compat"; import { useEffect, useRef, useState } from "preact/hooks"; -import { type Clip, clipApi, exeIconUrl, mediaUrl, thumbUrl } from "../lib/api.ts"; +import { type Clip, clipApi, clipKey, exeIconUrl, mediaUrl, thumbUrl } from "../lib/api.ts"; +import { enqueueMediaProbe } from "../lib/media-probes.ts"; import { appLabel, formatDate, formatDuration, formatSize } from "../lib/format.ts"; import { Icon } from "../shell/icons.tsx"; import { useMultiTrackAudio } from "../lib/multitrack.ts"; @@ -43,21 +44,29 @@ export const ClipCard = memo(function ClipCard( const [muted, setMuted] = useState(true); const [thumbBroken, setThumbBroken] = useState(false); const [thumbBust, setThumbBust] = useState(0); - const [localThumb, setLocalThumb] = useState(clip.thumbnail_file); + const [visible, setVisible] = useState(false); + const cardRef = useRef(null); + const latest = useRef({ clip, onChanged }); + latest.current = { clip, onChanged }; const [displayDuration, setDisplayDuration] = useState(clip.duration_seconds); const [exeIcon, setExeIcon] = useState(null); const [videoEl, setVideoEl] = useState(null); const hoverTimer = useRef(undefined); const regenTried = useRef(false); + const thumbAttempts = useRef(0); + const thumbRetryTimer = useRef | undefined>(undefined); + const thumbRepair = useRef<(() => void) | undefined>(undefined); + const mounted = useRef(true); const multitrack = useMultiTrackAudio(preview ? videoEl : null, preview ? mediaUrl(clip) : null); useEffect(() => { - setLocalThumb(clip.thumbnail_file); + clearTimeout(thumbRetryTimer.current); + thumbAttempts.current = 0; setDisplayDuration(clip.duration_seconds); setThumbBroken(false); regenTried.current = false; - }, [clip.id, clip.source, clip.thumbnail_file, clip.duration_seconds]); + }, [clipKey(clip), clip.thumbnail_path, clip.thumbnail_file, clip.duration_seconds]); // Prefer the executable icon, then cached artwork; avoid network lookup per card. useEffect(() => { @@ -77,78 +86,105 @@ export const ClipCard = memo(function ClipCard( const gameIcon = exeIcon ?? clip.game_icon_url ?? null; const hasGame = !!(clip.game_process_name || clip.game_display_name); - useEffect(() => () => clearTimeout(hoverTimer.current), []); + useEffect(() => () => { + mounted.current = false; + clearTimeout(hoverTimer.current); + clearTimeout(thumbRetryTimer.current); + thumbRepair.current?.(); + }, []); useEffect(() => { - let cancelled = false; - const needsThumb = !clip.thumbnail_file; - const video = document.createElement("video"); - video.preload = needsThumb ? "auto" : "metadata"; - video.muted = true; - video.playsInline = true; - - const finish = () => { - video.removeAttribute("src"); - video.load(); - }; - - // Use engine thumbnail generation when WebView2 cannot decode the container. - const fallbackToEngine = async () => { - if (cancelled || regenTried.current) return; - regenTried.current = true; - const res = await clipApi.regenThumb(clip); - if (!cancelled && res.ok) { - setThumbBust(Date.now()); - } - }; - - video.onloadedmetadata = async () => { - if (cancelled) return; - const duration = video.duration; - if (Number.isFinite(duration) && duration > 0 && !sameDuration(clip.duration_seconds, duration)) { - setDisplayDuration(duration); - const res = await clipApi.setDuration(clip, duration); - if (!cancelled && res.ok) onChanged({ ...clip, duration_seconds: duration }); - } - if (!needsThumb) finish(); - }; - - video.onloadeddata = async () => { - if (cancelled || !needsThumb) return; - const dataUrl = drawVideoThumb(video); - if (!dataUrl) { - await fallbackToEngine(); - finish(); - return; - } - const res = await clipApi.saveCapturedThumb(clip, dataUrl); - if (!cancelled && res.ok && typeof res.thumbnail_file === "string") { - setLocalThumb(res.thumbnail_file); - setThumbBust(Date.now()); - onChanged({ ...clip, thumbnail_file: res.thumbnail_file }); - } - finish(); - }; + const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting)); + if (cardRef.current) observer.observe(cardRef.current); + return () => observer.disconnect(); + }, []); - video.onerror = () => { - // Container/codec unsupported by WebView2 (e.g. .mkv) - hand off to the - // engine regenerator instead of silently giving up. - if (needsThumb) { - void fallbackToEngine().finally(finish); - } else { - finish(); - } - }; - video.src = mediaUrl(clip); - video.load(); + async function refreshMedia(): Promise { + const updated = await clipApi.snapshot(clip); + if (!mounted.current) return; + const current = latest.current.clip; + if (clipKey(updated) !== clipKey(current) || (updated.media_revision ?? 0) < (current.media_revision ?? 0)) return; + latest.current.onChanged({ + ...current, + thumbnail_file: updated.thumbnail_file, + thumbnail_path: updated.thumbnail_path, + duration_seconds: updated.duration_seconds, + media_revision: updated.media_revision, + }); + setThumbBroken(false); + setThumbBust(Date.now()); + } + useEffect(() => { + const needsThumb = !clip.thumbnail_path; + const needsDuration = !(typeof clip.duration_seconds === "number" && Number.isFinite(clip.duration_seconds) && clip.duration_seconds > 0); + if (!visible || (!needsThumb && !needsDuration)) return; + let cancelled = false; + let finish = () => {}; + const cancelQueued = enqueueMediaProbe(() => new Promise((resolve) => { + if (cancelled) { resolve(); return; } + const video = document.createElement("video"); + video.preload = needsThumb ? "auto" : "metadata"; + video.muted = true; + video.playsInline = true; + let working = false; + const timeout = setTimeout(() => { finish(); }, 10000); + finish = () => { + clearTimeout(timeout); + video.onloadedmetadata = null; + video.onloadeddata = null; + video.onerror = null; + video.removeAttribute("src"); + video.load(); + resolve(); + }; + const repairDuration = async () => { + const duration = video.duration; + if (needsDuration && Number.isFinite(duration) && duration > 0 && !sameDuration(clip.duration_seconds, duration)) { + await clipApi.setDuration(clip, duration); + } + }; + const fallback = async () => { + if (cancelled || regenTried.current) return; + regenTried.current = true; + const result = await clipApi.regenThumb(clip); + if (!cancelled && result.ok) await refreshMedia(); + }; + video.onloadedmetadata = () => { + if (cancelled || needsThumb || working) return; + working = true; + void repairDuration().then(() => { if (!cancelled) return refreshMedia(); }).catch((error) => console.error("Metadata refresh failed", error)).finally(finish); + }; + video.onloadeddata = () => { + if (cancelled || !needsThumb || working) return; + working = true; + void (async () => { + await repairDuration(); + if (cancelled) return; + const dataUrl = drawVideoThumb(video); + if (!dataUrl) await fallback(); + else { + const result = await clipApi.saveCapturedThumb(clip, dataUrl); + if (!cancelled && result.ok) await refreshMedia(); + } + })().catch((error) => console.error("Thumbnail repair failed", error)).finally(finish); + }; + video.onerror = () => { + if (working) return; + working = true; + void (needsThumb ? fallback() : Promise.resolve()).catch((error) => console.error("Thumbnail refresh failed", error)).finally(finish); + }; + video.src = mediaUrl(clip); + video.load(); + })); return () => { cancelled = true; + cancelQueued(); finish(); }; - }, [clip.id, clip.source, clip.video_file]); + }, [visible, clipKey(clip), clip.video_file, clip.thumbnail_path, clip.duration_seconds, clip.media_revision]); - const thumb = localThumb ? thumbUrl({ ...clip, thumbnail_file: localThumb }) : null; + const thumb = thumbUrl(clip); const showPlaceholder = !thumb || thumbBroken; const thumbSrc = thumb ? `${thumb}${thumbBust ? `?v=${thumbBust}` : ""}` : null; @@ -176,19 +212,25 @@ export const ClipCard = memo(function ClipCard( setMuted(true); } - async function onThumbError() { + function onThumbError() { + if (thumbAttempts.current < 3) { + const attempt = ++thumbAttempts.current; + clearTimeout(thumbRetryTimer.current); + thumbRetryTimer.current = setTimeout(() => setThumbBust(Date.now()), attempt * 1000); + return; + } if (regenTried.current) { setThumbBroken(true); return; } regenTried.current = true; - const res = await clipApi.regenThumb(clip); - if (res.ok) { - setThumbBust(Date.now()); - onChanged(clip); - } else { - setThumbBroken(true); - } + thumbRepair.current = enqueueMediaProbe(async () => { + if (!mounted.current || !visible) return; + const result = await clipApi.regenThumb(clip); + if (!mounted.current) return; + if (result.ok) await refreshMedia(); + else setThumbBroken(true); + }); } async function toggleFavorite(e: MouseEvent) { @@ -203,6 +245,7 @@ export const ClipCard = memo(function ClipCard( return (
{ e.preventDefault(); onContextMenu(e as unknown as MouseEvent, clip); diff --git a/app/desktop-ui/ui/src/home/collection-detail.tsx b/app/desktop-ui/ui/src/home/collection-detail.tsx index 53bfc6e..f70ce05 100644 --- a/app/desktop-ui/ui/src/home/collection-detail.tsx +++ b/app/desktop-ui/ui/src/home/collection-detail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks"; -import { type Clip, type CollectionSummary, clipApi, collectionsApi } from "../lib/api.ts"; +import { type Clip, clipKey, subscribeClips, type CollectionSummary, clipApi, collectionsApi } from "../lib/api.ts"; import { Icon } from "../shell/icons.tsx"; import { ClipCard } from "./clip-card.tsx"; import { ConfirmDialog } from "./confirm-dialog.tsx"; @@ -30,7 +30,9 @@ export function CollectionDetail({ collection, allHashtags, onBack, onChanged }: const [confirmDel, setConfirmDel] = useState(null); const [confirmRemove, setConfirmRemove] = useState(null); const [fullscreen, setFullscreen] = useState<{ clip: Clip; initialTime: number } | null>(null); - const [detailIndex, setDetailIndex] = useState(null); + const [detailKey, setDetailKey] = useState(null); + const detailIndex = detailKey === null ? -1 : clips.findIndex((clip) => clipKey(clip) === detailKey); + useEffect(() => { if (detailKey !== null && detailIndex < 0) setDetailKey(null); }, [detailKey, detailIndex]); const [renaming, setRenaming] = useState(false); const [confirmDeleteCol, setConfirmDeleteCol] = useState(false); const [busy, setBusy] = useState(false); @@ -57,13 +59,15 @@ export function CollectionDetail({ collection, allHashtags, onBack, onChanged }: return () => { ++loadId.current; }; }, [refetch]); + useEffect(() => subscribeClips(() => { void refetch(true); }), [refetch]); + const updateClip = (next: Clip) => { setClips((items) => items.map((item) => ( - item.source === next.source && item.id === next.id ? next : item + clipKey(item) === clipKey(next) ? next : item ))); - setTagDialog((item) => item && item.source === next.source && item.id === next.id ? next : item); + setTagDialog((item) => item && clipKey(item) === clipKey(next) ? next : item); setFullscreen((item) => ( - item && item.clip.source === next.source && item.clip.id === next.id + item && clipKey(item.clip) === clipKey(next) ? { ...item, clip: next } : item )); @@ -125,7 +129,7 @@ export function CollectionDetail({ collection, allHashtags, onBack, onChanged }: setBusy(false); if (res.ok) { setConfirmRemove(null); - setDetailIndex(null); + setDetailKey(null); await refetch(); onChanged(); } @@ -138,7 +142,7 @@ export function CollectionDetail({ collection, allHashtags, onBack, onChanged }: setBusy(false); if (res.ok) { setConfirmDel(null); - setDetailIndex(null); + setDetailKey(null); await refetch(); onChanged(); } @@ -171,6 +175,7 @@ export function CollectionDetail({ collection, allHashtags, onBack, onChanged }:
+ {(collection.unresolved_count ?? 0) > 0 &&

Legacy memberships need confirmation. Re-add the intended clips to restore them.

} {error &&

{error}

} {loading @@ -197,7 +202,7 @@ export function CollectionDetail({ collection, allHashtags, onBack, onChanged }: onChanged={updateClip} onContextMenu={openMenu} onFullscreen={(c, t) => setFullscreen({ clip: c, initialTime: t })} - onOpenDetail={() => setDetailIndex(i)} + onOpenDetail={() => setDetailKey(clipKey(c))} /> ))} @@ -256,13 +261,13 @@ export function CollectionDetail({ collection, allHashtags, onBack, onChanged }: /> )} - {detailIndex !== null && clips.length > 0 && ( + {detailIndex >= 0 && ( setDetailIndex(null)} + onIndex={(i) => setDetailKey(clipKey(clips[i]))} + onClose={() => setDetailKey(null)} onChanged={refetch} onClipUpdate={updateClip} onDelete={setConfirmDel} diff --git a/app/desktop-ui/ui/src/home/collection-picker.tsx b/app/desktop-ui/ui/src/home/collection-picker.tsx index 39945fd..1210ebc 100644 --- a/app/desktop-ui/ui/src/home/collection-picker.tsx +++ b/app/desktop-ui/ui/src/home/collection-picker.tsx @@ -22,15 +22,9 @@ export function CollectionPicker({ clip, onClose, onChanged }: { setLoading(true); setError(null); try { - const list = await collectionsApi.list(); - const flags = await Promise.all( - list.map(async (c) => { - const items = await collectionsApi.clips(c.id); - return items.some((item) => item.source === clip.source && item.id === clip.id); - }), - ); + const [list, ids] = await Promise.all([collectionsApi.list(), collectionsApi.memberships(clip)]); setCollections(list); - setMembership(new Set(list.filter((_, i) => flags[i]).map((c) => c.id))); + setMembership(new Set(ids)); } catch (err) { setError(String(err)); } finally { @@ -97,6 +91,7 @@ export function CollectionPicker({ clip, onClose, onChanged }: {